diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..b83d5a8f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the +# suite's shared gate workflow in craft-common-actions. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +# A newer push to a PR branch cancels its older run; every push to main runs to the end. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + gate: + # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. + uses: storytold/craft-common-actions/.github/workflows/gate.yml@main + with: + # Same craft-fonts commit as release.yml. + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + timeout-minutes: 90 + packages: >- + pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev + libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers + libgl1-mesa-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 6ee868152..0f91ed6d0 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,25 +1,19 @@ # FreeBSD build and package check in a QEMU VM. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull +# request: a drifted dependency still shows up within a day, and always before a release ships. +# PRs that change the FreeBSD workflow or packaging itself still run it. on: push: - branches: [main] - paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "packaging/env.sh" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/freebsd.yml" - - "packaging/freebsd/**" - - "packaging/env.sh" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh + schedule: + - cron: "17 4 * * *" workflow_dispatch: concurrency: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 54a8e53fb..5fd996c43 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,14 +13,56 @@ on: default: "" permissions: - contents: write + contents: read concurrency: group: release-${{ github.ref }} cancel-in-progress: false jobs: + git-sources: + name: Git dependencies come from storytold + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Git dependencies come from storytold + run: | + set -euo pipefail + test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; } + # Source replacement, patches and path overrides in cargo config never show up + # in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key. + # Cargo still reads the legacy .cargo/config. + python3 - <<'PY' + import os, sys, tomllib + for path in (".cargo/config", ".cargo/config.toml"): + if not os.path.exists(path): + continue + config = tomllib.load(open(path, "rb")) + replaced = [name for name, source in config.get("source", {}).items() + if isinstance(source, dict) and "replace-with" in source] + if replaced or "patch" in config or "paths" in config: + print(f"{path} replaces, patches or overrides a source") + sys.exit(1) + PY + # Fails when Cargo.lock is out of date with the manifests. + cargo metadata --locked --format-version 1 > /dev/null + python3 - <<'PY' + import sys, tomllib + lock = tomllib.load(open("Cargo.lock", "rb")) + bad = sorted({p["source"] for p in lock.get("package", []) + if p.get("source", "").startswith("git+") + and not p["source"].startswith("git+https://github.com/storytold/")}) + if bad: + print("Git dependencies outside github.com/storytold:", *bad, sep="\n") + sys.exit(1) + PY + release: + needs: git-sources + permissions: + contents: write # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml new file mode 100644 index 000000000..e78fe1ea3 --- /dev/null +++ b/.github/workflows/sonarcloud.yml @@ -0,0 +1,21 @@ +name: SonarCloud + +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: sonarcloud-${{ github.ref }} + # A push to main is the run that publishes coverage; superseded runs there would never finish. + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + sonarcloud: + # Tracked at @main so org-wide CI improvements propagate without PR churn. + uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main + secrets: inherit diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index f3ba1eeaa..2dc19466c 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -5,23 +5,17 @@ # on GitHub Actions' native windows-11-arm runner. name: Windows ARM64 +# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a +# day, and always before a release ships), and on PRs that change the Windows packaging itself. on: + schedule: + - cron: "3 5 * * *" push: - branches: [main] - paths: - - ".github/workflows/windows-arm64.yml" - - "packaging/windows/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + branches: [release] pull_request: - branches: [main] paths: - - ".github/workflows/windows-arm64.yml" - - "packaging/windows/**" - - "Cargo.toml" - - "Cargo.lock" - - "crates/**" + - .github/workflows/windows-arm64.yml + - packaging/windows/** workflow_dispatch: concurrency: diff --git a/AGENTS.md b/AGENTS.md index 0b6171cd3..61f141079 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **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`. - **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`). diff --git a/ASSETS.md b/ASSETS.md index f8121c35b..02c6d3b87 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -169,6 +169,7 @@ Generated-in-code art is original and has no file to list. This covers the defau | `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | | | `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | +| `assets/icons/dc-para-bullets.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three dots, each before a line | MIT OR Apache-2.0 | | | `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | | `assets/icons/dc-para-justify-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | | diff --git a/CLAUDE.md b/CLAUDE.md index 899f22e39..cbf9de129 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format - **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd. - **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`. - **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`). diff --git a/Cargo.lock b/Cargo.lock index ffd1b3d45..7c6755fa7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -64,7 +64,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.38.0", "hashbrown 0.16.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -129,7 +129,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -208,21 +208,21 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -344,13 +344,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -385,7 +385,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -449,15 +449,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -468,10 +459,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -479,6 +473,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -506,7 +510,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -515,7 +519,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -531,6 +535,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "brotli" version = "8.0.4" @@ -575,7 +603,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -606,14 +634,13 @@ dependencies = [ [[package]] name = "calloop" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ "bitflags 2.13.2", "polling", "rustix 1.1.5", - "slab", "tracing", ] @@ -635,7 +662,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "calloop 0.14.4", + "calloop 0.14.5", "rustix 1.1.5", "wayland-backend", "wayland-client", @@ -643,9 +670,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.5.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -674,6 +701,26 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + [[package]] name = "cipher" version = "0.4.4" @@ -731,9 +778,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -788,6 +835,12 @@ dependencies = [ "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -806,6 +859,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "craft-docpath" version = "0.1.0" @@ -839,11 +901,20 @@ dependencies = [ "serde", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" dependencies = [ "cfg-if", ] @@ -948,7 +1019,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -959,7 +1030,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1021,7 +1092,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1089,7 +1160,7 @@ dependencies = [ "bytemuck", "egui", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", @@ -1151,9 +1222,9 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" @@ -1222,7 +1293,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types 0.12.5", + "font-types 0.12.7", "harfrust 0.12.0", "log", "nohash-hasher", @@ -1244,9 +1315,9 @@ checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1322,9 +1393,9 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "find-msvc-tools" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "fixedbitset" @@ -1381,9 +1452,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.5" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -1417,7 +1488,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1468,7 +1539,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1540,6 +1611,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core", ] [[package]] @@ -1622,7 +1694,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2286,9 +2358,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -2315,7 +2387,7 @@ dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2357,9 +2429,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lock_api" @@ -2449,7 +2521,7 @@ dependencies = [ "crossbeam-channel", "dpi", "keyboard-types", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2581,9 +2653,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] name = "objc2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" dependencies = [ "objc-sys", "objc2-encode", @@ -2591,9 +2663,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -2607,7 +2679,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "libc", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", @@ -2622,7 +2694,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -2636,7 +2708,7 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -2648,7 +2720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2660,7 +2732,7 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2672,7 +2744,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -2683,7 +2755,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -2695,7 +2767,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -2707,7 +2779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-contacts", "objc2-foundation 0.2.2", ] @@ -2737,7 +2809,7 @@ dependencies = [ "block2 0.5.1", "dispatch", "libc", - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -2748,7 +2820,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -2759,7 +2831,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -2770,7 +2842,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -2783,7 +2855,7 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2795,7 +2867,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -2807,7 +2879,7 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -2819,7 +2891,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -2832,7 +2904,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2844,7 +2916,7 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", @@ -2864,7 +2936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -2876,7 +2948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -2888,7 +2960,7 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -3213,15 +3285,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -3254,16 +3317,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", "rand", - "rand_chacha", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3330,38 +3394,25 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.5" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", + "getrandom 0.4.3", "rand_core", ] [[package]] name = "rand_core" -version = "0.9.5" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ "rand_core", ] @@ -3384,7 +3435,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -3427,7 +3478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.7", "once_cell", ] @@ -3438,7 +3489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.7", "once_cell", ] @@ -3449,7 +3500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types 0.12.5", + "font-types 0.12.7", "once_cell", ] @@ -3473,9 +3524,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -3517,7 +3568,7 @@ dependencies = [ "js-sys", "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3726,14 +3777,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -3750,14 +3801,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -3769,7 +3820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3873,9 +3924,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -3912,7 +3963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ "bitflags 2.13.2", - "calloop 0.14.4", + "calloop 0.14.5", "calloop-wayland-source 0.4.1", "cursor-icon", "libc", @@ -4064,9 +4115,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4081,7 +4132,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4143,7 +4194,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4225,18 +4276,18 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" dependencies = [ "pin-project-lite", ] [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -4249,18 +4300,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime", @@ -4270,18 +4321,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tracing" @@ -4399,15 +4450,15 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -4512,9 +4563,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "js-sys", "serde_core", @@ -4523,18 +4574,20 @@ dependencies = [ [[package]] name = "vectorcraft" -version = "0.14.0" +version = "0.16.0" dependencies = [ "arboard", "block2 0.6.2", + "chrono", "clipboard-win", "craft-print", + "craft-window", "eframe", "egui", "image", "log", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-text", @@ -4555,12 +4608,13 @@ dependencies = [ "winit", "winresource", "winsafe", + "x11rb", "zbus", ] [[package]] name = "vectorcraft-affinity" -version = "0.14.0" +version = "0.16.0" dependencies = [ "crc32fast", "flate2", @@ -4571,7 +4625,7 @@ dependencies = [ [[package]] name = "vectorcraft-brush" -version = "0.14.0" +version = "0.16.0" dependencies = [ "kurbo 0.13.1", "serde", @@ -4583,7 +4637,7 @@ dependencies = [ [[package]] name = "vectorcraft-cad" -version = "0.14.0" +version = "0.16.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4599,7 +4653,7 @@ dependencies = [ [[package]] name = "vectorcraft-cli" -version = "0.14.0" +version = "0.16.0" dependencies = [ "log", "objc2-core-foundation", @@ -4621,7 +4675,7 @@ dependencies = [ [[package]] name = "vectorcraft-color" -version = "0.14.0" +version = "0.16.0" dependencies = [ "kurbo 0.13.1", "moxcms", @@ -4633,8 +4687,9 @@ dependencies = [ [[package]] name = "vectorcraft-doc" -version = "0.14.0" +version = "0.16.0" dependencies = [ + "chrono", "hayro-ccitt", "image", "jpeg-encoder", @@ -4651,7 +4706,7 @@ dependencies = [ [[package]] name = "vectorcraft-effects" -version = "0.14.0" +version = "0.16.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4667,7 +4722,7 @@ dependencies = [ [[package]] name = "vectorcraft-engine" -version = "0.14.0" +version = "0.16.0" dependencies = [ "craft-docpath", "crc32fast", @@ -4705,13 +4760,14 @@ dependencies = [ [[package]] name = "vectorcraft-eps" -version = "0.14.0" +version = "0.16.0" dependencies = [ "fax", "flate2", "hayro-ccitt", "image", "kurbo 0.13.1", + "memchr", "ruzstd", "serde_json", "vectorcraft-brush", @@ -4728,7 +4784,7 @@ dependencies = [ [[package]] name = "vectorcraft-format" -version = "0.14.0" +version = "0.16.0" dependencies = [ "flate2", "proptest", @@ -4743,7 +4799,7 @@ dependencies = [ [[package]] name = "vectorcraft-geom" -version = "0.14.0" +version = "0.16.0" dependencies = [ "kurbo 0.13.1", "proptest", @@ -4755,7 +4811,7 @@ dependencies = [ [[package]] name = "vectorcraft-mcp" -version = "0.14.0" +version = "0.16.0" dependencies = [ "image", "log", @@ -4774,7 +4830,7 @@ dependencies = [ [[package]] name = "vectorcraft-metafile" -version = "0.14.0" +version = "0.16.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4788,7 +4844,7 @@ dependencies = [ [[package]] name = "vectorcraft-pathops" -version = "0.14.0" +version = "0.16.0" dependencies = [ "kurbo 0.13.1", "linesweeper", @@ -4800,9 +4856,10 @@ dependencies = [ [[package]] name = "vectorcraft-pdf" -version = "0.14.0" +version = "0.16.0" dependencies = [ "aes", + "chrono", "craft-pdf", "flate2", "image", @@ -4830,7 +4887,7 @@ dependencies = [ [[package]] name = "vectorcraft-plugins" -version = "0.14.0" +version = "0.16.0" dependencies = [ "serde", "serde_json", @@ -4844,7 +4901,7 @@ dependencies = [ [[package]] name = "vectorcraft-render" -version = "0.14.0" +version = "0.16.0" dependencies = [ "flate2", "gif", @@ -4871,14 +4928,14 @@ dependencies = [ [[package]] name = "vectorcraft-scriptcheck" -version = "0.14.0" +version = "0.16.0" dependencies = [ "proptest", ] [[package]] name = "vectorcraft-svg" -version = "0.14.0" +version = "0.16.0" dependencies = [ "craft-docpath", "flate2", @@ -4903,7 +4960,7 @@ dependencies = [ [[package]] name = "vectorcraft-testkit" -version = "0.14.0" +version = "0.16.0" dependencies = [ "fax", "image", @@ -4921,7 +4978,7 @@ dependencies = [ [[package]] name = "vectorcraft-text" -version = "0.14.0" +version = "0.16.0" dependencies = [ "harfrust 0.13.3", "icu_segmenter", @@ -4935,13 +4992,14 @@ dependencies = [ "unicode-segmentation", "vectorcraft-doc", "vectorcraft-geom", + "vello_cpu 0.2.0", "windows-registry", "write-fonts", ] [[package]] name = "vectorcraft-three-d" -version = "0.14.0" +version = "0.16.0" dependencies = [ "vectorcraft-geom", "vectorcraft-pathops", @@ -4949,7 +5007,7 @@ dependencies = [ [[package]] name = "vectorcraft-tools" -version = "0.14.0" +version = "0.16.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4965,7 +5023,7 @@ dependencies = [ [[package]] name = "vectorcraft-trace" -version = "0.14.0" +version = "0.16.0" dependencies = [ "image", "kurbo 0.13.1", @@ -4977,14 +5035,16 @@ dependencies = [ [[package]] name = "vectorcraft-ui-egui" -version = "0.14.0" +version = "0.16.0" dependencies = [ + "bytemuck", "craft-ui", "egui", "egui_extras", "egui_kittest", "image", "log", + "rustix 1.1.5", "serde", "serde_json", "toml", @@ -5011,7 +5071,7 @@ dependencies = [ [[package]] name = "vectorcraft-web" -version = "0.14.0" +version = "0.16.0" dependencies = [ "eframe", "egui", @@ -5165,7 +5225,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -5180,12 +5240,12 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2608e8bb6d67fd68f5a8d0eb1363d6e7bcbc1f8ded5a0bd3a1e382462b876b22" +checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a" dependencies = [ "leb128fmt", - "wasmparser 0.261.0", + "wasmparser 0.262.0", ] [[package]] @@ -5240,9 +5300,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.261.0" +version = "0.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f20f20e44f7e8aeb6744823ea9d869ede51e51be4fdaedede2852282e54d2d8" +checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b" dependencies = [ "bitflags 2.13.2", "indexmap", @@ -5251,9 +5311,9 @@ dependencies = [ [[package]] name = "wast" -version = "261.0.0" +version = "262.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "776443145731a4062e5b0d392892a2005909b6ab72d9fdc3cad53dd1a714e44a" +checksum = "30612a0c29249b5f185888a107e8a519d920226b7d07f9d351fd094e9f75eb23" dependencies = [ "bumpalo", "leb128fmt", @@ -5264,9 +5324,9 @@ dependencies = [ [[package]] name = "wat" -version = "1.261.0" +version = "1.262.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7b4d1a49ea73a8f3326e74e3a05db667001b16bd1035ed3356fc1a0ed05ca7f" +checksum = "e228027f2b07bbaf946980ebbe2437fca73be6f54c40c2a8d4f42f277075f8d1" dependencies = [ "wast", ] @@ -5435,7 +5495,7 @@ dependencies = [ "jni", "log", "ndk-context", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "url", @@ -5576,7 +5636,7 @@ dependencies = [ "naga", "naga-types", "ndk-sys", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -5758,7 +5818,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5767,16 +5827,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5794,31 +5845,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5836,96 +5870,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -5949,7 +5935,7 @@ dependencies = [ "libc", "memmap2", "ndk", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", @@ -5989,9 +5975,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -6123,7 +6109,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.14.0" +version = "0.16.0" dependencies = [ "serde_json", ] @@ -6141,13 +6127,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6219,7 +6205,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -6227,9 +6213,9 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", @@ -6259,18 +6245,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.59" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6294,7 +6280,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -6330,7 +6316,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6362,9 +6348,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", @@ -6377,14 +6363,14 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -6397,6 +6383,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 30eff51df..f45b0b047 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.14.0" +version = "0.16.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -15,6 +15,9 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b77 craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per +# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } @@ -22,8 +25,9 @@ vectorcraft-doc = { path = "crates/doc" } vectorcraft-pathops = { path = "crates/pathops" } vectorcraft-brush = { path = "crates/brush" } vectorcraft-trace = { path = "crates/trace" } -# Without its default `bundled-fonts`: the engine turns them on (its own default feature), so a -# library crate never forces them on an embedder that supplies its fonts at run time. +# Without its default `bundled-fonts`: a crate that wants the fonts asks for them (the apps and +# vectorcraft-testkit through the engine's `bundled-fonts`), so a library crate never forces them on +# an embedder that supplies its fonts at run time. vectorcraft-text = { path = "crates/text", default-features = false } vectorcraft-effects = { path = "crates/effects" } vectorcraft-three-d = { path = "crates/three-d" } @@ -32,7 +36,9 @@ vectorcraft-svg = { path = "crates/svg" } vectorcraft-pdf = { path = "crates/pdf" } vectorcraft-format = { path = "crates/format" } vectorcraft-tools = { path = "crates/tools" } -vectorcraft-engine = { path = "crates/engine" } +# Without its default `bundled-fonts`, as vectorcraft-text: each crate that wants the fonts asks for +# them, so an embedder can build without them (docs/development.md › Leaving out the bundled fonts). +vectorcraft-engine = { path = "crates/engine", default-features = false } vectorcraft-ui-egui = { path = "crates/ui-egui" } vectorcraft-mcp = { path = "crates/mcp" } vectorcraft-testkit = { path = "crates/testkit" } @@ -50,6 +56,8 @@ thiserror = "2" serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } log = "0.4" +# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). +chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" crc32fast = "1.5" # tests: a TCP port bound without listening (connections to it are refused) @@ -83,6 +91,8 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } # CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through # `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version. hayro-ccitt = "0.3" +# Substring search for the EPS reader's PostScript `search`. +memchr = "2" jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] } gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] } # CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG @@ -114,11 +124,15 @@ todo = "deny" unimplemented = "deny" unreachable = "deny" +# Workspace crates keep line tables, so panics and backtraces name file and line; dependencies +# carry no debug info. Full debug info would make the dev target dir several times larger. [profile.dev.package."*"] opt-level = 2 +debug = false [profile.dev] opt-level = 1 +debug = "line-tables-only" [profile.release] lto = "thin" diff --git a/README.md b/README.md index 294824164..d85cef156 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@

+

+ VectorCraft icon: an engraved dragon on a red tile +

+

VectorCraft

@@ -202,10 +206,10 @@ the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayl (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)). On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's -pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same -preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is -attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see -[`docs/development.md`](docs/development.md#linux-wayland-and-x11)). +pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse and the app reads its +pressure: set the same preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use +XWayland only when a pen is attached as VectorCraft starts, and restart; or start the app with +`WAYLAND_DISPLAY= vectorcraft` (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)). ### Use it from Claude Code and other agents @@ -238,8 +242,9 @@ Pathfinder and Shape Builder, paint, gradients, appearance and transparency, typ files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package). Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping -and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the -import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft +and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master +pages, shadows, glows, outlines and overlays, embedded Affinity documents and images, with what didn't come in +(adjustments, blurs, bevels, brushes…) listed in the import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs. [Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md). @@ -253,7 +258,7 @@ The scores are self-assessed from our code against public documentation; - reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); -- most of the raster effects (11 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation and Stamp under Sketch) and the Effect Gallery; +- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); - CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface); - scripting, and the rest of Variables (image and graph kinds, dataset import); - generative AI features. diff --git a/ROADMAP.md b/ROADMAP.md index 8ae1cf14a..28c15f326 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-19 · **Change:** minor (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-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) 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 @@ -29,7 +29,7 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)). | | Value | Kind | |---|---|---| -| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 46/57, 3D 4/4 (initial types, no materials) | +| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) | | **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated | | **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated | | **To full parity** | **~360–590 h** one agent · ~95–165 h with 4–6 agents | estimated | @@ -75,8 +75,8 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so | Colour, swatches, gradients, patterns, mesh, recolor | 92% | 4–7 | | Strokes, brushes, width profiles | 88% | 8–15 | | Appearance, transparency, graphic styles, masks | 97% | 1–2 | -| Live vector effects | 85% | 6–10 | -| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 81% | 8–14 | +| Live vector effects | 85% | 4–8 | +| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 | | 3D and Materials | ~25% | 30–55 | | Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 | | Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 | @@ -123,21 +123,34 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap 2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h. 3. **Launch and stability on real machines** (G10): 20–35 h. 4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h. -5. **Raster effects** (G2) alongside: the 11 remaining filters (7 Artistic, 3 Sketch, 1 more) and the Effect Gallery, 8–14 h. +5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h. 6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). ## Progress log +- 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. +- 2026-10-23: COLR colour fonts (v0 and v1, as in Noto Color Emoji's vector build, Segoe UI Emoji, Nabla) draw as vector art: their layers' colours, gradients, clips and blend modes on the canvas and in SVG, EPS, legacy `.ai` and PDF, and as editable paths with Create Outlines; glyphs using sweep gradients or compositing modes are drawn as images of the glyph. +- 2026-10-22: 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. Filter thumbnails and a preview area inside the dialog remain ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). Raster effects 82% → 85% (estimated); headline numbers unchanged. +- 2026-10-22: Colour emoji fonts with bitmap glyphs draw and export: `sbix` (Apple Color Emoji, macOS's emoji font) and `CBDT` (bitmap Noto Color Emoji) glyphs show as their pictures on the canvas, whatever the type's fill, scale and turn with the type, and export to PDF (in the font's Type 3 glyphs, or as images), SVG (images when type is outlined; live text leaves emoji to the viewer), EPS and legacy `.ai` (images); Create Outlines turns them into embedded images. Fonts with bitmaps but no outlines, such as Noto Color Emoji, are now found among the installed fonts. COLR and OpenType SVG fonts remain; headline numbers unchanged. + +- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h). +- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged. +- 2026-10-22: PDF accessibility: titled exports set `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers show the document title rather than the file name and title checks can pass (#3646). Headline numbers unchanged. + - 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. - 2026-10-13: PngSuite downloads use pinned HTTPS files and a SHA-256 manifest; no archive extraction (#1729). | Date | What landed | |---|---| +| 2026-10-21 | 3D lighting: four original lighting presets shared by Revolve, Extrude & Bevel, Inflate and Rotate, with command access and manual Custom settings; scores and estimates unchanged. | +| 2026-10-21 | Print: the Print dialog's Media list has the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5 (which keep their ISO 216 dimensions for settings saved before them), はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm / 長形4号 90 × 225 mm / 角形2号 240 × 332 mm / 角形3号 216 × 277 mm and 四六判 127 × 188 mm. A paper is named by its JSON id or its dialog label, any case and spacing (`B5 (JIS)` or `b5jis`), and every added size prints as its own PDF page size. Headline numbers unchanged. | | 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. | | 2026-10-18 | Exported and saved files can repeat their bytes: PNG, PDF, EPS, legacy `.ai`, packaged zips and `.vectorcraft` saves recorded the wall-clock time they were made in, so the same commands run twice gave different bytes though the pixels matched. `SOURCE_DATE_EPOCH` now stands in for the clock in everything a file records, as the reproducible-builds specification defines it; a date a command is given still wins, and a malformed value is ignored rather than refusing the export. The recovery heartbeat and the seed PDF encryption draws on keep the real clock, so a password-protected PDF still differs between runs (#2880). Headline numbers unchanged. | | 2026-10-18 | About 85 community PRs landed in two batches through the full gate, with our fixes on top. New: Word `.docx` files place as area type with their formatting (#2766), CCITT Group 3 TIFF files open (#2770), Paragraph panel menu › Roman Hanging Punctuation (#2775), the Anchor Point tool edits blend spine points and handles (#2643, fixes #2544), shadows and glows each draw from the art with the effects before them, without the old exponential cost (#2395), one to ten coloured lights in 3D effects (#2706), Artboard Options X and Y with a reference point (#2739), more Contextual Task Bar actions (#2471), Optical Margin Alignment for a whole thread (#2773), History panel Step Backward/Forward and Set History Limit (#2764), Edit › Undo and Redo name the step as the History panel does (#2774), bounding-box handles snap to the grid, pixels and guides (#2715), Ctrl/Cmd+A in the Artboards panel (#2761), Tabs panel menu items (#2742), Import Data Sets defines variables from header cells (#2736), a Linux option to use X11 when a pen is attached (#2717). Fidelity and robustness: variable fonts named by their default weight and fallback glyphs keep bold and italic (#2733, #2732), legacy `.ai` layer names in Windows Latin-1 (#2722), WMF window mapping (#2721), Magic Wand inside isolation (#2720), type values out of range in files are clamped (#2728, #2757), raster effects stay responsive in drags (#2745), exports and Save for Web report undecodable images (#2762, #2737, #2684), Windows-safe exported file names (#2738), a marquee skips a Shaper Group's originals (#2772). Second batch: an export progress dialog with Cancel on the shared cancel token (#2564), CMYK gradient meshes drawn several times faster within a 16 MB cap (#2802), Paragraph panel menu › Justification… with file values clamped (#2771), Edit › Clear History (#2843), SVG image links keep their spelling (#2837, fixes main's Windows test failures), the Linux save dialog through the XDG portal (#2783), RGB input stored as CMYK in CMYK documents (#2812), legacy `.ai` export of images and radial highlights (#2801, #2807), `.ai` import notes for damaged editing data, page sizes and variable libraries (#2799, #2800, #2842), PDF and `.ai` keep their creation date (#2838), undecodable images left out of PDF exports with a note (#2790), MCP input waits for queue room (#2776), slice, snapping, selection, type and accessibility fixes (#2794–#2797, #2805, #2806, #2809, #2815, #2844–#2846), dropdowns close after a keyboard pick (#2824), plug-in dialogs scroll (#2840). Languages: the Dutch catalog completed (#2730). Headline numbers unchanged (estimated). | | 2026-10-19 | About 90 community PRs landed in batches through the full gate, with our fixes on top (formatting, catalog rows, small correctness fixes). New: Objects on Path (#3066), Object › Path › Simplify's slider with Auto-Simplify and More Options (#2555), Pencil Edit Selected Paths redraws the middle of a selected path (#2905), Pencil Tool Options' Alt key toggles to Smooth Tool (#2685), 3D solids built from each stroke's outline (#3049), Live Paint Selection Tool Options (#3052), Locate Selected Objects in the Layers panel (#3033), Snap to Unit for tab stops (#3039), DXF circles and arcs as native entities (#2962), Targa files (#2765), a Georgian interface (#2045), Traditional Chinese status and error messages (#2784), the web page's `window.vectorcraft` control channel (#2943), `select.color` (#2939), reproducible export bytes with `SOURCE_DATE_EPOCH` (#2947), Spacing Guides on resizes (#2966) and measurement labels on more tools. Fixed: number fields step to the next whole unit (#2916), a click away from edited type makes no new text (#2922), inch rulers in halves (#2951), Recent cards in New Document (#2904), Japanese W-weights in SVG (#2896), the point-type baseline marker (#2897) and the Reshape tool's falloff (#2900). Headline numbers unchanged. | +| 2026-10-21 | Affinity import (AF.5), brought over from DesignCraft's (storytold/designcraft#291): master pages show on the pages that use them (a locked layer at the bottom); tables open as a text frame per cell and a line per edge; each paragraph keeps its alignment, indents, spacing and leading; frame text takes its text scale, not its frame's; drop shadows and outer glows become live effects, outlines strokes, colour and gradient overlays are baked into the paint; embedded Affinity documents open as editable content, and their artboards no longer become artboards; large pixel layers come in at a reduced resolution; text placed as Affinity places it (lines of differently aligned paragraphs, a frame's first baseline at cap height, last lines whose descenders hang out of the frame), underlines and strikethrough, line breaks inside paragraphs, table cells taller than their row, no stroke on shapes whose stroke style is None, CMYK pixel layers (phone photos in CMYK documents) shown through the document's own CMYK profile instead of the profile-free formula and its blue cast, outlines on stroked shapes. Headline numbers unchanged. Hostile files stay bounded (one budget for the whole read, embedded documents included; overlay work; master page copies), covered by the import fuzzer and limit tests. | | 2026-10-17 | Background Export shows a progress dialog: the file being exported, the artboard count and a bar, Cancel (Esc) and Continue in Background. It runs on the shared cancel token of #2546, so a cancelled export writes no file and keeps no half-drawn pattern tile or effect raster; translated in every language with complete menus (#2564). Headline numbers unchanged. | | 2026-10-17 | About 145 community PRs landed in six batches, each through the full gate, plus our fixes on top. Crashes and hostile input: an image's RGBA size is checked from its header before decoding, without overflowing, in the renderer, sampling, recoloring and Crop Image (#2442, #2468, #2407), PDF Add Around and huge art are clamped (#2440), graph axis labels past i64, `+` in hex colours and XML ids are handled (#2610, #2470, #2447), Document Raster Effects resolutions open in range (#2579), Batch names stay in their folder (#2504). Regressions: floating panel groups are opaque again while the docked card keeps its rounded corners (#2499), the Wayland pointer returns over panels (#2502). New: MCP cancels long exports, answers while one runs and reports progress (#2546), `doc_inspect` takes ids, kind and paging (#2543), raster export of a page rectangle (#2511), `text.create` returns bounds (#2478), PDF presets and ICC profiles persist (#2547), screen-reader names for widgets (#2611), Expand on Intertwine (#2622, fixes #2540), Duplicate/Copy/Cut of several artboards (#2567), print presets by name (#2607), TSV data sets (#2608), planar TIFF (#2598), Alt-click a swatch onto a gradient stop (#2623), Shift-click ranges in Links, Graphic Styles and Libraries (#2612, #2613, #2618), Space+Shift rotates the view (#2601), Presentation Mode keys (#2583), Small Caps and super/subscript buttons (#2508). Fidelity: editable vector art from Illustrator EPS (#2571), `.ai` Drop Shadow Darkness (#2513), overprint preview with Darken (#2514), Generic CMYK black round-trips (#2503), PNG sRGB chunk (#2519), kerning at a caret (#2518), tab widths in justified area type (#2487), Transform copies keep their own gradients (#2486), smooth Zig Zag (#2475), Live Corners round up to their neighbours (#2484), kerning across style runs (#2554), SVG letter spacing on justified lines (#2574), Dimension tool snapping (#2482), gradient chips (#2495), Transform panel flips and percentages (#2400, #2404). Later the same day: PostScript `.ps` and `.epsf` files open (#2461), EPS images with CCITTFax compression draw (#2445), Illustrator 3 EPS without layers (#2436), `.ai` type opacity and blend modes (#2420), envelope meshes and compound holes in `.ai` (#2410, #2444), CMYK image soft masks in PDF export (#2448), Show Transparency Grid saved with the document (#2488), Save Selection in the Layers panel menu (#2483), Character and Paragraph Reset Panel (#2479, #2438), Cmd/Ctrl+Shift double-click releases a guide (#2467), Shift+Enter keeps a field active (#2431), Shift keeps reshaped handles perpendicular (#2425), emptied type keeps its style (#2464), Smart Blur radius (#2443), freeform gradients recolour (#2397), mesh slivers (#2456), Recolor wheel handles in CMYK (#2454), and six Image Trace fixes (#2387, #2584, #2586–#2589). Fifth batch: Optical Margin Alignment (#2563), Window › Blend panel (#2573), graph column designs (#2406), Live Paint Gap Options (#2424), the SVG Interactivity panel (#2437), RTF placed as area type (#2524), the Touch Type tool (#2469), pattern fills expand into clipped tiles (#2616), Save and Export ask before replacing a file an added extension names (#2619), the Artboard tool's Control bar and Contextual Task Bar (#2559, #2556), Alt-click New Artboard for its options (#2496), Shift-click in Brushes (#2562), the Move dialog remembers the last move (#2542), PDF export uses the document's assigned profiles (#2536), Printer Setup opens the selected printer's preferences on Windows (#2577). Sixth batch: Effect › Artistic › Cutout, Neon Glow and Poster Edges (#2453), View › Rulers › Change to Global / Artboard Rulers (#2416), Symbol Options 9-slice guides (#2512), Create Gradient Mesh on an embedded image (#2538), Rotate View cursors and the Mac trackpad rotation gesture (#2594, #2644), choice parameters as dropdowns in the inline effect editor (#2582), Group Selection picks and moves one path of a compound path (part of #2441), Shift-click ranges in the Artboards panel (#2626), Lock Variables (#2631), a layer dropped under the last row goes to the bottom (#2632), and robustness work: huge selections draw bounded overlays (#2635, #2645, part of #2460), values out of range in presets and files are clamped (#2656), whole-number checks for counts (#2638, #2659), exports warn about overset type (#2628), symbol instances measured by their art (#2614), colour-managed Eyedropper in CMYK documents (#2664). Later: Effect › Rasterize (#2409), variable-font axis sliders in the Character panel (#2710), Image Trace Create Strokes on an exact distance transform with Guo–Hall thinning and a skeleton graph (#2703–#2705), Auto Color keeps rare colours (#2702), large images trace with a warning (#2700), a 1e9 pt input bound on Reshape Segment, Convert Anchor Point and the Pencil (#2709), task-bar Connect, Corner and Smooth for selected anchors (#2697), PDF 0 w strokes import as hairlines (#2696), threading text from the out port (#2713, part of #2572), a panel per swatch library with Persistent (#2714, #2603), installed plug-ins kept across restarts (#2695, #2541). Languages: Dutch, Danish and Indonesian complete (#2393), Simplified Chinese status and error messages (#2578), translated file filters and presets (#2590). Headline numbers unchanged (estimated). | | 2026-10-16 | Symbol Options › Export Type (Graphic or Movie Clip, default Movie Clip) with Registration; `symbol.options` / `symbol.list` (G5). @@ -185,6 +198,14 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. | +| 2026-10-23 | minor | Progress log row for OpenType SVG colour fonts; headline numbers unchanged. | +| 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. | +| 2026-10-22 | minor | G2: the Effect Gallery shipped (#2883): Effect › Effect Gallery… browses the Artistic, Brush Strokes, Distort, Sketch, Stylize and Texture filters, edits each filter's settings, builds an ordered stack (add, reorder, hide, remove) with a live canvas preview, and OK applies it as separate appearance effects in one undo step; remaining: filter thumbnails and a preview area inside the dialog; G2 estimate 4–6 → 1–2 h; Raster effects 82% → 85% (power-user view 49% → 52%), estimated, a modest move because the named missing dialog landed without thumbnails or an in-dialog preview. | +| 2026-10-22 | minor | Progress log row for colour emoji fonts with bitmap glyphs; headline numbers unchanged. | +| 2026-10-22 | minor | Effects: Sketch › Conté Crayon; measured Photoshop-style filters 57/57, the Effect Gallery remains (G2 estimate 4–6 h). | +| 2026-10-21 | minor | 3D: original Standard, Diffuse, Top Left and Right lighting presets; command access, effective-setting recognition and preview/history; scores and estimates unchanged | +| 2026-10-21 | minor | Print: Japanese paper sizes in the Print dialog's Media list (JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号, 四六判); ISO B4/B5 keep their sizes, and a paper is named by id or label, any case and spacing. Headline numbers unchanged. | | 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. | | 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged | | 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged | diff --git a/apps/vectorcraft-cli/Cargo.toml b/apps/vectorcraft-cli/Cargo.toml index c31998d00..3827ab1eb 100644 --- a/apps/vectorcraft-cli/Cargo.toml +++ b/apps/vectorcraft-cli/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } -vectorcraft-engine = { workspace = true } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } vectorcraft-format = { workspace = true } vectorcraft-geom = { workspace = true } vectorcraft-svg = { workspace = true } diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 96cc2d0fb..10a859ac5 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -1,8 +1,8 @@ //! `vectorcraft-cli`: VectorCraft from the command line. //! //! ```text -//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR] -//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2] +//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... +//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] //! vectorcraft-cli commands //! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit] //! vectorcraft-cli info FILE @@ -68,19 +68,24 @@ const USAGE: &str = "\ vectorcraft-cli — VectorCraft automation USAGE: - vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR] + vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 (vectorcraft --control 7979), falling back to a headless in-process session. --automation-read-root / --automation-write-root confine the files the agent's commands read (open, place, relink, library loads…) and write (save, export, package, library saves…) to - DIR, links followed; a root left out grants none of its access. They imply --headless: a - running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …). + DIR, links followed; each may be given more than once, a path inside any of its folders being + allowed, and a kind left out grants none of its access. They imply --headless: a running app + enforces its own (vectorcraft --control PORT --automation-read-root DIR …). - vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N] + vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] Headless batch: open FILE (any readable format) or start a new document, run commands in order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE is a template, written as file.saveAsTemplate writes it). --scale sizes raster exports (pixels per point, default 1); other formats leave it out. Prints one JSON result per step. + --commands reads steps from FILE, or from standard input for -, where it stands among the + --cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for + \"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps. + Errors name the step, counting from 0 as \"$N\" references do. vectorcraft-cli commands Print the command catalogue as JSON. @@ -96,8 +101,9 @@ USAGE: SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate precision (1–7, default 3). OUT may not be IN when reading IN left things out (hidden text, art or layers it could not read; the notes say which) unless - --replace-lossy is given. --editing-data opens a .ai from its editing copy even where that - draws differently from its PDF part (which can be older), as long as it can be read. The + --replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy + even where that draws differently from its PDF part or page (which can be older), as long as + it can be read. The result has the export's warnings, and importWarnings for what reading IN left out. --ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file per artboard, OUT-.ai when there are several; default pdf, PDF-compatible), with @@ -176,7 +182,7 @@ fn with_stores(mut h: Headless) -> Headless { fn mcp(args: &[String]) -> Result<(), String> { let mut connect: Option = None; let mut headless = false; - let (mut read_root, mut write_root): (Option, Option) = (None, None); + let (mut read_roots, mut write_roots): (Vec, Vec) = (vec![], vec![]); let mut it = args.iter(); while let Some(a) = it.next() { // The roots also come as `--flag=DIR`, as the other Craft apps take them. @@ -189,10 +195,7 @@ fn mcp(args: &[String]) -> Result<(), String> { "--headless" => headless = true, "--automation-read-root" | "--automation-write-root" => { let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?; - let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root }; - if slot.replace(dir).is_some() { - return Err(format!("{flag} is given twice: it takes one folder")); - } + if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) } } other => return Err(format!("unknown mcp option `{other}`")), } @@ -200,7 +203,9 @@ fn mcp(args: &[String]) -> Result<(), String> { if headless && connect.is_some() { return Err("use either --connect or --headless".into()); } - let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?; + let read: Vec<&Path> = read_roots.iter().map(Path::new).collect(); + let write: Vec<&Path> = write_roots.iter().map(Path::new).collect(); + let roots = AutomationRoots::new(&read, &write)?; if roots.is_some() && connect.is_some() { return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control --automation-read-root

--automation-write-root `" .into()); @@ -208,8 +213,7 @@ fn mcp(args: &[String]) -> Result<(), String> { let backend: Box = if let Some(roots) = roots { // Every thread of this process, not only the server's: nothing reaches past the roots. file_access::confine_process(roots.clone()); - let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); - eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + eprintln!("vectorcraft-cli: files confined: {}", roots.describe()); Box::new(Headless::with_document().with_automation_roots(Some(roots))) } else if headless { Box::new(with_stores(Headless::with_document())) @@ -337,28 +341,127 @@ enum Step { Export(String), } +/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input. +const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024; +/// Most steps one `run --commands` takes. +const MAX_COMMANDS_STEPS: usize = 100_000; + +/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past +/// [`MAX_COMMANDS_BYTES`]. +fn read_commands(source: &str) -> Result { + use std::io::Read; + let mut bytes = vec![]; + let read = if source == "-" { + std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes) + } else { + std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)) + }; + read.map_err(|e| format!("--commands {source}: {e}"))?; + if bytes.len() as u64 > MAX_COMMANDS_BYTES { + return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024))); + } + String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text")) +} + +/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params": +/// {…}}` objects (`"id"` may stand for `"command"`, as in MCP's `command_batch`), or one such object +/// per line (JSON Lines, blank lines skipped). `first` is the run's index of the first of them +/// (the `N` of `"$N"`), which an error names with the entry's place in the text. +fn parse_commands(source: &str, text: &str, first: usize) -> Result, String> { + // Notepad on Windows starts UTF-8 files with a byte order mark. + let text = text.strip_prefix('\u{feff}').unwrap_or(text); + let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps"); + // Each entry with where it is in the text: its line for JSON Lines. + let mut entries: Vec<(Option, Value)> = vec![]; + if text.trim_start().starts_with('[') { + let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?; + let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) }; + if items.len() > MAX_COMMANDS_STEPS { + return Err(too_many()); + } + entries.extend(items.into_iter().map(|v| (None, v))); + } else { + for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) { + if entries.len() == MAX_COMMANDS_STEPS { + return Err(too_many()); + } + let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?; + entries.push((Some(n + 1), v)); + } + } + entries + .into_iter() + .enumerate() + .map(|(k, (line, v))| { + command_step(v).map_err(|e| { + let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}")); + format!("--commands {source}: step {} ({place}): {e}", first + k) + }) + }) + .collect() +} + +/// The step of one `--commands` entry, `{"command": ID, "params": {…}}`. +fn command_step(v: Value) -> Result { + let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"select.all\", \"params\": {}}".into()) }; + let id = match (o.remove("command"), o.remove("id")) { + (Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id, + (Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()), + (None, None) => return Err("no `command`".into()), + _ => return Err("`command` must be a string, a command id".into()), + }; + let params = match o.remove("params") { + None | Some(Value::Null) => json!({}), + Some(p @ Value::Object(_)) => p, + Some(_) => return Err(format!("`params` of {id} must be a JSON object")), + }; + if let Some(key) = o.keys().next() { + return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`")); + } + Ok(Step::Cmd(id, params)) +} + fn run(args: &[String]) -> Result<(), String> { let mut input: Option = None; let mut steps: Vec = vec![]; + // The step of the `--cmd` just given, which a `--params` sets. + let mut params_for: Option = None; + let mut stdin_read = false; let mut scale = 1.0; let mut it = args.iter(); while let Some(a) = it.next() { let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}")); match a.as_str() { "--in" | "-i" => input = Some(val("a file")?), - "--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))), + "--cmd" | "-c" => { + steps.push(Step::Cmd(val("a command id")?, json!({}))); + params_for = Some(steps.len() - 1); + } "--params" | "-p" => { let raw = val("JSON")?; let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?; if !p.is_object() { return Err(format!("--params must be a JSON object, got {raw}")); } - match steps.last_mut() { + match params_for.and_then(|i| steps.get_mut(i)) { Some(Step::Cmd(_, params)) => *params = p, _ => return Err("--params must follow a --cmd".into()), } } - "--export" | "-o" => steps.push(Step::Export(val("a file")?)), + "--commands" => { + let source = val("a file, or - for standard input")?; + if source == "-" && std::mem::replace(&mut stdin_read, true) { + return Err("--commands - is given twice: standard input is read once".into()); + } + let text = read_commands(&source)?; + let read = parse_commands(&source, &text, steps.len())?; + steps.extend(read); + params_for = None; + } + "--export" | "-o" => { + steps.push(Step::Export(val("a file")?)); + params_for = None; + } "--scale" | "-s" => { let raw = val("a number")?; scale = raw.parse::().map_err(|_| format!("--scale {raw}: not a number"))?; @@ -393,10 +496,12 @@ fn run(args: &[String]) -> Result<(), String> { // Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`). let mut results = vec![]; for step in steps { + // The step's index, as `"$N"` names its result. + let n = results.len(); let r = match step { Step::Cmd(id, params) => { - let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("{id}: {e}"))?; - let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?; + let params = vectorcraft_engine::steps::resolve(¶ms, &results).map_err(|e| format!("step {n} ({id}): {e}"))?; + let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("step {n} ({id}): {e}"))?; emit(json!({"step": "cmd", "command": id, "result": r}))?; r } @@ -406,7 +511,7 @@ fn run(args: &[String]) -> Result<(), String> { } else { h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale)) }; - let r = r.map_err(|e| format!("export {path}: {e}"))?; + let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?; emit(json!({"step": "export", "result": r}))?; r } diff --git a/apps/vectorcraft-cli/src/perf.rs b/apps/vectorcraft-cli/src/perf.rs index 3aa1db6a4..d760cfefe 100644 --- a/apps/vectorcraft-cli/src/perf.rs +++ b/apps/vectorcraft-cli/src/perf.rs @@ -23,13 +23,16 @@ impl Rng { } } +/// The synthetic document's page, in points. +const SYNTHETIC_PAGE: (f64, f64) = (1600.0, 1200.0); + /// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page. pub fn synthetic(n: usize) -> Result { - let mut d = Document::new(1600.0, 1200.0); + let mut d = Document::new(SYNTHETIC_PAGE.0, SYNTHETIC_PAGE.1); let l = d.layers.first().map(|l| l.id); let mut r = Rng(42); for i in 0..n { - let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0); + let (x, y, s) = (r.next() * SYNTHETIC_PAGE.0, r.next() * SYNTHETIC_PAGE.1, 3.0 + r.next() * 22.0); let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32); let id = d.alloc_id(); let mut node = match i % 3 { @@ -282,7 +285,7 @@ fn deep(n: usize) -> Result<(), String> { let key = format!("photo {i}"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo)); let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0); - let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() }; + let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default(), pixels: None }; let id = d.alloc_id(); d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?; } @@ -296,6 +299,27 @@ fn deep(n: usize) -> Result<(), String> { rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms)); note("after rendering the photos with 3 renderers"); } + // An SVG with one embedded picture opened from its text, as picture-heavy SVGs come: the + // importer's passes over the text before usvg reads it, most of which is the data URL. The + // picture is the synthetic page drawn at 1.25x (about 2 MB as a PNG; the row names its size); + // the hidden rectangle is a marker, so the prepass walks the tree as it does for a file that + // has one, instead of returning at its first check. + { + let k = 1.25; + let (w, h) = ((SYNTHETIC_PAGE.0 * k).round() as u32, (SYNTHETIC_PAGE.1 * k).round() as u32); + let photo = Renderer::new().render(&synthetic(3000)?, w, h, Affine::scale(k), &opts).to_png()?; + let data = vectorcraft_svg::base64_encode(&photo); + let svg = format!( + r#""# + ); + // One import checked first, so a failure is reported rather than timed; the same text + // imports the same way each time. Its document is kept for the memory reading. + let imported = vectorcraft_svg::import_with_report(&svg).map_err(|e| e.to_string())?; + let ms = median_ms(5, || drop(vectorcraft_svg::import_with_report(&svg))); + rows.push((format!("import SVG, one embedded {} KB PNG", photo.len() / 1024), ms)); + note("after the import of an embedded picture"); + drop(imported); + } // Output hashes, to compare builds: the SVG export and a save → open → save round trip. let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3)); let doc = synthetic(n)?; diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/it/cli.rs similarity index 72% rename from apps/vectorcraft-cli/tests/cli.rs rename to apps/vectorcraft-cli/tests/it/cli.rs index b376a139f..e06444b39 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/it/cli.rs @@ -1,5 +1,3 @@ -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::Write; use std::process::{Command, Stdio}; @@ -84,11 +82,125 @@ fn scale_sizes_raster_exports_only() { fn run_reports_errors() { let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap(); assert!(!out.status.success()); - assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing")); + let stderr = String::from_utf8_lossy(&out.stderr).to_string(); + assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}"); let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap(); assert!(!out.status.success()); } +/// The JSON lines `run` printed. +fn json_lines(stdout: &[u8]) -> Vec { + String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect() +} + +/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them +/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a +/// placed file's `dataBase64` longer than a Windows command line holds fits. +#[test] +fn run_reads_steps_from_a_commands_file() { + let steps = tmp("steps.json"); + let svg = tmp("steps.svg"); + // Base64 of an SVG well past 32,767 characters, the most a Windows command line holds. + let padding = "x".repeat(40_000); + let placed = format!( + r##""## + ); + let data = vectorcraft_format::base64_encode(placed.as_bytes()); + assert!(data.len() > 32_767); + let file = json!([ + {"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}}, + {"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}}, + {"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}}, + ]); + std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap(); + let out = Command::new(BIN) + .args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()]) + .args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()]) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let lines = json_lines(&out.stdout); + let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect(); + assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]); + // `$2` in the file and in the `--cmd` after it is the placed art. + assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]); + let written = std::fs::read_to_string(&svg).unwrap(); + assert!(written.contains("118833") && written.contains("barcode"), "{written}"); +} + +/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and +/// CRLF line ends are fine, and the first step may be `file.new`. +#[test] +fn run_reads_json_lines_from_standard_input() { + let svg = tmp("stdin.svg"); + let mut child = Command::new(BIN) + .args(["run", "--commands", "-", "--export", svg.to_str().unwrap()]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n"; + child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let lines = json_lines(&out.stdout); + assert_eq!(lines.len(), 4); + assert_eq!(lines[0]["command"], "file.new"); + assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff")); +} + +/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from +/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is +/// refused; a step that fails names its number. +#[test] +fn run_refuses_malformed_commands_naming_the_step() { + let fails = |args: &[&str]| { + let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap(); + assert!(!out.status.success(), "{args:?}"); + (String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len()) + }; + let bad = |name: &str, text: &[u8], wanted: &[&str]| { + let path = tmp(name); + std::fs::write(&path, text).unwrap(); + let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]); + assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}"); + assert_eq!(printed, 0, "{name}: nothing runs"); + }; + bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]); + bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]); + bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]); + bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]); + bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]); + bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]); + bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]); + bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["step 2 (line 3)"]); + bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]); + bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]); + let many = "{\"command\":\"select.all\"}\n".repeat(100_001); + bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]); + // Past 64 MiB (a sparse file: nothing is written). + let huge = tmp("huge.json"); + std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap(); + let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]); + assert!(e.contains("more than 64 MiB"), "{e}"); + let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]); + assert!(e.contains("missing.json"), "{e}"); + // `--params` sets the `--cmd` just before it, never a step of the file. + let ok = tmp("ok.json"); + std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap(); + let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]); + assert!(e.contains("--params must follow a --cmd"), "{e}"); + let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]); + assert!(e.contains("standard input is read once"), "{e}"); + // A step that fails is named by its number, after the steps before it ran. + let failing = tmp("failing.json"); + std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap(); + let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]); + assert!(e.contains("step 2 (nope.nothing)"), "{e}"); + assert_eq!(printed, 2); +} + #[test] fn mcp_headless_over_stdio() { let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); @@ -130,6 +242,58 @@ fn mcp_automation_roots_in_a_folder_an_earlier_run_left() { automation_roots(&empty_dir(dir).join("roots")); } +/// #3546: each root flag may be given more than once: a path inside any of the read roots opens, +/// one inside any of the write roots is written, and everything else, a write root's sibling read +/// root included, is refused. +#[test] +fn mcp_automation_roots_take_several_folders() { + let base = tmp("several-roots"); + for d in ["in", "in2", "out", "out2", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let svg = r#""#; + for f in ["in/a.svg", "in2/b.svg", "outside/c.svg"] { + std::fs::write(base.join(f), svg).unwrap(); + } + let dir = |d: &str| base.join(d).to_string_lossy().into_owned(); + let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned(); + let mut child = Command::new(BIN) + .args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))]) + .args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}}); + let calls = [ + (call(1, "open_file", json!({"path": file("in", "a.svg")})), false), + (call(2, "open_file", json!({"path": file("in2", "b.svg")})), false), + (call(3, "open_file", json!({"path": file("outside", "c.svg")})), true), + (call(4, "export", json!({"path": file("out", "a.png")})), false), + (call(5, "export", json!({"path": file("out2", "b.png")})), false), + (call(6, "export", json!({"path": file("outside", "c.png")})), true), + (call(7, "export", json!({"path": file("in2", "d.png")})), true), + (call(8, "open_file", json!({"path": file("out2", "b.png")})), true), + ]; + { + let mut stdin = child.stdin.take().unwrap(); + for (m, _) in &calls { + writeln!(stdin, "{m}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("files confined: read ") && stderr.contains("; write "), "{stderr}"); + let replies: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + let errors: Vec = replies.iter().map(|r| r["result"]["isError"] == true).collect(); + assert_eq!(errors, calls.iter().map(|(_, e)| *e).collect::>(), "{replies:?}"); + assert!(replies[2]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read roots"), "{}", replies[2]); + assert!(base.join("out/a.png").is_file() && base.join("out2/b.png").is_file()); + assert!(!base.join("outside/c.png").exists() && !base.join("in2/d.png").exists()); +} + fn automation_roots(base: &std::path::Path) { for d in ["in", "out", "outside"] { std::fs::create_dir_all(base.join(d)).unwrap(); @@ -153,7 +317,7 @@ fn automation_roots(base: &std::path::Path) { assert!(!out.status.success() && err.contains(why), "{args:?}: {err}"); }; fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root"); - fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice"); + fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root"); fails(&["mcp", "--automation-write-root"], "needs a folder"); fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots"); diff --git a/apps/vectorcraft-cli/tests/fileio.rs b/apps/vectorcraft-cli/tests/it/fileio.rs similarity index 97% rename from apps/vectorcraft-cli/tests/fileio.rs rename to apps/vectorcraft-cli/tests/it/fileio.rs index 4de10d932..104aeb0fe 100644 --- a/apps/vectorcraft-cli/tests/fileio.rs +++ b/apps/vectorcraft-cli/tests/it/fileio.rs @@ -1,6 +1,4 @@ //! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::process::Command; diff --git a/apps/vectorcraft-cli/tests/it/main.rs b/apps/vectorcraft-cli/tests/it/main.rs new file mode 100644 index 000000000..6918ea19b --- /dev/null +++ b/apps/vectorcraft-cli/tests/it/main.rs @@ -0,0 +1,7 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod cli; +mod fileio; +mod mcp_e2e; diff --git a/apps/vectorcraft-cli/tests/mcp_e2e.rs b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs similarity index 98% rename from apps/vectorcraft-cli/tests/mcp_e2e.rs rename to apps/vectorcraft-cli/tests/it/mcp_e2e.rs index e75ef0940..5a353070a 100644 --- a/apps/vectorcraft-cli/tests/mcp_e2e.rs +++ b/apps/vectorcraft-cli/tests/it/mcp_e2e.rs @@ -1,6 +1,4 @@ //! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; diff --git a/apps/vectorcraft-web/Cargo.toml b/apps/vectorcraft-web/Cargo.toml index f67a377ef..211bfdb18 100644 --- a/apps/vectorcraft-web/Cargo.toml +++ b/apps/vectorcraft-web/Cargo.toml @@ -14,7 +14,7 @@ name = "vectorcraft-web" path = "src/main.rs" [dependencies] -vectorcraft-engine = { workspace = true } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } vectorcraft-ui-egui = { workspace = true } vectorcraft-format = { workspace = true } vectorcraft-text = { workspace = true } diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 345968911..86235afbb 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -17,13 +17,17 @@ accessibility = ["eframe/accesskit"] windows7 = ["eframe/glow"] [dependencies] -vectorcraft-engine = { workspace = true } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } vectorcraft-ui-egui = { workspace = true } vectorcraft-text = { workspace = true } +# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app) +# so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217. +craft-window = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] } egui = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +chrono = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } @@ -47,8 +51,10 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o # DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as # Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit. +# 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. [target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies] -windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] } +windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_WindowsAndMessaging"] } [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 @@ -74,6 +80,10 @@ pollster = "1" [target.'cfg(target_os = "linux")'.dependencies] # GNOME/Wayland needs client-side window controls without a bundled title font. winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] } +# A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a +# second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build +# through winit with the `xinput` feature. +x11rb = { version = "0.13", default-features = false, features = ["xinput"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), # so Automatic draws on that one. Both are already in the build through winit. diff --git a/apps/vectorcraft/src/automation_args.rs b/apps/vectorcraft/src/automation_args.rs index 26157b76d..979f42e7b 100644 --- a/apps/vectorcraft/src/automation_args.rs +++ b/apps/vectorcraft/src/automation_args.rs @@ -6,29 +6,29 @@ use std::ffi::OsString; use std::path::PathBuf; pub struct AutomationArgs { - pub read_root: Option, - pub write_root: Option, + pub read_roots: Vec, + pub write_roots: Vec, read_given: bool, write_given: bool, } impl AutomationArgs { + /// The roots the environment gives (`VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`), until + /// the command line gives its own. pub fn new(read_root: Option, write_root: Option) -> Self { - Self { read_root, write_root, read_given: false, write_given: false } + Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect(), read_given: false, write_given: false } } - /// Returns whether this argument is a root flag. CLI values override environment defaults - /// once; a second occurrence is an error, including when the two spellings are mixed. + /// Returns whether this argument is a root flag. Each flag may be given more than once (#3546), + /// in either spelling: the first replaces the environment's root of its kind, the others add + /// folders. pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); - let (root, given) = match flag { - "--automation-read-root" => (&mut self.read_root, &mut self.read_given), - "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + let (roots, given) = match flag { + "--automation-read-root" => (&mut self.read_roots, &mut self.read_given), + "--automation-write-root" => (&mut self.write_roots, &mut self.write_given), _ => return Ok(false), }; - if *given { - return Err(format!("{flag} given twice")); - } let value = match inline { Some(value) => value.to_owned(), None => { @@ -42,8 +42,10 @@ impl AutomationArgs { if value.is_empty() { return Err(format!("{flag} needs a non-empty folder")); } - *root = Some(value.into()); - *given = true; + if !std::mem::replace(given, true) { + roots.clear(); + } + roots.push(value.into()); Ok(true) } } @@ -97,17 +99,24 @@ mod tests { use super::*; #[test] - fn roots_override_environment_once_and_leave_other_arguments() { - let mut roots = AutomationArgs::new(Some("environment".into()), None); + fn roots_override_environment_and_add_up() { + let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into())); let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter(); assert!(roots.consume("--automation-read-root", &mut args).unwrap()); assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); assert!(!roots.consume("--in-window-menus", &mut args).unwrap()); - assert_eq!(roots.read_root, Some("a folder".into())); - assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(roots.read_roots, [PathBuf::from("a folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("out")]); assert_eq!(args.next().as_deref(), Some("drawing.svg")); - assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); - assert_eq!(roots.read_root, Some("a folder".into())); + // #3546: a flag given again adds a folder, in either spelling. + assert!(roots.consume("--automation-read-root=other", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]); + assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]); + // A kind the command line leaves alone keeps the environment's root. + let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into())); + assert!(roots.consume("--automation-read-root=a", &mut std::iter::empty()).unwrap()); + assert_eq!(roots.write_roots, [PathBuf::from("environment out")]); } #[test] @@ -117,11 +126,15 @@ mod tests { let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); let mut args = operand.map(str::to_owned).into_iter(); assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); - assert_eq!(roots.read_root, Some("read".into())); - assert_eq!(roots.write_root, Some("write".into())); + assert_eq!(roots.read_roots, [PathBuf::from("read")]); + assert_eq!(roots.write_roots, [PathBuf::from("write")]); } let mut roots = AutomationArgs::new(None, None); assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); + // A bad second folder is an error too, and keeps the first. + assert!(roots.consume(&format!("{flag}=first"), &mut std::iter::empty()).unwrap()); + assert!(roots.consume(flag, &mut std::iter::empty()).is_err()); + assert_eq!([roots.read_roots.len(), roots.write_roots.len()].iter().sum::(), 1); } } @@ -130,8 +143,8 @@ mod tests { let mut roots = AutomationArgs::new(None, None); assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); - assert_eq!(roots.read_root, Some("-folder".into())); - assert_eq!(roots.write_root, Some("./-folder".into())); + assert_eq!(roots.read_roots, [PathBuf::from("-folder")]); + assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]); } fn control(args: &[&str]) -> Result, String> { diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index 165c0a81d..957d302d5 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -143,6 +143,13 @@ fn copied_file(paths: Vec, mimes: &[&'static str]) -> Option { Some(p) if p.ends_with('\r') => PathBuf::from(p.trim_end_matches('\r')), _ => path, }; + // A paste sent by automation reads only what its roots allow. + match path.to_str() { + Some(p) => vectorcraft_engine::file_access::check_read(p).ok()?, + // A lossy name would check another file than the one opened. + None if vectorcraft_engine::file_access::current().is_none() => {} + None => return None, + } let mut head = Vec::with_capacity(FILE_HEAD); std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?; let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?; diff --git a/apps/vectorcraft/src/linux_stylus.rs b/apps/vectorcraft/src/linux_stylus.rs new file mode 100644 index 000000000..f86a9cc53 --- /dev/null +++ b/apps/vectorcraft/src/linux_stylus.rs @@ -0,0 +1,116 @@ +//! Linux pen tablets: winit reports barrel buttons as [`winit::event::MouseButton::Other`] +//! (for example `BTN_STYLUS`), and egui-winit drops those before they reach the UI (#3632). + +use eframe::AppCreator; +use winit::application::ApplicationHandler; +use winit::event::{DeviceEvent, DeviceId, StartCause, WindowEvent}; +use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; +use winit::window::WindowId; + +use eframe::UserEvent; + +/// `BTN_STYLUS` and neighbours (`linux/input-event-codes.h`): pen barrel buttons Wayland passes +/// through the pointer protocol as non-standard mouse buttons. +const BTN_STYLUS: u16 = 0x014b; +const BTN_STYLUS2: u16 = 0x014c; +const BTN_STYLUS3: u16 = 0x014d; + +/// Like [`eframe::run_native`], but turns pen barrel buttons into mouse buttons egui understands. +pub fn run_native(app_name: &str, mut native_options: eframe::NativeOptions, app_creator: AppCreator<'_>) -> eframe::Result { + let mut builder = EventLoop::::with_user_event(); + if let Some(hook) = native_options.event_loop_builder.take() { + hook(&mut builder); + } + let event_loop = builder.build().map_err(eframe::Error::WinitEventLoop)?; + event_loop.set_control_flow(ControlFlow::Poll); + let inner = eframe::create_native(app_name, native_options, app_creator, &event_loop); + let mut app = Forwarder { inner }; + event_loop.run_app(&mut app).map_err(eframe::Error::WinitEventLoop)?; + Ok(()) +} + +struct Forwarder<'a> { + inner: eframe::EframeWinitApplication<'a>, +} + +impl ApplicationHandler for Forwarder<'_> { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + self.inner.resumed(event_loop); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) { + self.inner.window_event(event_loop, window_id, map_window_event(event)); + } + + fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) { + self.inner.new_events(event_loop, cause); + } + + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { + self.inner.user_event(event_loop, event); + } + + fn device_event(&mut self, event_loop: &ActiveEventLoop, device_id: DeviceId, event: DeviceEvent) { + self.inner.device_event(event_loop, device_id, event); + } + + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + self.inner.about_to_wait(event_loop); + } + + fn suspended(&mut self, event_loop: &ActiveEventLoop) { + self.inner.suspended(event_loop); + } + + fn exiting(&mut self, event_loop: &ActiveEventLoop) { + self.inner.exiting(event_loop); + } + + fn memory_warning(&mut self, event_loop: &ActiveEventLoop) { + self.inner.memory_warning(event_loop); + } +} + +/// Map a tablet button code winit labels `MouseButton::Other` to a standard mouse button. +pub(crate) fn map_tablet_button(code: u16) -> Option { + use winit::event::MouseButton; + match code { + BTN_STYLUS | BTN_STYLUS2 => Some(MouseButton::Right), + BTN_STYLUS3 => Some(MouseButton::Middle), + _ => None, + } +} + +fn map_window_event(event: WindowEvent) -> WindowEvent { + if let WindowEvent::MouseInput { device_id, state, button: winit::event::MouseButton::Other(code), .. } = event + && let Some(button) = map_tablet_button(code) + { + WindowEvent::MouseInput { device_id, state, button } + } else { + event + } +} + +#[cfg(test)] +mod tests { + use super::*; + use winit::event::{ElementState, MouseButton}; + + #[test] + fn stylus_barrel_buttons_become_secondary_clicks() { + assert_eq!(map_tablet_button(BTN_STYLUS), Some(MouseButton::Right)); + assert_eq!(map_tablet_button(BTN_STYLUS2), Some(MouseButton::Right)); + assert_eq!(map_tablet_button(BTN_STYLUS3), Some(MouseButton::Middle)); + assert_eq!(map_tablet_button(0x110), None); + } + + #[test] + fn window_mouse_input_is_remapped_before_eframe() { + let device_id = winit::event::DeviceId::dummy(); + let press = WindowEvent::MouseInput { device_id, state: ElementState::Pressed, button: MouseButton::Other(BTN_STYLUS) }; + let mapped = map_window_event(press); + assert!(matches!(mapped, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Pressed, .. })); + let release = map_window_event(WindowEvent::MouseInput { device_id, state: ElementState::Released, button: MouseButton::Other(BTN_STYLUS2) }); + assert!(matches!(release, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Released, .. })); + } +} diff --git a/apps/vectorcraft/src/logging.rs b/apps/vectorcraft/src/logging.rs index cd2ea1eba..a9fded642 100644 --- a/apps/vectorcraft/src/logging.rs +++ b/apps/vectorcraft/src/logging.rs @@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool { } /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so +/// logging never panics and never silently resets to the epoch for a convertible instant. pub fn timestamp(t: SystemTime) -> String { let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); - let secs = since.as_secs(); - let (days, rem) = (secs / 86_400, secs % 86_400); - let (y, m, d) = civil_from_days(days); - format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) + // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value + // past `i64::MAX` seconds saturates rather than falling back to the epoch. + let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX); + let (y, t) = utc_parts(secs, since.subsec_nanos()); + // `{:04}` rather than `%Y`, which writes a `+` before years past 9999. + format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ")) } -/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's -/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock -/// value can overflow it. -fn civil_from_days(days: u64) -> (u64, u64, u64) { - let z = days.saturating_add(719_468); - let era = z / 146_097; - let doe = z % 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); - (y, m, d) +/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into +/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days), +/// so month, day and time of day come from chrono and only the year is carried, which keeps every +/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic. +fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) { + use chrono::Datelike; + let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999))); + match (date, time) { + (Some(d), Some(t)) => { + let y = i128::from(d.year()) + i128::from(cycles) * 400; + (i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t)) + } + _ => (1970, chrono::NaiveDateTime::default()), // unreachable + } } fn numbered(dir: &Path, n: usize) -> PathBuf { @@ -387,6 +395,20 @@ mod tests { assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); // Before the epoch (a clock set wrong) clamps instead of panicking. assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + // Past chrono's year range still converts via the 400-year cycle (the old code did too, + // through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows' + // SystemTime can't hold this instant, so it is only checked where it exists. + // `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output). + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) { + assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z"); + } + // Years past 9999 keep the plain zero-padded year (no `+`). + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z"); + // 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857. + // Windows' SystemTime holds only up to about year 30828, so this is checked where it fits. + if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) { + assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z"); + } } #[test] diff --git a/apps/vectorcraft/src/mac_save_format.rs b/apps/vectorcraft/src/mac_save_format.rs index 519c98097..fe2097157 100644 --- a/apps/vectorcraft/src/mac_save_format.rs +++ b/apps/vectorcraft/src/mac_save_format.rs @@ -26,9 +26,7 @@ use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePan use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL}; use raw_window_handle::{HasWindowHandle, RawWindowHandle}; use vectorcraft_engine::cmd::fileio; -use vectorcraft_ui_egui::{FilePick, i18n}; - -type Filter = (&'static str, &'static [&'static str]); +use vectorcraft_ui_egui::{FileFilter as Filter, FilePick, i18n}; /// The accessory's height and the room around and between its label and menu, in points. const HEIGHT: f64 = 38.0; @@ -70,7 +68,8 @@ define_class!( /// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than /// two remain, and the panel is then left to rfd. fn menu_formats(filters: &[Filter]) -> Vec { - let named: Vec = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect(); + let named: Vec = + filters.iter().filter(|(_, extensions)| !extensions.is_empty() && !extensions.iter().any(|x| x == "*")).cloned().collect(); if named.len() < 2 { Vec::new() } else { named } } @@ -95,10 +94,10 @@ fn filename(name: &str, extension: &str, filters: &[Filter]) -> String { /// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds /// the first one to a name typed without an extension, and a name typed with another of them is /// saved in that format. -fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> { - let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions); +fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&str> { + let first = filters.get(selected).map_or(&[][..], |(_, extensions)| extensions.as_slice()); let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter()); - first.iter().chain(others).copied().collect() + first.iter().chain(others).map(String::as_str).collect() } /// Set `panel`'s allowed file types ([`allowed_types`]). @@ -184,26 +183,28 @@ fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec, s mod tests { use super::*; - const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])]; + fn filters() -> Vec { + FilePick::filters([("VectorCraft", &["vectorcraft"][..]), ("SVG", &["svg"]), ("PDF", &["pdf"])]) + } /// Save, Save As and Save a Copy list every save format with the default first; a panel with one /// named type, or one for every file, gets no menu, and rfd shows it. #[test] fn save_panels_with_several_formats_get_the_menu() { - let save = menu_formats(&fileio::save_filters("vectorcraft")); + let save = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft"))); assert!(save.len() >= 2, "{save:?}"); - assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..])); - assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg"))); - assert!(menu_formats(&fileio::template_filters("template")).len() >= 2); - assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty()); - assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty()); - assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2); + assert_eq!(save.first().map(|(_, e)| e.as_slice()), Some(&["vectorcraft".to_owned()][..])); + assert!(menu_formats(&FilePick::filters(fileio::save_filters("svg"))).first().is_some_and(|(_, e)| e.iter().any(|e| e == "svg"))); + assert!(menu_formats(&FilePick::filters(fileio::template_filters("template"))).len() >= 2); + assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..])])).is_empty()); + assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("Any", &[])])).is_empty()); + assert_eq!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("SVG", &["svg"])])).len(), 2); } #[test] fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() { for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] { - assert_eq!(initial_format(name, FILTERS), index, "{name}"); + assert_eq!(initial_format(name, &filters()), index, "{name}"); } } @@ -219,7 +220,7 @@ mod tests { ("photo.png", "photo.svg"), ("", ""), ] { - assert_eq!(filename(name, "svg", FILTERS), expected, "{name}"); + assert_eq!(filename(name, "svg", &filters()), expected, "{name}"); } } @@ -227,27 +228,19 @@ mod tests { /// type. The others stay allowed. #[test] fn the_chosen_format_is_the_first_allowed_type() { - assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]); - assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]); - assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]); - assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]); + assert_eq!(allowed_types(&filters(), 0), ["vectorcraft", "svg", "pdf"]); + assert_eq!(allowed_types(&filters(), 1), ["svg", "vectorcraft", "pdf"]); + assert_eq!(allowed_types(&filters(), 2), ["pdf", "vectorcraft", "svg"]); + assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]); } /// Each entry of the Save As menu gives a name the save command reads as that entry's format. #[test] fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() { - let filters = menu_formats(&fileio::save_filters("vectorcraft")); + let filters = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft"))); for (label, extensions) in &filters { - let name = filename("Untitled-1.vectorcraft", extensions[0], &filters); - assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}"); - } - } - - /// The menu's label is translated in every language with complete menus. - #[test] - fn the_label_is_translated() { - for lang in i18n::Lang::all().filter(|l| l.complete_menus()) { - assert!(i18n::has(lang, "Format:"), "{}", lang.code()); + let name = filename("Untitled-1.vectorcraft", &extensions[0], &filters); + assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}"); } } } diff --git a/apps/vectorcraft/src/mac_tablet.rs b/apps/vectorcraft/src/mac_tablet.rs new file mode 100644 index 000000000..b624262aa --- /dev/null +++ b/apps/vectorcraft/src/mac_tablet.rs @@ -0,0 +1,275 @@ +//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a +//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's +//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]). +//! +//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with +//! each event it is about to dispatch" in this app: +//! ) +//! reads each mouse and tablet event on the main thread before winit's view handles it, and passes +//! it on unchanged: +//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure` +//! (0..1) goes into the feed, kept for the pointer move winit makes of the event +//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event +//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`. +//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move. +//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says +//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so, +//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck +//! (`NSPointingDeviceTypeCursor`) is a mouse. +//! +//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which +//! merges the drags that queue up between two reads into one: every report of the pen arrives. +//! +//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the +//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor +//! reads them. +#![cfg_attr(not(target_os = "macos"), allow(dead_code))] + +use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; + +/// `NSEventType` values (AppKit headers). +mod event_type { + pub const TABLET_POINT: usize = 23; + pub const TABLET_PROXIMITY: usize = 24; + /// Left, right and other mouse down, up, moved and dragged: winit makes a pointer move of each. + pub const MOUSE: [usize; 10] = [1, 2, 3, 4, 5, 6, 7, 25, 26, 27]; +} + +/// `NSEventSubtype` values of mouse events. +mod subtype { + pub const TABLET_POINT: i16 = 1; + pub const TABLET_PROXIMITY: i16 = 2; +} + +/// `NSPointingDeviceType` values. +mod device { + pub const CURSOR: usize = 2; + pub const ERASER: usize = 3; +} + +/// The tablet fields of one `NSEvent`, as plain values. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct RawEvent { + /// `type`. + kind: usize, + /// `subtype` (mouse events only). + subtype: i16, + /// `pressure` (tablet point events and mouse events of that subtype only). + pressure: f32, + /// `pointingDeviceType` (proximity events only). + device: usize, + /// `isEnteringProximity` (proximity events only). + entering: bool, +} + +impl RawEvent { + fn mouse(&self) -> bool { + event_type::MOUSE.contains(&self.kind) + } + + fn proximity(&self) -> bool { + self.kind == event_type::TABLET_PROXIMITY || (self.mouse() && self.subtype == subtype::TABLET_PROXIMITY) + } + + fn point(&self) -> bool { + self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT) + } +} + +/// The events the monitor reads (`NSEventMask` bits are `1 << type`). +fn event_mask() -> u64 { + event_type::MOUSE.iter().chain(&[event_type::TABLET_POINT, event_type::TABLET_PROXIMITY]).fold(0, |m, t| m | 1u64 << t) +} + +/// What an event means for the feed. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Update { + /// Nothing: an event the monitor doesn't map. + Keep, + /// The pressure and end of a pen, or `None` for a mouse. + Set(Option), + /// A pen's sample that comes with a pointer move. + Move(PenSample), +} + +impl Update { + fn apply(self, feed: &StylusFeed) { + match self { + Update::Keep => {} + Update::Set(sample) => feed.set_sample(sample), + Update::Move(sample) => feed.set_move(sample), + } + } +} + +/// Which end of the pen is near the tablet. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Pen { + eraser: bool, +} + +impl Pen { + fn handle(&mut self, e: &RawEvent) -> Update { + if e.proximity() { + // Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse. + self.eraser = e.entering && e.device == device::ERASER; + let pen = e.entering && e.device != device::CURSOR; + return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser })); + } + let sample = PenSample { pressure: e.pressure, eraser: self.eraser }; + match (e.point(), e.mouse()) { + (true, true) => Update::Move(sample), + (true, false) => Update::Set(Some(sample)), + (false, true) => Update::Set(None), + (false, false) => Update::Keep, + } + } +} + +/// Start reading pen pressure: the feed the monitor writes into, or `None` (logged) when AppKit +/// refuses the monitor or this isn't the main thread. +#[cfg(target_os = "macos")] +pub fn start() -> Option { + // The monitor writes on the main thread before winit hands the event on: a press never waits. + let feed = StylusFeed::on_ui_thread(); + match monitor::install(feed.clone()) { + Ok(()) => Some(feed), + Err(e) => { + log::warn!("tablet pressure: {e}"); + None + } + } +} + +#[cfg(target_os = "macos")] +mod monitor { + use std::cell::Cell; + use std::ptr::NonNull; + + use block2::RcBlock; + use objc2::MainThreadMarker; + use objc2_app_kit::{NSEvent, NSEventMask}; + use vectorcraft_ui_egui::stylus::StylusFeed; + + use super::{Pen, RawEvent, event_mask}; + + /// Install the monitor for the life of the app. Main thread only. + pub(super) fn install(feed: StylusFeed) -> Result<(), &'static str> { + MainThreadMarker::new().ok_or("the event monitor can only be installed from the main thread")?; + let pen = Cell::new(Pen::default()); + let handler = RcBlock::new(move |event: NonNull| -> *mut NSEvent { + // SAFETY: AppKit calls the handler with the event it is about to dispatch, which it + // keeps alive while the handler runs. + #[allow(unsafe_code)] + let e = unsafe { event.as_ref() }; + let mut p = pen.get(); + p.handle(&read(e)).apply(&feed); + pen.set(p); + event.as_ptr() + }); + // SAFETY: the handler returns the event it was given, a valid pointer ("the block's return + // must be a valid pointer or null"). AppKit copies the block and keeps it while the monitor + // is installed. + #[allow(unsafe_code)] + let token = unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask(event_mask()), &handler) }; + // The monitor stays until the app quits: its token is never handed to `removeMonitor:`. + std::mem::forget(token.ok_or("AppKit refused the event monitor")?); + NSEvent::setMouseCoalescingEnabled(false); + Ok(()) + } + + /// The tablet fields of `e`. `subtype` raises an exception for events without one, and the + /// pressure and proximity fields are read only for the events that define them. + fn read(e: &NSEvent) -> RawEvent { + let mut raw = RawEvent { kind: e.r#type().0, ..Default::default() }; + if raw.mouse() { + raw.subtype = e.subtype().0; + } + if raw.point() { + raw.pressure = e.pressure(); + } + if raw.proximity() { + raw.device = e.pointingDeviceType().0; + raw.entering = e.isEnteringProximity(); + } + raw + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const DRAG: usize = 6; + const KEY_DOWN: usize = 10; + + fn pen(kind: usize, pressure: f32) -> RawEvent { + RawEvent { kind, subtype: subtype::TABLET_POINT, pressure, ..Default::default() } + } + + fn proximity(device: usize, entering: bool) -> RawEvent { + RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() } + } + + fn tip(pressure: f32) -> PenSample { + PenSample { pressure, eraser: false } + } + + #[test] + fn a_pens_mouse_events_are_moves_with_its_pressure() { + let mut p = Pen::default(); + assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5))); + assert_eq!(p.handle(&pen(1, 0.25)), Update::Move(tip(0.25)), "a press"); + assert_eq!(p.handle(&pen(event_type::TABLET_POINT, 0.8)), Update::Set(Some(tip(0.8))), "no move"); + } + + #[test] + fn other_mouse_events_are_a_mouse_and_other_events_are_kept() { + let mut p = Pen::default(); + for subtype in [0, 3, 99, -1] { + let e = RawEvent { kind: DRAG, subtype, pressure: 1.0, ..Default::default() }; + assert_eq!(p.handle(&e), Update::Set(None), "subtype {subtype}"); + } + for kind in [0, KEY_DOWN, 22, 29, 34, usize::MAX] { + // Other events' subtypes don't make them a pen's. + let e = RawEvent { kind, subtype: subtype::TABLET_POINT, ..Default::default() }; + assert_eq!(p.handle(&e), Update::Keep, "type {kind}"); + } + } + + #[test] + fn proximity_says_which_end_is_near() { + let mut p = Pen::default(); + let eraser = |pressure| PenSample { pressure, eraser: true }; + assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0)))); + assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4))); + assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left"); + assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4))); + assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip"); + assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip"); + assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse"); + let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() }; + assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype"); + } + + #[test] + fn the_feed_gets_each_moves_pressure_within_0_to_1() { + let feed = StylusFeed::default(); + let mut p = Pen::default(); + for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] { + p.handle(&e).apply(&feed); + } + assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3))); + p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed); + assert_eq!(feed.held(), None, "a mouse moved"); + } + + #[test] + fn the_mask_covers_the_mapped_events() { + let m = event_mask(); + for t in [1, 2, 3, 4, 5, 6, 7, 23, 24, 25, 26, 27] { + assert!(m & (1 << t) != 0, "{t}"); + } + assert_eq!(m & (1 << KEY_DOWN), 0, "keys are not read"); + } +} diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index a457d9808..79e4c1929 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -1,6 +1,6 @@ //! VectorCraft desktop app. //! -//! Usage: `vectorcraft [--control [--automation-read-root ] [--automation-write-root ]] +//! Usage: `vectorcraft [--control [--automation-read-root ]... [--automation-write-root ]...] //! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`) //! //! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on @@ -30,6 +30,8 @@ mod control_server; mod cursor_theme; #[cfg(feature = "wgpu")] mod gpu; +#[cfg(target_os = "linux")] +mod linux_stylus; mod logging; #[cfg(target_os = "macos")] mod mac_fonts; @@ -39,6 +41,7 @@ mod mac_menu; mod mac_save_format; #[cfg(target_os = "macos")] mod mac_screen_color; +mod mac_tablet; #[cfg(target_os = "macos")] mod mac_window; #[cfg(target_os = "macos")] @@ -49,8 +52,10 @@ mod prefs_dir; mod printing; #[cfg(all(windows, not(target_vendor = "win7")))] mod system_fonts; +mod tablet; mod window; mod window_frame; +mod windows_stylus; mod xwayland; use vectorcraft_engine::Session; @@ -70,6 +75,12 @@ struct App { frames: u64, /// Keeps the custom title bar's window borderless (Windows, #1758). window_frame: Option, + /// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635). + winit_window: Option>, + /// A saved or fitted geometry asked for maximize once the window is mapped (Linux). + pending_maximize: bool, + /// Frames left to retry [`window::apply_pending_maximize`]. + maximize_tries: u8, } impl eframe::App for App { @@ -89,6 +100,9 @@ impl eframe::App for App { if self.app.services.native_menu.is_some() { mac_menu::raw_input_hook(raw); } + // A pen contact with the barrel button held presses the secondary button (Windows Ink). + #[cfg(all(windows, not(target_vendor = "win7")))] + windows_stylus::raw_input_hook(raw); file_access::unconfined(|| self.app.raw_input_hook(raw)); } #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] @@ -144,14 +158,22 @@ impl App { // again on its document when it answers. #[cfg(target_os = "macos")] if !self.app.file_dialog_open() { - // Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs - // again on its document when it answers. - if !self.app.file_dialog_open() { - open_files(&mut self.app, open_documents::take()); + let files = open_documents::take(); + if !files.is_empty() { + bring_window_forward(ctx); } + open_files(&mut self.app, files); } self.app.logic(ctx); - window::track(ctx, &mut self.app.ui.window); + if self.pending_maximize { + if let Some(w) = &self.winit_window { + self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries); + } else { + self.pending_maximize = false; + } + } + let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized()); + window::track(ctx, &mut self.app.ui.window, host_maximized); if self.app.ui.status == "quit" { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } @@ -174,6 +196,14 @@ fn end_before_teardown(frames: u64) { } } +/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back +/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769). +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn bring_window_forward(ctx: &egui::Context) { + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); +} + /// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that /// can't be opened is reported to the user and on stderr; the others still open. fn open_files(app: &mut VectorcraftApp, files: Vec) { @@ -251,10 +281,13 @@ fn decode_saved_prefs(bytes: &[u8]) -> Option { let mut value: Value = serde_json::from_slice(bytes).ok()?; let object = value.as_object_mut()?; recover(object, "docking_hidden"); - if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") { - for workspace in workspaces { - if let Some(object) = workspace.as_object_mut() { - recover(object, "dockingHidden"); + // Saved workspaces and the layout changes kept for each workspace carry the same layouts. + for key in ["custom_workspaces", "workspace_changes"] { + if let Some(Value::Array(workspaces)) = object.get_mut(key) { + for workspace in workspaces { + if let Some(object) = workspace.as_object_mut() { + recover(object, "dockingHidden"); + } } } } @@ -293,7 +326,7 @@ type Parent = Option>; trait DialogSettings: Sized { fn parent(self, window: &winit::window::Window) -> Self; fn title(self, title: &str) -> Self; - fn filter(self, name: &str, exts: &[&str]) -> Self; + fn filter(self, name: &str, exts: &[impl ToString]) -> Self; fn folder(self, folder: &str) -> Self; fn file_name(self, name: &str) -> Self; } @@ -307,7 +340,7 @@ macro_rules! dialog_settings { fn title(self, title: &str) -> Self { self.set_title(title) } - fn filter(self, name: &str, exts: &[&str]) -> Self { + fn filter(self, name: &str, exts: &[impl ToString]) -> Self { self.add_filter(name, exts) } fn folder(self, folder: &str) -> Self { @@ -333,10 +366,9 @@ fn dialog_for(d: D, request: &PickRequest) -> D { } PickRequest::Folder => return d, }; - let d = pick - .filters - .iter() - .fold(d, |d, (name, exts)| d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts)); + let d = pick.filters.iter().fold(d, |d, (name, exts)| { + d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts) + }); let d = match &pick.folder { Some(folder) => d.folder(folder), None => d, @@ -528,6 +560,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti fn services(parent: Parent, ui: egui::Context) -> Services { use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle}; let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_))); + // A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764). + #[cfg(target_os = "linux")] + let x11 = parent + .as_ref() + .and_then(|w| w.window_handle().ok()) + .is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_))); let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent); #[cfg(target_os = "macos")] let sampled_ui = ui.clone(); @@ -541,6 +579,11 @@ fn services(parent: Parent, ui: egui::Context) -> Services { dialogs_are_sheets: cfg!(target_os = "macos"), wayland, no_cursor_theme: wayland && cursor_theme::theme_missing(), + #[cfg(target_os = "linux")] + stylus: if x11 { tablet::start() } else { None }, + // macOS reads it from AppKit's events (#852). + #[cfg(target_os = "macos")] + stylus: mac_tablet::start(), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), write: Some(Box::new(write_file)), exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())), @@ -629,13 +672,73 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String { /// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`). /// macOS keeps its traffic lights over the integrated title strip. +/// +/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the +/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe. +#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos"); -/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences › -/// User Interface › System Title Bar asks for the system's. Read from the saved preferences -/// before the window opens; a missing or unreadable value keeps the default. +/// Whether the user asked for the system title bar in Preferences › User Interface › System Title +/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is +/// `false` (the default). This preference forces the desktop/native frame back on. +fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { + saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) +} + +/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate +/// decided and the user's System Title Bar preference. +/// +/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame +/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with +/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it +/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter), +/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly +/// when `craft-window` says the app owns the frame **and** the user has not opted into the system +/// title bar; the preference always forces the native frame back on. +/// +/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the +/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but +/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons +/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not +/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool { + matches!(frame, craft_window::Frame::App(_)) && !opted_into_system +} + +/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see +/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`); +/// Windows draws its own caption unless the user opted into the system title bar. +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool { + CUSTOM_TITLEBAR && !opted_into_system +} + +/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its +/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System +/// Title Bar preference. Call once at startup, before [`native_options`]. +/// +/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through +/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs +/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native +/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins. fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool { - CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false) + let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")); + let opted_into_system = opted_into_system_title_bar(saved); + #[cfg(target_os = "linux")] + { + let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str); + // `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window + // will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are + // present, the same two conditions the launch path checks below — that run is effectively + // X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so + // the probe's own decision (app-drawn on GNOME) must stand. + let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found(); + if choice.overridden().is_none() && forced_to_x11 { + return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system); + } + } + custom_titlebar_from(choice.frame(), opted_into_system) } fn native_options(custom_titlebar: bool) -> eframe::NativeOptions { @@ -667,7 +770,9 @@ Options: --control Start the localhost JSON-lines control server on (or VECTORCRAFT_CONTROL_PORT) --automation-read-root Confine the files control requests read to + (may be given more than once) --automation-write-root Confine the files control requests write to + (may be given more than once) --in-window-menus Keep the menus inside the window on macOS (or VECTORCRAFT_IN_WINDOW_MENUS=1) --version Print the version and exit @@ -716,7 +821,7 @@ fn main() -> std::process::ExitCode { _ => files.push(a), } } - let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args; + let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args; // After the arguments: `--control` and `--version` win over a bad environment value. let control_port = match control.port { Some(port) => Some(port), @@ -755,12 +860,14 @@ fn main() -> std::process::ExitCode { // The roots confine the control channel: a bad one keeps the app from starting rather than // leaving agents unconfined. Without a control port there is nothing to confine. let roots = if control_port.is_none() { - if read_root.is_some() || write_root.is_some() { + if !read_roots.is_empty() || !write_roots.is_empty() { log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control"); } None } else { - match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) { + let read: Vec<&std::path::Path> = read_roots.iter().map(std::path::PathBuf::as_path).collect(); + let write: Vec<&std::path::Path> = write_roots.iter().map(std::path::PathBuf::as_path).collect(); + match AutomationRoots::new(&read, &write) { Ok(roots) => roots, Err(e) => { log::error!("{e}"); @@ -832,6 +939,18 @@ fn main() -> std::process::ExitCode { } options }; + // With Windows Ink, winit reports a pen as a touch and drops its barrel button; the message + // hook notes each pointer contact and whether it starts with the barrel button held + // (`windows_stylus`). + #[cfg(all(windows, not(target_vendor = "win7")))] + let options = { + use winit::platform::windows::EventLoopBuilderExtWindows as _; + let mut options = options; + options.event_loop_builder = Some(Box::new(|builder| { + builder.with_msg_hook(windows_stylus::msg_hook); + })); + options + }; #[cfg(feature = "wgpu")] let options = { let mut options = options; @@ -876,9 +995,18 @@ fn main() -> std::process::ExitCode { #[cfg(feature = "wgpu")] let created = startup.clone(); // A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash. + let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| { + #[cfg(target_os = "linux")] + { + linux_stylus::run_native("VectorCraft", options, app_creator) + } + #[cfg(not(target_os = "linux"))] + { + eframe::run_native("VectorCraft", options, app_creator) + } + }; let outcome = vectorcraft_engine::guard::catch_panic(|| { - eframe::run_native( - "VectorCraft", + run( options, Box::new(move |cc| { let mut session = Session::new(); @@ -891,16 +1019,22 @@ fn main() -> std::process::ExitCode { // which takes over from the copy the preferences file used to hold. prefs_dir::attach_pdf_preset_store(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). - if let Some(w) = cc.winit_window() { - app.ui.window = Some(window::restore(w, saved_window)); + let winit_window = cc.winit_window().cloned(); + if let Some(w) = &winit_window { + let g = window::restore(w, saved_window); + app.ui.window = Some(g); #[cfg(windows)] set_taskbar_icon(w); } - // User Defined swatch and graphic style libraries live next to the preferences. + // Linux may not map the window before the first frames, so a saved maximize is retried then (#3635). + let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized); + // User Defined swatch, graphic style and symbol libraries live next to the preferences. let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string())); app.session.swatch_libraries.set_user_dir(swatches); let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string())); app.session.style_libraries.set_user_dir(styles); + let symbols = prefs_path().and_then(|p| Some(p.parent()?.join("Symbols").to_string_lossy().to_string())); + app.session.symbol_libraries.set_user_dir(symbols); // With the User Defined libraries listed, the persistent library panels reopen. vectorcraft_ui_egui::panels::library_panel::restore(&mut app); // So do the Libraries panel's libraries; runs without preferences (agents' test @@ -957,7 +1091,7 @@ fn main() -> std::process::ExitCode { // Everywhere the app doesn't say whose work it is (worker threads), the // roots hold: unconfined is only what the person at the keyboard does. file_access::confine_process(roots.clone()); - log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root()); + log::info!("control channel confined to the automation roots: {}", roots.describe()); } } #[cfg(target_os = "macos")] @@ -976,7 +1110,16 @@ fn main() -> std::process::ExitCode { created.ready(); // The content and the pointer stay aligned on Windows (#1758). let window_frame = window_frame::service(cc, custom_titlebar); - Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame })) + Ok(Box::new(App { + app, + graphics_loss, + graphics_lost: false, + frames: 0, + window_frame, + winit_window, + pending_maximize, + maximize_tries: 8, + })) }), ) }); @@ -1001,6 +1144,17 @@ mod tests { vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() } } + #[test] + fn opening_files_unminimizes_and_focuses_the_window() { + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx())); + // Nothing paints in the test: drop the font atlas upload egui expects to be applied. + out.textures_delta.clear(); + let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands; + assert!(cmds.contains(&egui::ViewportCommand::Minimized(false))); + assert!(cmds.contains(&egui::ViewportCommand::Focus)); + } + #[test] fn unreadable_saved_docking_keeps_preferences_and_every_workspace() { use serde_json::json; @@ -1010,6 +1164,7 @@ mod tests { app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"}); app.ui.custom_workspaces = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")]; + app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")]; let original = serde_json::to_value(&app.ui).unwrap(); let valid_location = original["docking_hidden"]["layers"].clone(); assert!(valid_location.is_object()); @@ -1020,6 +1175,8 @@ mod tests { raw["docking_hidden"] = invalid_hidden.clone(); raw["custom_workspaces"][0]["docking"] = invalid_layout.clone(); raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone(); + raw["workspace_changes"][0]["docking"] = invalid_layout.clone(); + raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone(); assert!(serde_json::from_value::(raw.clone()).is_err(), "runtime serde stays strict"); let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap(); assert_eq!(loaded.engine_prefs, app.ui.engine_prefs); @@ -1036,6 +1193,8 @@ mod tests { assert!(loaded.custom_workspaces[0].docking.is_none()); assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers")); assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]); + assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost"); + assert!(loaded.workspace_changes[0].docking.is_none()); } } let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap(); @@ -1048,17 +1207,41 @@ mod tests { #[test] fn the_system_title_bar_preference_keeps_the_window_decorations() { - // Preferences › User Interface › System Title Bar gives the window back its system - // decorations on Windows and Linux; macOS always has them. - assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default"); + use craft_window::{Frame, Layout}; + + // The System Title Bar preference (Preferences › User Interface) is read the same way on + // every platform; a missing or unreadable value is `false`. + assert!(!super::opted_into_system_title_bar(None), "no preferences: the default"); let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false})); - assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR); + assert!(!super::opted_into_system_title_bar(Some(&off))); let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true})); - assert!(!super::custom_titlebar(Some(&on))); - assert_eq!(super::native_options(false).viewport.decorations, Some(true)); - if super::CUSTOM_TITLEBAR { - assert_eq!(super::native_options(true).viewport.decorations, Some(false)); + assert!(super::opted_into_system_title_bar(Some(&on))); + + // The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title + // bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native + // decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws + // our own title bar — unless the user opted into the system title bar, which forces the + // native frame back on. + let app = Frame::App(Layout::default()); + let desktop = Frame::Desktop; + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own"); + assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native"); + assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame"); + assert!(!super::custom_titlebar_from(&desktop, true)); } + // macOS and Windows keep today's behavior, independent of the probed frame: the pref still + // forces the native frame back on where a custom one would otherwise be drawn. + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR); + assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows"); + assert!(!super::custom_titlebar_from(&desktop, true)); + } + + assert_eq!(super::native_options(false).viewport.decorations, Some(true)); + assert_eq!(super::native_options(true).viewport.decorations, Some(false)); } /// The file extensions the macOS bundle declares: its document types and its own exported type. diff --git a/apps/vectorcraft/src/portal_save.rs b/apps/vectorcraft/src/portal_save.rs index c374c8949..b6362107e 100644 --- a/apps/vectorcraft/src/portal_save.rs +++ b/apps/vectorcraft/src/portal_save.rs @@ -326,7 +326,11 @@ mod tests { let pick = FilePick { name: "Untitled-1.vectorcraft".into(), folder: Some("/home/me/Art".into()), - filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])], + filters: vec![ + FilePick::filter("VectorCraft", &["vectorcraft"]), + FilePick::filter("SVG", &["svg"]), + FilePick::filter("PDF-compatible .ai", &["ai"]), + ], }; SaveRequest::new(&pick, "x11:3a0000f".into()) } diff --git a/apps/vectorcraft/src/tablet.rs b/apps/vectorcraft/src/tablet.rs new file mode 100644 index 000000000..f2c1fd725 --- /dev/null +++ b/apps/vectorcraft/src/tablet.rs @@ -0,0 +1,417 @@ +//! Linux: a drawing tablet's pen pressure under X11, XWayland included (#764). winit 0.30 hands a +//! pen to the window there as a mouse, without its pressure, so the app reads the pressure from +//! XInput2 itself and writes it into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas +//! reads. +//! +//! The reader opens a second connection to the X server (x11rb's `RustConnection`, without +//! `unsafe` or libxcb) and selects XInput2 raw events for every device on the root window. Raw +//! events reach every client that selects them, also while winit holds the pointer grab of a +//! drag, and name the device that moved. A pointer device with an "Abs Pressure" valuator is a pen +//! (xf86-input-wacom, xf86-input-libinput and XWayland give a pen's pressure axis that label), +//! unless its XInput type is a touchpad's or a touchscreen's: its pressure, scaled from the +//! valuator's range to 0..1, goes into the feed. Any other pointer +//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so +//! the last pressure of each pen is kept. +//! +//! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains +//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's +//! "… Eraser (0x…)" of type `ERASER`, XWayland's "xwayland-tablet eraser:N"). Its samples go into +//! the feed marked as the eraser end, and the canvas switches to the Eraser tool +//! ([`vectorcraft_ui_egui::stylus`]). +//! +//! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland +//! that receives none of the window's input. +//! +//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`), with +//! the pressure axis only. +#![cfg_attr(not(target_os = "linux"), allow(dead_code))] + +use std::collections::HashMap; + +use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed}; + +/// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer +/// than 16). +const MAX_AXES: u16 = 64; + +/// Devices kept (a server could list thousands). +const MAX_DEVICES: usize = 256; + +/// The XInput types of finger devices (`XI_TOUCHPAD` and `XI_TOUCHSCREEN`): xf86-input-evdev labels +/// a touchpad's or a touchscreen's pressure axis "Abs Pressure" too, and they are not pens. +const FINGER_TYPES: &[&str] = &["TOUCHPAD", "TOUCHSCREEN"]; + +/// The "Abs Pressure" valuator of a pen: its number in a raw event's valuator mask, and its range. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Axis { + number: u16, + min: f64, + max: f64, +} + +impl Axis { + /// A raw value of this valuator as a pressure of 0..1; a value that is not a number is a + /// mouse's, 1. + fn pressure(&self, v: f64) -> f32 { + if !v.is_finite() { + return 1.0; + } + let span = self.max - self.min; + let unit = if span.is_finite() && span > 0.0 { (v - self.min) / span } else { v }; + unit.clamp(0.0, 1.0) as f32 + } +} + +/// A pointer device the X server lists. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Device { + id: u16, + /// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none. + pressure: Option, + /// The eraser end of a pen: its name or its XInput type contains "eraser", in any case. + eraser: bool, +} + +impl Device { + /// From what the X server lists for the device: its name and each valuator's number, label, + /// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`). + fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator) -> Self { + let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k)); + let pressure = valuators + .into_iter() + .filter(|(number, ..)| *number < MAX_AXES) + .find(|(_, label, ..)| label == "Abs Pressure") + .map(|(number, _, min, max)| Axis { number, min, max }) + .filter(|_| !finger); + let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser")); + Self { id, pressure, eraser } + } +} + +/// What a raw event means for the feed. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Update { + /// Nothing: a device the last listing didn't have (a master, or one added since). + Keep, + /// The pressure and end of a pen, or `None` for another pointer. + Set(Option), +} + +/// The pointer devices and the last pressure of each pen. +#[derive(Debug, Default)] +struct Pens { + devices: HashMap, + last: HashMap, +} + +impl Pens { + /// Replace the device list (at the start, and when devices come and go or change). + fn set_devices(&mut self, devices: impl IntoIterator) { + self.devices = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect(); + self.last.retain(|id, _| self.devices.contains_key(id)); + } + + /// A raw motion or button event of device `id`, with the valuators that changed. + fn handle(&mut self, id: u16, valuators: impl IntoIterator) -> Update { + let Some(device) = self.devices.get(&id) else { return Update::Keep }; + let Some(axis) = device.pressure else { return Update::Set(None) }; + let eraser = device.eraser; + let last = self.last.entry(id).or_insert(0.0); + for (number, v) in valuators { + if number == axis.number { + *last = axis.pressure(v); + } + } + Update::Set(Some(PenSample { pressure: *last, eraser })) + } + + /// A raw event as the X server sends it: its valuator mask and the values of the valuators + /// set in it. A pen's sample or another pointer's `None` goes into `feed`. + fn raw_event(&mut self, feed: &StylusFeed, id: u16, mask: &[u32], values: &[f64]) { + if let Update::Set(sample) = self.handle(id, valuators(mask, values)) { + feed.set_sample(sample); + } + } +} + +/// The values a raw event carries: the valuator numbers set in `mask` (32 to a word, lowest bit +/// first), each paired in order with one of `values`. Mask bits past the last value are ignored. +fn valuators<'a>(mask: &'a [u32], values: &'a [f64]) -> impl Iterator + 'a { + let numbers = mask + .iter() + .enumerate() + .flat_map(|(w, bits)| (0..32u32).filter(move |b| bits & (1 << b) != 0).map(move |b| w.saturating_mul(32).saturating_add(b as usize))); + numbers.zip(values.iter().copied()).filter_map(|(n, v)| u16::try_from(n).ok().map(|n| (n, v))) +} + +/// An X 32.32 fixed-point value. +fn fp3232(integral: i32, frac: u32) -> f64 { + f64::from(integral) + f64::from(frac) / 4_294_967_296.0 +} + +/// Start reading pen pressure for a window on X11: the feed the reader writes into, or `None` +/// (logged) when the X server can't be reached or has no XInput 2.1. +#[cfg(target_os = "linux")] +pub fn start() -> Option { + let feed = StylusFeed::default(); + match x11::spawn(feed.clone()) { + Ok(()) => Some(feed), + Err(e) => { + log::warn!("tablet pressure: {e}"); + None + } + } +} + +#[cfg(target_os = "linux")] +mod x11 { + use std::collections::HashMap; + + use vectorcraft_ui_egui::stylus::StylusFeed; + use x11rb::connection::{Connection as _, RequestConnection as _}; + use x11rb::protocol::Event; + use x11rb::protocol::xinput::{self, ConnectionExt as _, DeviceClassData, DeviceType, XIEventMask}; + use x11rb::protocol::xproto::ConnectionExt as _; + use x11rb::rust_connection::RustConnection; + + use super::{Device, MAX_AXES, MAX_DEVICES, Pens, fp3232}; + + /// `XIAllDevices`: every device, the slave devices that name a pen included. + const ALL_DEVICES: u16 = 0; + + /// Connect to `$DISPLAY` (the display winit opened the window on), check for XInput 2.1 (raw + /// events for every client that selects them), select the raw events, and read them on a + /// thread of its own until the connection closes. + pub fn spawn(feed: StylusFeed) -> Result<(), String> { + let (conn, screen) = RustConnection::connect(None).map_err(|e| format!("can't connect to the X server: {e}"))?; + let root = conn.setup().roots.get(screen).map(|s| s.root).ok_or_else(|| format!("X screen {screen} not found"))?; + let version = + conn.xinput_xi_query_version(2, 2).map_err(|e| e.to_string())?.reply().map_err(|e| format!("the X server has no XInput 2: {e}"))?; + if (version.major_version, version.minor_version) < (2, 1) { + return Err(format!("the X server has XInput {}.{}, and raw events need 2.1", version.major_version, version.minor_version)); + } + let mask = XIEventMask::RAW_MOTION + | XIEventMask::RAW_BUTTON_PRESS + | XIEventMask::RAW_BUTTON_RELEASE + | XIEventMask::HIERARCHY + | XIEventMask::DEVICE_CHANGED; + conn.xinput_xi_select_events(root, &[xinput::EventMask { deviceid: ALL_DEVICES, mask: vec![mask] }]) + .map_err(|e| e.to_string())? + .check() + .map_err(|e| format!("XISelectEvents failed: {e}"))?; + let mut pens = Pens::default(); + pens.set_devices(query_devices(&conn)); + std::thread::Builder::new() + .name("vectorcraft-tablet-x11".into()) + .spawn(move || run(&conn, &mut pens, &feed)) + .map(drop) + .map_err(|e| format!("can't start the tablet thread: {e}")) + } + + fn run(conn: &RustConnection, pens: &mut Pens, feed: &StylusFeed) { + // A connection error (the display went away) ends the loop; an event that x11rb cannot + // parse is skipped, and `Pens` bounds what the rest can hold. + while let Ok(raw) = conn.wait_for_raw_event() { + let Ok(event) = conn.parse_event(&raw) else { continue }; + match event { + Event::XinputRawMotion(e) | Event::XinputRawButtonPress(e) | Event::XinputRawButtonRelease(e) => { + let values: Vec = e.axisvalues.iter().map(|v| fp3232(v.integral, v.frac)).collect(); + pens.raw_event(feed, e.deviceid, &e.valuator_mask, &values); + } + Event::XinputHierarchy(_) | Event::XinputDeviceChanged(_) => pens.set_devices(query_devices(conn)), + _ => {} + } + } + } + + /// The slave pointer devices with their names and pressure valuators (none on any error). + fn query_devices(conn: &RustConnection) -> Vec { + let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() }; + let kinds = device_kinds(conn); + let mut out = Vec::new(); + for info in reply.infos.iter().take(MAX_DEVICES) { + if info.type_ != DeviceType::SLAVE_POINTER && info.type_ != DeviceType::FLOATING_SLAVE { + continue; + } + let valuators = info.classes.iter().take(usize::from(MAX_AXES)).filter_map(|c| match &c.data { + DeviceClassData::Valuator(v) => { + let label = atom_name(conn, v.label)?; + Some((v.number, label, fp3232(v.min.integral, v.min.frac), fp3232(v.max.integral, v.max.frac))) + } + _ => None, + }); + let name = String::from_utf8_lossy(&info.name); + out.push(Device::new(info.deviceid, &name, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::>())); + } + out + } + + /// Each device's XInput type name, by device id (none on any error). + fn device_kinds(conn: &RustConnection) -> HashMap { + let Ok(reply) = conn.xinput_list_input_devices().map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return HashMap::new() }; + reply.devices.iter().take(MAX_DEVICES).filter_map(|d| Some((u16::from(d.device_id), atom_name(conn, d.device_type)?))).collect() + } + + fn atom_name(conn: &RustConnection, atom: u32) -> Option { + if atom == 0 { + return None; + } + let reply = conn.get_atom_name(atom).ok()?.reply().ok()?; + Some(String::from_utf8_lossy(&reply.name).into_owned()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The valuators of a Wacom pen as the X server lists them: x and y, then pressure from 0 to + /// 65,535. + fn pen_valuators() -> Vec<(u16, String, f64, f64)> { + vec![ + (0, "Abs X".to_string(), 0.0, 44704.0), + (1, "Abs Y".to_string(), 0.0, 27940.0), + (2, "Abs Pressure".to_string(), 0.0, 65535.0), + (3, "Abs Tilt X".to_string(), -64.0, 63.0), + ] + } + + fn pen(id: u16) -> Device { + Device::new(id, "Wacom Intuos Pro M Pen stylus", Some("STYLUS"), pen_valuators()) + } + + fn mouse(id: u16) -> Device { + Device::new(id, "Logitech USB Optical Mouse", Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)]) + } + + #[test] + fn the_mask_pairs_valuator_numbers_with_values() { + let v: Vec<_> = valuators(&[0b10_0101, 1], &[1.0, 2.0, 3.0, 4.0]).collect(); + assert_eq!(v, vec![(0, 1.0), (2, 2.0), (5, 3.0), (32, 4.0)]); + // Fewer values than mask bits (malformed): the values that are there. + assert_eq!(valuators(&[u32::MAX; 4], &[7.0]).count(), 1); + assert_eq!(valuators(&[], &[7.0]).count(), 0); + } + + #[test] + fn a_device_with_an_abs_pressure_valuator_is_a_pen() { + assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 })); + assert_eq!(mouse(12).pressure, None); + let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]); + assert_eq!(pad.pressure, None, "a tablet's pad is no pen"); + let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]); + assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored"); + // The types libinput and XWayland give pens, and a device whose type wasn't listed. + for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] { + assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}"); + } + // xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure". + for kind in ["TOUCHPAD", "TOUCHSCREEN"] { + assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}"); + } + } + + /// The eraser end of a pen is a device of its own, whose name or XInput type contains + /// "eraser", as xf86-input-wacom, xf86-input-libinput and XWayland list it. + #[test] + fn an_eraser_device_is_named_so() { + let erasers = [ + ("Wacom Intuos Pro M Pen eraser", Some("ERASER")), + ("HUION Kamvas Pen Eraser (0x1)", Some("ERASER")), + ("xwayland-tablet eraser:16", Some("xwayland-tablet eraser")), + ("Wacom Intuos Pro M Pen eraser", None), + ("Tablet Pen (0x2)", Some("ERASER")), + ]; + for (name, kind) in erasers { + let d = Device::new(10, name, kind, pen_valuators()); + assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})"); + } + let tips = [ + ("Wacom Intuos Pro M Pen stylus", Some("STYLUS")), + ("HUION Kamvas Pen Pen (0x1)", Some("STYLUS")), + ("xwayland-tablet stylus:16", Some("xwayland-tablet stylus")), + ("Logitech USB Optical Mouse", Some("MOUSE")), + ("", None), + ]; + for (name, kind) in tips { + assert!(!Device::new(9, name, kind, pen_valuators()).eraser, "{name} ({kind:?})"); + } + } + + #[test] + fn the_eraser_device_sets_the_eraser_end() { + let mut pens = Pens::default(); + pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]); + let feed = StylusFeed::default(); + pens.raw_event(&feed, 9, &[0b100], &[32767.5]); + assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false }))); + assert_eq!(feed.eraser(), Some(false)); + // Turned over, the pen hovers with its eraser end: the eraser device's first event, with + // no pressure yet. + pens.raw_event(&feed, 10, &[0b1], &[100.0]); + assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true }))); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0))); + pens.raw_event(&feed, 10, &[0b100], &[65535.0]); + assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0))); + // Back to the tip, with the tip's own last pressure. + pens.raw_event(&feed, 9, &[0b1], &[200.0]); + assert_eq!((feed.eraser(), feed.held()), (Some(false), Some(0.5))); + // A mouse: no pen end. + pens.raw_event(&feed, 12, &[0b1], &[3.0]); + assert_eq!(feed.eraser(), None); + } + + #[test] + fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() { + let mut pens = Pens::default(); + pens.set_devices([pen(9), mouse(12)]); + let feed = StylusFeed::default(); + pens.raw_event(&feed, 9, &[0b101], &[100.0, 32767.5]); + assert_eq!(feed.held(), Some(0.5)); + // Raw events carry only the valuators that changed: a move without pressure keeps it. + pens.raw_event(&feed, 9, &[0b1], &[200.0]); + assert_eq!(feed.held(), Some(0.5)); + pens.raw_event(&feed, 9, &[0b100], &[65535.0]); + assert_eq!(feed.held(), Some(1.0)); + // The mouse moves: no pen pressure until the pen moves again. + pens.raw_event(&feed, 12, &[0b1], &[3.0]); + assert_eq!(feed.held(), None); + pens.raw_event(&feed, 9, &[0b1], &[300.0]); + assert_eq!(feed.held(), Some(1.0), "the pen's last pressure"); + // A master device or one not listed yet changes nothing. + pens.raw_event(&feed, 2, &[0b100], &[0.0]); + assert_eq!(feed.held(), Some(1.0)); + // A pen whose first event carries no pressure is hovering: it presses with 0. + pens.set_devices([pen(9), pen(10)]); + pens.raw_event(&feed, 10, &[0b1], &[5.0]); + assert_eq!(feed.press(), Some(0.0)); + } + + #[test] + fn hostile_device_data_gives_a_pressure_within_0_to_1() { + let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)]; + for (min, max) in ranges { + let mut pens = Pens::default(); + pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]); + for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] { + let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") }; + assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}"); + } + } + let mut pens = Pens::default(); + pens.set_devices((0..2000u16).map(|i| Device::new(i, "", None, []))); + assert!(pens.devices.len() <= MAX_DEVICES, "the device list is bounded"); + // Values for valuators the pen doesn't have are ignored. + pens.set_devices([pen(3)]); + let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::>()) else { + panic!("the pen gave no pressure") + }; + assert!(p < 0.001, "{p}"); + } + + #[test] + fn fixed_point_values() { + assert_eq!(fp3232(3, 1 << 31), 3.5); + assert_eq!(fp3232(-2, 0), -2.0); + } +} diff --git a/apps/vectorcraft/src/window.rs b/apps/vectorcraft/src/window.rs index e95326423..abe60f69f 100644 --- a/apps/vectorcraft/src/window.rs +++ b/apps/vectorcraft/src/window.rs @@ -110,7 +110,8 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] { } /// Size and place the new window (eframe shows it after drawing the first frame) and return its -/// geometry. +/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run +/// until the host reports maximized (fixes #3635). pub fn restore(window: &winit::window::Window, saved: Option) -> WindowGeometry { let handles: Vec<_> = window.available_monitors().collect(); let monitors: Vec = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect(); @@ -121,22 +122,41 @@ pub fn restore(window: &winit::window::Window, saved: Option) -> } // Returns the new size when it applies at once; either way the window gets it. let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1])); + #[cfg(not(target_os = "linux"))] if g.maximized { window.set_maximized(true); } g } +/// Keep asking the host to maximize until it sticks, or give up after a few frames. +pub fn apply_pending_maximize(window: &winit::window::Window, want: bool, tries: &mut u8) -> bool { + if !want { + return false; + } + if window.is_maximized() { + return false; + } + if *tries == 0 { + return false; + } + *tries -= 1; + window.set_maximized(true); + true +} + /// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps /// the last one; maximized only sets its flag, so un-maximizing next time restores the last size. -pub fn track(ctx: &egui::Context, geometry: &mut Option) { +/// `host_maximized` is the window manager's idea of maximized when egui does not report it (#3635). +pub fn track(ctx: &egui::Context, geometry: &mut Option, host_maximized: Option) { let zoom = ctx.zoom_factor(); ctx.input(|i| { let v = i.viewport(); if v.minimized == Some(true) || v.fullscreen == Some(true) { return; } - if v.maximized == Some(true) { + let maximized = v.maximized == Some(true) || host_maximized == Some(true); + if maximized { if let Some(g) = geometry { g.maximized = true; } @@ -222,7 +242,7 @@ mod tests { let mut frame = |info: egui::ViewportInfo| { let mut input = egui::RawInput::default(); input.viewports.insert(egui::ViewportId::ROOT, info); - ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear(); + ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, None)).textures_delta.clear(); geometry }; let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0)); @@ -253,4 +273,19 @@ mod tests { let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap(); assert_eq!(back.window, None); } + + #[test] + fn the_host_maximized_flag_is_remembered_when_egui_does_not_report_it() { + let ctx = egui::Context::default(); + let mut geometry = Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: false }); + let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1920.0, 1080.0)); + let mut input = egui::RawInput::default(); + input.viewports.insert( + egui::ViewportId::ROOT, + egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: None, ..Default::default() }, + ); + let mut out = ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, Some(true))); + out.textures_delta.clear(); + assert_eq!(geometry, Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: true })); + } } diff --git a/apps/vectorcraft/src/windows_stylus.rs b/apps/vectorcraft/src/windows_stylus.rs new file mode 100644 index 000000000..cf17976b5 --- /dev/null +++ b/apps/vectorcraft/src/windows_stylus.rs @@ -0,0 +1,299 @@ +//! Windows pen tablets: with Windows Ink, winit reports a pen in contact as a touch and drops its +//! barrel button, and egui-winit presses the primary button for the touch it makes the pointer, +//! so a pen pressed with its barrel button held clicks and drags with the left button. The +//! `WM_POINTERDOWN` message winit is about to handle still has the barrel button's state in its +//! second-button flag ("analogous to a mouse right button down", +//! ): [`msg_hook`] notes +//! it, and [`raw_input_hook`] makes the primary button of that contact the secondary one. +#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))] + +use std::cell::RefCell; + +use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase}; + +/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's +/// `wParam`, a pen in contact with its barrel button pressed. +const POINTER_MESSAGE_FLAG_SECONDBUTTON: usize = 0x0020; +/// At most this many pointer contacts wait for their touch event. +const MAX_PENDING: usize = 16; + +thread_local! { + static STROKES: RefCell = RefCell::new(BarrelStrokes::default()); +} + +/// winit's message hook (`EventLoopBuilderExtWindows::with_msg_hook`), which winit calls for each +/// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id +/// and whether the barrel button was held. +/// It never consumes the message. +#[cfg(all(windows, not(target_vendor = "win7")))] +#[allow(unsafe_code)] +pub fn msg_hook(msg: *const std::ffi::c_void) -> bool { + use windows::Win32::UI::WindowsAndMessaging::{MSG, WM_POINTERDOWN}; + // SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this + // call; `as_ref` gives `None` for a null pointer. + let Some(msg) = (unsafe { msg.cast::().as_ref() }) else { + return false; + }; + if msg.message == WM_POINTERDOWN { + STROKES.with(|s| { + if let Ok(mut s) = s.try_borrow_mut() { + s.pointer_down(msg.wParam.0); + } + }); + } + false +} + +/// Before egui reads a frame's input: the primary button of a contact that started with the +/// barrel button held becomes the secondary button. +pub fn raw_input_hook(raw: &mut egui::RawInput) { + STROKES.with(|s| { + if let Ok(mut s) = s.try_borrow_mut() { + s.rewrite(&mut raw.events); + } + }); +} + +/// The pen contacts that press the secondary button. +#[derive(Default)] +struct BarrelStrokes { + /// Each `WM_POINTERDOWN`'s pointer id and whether the barrel button was held, oldest first, + /// until the touch of that contact starts. + pending: Vec<(u64, bool)>, + /// The contact whose touch started with the barrel button held, until it ends, the same pointer + /// starts another contact, or another contact starts with the barrel button held. + right: Option, +} + +/// A contact that started with the barrel button held. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Right { + id: TouchId, + /// Its secondary button is down: the press went to egui and the release has not. + down: bool, +} + +impl BarrelStrokes { + /// A `WM_POINTERDOWN` with this `wParam`, whose low word is the pointer id and whose high word + /// holds the pointer flags. + fn pointer_down(&mut self, wparam: usize) { + if self.pending.len() >= MAX_PENDING { + self.pending.remove(0); + } + self.pending.push(((wparam & 0xffff) as u64, (wparam >> 16) & POINTER_MESSAGE_FLAG_SECONDBUTTON != 0)); + } + + /// Makes the primary button of the right contact the secondary one. egui-winit sends a touch's + /// pointer button with the touch event it comes from: the press after the touch's start and + /// the pointer's move to it, the release right after its end. A canceled contact, or one + /// whose end never came (Windows does not promise one), gets a secondary release of its own. + fn rewrite(&mut self, events: &mut Vec) { + let release = |pos: Pos2| Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }; + let mut out = Vec::with_capacity(events.len() + 1); + // The button event that comes next for the right contact: `Some(true)` for its press, + // `Some(false)` for its release. + let mut next = None; + for mut event in events.drain(..) { + let mut after = None; + match &mut event { + Event::Touch { id, phase, pos, .. } => { + let (id, phase, pos) = (*id, *phase, *pos); + if phase == TouchPhase::Start { + // Each start decides afresh, from the oldest contact of its pointer still waiting. + let barrel = match self.pending.iter().position(|&(p, _)| p == id.0) { + Some(i) => self.pending.remove(i).1, + None => false, + }; + if let Some(r) = self.right + && (barrel || r.id == id) + { + if r.down { + out.push(release(pos)); + } + self.right = None; + } + if barrel { + self.right = Some(Right { id, down: false }); + } + } + next = None; + if let Some(r) = self.right.filter(|r| r.id == id) { + match phase { + TouchPhase::Start => next = Some(true), + TouchPhase::Move => {} + TouchPhase::End => { + self.right = None; + next = Some(false); + } + TouchPhase::Cancel => { + self.right = None; + if r.down { + after = Some(release(pos)); + } + } + } + } + } + Event::PointerMoved(_) if next == Some(true) => {} + Event::PointerButton { button, pressed, .. } if next == Some(*pressed) && *button == PointerButton::Primary => { + *button = PointerButton::Secondary; + if let Some(r) = self.right.as_mut() { + r.down = *pressed; + } + next = None; + } + _ => next = None, + } + out.push(event); + out.extend(after); + } + *events = out; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use egui::{TouchDeviceId, pos2}; + + /// `POINTER_MESSAGE_FLAG_INRANGE`, `_INCONTACT` and `_FIRSTBUTTON` (`winuser.h`): a pen tip + /// touching down. + const TIP: usize = 0x0002 | 0x0004 | 0x0010; + /// `POINTER_MESSAGE_FLAG_INRANGE` and `_INCONTACT` with the barrel button. + const BARREL: usize = 0x0002 | 0x0004 | POINTER_MESSAGE_FLAG_SECONDBUTTON; + + fn down(strokes: &mut BarrelStrokes, id: usize, flags: usize) { + strokes.pointer_down((flags << 16) | id); + } + + /// The events egui reads after the hook. + fn rewrite(strokes: &mut BarrelStrokes, events: &[Event]) -> Vec { + let mut events = events.to_vec(); + strokes.rewrite(&mut events); + events + } + + fn touch(id: u64, phase: TouchPhase) -> Event { + Event::Touch { device_id: TouchDeviceId(1), id: TouchId(id), phase, pos: pos2(10.0, 20.0), force: Some(0.5) } + } + + fn button(button: PointerButton, pressed: bool) -> Event { + Event::PointerButton { pos: pos2(10.0, 20.0), button, pressed, modifiers: Modifiers::NONE } + } + + /// What egui-winit sends for the touch that drives the pointer. + fn start(id: u64) -> Vec { + vec![touch(id, TouchPhase::Start), Event::PointerMoved(pos2(10.0, 20.0)), button(PointerButton::Primary, true)] + } + + fn end(id: u64) -> Vec { + vec![touch(id, TouchPhase::End), button(PointerButton::Primary, false), Event::PointerGone] + } + + fn buttons(events: &[Event]) -> Vec<(PointerButton, bool)> { + events.iter().filter_map(|e| if let Event::PointerButton { button, pressed, .. } = e { Some((*button, *pressed)) } else { None }).collect() + } + + const SECONDARY_DOWN: (PointerButton, bool) = (PointerButton::Secondary, true); + const SECONDARY_UP: (PointerButton, bool) = (PointerButton::Secondary, false); + const PRIMARY_DOWN: (PointerButton, bool) = (PointerButton::Primary, true); + const PRIMARY_UP: (PointerButton, bool) = (PointerButton::Primary, false); + + #[test] + fn a_pen_contact_with_the_barrel_button_presses_the_secondary_button() { + let mut strokes = BarrelStrokes::default(); + down(&mut strokes, 7, BARREL); + assert_eq!(buttons(&rewrite(&mut strokes, &start(7))), [SECONDARY_DOWN]); + let moved = [touch(7, TouchPhase::Move), Event::PointerMoved(pos2(15.0, 25.0))]; + assert_eq!(rewrite(&mut strokes, &moved), moved); + assert_eq!(buttons(&rewrite(&mut strokes, &end(7))), [SECONDARY_UP]); + assert_eq!(strokes.right, None, "the next contact starts afresh"); + assert!(strokes.pending.is_empty()); + } + + #[test] + fn a_pen_contact_with_the_tip_alone_keeps_the_primary_button() { + let mut strokes = BarrelStrokes::default(); + down(&mut strokes, 7, TIP); + let events = [start(7), end(7)].concat(); + assert_eq!(rewrite(&mut strokes, &events), events); + } + + #[test] + fn contacts_of_a_pen_within_one_frame_keep_their_order() { + // A tap with the barrel button held, then one with the tip alone, before the next frame. + let mut strokes = BarrelStrokes::default(); + down(&mut strokes, 7, BARREL); + down(&mut strokes, 7, TIP); + let events = rewrite(&mut strokes, &[start(7), end(7), start(7), end(7)].concat()); + assert_eq!(buttons(&events), [SECONDARY_DOWN, SECONDARY_UP, PRIMARY_DOWN, PRIMARY_UP]); + } + + #[test] + fn a_mouse_button_right_after_a_barrel_contact_keeps_its_button() { + let mut strokes = BarrelStrokes::default(); + down(&mut strokes, 7, BARREL); + let events = [start(7), vec![button(PointerButton::Primary, true)], end(7), vec![button(PointerButton::Primary, false)]].concat(); + assert_eq!(buttons(&rewrite(&mut strokes, &events)), [SECONDARY_DOWN, PRIMARY_DOWN, SECONDARY_UP, PRIMARY_UP]); + } + + #[test] + fn other_touches_and_the_mouse_keep_the_primary_button_during_a_barrel_contact() { + let mut strokes = BarrelStrokes::default(); + down(&mut strokes, 7, BARREL); + down(&mut strokes, 9, TIP); + let events = [start(7), start(9), vec![button(PointerButton::Primary, true)], end(9)].concat(); + assert_eq!(buttons(&rewrite(&mut strokes, &events)), [SECONDARY_DOWN, PRIMARY_DOWN, PRIMARY_DOWN, PRIMARY_UP]); + assert_eq!(strokes.right, Some(Right { id: TouchId(7), down: true }), "the barrel contact goes on"); + } + + #[test] + fn a_contact_after_a_lost_end_releases_the_secondary_button_first() { + let mut strokes = BarrelStrokes::default(); + down(&mut strokes, 7, BARREL); + rewrite(&mut strokes, &start(7)); + // No end arrives for that contact; the pen touches down again with the tip alone. + down(&mut strokes, 7, TIP); + let events = rewrite(&mut strokes, &[start(7), end(7)].concat()); + assert_eq!(buttons(&events), [SECONDARY_UP, PRIMARY_DOWN, PRIMARY_UP]); + assert_eq!(strokes.right, None); + // Another pen's barrel contact takes over from one whose end was lost. + down(&mut strokes, 7, BARREL); + rewrite(&mut strokes, &start(7)); + down(&mut strokes, 8, BARREL); + let events = rewrite(&mut strokes, &[start(8), end(8)].concat()); + assert_eq!(buttons(&events), [SECONDARY_UP, SECONDARY_DOWN, SECONDARY_UP]); + } + + #[test] + fn a_canceled_barrel_contact_releases_the_secondary_button() { + let mut strokes = BarrelStrokes::default(); + down(&mut strokes, 7, BARREL); + let events = rewrite(&mut strokes, &[start(7), vec![touch(7, TouchPhase::Cancel), Event::PointerGone]].concat()); + assert_eq!(buttons(&events), [SECONDARY_DOWN, SECONDARY_UP]); + assert_eq!(strokes.right, None); + } + + #[test] + fn a_barrel_contact_that_never_pressed_releases_nothing() { + // A finger drives the pointer, so egui-winit sends no button for the pen. + let mut strokes = BarrelStrokes::default(); + down(&mut strokes, 7, BARREL); + let events = [vec![touch(7, TouchPhase::Start)], vec![touch(7, TouchPhase::Cancel)]].concat(); + assert_eq!(rewrite(&mut strokes, &events), events); + down(&mut strokes, 7, BARREL); + down(&mut strokes, 7, TIP); + let events = [vec![touch(7, TouchPhase::Start)], start(7)].concat(); + assert_eq!(buttons(&rewrite(&mut strokes, &events)), [PRIMARY_DOWN]); + } + + #[test] + fn contacts_whose_touch_never_arrives_are_bounded() { + let mut strokes = BarrelStrokes::default(); + for id in 0..40 { + down(&mut strokes, id, BARREL); + } + assert_eq!(strokes.pending.len(), MAX_PENDING); + assert_eq!(strokes.pending.last(), Some(&(39, true))); + } +} diff --git a/apps/vectorcraft/tests/automation_args.rs b/apps/vectorcraft/tests/it/automation_args.rs similarity index 80% rename from apps/vectorcraft/tests/automation_args.rs rename to apps/vectorcraft/tests/it/automation_args.rs index ffb8773ef..93b33fa16 100644 --- a/apps/vectorcraft/tests/automation_args.rs +++ b/apps/vectorcraft/tests/it/automation_args.rs @@ -1,6 +1,3 @@ -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -25,7 +22,9 @@ fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) { .stderr(Stdio::piped()) .spawn() .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); + // Long enough for the binary to start on a loaded machine (the workspace's other tests run + // meanwhile), short enough to catch a desktop that started instead of exiting. + let deadline = Instant::now() + Duration::from_secs(30); let timed_out = loop { if child.try_wait().unwrap().is_some() { break false; @@ -58,11 +57,17 @@ fn missing_empty_or_option_operands_fail_before_startup() { } } +/// #3546: a root flag may be given more than once, in either spelling; a bad later operand still +/// stops the app before it starts. #[test] -fn duplicate_roots_fail_for_both_spellings() { +fn repeated_roots_are_accepted_for_both_spellings() { for flag in ["--automation-read-root", "--automation-write-root"] { - rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice")); - rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice")); + let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap(); + assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION"))); + assert!(out.stderr.is_empty()); + rejects(&[flag, ".", flag, ""], &format!("{flag} needs a non-empty folder")); + rejects(&[&format!("{flag}=."), flag], &format!("{flag} needs a folder")); } } diff --git a/apps/vectorcraft/tests/help.rs b/apps/vectorcraft/tests/it/help.rs similarity index 92% rename from apps/vectorcraft/tests/help.rs rename to apps/vectorcraft/tests/it/help.rs index 5c77ecdbb..7c7624031 100644 --- a/apps/vectorcraft/tests/help.rs +++ b/apps/vectorcraft/tests/it/help.rs @@ -1,6 +1,3 @@ -// 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)] - //! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a //! terminal, SSH or an agent's shell it used to open the window (or hang without a display). diff --git a/apps/vectorcraft/tests/it/main.rs b/apps/vectorcraft/tests/it/main.rs new file mode 100644 index 000000000..d9dcf39cb --- /dev/null +++ b/apps/vectorcraft/tests/it/main.rs @@ -0,0 +1,6 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod automation_args; +mod help; diff --git a/assets/icons/dc-para-bullets.svg b/assets/icons/dc-para-bullets.svg new file mode 100644 index 000000000..b87e67860 --- /dev/null +++ b/assets/icons/dc-para-bullets.svg @@ -0,0 +1 @@ + diff --git a/contributors/people.toml b/contributors/people.toml index 62fcd8d18..f8c651239 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -140,3 +140,22 @@ sync_github_name = false [people.KaboomFox] sync_github_name = true + +[people.odysseexp] +real_name = "Harley Paiva" +sync_github_name = true + +[people.addibble] +sync_github_name = true + +[people.mockydevs] +real_name = "Don Omondi" +display_name = "Don Omondi" + +[people.alpha-rock] +display_name = "Aditya Sisodiya" + +[people.Ronfo2013] +real_name = "Angelo Bernardini" +display_name = "Angelo Bernardini" +sync_github_name = false diff --git a/crates/affinity/README.md b/crates/affinity/README.md index 4ffdbabd8..62e2974d2 100644 --- a/crates/affinity/README.md +++ b/crates/affinity/README.md @@ -11,6 +11,7 @@ Affinity files. | Affinity | VectorCraft | |---|---| | Pages, Publisher spreads (one or two pages) | artboards, spreads side by side | +| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it | | Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke | | Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups | | Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) | @@ -21,22 +22,24 @@ Affinity files. | Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask | | Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) | | Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) | -| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance | +| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance | | Fill layers (`FRst`) | filled rectangles | -| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type | +| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) | +| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) | | Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images | -| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images | -| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them | +| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks | +| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards; their master pages are left out); other embedded documents and symbols as the picture Affinity cached of them | +| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels (a group's over its children's) | | Opacity, visibility, lock, names, the blend modes VectorCraft has | the same | Everything else is reported in the import warnings, one line per kind with a count, never -dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes, -transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial), +dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes, +transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial), special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner types other than round or straight, stars with rounded points, several fills or strokes on one object (the -active one is used), outlined or scaled text, text fields such as page numbers, frames with -columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the -document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal). +active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with +columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document +without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal). When the native document can't be read at all, the engine opens the embedded PNG preview instead, with a warning that names the reason; that preview can't be placed, used as a template or mined @@ -78,10 +81,22 @@ of CC0 sample files. * **Read-only.** Nothing is written in Affinity's format; export waits until someone can check written files in Affinity itself. -The reader also has an independent copy in PhotoCraft's `photocraft-affinity`. +The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's +`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects, +overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft +([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back +here, from the same public fixtures and the thumbnails of documents their owners made, used only +locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text) +was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy +text-frame fixture. ## Validation +* `engine/tests/it/affinity_features.rs` covers pages, several artboards per canvas and per spread, + master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded + documents (whose artboards don't become artboards) on synthetic documents, and mutates them. + `engine/tests/it/affinity_local.rs` compares every artboard of local documents with their thumbnail + when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed). * Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries, checksums, budgets, cycles, hostile streams, every truncation) and property tests of random mutations. @@ -91,7 +106,7 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`. feature probes plus one separately identified derived regression input from [SethRobinson/Patchy](https://github.com/SethRobinson/Patchy). Only document data and provenance notices were inspected, never that project's importer or authoring scripts. -* `tests/real_files.rs` parses every file and freezes the modeled features of all 33 current +* `tests/it/real_files.rs` parses every file and freezes the modeled features of all 33 current `.af` files. Native raster tests compare decoded pixels with independent embedded images; text tests assert Unicode run boundaries, fallback fonts and feature retention. * `engine/tests/affinity_corpus.rs` requires editable native import, exact `.vectorcraft` @@ -104,9 +119,14 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`. pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate. * Synthetic engine regressions cover nested, rotated and curved artboards, negative origins, multiple spreads and resolution conversion, alongside text recovery and native save/export. -* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks - that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole - reader (`container`) and the object stream (`stream`): +* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and + documents with layer effects, colour overlays (also over tables), line styles, paragraph and + character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding + and the embedded stream), and checks that whatever opens also renders and exports. + `tests/it/import_limits.rs` builds hostile documents against the limits below: grids of 16 million + cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds + of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object + stream (`stream`): ```sh cd crates/affinity @@ -132,11 +152,23 @@ anything is allocated: a value takes about 40 bytes in memory however few it too those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000 and cumulative geometry work at four million, rejecting cycles and partial results on limits. -Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels -(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a -million characters per node before allocation, with a cumulative 16 MiB budget for owned font -names, features and gradient stops. Exhaustion warns and uses default attributes for remaining -text, preserving its characters. +Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64 +megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of +their resolution) and 512 megapixels for the whole document; pixel masks are read only over what +they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers +the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so +content that many layers link, or a document embedded many times, can't multiply them. Embedded +documents nest four deep at most; both of their readings are charged to that budget and to the +importing document's archive limits, also when they fail part of the way. Tables have at most +4096 rows and columns and 65 536 cells, and 64 layer effects per node are read. Gradients are +limited to 1024 stops. Text collection is bounded to a million characters per node before +allocation, with a cumulative 16 MiB budget for owned font names (256 bytes at most each), +features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion +warns and uses default attributes for remaining text, preserving its characters. Colour and +gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels +each is baked into); past it they are left out, and said. In VectorCraft, master pages are +copied onto at most as many objects, characters and pixels as a document may hold, a few times +over, and objects beyond `MAX_COORD` of the origin are left out. Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the crate has no panics outside tests. diff --git a/crates/affinity/src/container.rs b/crates/affinity/src/container.rs index 31179a3a0..6b1df9510 100644 --- a/crates/affinity/src/container.rs +++ b/crates/affinity/src/container.rs @@ -263,6 +263,21 @@ impl<'a> Archive<'a> { } /// Extract an entry by name: decompress, undo the byte predictor and verify the CRC. + /// What is left of the limits for documents embedded in this one. + pub(crate) fn remaining(&self) -> Limits { + Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) } + } + + /// Count `n` bytes extracted elsewhere (from embedded documents) against this import. + pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> { + let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?; + if total > self.limits.max_total { + return Err(Error::Limit("document too large")); + } + self.extracted = total; + Ok(()) + } + pub fn read(&mut self, name: &str) -> Result, Error> { let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone(); let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?; diff --git a/crates/affinity/src/geometry.rs b/crates/affinity/src/geometry.rs index 5d74638ad..8e84e4807 100644 --- a/crates/affinity/src/geometry.rs +++ b/crates/affinity/src/geometry.rs @@ -215,7 +215,13 @@ fn anchors(records: &[Value]) -> Option> { fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option { let first = anchors.first()?; if let Some(radii) = radii.filter(|r| !r.is_empty()) { - let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max); + // The largest radius of each record, once (not a scan of all of them for each anchor). + let mut largest: std::collections::HashMap = std::collections::HashMap::new(); + for (rec, r) in radii { + let e = largest.entry(*rec).or_insert(0.0); + *e = e.max(*r); + } + let corner = |a: &Anchor| largest.get(&a.record).copied().unwrap_or(0.0); let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p)); if polyline { let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect(); diff --git a/crates/affinity/src/lib.rs b/crates/affinity/src/lib.rs index 4be46c777..38d86a899 100644 --- a/crates/affinity/src/lib.rs +++ b/crates/affinity/src/lib.rs @@ -14,6 +14,7 @@ mod access; pub mod container; mod geometry; pub mod model; +pub mod overlay; pub mod paint; pub mod preview; mod raster; diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index 298e32be2..7f10f8823 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -1,4 +1,4 @@ -//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes, +//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes, //! text and images, with transforms composed into document space. Anything else is reported //! by [`Document::warnings`] instead of being dropped silently. @@ -175,17 +175,54 @@ pub struct TextRun { /// Boolean OpenType feature overrides, as tags or `-tag` for disabled features. pub features: Vec, pub all_caps: bool, + /// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its + /// owner made, all other characters there and in the public corpus have 0). + pub underline: bool, + /// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck + /// through in another document its owner made). + pub strikethrough: bool, /// Font size in document pixels, before the node's transform. pub size: f64, /// Tracking in em. pub tracking: f64, + /// Horizontal scale of the glyphs (1 = as designed). + pub h_scale: f64, /// Fixed line pitch in document pixels, when the run overrides automatic leading. pub leading: Option, pub fill: Paint, + /// Outlined characters: the outline's paint and width (node space). + pub stroke: Option, + /// The run's paragraph: alignment, indents and spacing. + pub paragraph: Paragraph, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the +/// node's text scale). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Paragraph { + pub align: Align, + /// Indent of every line but the first, from the left. + pub left: f64, + pub right: f64, + /// Indent of the first line, from the left. + pub first: f64, + pub space_before: f64, + pub space_after: f64, + /// Fixed distance between baselines, when the paragraph sets one. + pub leading: Option, + /// Automatic leading as a multiple of the font's own line height (ascent plus descent). + pub auto_leading: f64, +} + +impl Default for Paragraph { + fn default() -> Self { + Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Align { + #[default] Left, Center, Right, @@ -202,12 +239,69 @@ pub struct Text { pub frame: Option, /// Node space to document pixels. pub transform: Affine, + /// Node space to document pixels for the type (its linear part matters): artistic text scales + /// with its node, frame text only with its frame's text scale. + pub text_transform: Affine, +} + +/// Vertical alignment of a table cell's text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VAlign { + Top, + Center, + Bottom, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TableCell { + pub runs: Vec, + /// Space between the cell edges and its text, in node space: left, top, right, bottom. + pub inset: [f64; 4], + pub valign: VAlign, + /// Part of the cell to its left (merged): it has no text of its own. + pub merged_left: bool, +} + +/// A table: a grid of cells with text, and a line on every cell edge. +#[derive(Debug, Clone, PartialEq)] +pub struct Table { + /// Column boundaries (one more than the columns), increasing, in node space. + pub columns: Vec, + /// Row boundaries (one more than the rows), increasing, in node space. + pub rows: Vec, + /// Row by row. + pub cells: Vec, + /// Vertical edges row by row, one more per row than the columns; `None` draws no line, and + /// the list is empty when the file's didn't match the grid. + pub vertical: Vec>, + /// Horizontal edges boundary by boundary (one more than the rows), one per column (empty + /// as `vertical` can be). + pub horizontal: Vec>, + /// Node space to document pixels. + pub transform: Affine, + /// Node space to document pixels for the cells' type: resizing a table moves its grid, not + /// its type. + pub text_transform: Affine, +} + +impl Table { + pub fn ncols(&self) -> usize { + self.columns.len().saturating_sub(1) + } + pub fn nrows(&self) -> usize { + self.rows.len().saturating_sub(1) + } } #[derive(Debug, Clone, PartialEq)] pub enum Pixels { /// Straight-alpha RGBA, 8 bits per channel, row by row. Rgba8(Vec), + /// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and + /// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a + /// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals + /// into a blue cast. + Cmyka8(Vec), /// The original file a placed image was made from (PNG, JPEG…), as embedded. Encoded(Vec), } @@ -246,11 +340,78 @@ pub enum Kind { even_odd: bool, }, Text(Text), + Table(Table), Image(Image), + /// A master page shown on this spread's pages: pages `page..page + count` show the master + /// spread [`Document::masters`]`[master]`'s pages from `master_page` on. + MasterInstance { + master: usize, + page: usize, + master_page: usize, + count: usize, + }, /// Something this reader does not import; its children are still imported. Unsupported, } +/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in +/// (radians, clockwise from +x as y points down: π/2 puts it below). +#[derive(Debug, Clone, PartialEq)] +pub enum Effect { + DropShadow { + color: crate::paint::Color, + opacity: f64, + radius: f64, + distance: f64, + angle: f64, + }, + InnerShadow { + color: crate::paint::Color, + opacity: f64, + radius: f64, + distance: f64, + angle: f64, + }, + OuterGlow { + color: crate::paint::Color, + opacity: f64, + radius: f64, + }, + /// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred. + Outline { + color: crate::paint::Color, + opacity: f64, + width: f64, + align: u16, + }, + /// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth + /// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default); + /// `inner` is false for the outer, emboss and pillow kinds. + Bevel { + size: f64, + depth: f64, + angle: f64, + highlight: crate::paint::Color, + highlight_opacity: f64, + shadow: crate::paint::Color, + shadow_opacity: f64, + inner: bool, + }, + /// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at + /// `opacity` (the node's own alpha is kept). + ColorOverlay { + color: crate::paint::Color, + opacity: f64, + blend: Blend, + }, + /// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels). + GradientOverlay { + gradient: crate::paint::Gradient, + opacity: f64, + blend: Blend, + }, +} + #[derive(Debug, Clone, PartialEq)] pub struct Node { pub class: Tag, @@ -264,6 +425,8 @@ pub struct Node { pub mask: Option, /// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden. pub pixel_mask: Option, + /// Layer effects the importer can show or bake in (others are reported). + pub effects: Vec, /// Bottom to top. pub children: Vec, } @@ -285,12 +448,21 @@ pub struct Document { /// Pixels per inch: all geometry is in document pixels at this resolution. pub dpi: f64, pub spreads: Vec, + /// Master spreads, which [`Kind::MasterInstance`] nodes show on pages. + pub masters: Vec, /// Application that last saved the file, as stored (`Affinity 3.0.2`), when present. pub saved_by: Option, /// Content that was not imported or was approximated, one line per kind, deduplicated. pub warnings: Vec, + /// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`, + /// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative + /// colorimetric intent and black-point compensation in every document checked. + pub cmyk_profile: Option>, } +/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes). +const MAX_PROFILE_BYTES: usize = 32 << 20; + /// Deepest layer nesting followed; deeper content is reported, not imported. const MAX_TREE_DEPTH: usize = 128; /// Total layer nodes imported. @@ -300,6 +472,32 @@ const MAX_NODES: usize = 500_000; /// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). const MAX_LAYER_PIXELS: u64 = 512 << 20; +/// Embedded documents inside embedded documents are followed this deep. +const MAX_EMBED_DEPTH: usize = 4; +/// Characters of all stories together (a story may be shared by many text layers). +const MAX_DOCUMENT_CHARS: usize = 16 << 20; +/// Table cells of all tables together (each becomes a text object with its edge lines). +const MAX_DOCUMENT_CELLS: usize = 1 << 18; + +/// What a whole read may still use. Shared objects can be linked from many layers, and embedded +/// documents are read with what their embedding document left, so linking or embedding the +/// same content many times can't multiply the work. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Budget { + /// Layer nodes ([`MAX_NODES`] in all). + pub(crate) nodes: usize, + /// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all). + pub(crate) pixels: u64, + /// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all). + pub(crate) chars: usize, + /// Table cells ([`MAX_DOCUMENT_CELLS`] in all). + pub(crate) cells: usize, +} + +impl Budget { + pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS }; +} + /// Read a document: archive, `doc.dat` and the model. pub fn read(bytes: &[u8], limits: Limits) -> Result { read_within(bytes, limits, MAX_LAYER_PIXELS) @@ -307,35 +505,84 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result { /// [`read`], its pixel layers holding at most `pixels` pixels together. pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { - let mut archive = Archive::open(bytes, limits)?; + read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document +} + +/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted +/// either way. +struct ReadAt { + document: Result, + extracted: usize, +} + +/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth` +/// deep). What it uses is taken from `budget`, also when it fails part of the way. +/// The most characters of a layer's name kept. +const MAX_NAME_CHARS: usize = 1024; + +fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt { + let mut archive = match Archive::open(bytes, limits) { + Ok(a) => a, + Err(e) => return ReadAt { document: Err(e), extracted: 0 }, + }; + let document = read_archive(&mut archive, budget, base, depth); + ReadAt { document, extracted: archive.extracted() } +} + +fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result { let doc = archive.read("doc.dat")?; let s = stream::parse(&doc)?; - let mut reader = Reader::new(&s, &mut archive); - reader.pixels_left = pixels; - reader.document() + let mut reader = Reader::new(&s, archive, base, depth); + reader.budget = *budget; + let document = reader.document(); + *budget = reader.budget; + document } pub(crate) struct Reader<'s, 'a, 'b> { pub(crate) s: &'s Stream, pub(crate) archive: &'b mut Archive<'a>, pub(crate) dpi: f64, - warnings: BTreeMap, - nodes: usize, + pub(crate) warnings: BTreeMap, active: HashSet, - /// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all). - pub(crate) pixels_left: u64, + /// Where the spreads go: the identity, or an embedding document's placement. + base: Affine, + /// The linear part of `base`, which also scales frame text. + pub(crate) type_base: Affine, + /// How deep inside embedded documents this one is. + depth: usize, + /// An embedded document's content, waiting to become its node's children. + pending: Option>, + /// The master spreads, in the order of [`Document::masters`]. + master_ids: Vec, + /// What the rest of the read may use. + pub(crate) budget: Budget, } impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { - fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS } + pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self { + let [a, b, c, d, ..] = base.0; + Self { + s, + archive, + dpi: 72.0, + warnings: BTreeMap::new(), + active: HashSet::new(), + base, + type_base: Affine([a, b, c, d, 0.0, 0.0]), + depth, + pending: None, + master_ids: Vec::new(), + budget: Budget::FULL, + } } pub(crate) fn warn(&mut self, what: impl Into) { *self.warnings.entry(what.into()).or_default() += 1; } - fn document(mut self) -> Result { + /// The document. + fn document(&mut self) -> Result { let s = self.s; let root = s.root; self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0); @@ -347,40 +594,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } }); let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?; + self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect(); let mut spreads = Vec::new(); for spread in s.objs(doc, b"Chld") { - if !s.is(spread, b"Sprd") { - self.warn("an unknown kind of page"); - continue; + if let Some(sp) = self.spread(doc, spread)? { + spreads.push(sp); } - let pages = pages(s, spread); - let default_size = - s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); - let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { - x0: 0.0, - y0: 0.0, - x1: 0.0, - y1: 0.0, - }); - let transparent = s.bool(spread, b"SprT").unwrap_or(false); - let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?; - if mask.is_some() { - self.warn("mask layers directly on a page (imported without them)"); - } - spreads.push(Spread { bounds, pages, transparent, nodes }); } if spreads.is_empty() { return Err(Error::Malformed("document has no pages")); } - if !s.objs(doc, b"MpCh").is_empty() { - self.warn("master pages (their content is not placed on the pages that use them)"); + let mut masters = Vec::new(); + for master in self.master_ids.clone() { + match self.spread(doc, master)? { + Some(sp) => masters.push(sp), + // Keep the indices of `MasterInstance::master` valid. + None => masters.push(Spread { + bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 }, + pages: Vec::new(), + transparent: true, + nodes: Vec::new(), + }), + } } - let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); - Ok(Document { dpi: self.dpi, spreads, saved_by, warnings }) + let cmyk_profile = s + .obj(root, b"CMYP") + .and_then(|p| s.bytes(p, b"Data")) + .filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice())) + .map(<[u8]>::to_vec); + let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect(); + Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile }) } - /// The children of `parent`, and the pixel mask a mask layer among them puts on the parent. - fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option), Error> { + /// A spread (`Sprd`) of the document `doc`, or of its master spreads. + fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result, Error> { + let s = self.s; + if !s.is(spread, b"Sprd") { + self.warn("an unknown kind of page"); + return Ok(None); + } + let pages = pages(s, spread); + let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h }); + let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect { + x0: 0.0, + y0: 0.0, + x1: 0.0, + y1: 0.0, + }); + let transparent = s.bool(spread, b"SprT").unwrap_or(false); + let (nodes, mask) = self.children(spread, self.base, 0)?; + if mask.is_some() { + self.warn("mask layers directly on a page (imported without them)"); + } + Ok(Some(Spread { bounds, pages, transparent, nodes })) + } + + /// The children of `parent`, and the mask layer among them that masks the parent (with its + /// placement), read later over just what it masks. + #[allow(clippy::type_complexity)] + fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec, Option<(ObjId, Affine)>), Error> { let s = self.s; let mut out = Vec::new(); let mut mask = None; @@ -391,10 +663,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { continue; } let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); - match crate::raster::mask(self, c, local.then(world))? { - Some(m) if mask.is_none() => mask = Some(m), - Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), - None => {} + if mask.is_none() { + mask = Some((c, local.then(world))); + } else { + self.warn("several pixel masks on one layer (only the first is used)"); } continue; } @@ -411,10 +683,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { self.warn("layers nested deeper than 128 levels"); return Ok(None); } - self.nodes += 1; - if self.nodes > MAX_NODES { + let Some(left) = self.budget.nodes.checked_sub(1) else { return Err(Error::Limit("too many layers")); - } + }; + self.budget.nodes = left; // A node that contains itself (a shared object linked back into its own subtree). if !self.active.insert(id) { self.warn("a layer that contains itself"); @@ -423,7 +695,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY); let world = local.then(parent); let class = s.class(id).unwrap_or(Tag(0)); - let name = s.str(id, b"Desc").unwrap_or_default().to_string(); + // A layer name is short; a long one is cut, as every visit of a shared object keeps a copy. + let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect(); let visible = s.bool(id, b"Visi").unwrap_or(true); let locked = s.bool(id, b"Edtb").is_some_and(|e| !e); let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0)); @@ -441,15 +714,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } else { self.kind(id, class, world, parent)? }; + // This node's embedded content, before its children's `node` calls can take it. + let embedded = self.pending.take(); // A compound's children are its operands, already merged into its outline. - let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; - let (mask, attached_mask) = self.attached_masks(id, world)?; + let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? }; + if let Some(embedded) = embedded { + children.extend(embedded); + } + // Pixel masks are read only over what they mask (they can span a whole spread at print + // resolution, a gigapixel). + let over = mask_extent(&kind, &children); + let child_mask = match child_mask { + Some((c, at)) => crate::raster::mask(self, c, at, over)?, + None => None, + }; + let (mask, attached_mask) = self.attached_masks(id, world, over)?; let pixel_mask = attached_mask.or(child_mask); self.active.remove(&id); - if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) { - self.warn("layer effects (shadows, glows, outlines, bevels…)"); - } - Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children })) + let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children }; + // Gradient overlays are laid out over the node's bounds, also while it is hidden. + let bounds = tree_bounds(&node); + node.effects = self.effects(id, world, bounds); + Ok(Some(node)) } fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result { @@ -471,6 +757,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { let (fills, strokes) = paint::node_paint(self, id, world); Kind::Shape { path: local.transformed(world), fills, strokes, even_odd } } + b"TxtT" => match crate::text::table(self, id, world) { + Some(t) => Kind::Table(t), + None => { + self.warn("tables that could not be read"); + Kind::Unsupported + } + }, b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) { Some(t) => Kind::Text(t), None => { @@ -482,15 +775,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Some(img) => Kind::Image(img), None => Kind::Unsupported, }, - b"EmbN" => match crate::raster::embedded(self, id, world)? { - Some(img) => { - self.warn("embedded documents and symbols (imported as pictures of them)"); - Kind::Image(img) + b"MPIN" => { + // `SLnk` links the instance with the master spread it shows. + let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default(); + let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o)); + let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096); + match master { + Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) }, + None => { + self.warn("master page instances without their master"); + Kind::Unsupported + } } - None => { - self.warn("embedded documents and symbols without a cached picture"); - Kind::Unsupported + } + b"EmbN" => match self.embedded_document(id, world) { + // The embedded document itself, as editable content. + Some(nodes) => { + self.pending = Some(nodes); + Kind::Group } + None => match crate::raster::embedded(self, id, world)? { + Some(img) => { + self.warn("embedded documents and symbols (imported as pictures of them)"); + Kind::Image(img) + } + None => { + self.warn("embedded documents and symbols without a cached picture"); + Kind::Unsupported + } + }, }, b"FRst" => { // A fill layer paints its whole bitmap area; its gradient lives in page space. @@ -515,9 +828,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { }) } + /// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the + /// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below. + fn effects(&mut self, id: ObjId, world: Affine, bounds: Option) -> Vec { + let s = self.s; + let mut out = Vec::new(); + for e in s.objs(id, b"FiEf").into_iter().take(64) { + if s.bool(e, b"Enab") == Some(false) { + continue; + } + // "Scale with object": lengths follow the node's transform; otherwise they are absolute. + let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 }; + let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k; + let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0); + let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0); + let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c)); + let class = s.class(e).map(|c| c.0.to_be_bytes()); + match (class.as_ref(), color) { + (Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), + (Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }), + (Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }), + (Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => { + let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a); + out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align }); + } + (Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"), + (Some(b"BevE" | b"EmbE"), _) => { + // No radius, no bevel (as Affinity draws it). + let size = len(b"Radi"); + if size > 0.0 { + let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; + let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }; + let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white); + let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black); + let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0); + let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0); + let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0); + let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0); + out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner }); + } + } + (Some(b"ColO"), Some(color)) => { + let blend = self.effect_blend(e); + out.push(Effect::ColorOverlay { color, opacity, blend }); + } + (Some(b"GrdO"), _) => { + let blend = self.effect_blend(e); + // The gradient's unit space spans the node's bounding box from -1 to 1, centred + // on it (y down): checked against the stored previews of real documents. + let space = + bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0])); + match (s.obj(e, b"GrFl"), space) { + (Some(fill), Some(space)) => match paint::descriptor(self, fill, space) { + Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }), + Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }), + _ => {} + }, + // Nothing visible to lay it over. + (Some(_), None) => {} + (None, _) => self.warn("gradient overlay effects that could not be read (left out)"), + } + } + (Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"), + (Some(b"Gaus"), _) => self.warn("blur effects (left out)"), + _ => self.warn("layer effects of other kinds (left out)"), + } + } + out + } + + /// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned). + fn effect_blend(&mut self, e: ObjId) -> Blend { + match self.s.enumeration(e, b"BlnM") { + None => Blend::Normal, + Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| { + self.warn("an unknown blend mode (imported as Normal)"); + Blend::Normal + }), + } + } + /// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines; /// an attached mask layer is a pixel mask. Adjustments attached the same way are reported. - fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option, Option), Error> { + fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option) -> Result<(Option, Option), Error> { let s = self.s; let attached = s.objs(id, b"AdCh"); let mut mask: Option = None; @@ -534,7 +927,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } } else if class == Tag::of(b"MRst") { let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world); - match crate::raster::mask(self, a, local)? { + match crate::raster::mask(self, a, local, over)? { Some(m) if pixels.is_none() => pixels = Some(m), Some(_) => self.warn("several pixel masks on one layer (only the first is used)"), None => {} @@ -546,6 +939,93 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { Ok((mask, pixels)) } + /// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds + /// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes, + /// placed so the centre of its page, first artboard or content is the node's origin. + fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option> { + let s = self.s; + let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string(); + if entry.is_empty() { + return None; + } + if self.depth >= MAX_EMBED_DEPTH { + self.warn("embedded documents nested more than four deep"); + return None; + } + let data = self.archive.read(&entry).ok()?; + let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?; + if !crate::is_affinity(inner) { + return None; + } + // A first reading finds its page and resolution, the second places it. + // Both readings are charged to the budget and the archive's limits, also when they fail: + // each does the work. + let mut budget = self.budget; + let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1); + self.budget = budget; + if self.archive.charge(probe.extracted).is_err() { + self.warn("embedded documents beyond the size limit (left out)"); + return None; + } + let probe = match probe.document { + Ok(d) => d, + Err(e) => { + self.warn(format!("embedded documents that could not be read ({e})")); + return None; + } + }; + let spread = probe.spreads.first()?; + // `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page. + let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2); + let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None }); + let page = if content { None } else { spread.pages.first().copied().or(first_board) }; + let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds); + // Its pixels aren't needed any more: never hold both readings' at once. + drop(probe); + // Its document pixels are the node's units, whatever either document's resolution. + let k = 1.0; + let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0); + let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world); + if !base.is_finite() { + self.warn("embedded documents with an invalid placement"); + return None; + } + let mut budget = self.budget; + let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1); + self.budget = budget; + if self.archive.charge(placed.extracted).is_err() { + self.warn("embedded documents beyond the size limit (left out)"); + return None; + } + let doc = placed.document.ok()?; + for w in doc.warnings { + self.warn(format!("{w} [in embedded documents]")); + } + if doc.spreads.len() > 1 { + self.warn("embedded documents with several pages (the first is used)"); + } + let mut nodes = doc.spreads.into_iter().next()?.nodes; + // Its master page instances count its own masters, which aren't kept: in this document + // they would show other masters. + fn strip_masters(nodes: &mut [Node], depth: usize) -> bool { + let mut found = false; + for n in nodes { + if matches!(n.kind, Kind::MasterInstance { .. }) { + n.kind = Kind::Unsupported; + found = true; + } + if depth < MAX_TREE_DEPTH { + found |= strip_masters(&mut n.children, depth + 1); + } + } + found + } + if strip_masters(&mut nodes, 0) { + self.warn("master pages of embedded documents (left out)"); + } + Some(nodes) + } + fn artboard(&mut self, id: ObjId, world: Affine) -> Kind { let s = self.s; let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else { @@ -580,6 +1060,96 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { } } +/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames +/// and tables), in document pixels. +pub fn nodes_bounds(nodes: &[Node]) -> Option { + let mut out = None; + walk_bounds(nodes, true, 0, &mut out); + out +} + +/// The bounds of `node` and everything in it, hidden or not. +fn tree_bounds(node: &Node) -> Option { + let mut out = None; + walk_bounds(std::slice::from_ref(node), false, 0, &mut out); + out +} + +fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option) { + if depth > MAX_TREE_DEPTH { + return; + } + for n in nodes.iter().filter(|n| n.visible || !visible_only) { + kind_points(&n.kind, &mut |p| add_point(out, p)); + walk_bounds(&n.children, visible_only, depth + 1, out); + } +} + +/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they +/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here: +/// artistic text, whose glyphs only the layout places. +fn mask_extent(kind: &Kind, children: &[Node]) -> Option { + fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option) -> bool { + if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) { + return false; + } + let mut own = kind_bounds(kind); + if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) { + // As far as a stroke can reach out of its path, miter joins included. + let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max); + own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach }); + } + if let Some(r) = own { + *out = Some(out.map_or(r, |o| union(o, r))); + } + children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out)) + } + let mut out = None; + if visit(kind, children, 0, &mut out) { out } else { None } +} + +/// The bounds of what one node's own kind draws (not its children): its outline's points, its +/// image's or frame's corners, or artistic text's anchor. +pub fn kind_bounds(kind: &Kind) -> Option { + let mut out = None; + kind_points(kind, &mut |p| add_point(&mut out, p)); + out +} + +fn add_point(out: &mut Option, p: Point) { + if p.x.is_finite() && p.y.is_finite() { + let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y }; + *out = Some(out.map_or(r, |o| union(o, r))); + } +} + +fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) { + let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y })); + match kind { + Kind::Shape { path, .. } | Kind::Artboard { path, .. } => { + for sp in &path.subpaths { + add(sp.start); + for seg in &sp.segments { + seg.iter().for_each(|p| add(*p)); + } + } + } + Kind::Image(img) => { + corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add) + } + Kind::Text(t) => match t.frame { + Some(f) => corners(f, t.transform).into_iter().for_each(add), + None => add(t.transform.apply(t.anchor)), + }, + Kind::Table(t) => { + if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) { + corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add); + } + } + Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} + } +} + /// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC` /// equal pages (Publisher 1 facing spreads). fn pages(s: &Stream, spread: ObjId) -> Vec { diff --git a/crates/affinity/src/overlay.rs b/crates/affinity/src/overlay.rs new file mode 100644 index 000000000..170c50ec3 --- /dev/null +++ b/crates/affinity/src/overlay.rs @@ -0,0 +1,663 @@ +//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what +//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's +//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps). +//! +//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid +//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow, +//! Darker/Lighter Color) use their usual definitions. + +use std::borrow::Cow; + +use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point}; +use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke}; + +/// Deepest subtree an overlay is baked into (the reader bounds trees well below this). +const MAX_DEPTH: usize = 256; + +/// Most pixels an overlay is baked into per image. +const MAX_PIXELS: usize = 64 << 20; + +/// Work the overlays of one import may take together: each overlay counts the nodes and pixels +/// of the subtree it is baked into (which is copied once more for them all). Nested overlays +/// and shared content would otherwise multiply it; past it, overlays are left out and said. +pub const MAX_WORK: u64 = 1 << 29; + +#[derive(Clone, Copy)] +enum Source<'a> { + Solid(Color), + Gradient(&'a Gradient), +} + +#[derive(Clone, Copy)] +struct Overlay<'a> { + source: Source<'a>, + opacity: f64, + blend: Blend, +} + +/// `node` with its colour and gradient overlays baked in, and those of everything in it +/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`], +/// can't pay for copying it). `warn` hears what could only be approximated or was left out. +pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> { + if overlays(&node.effects).is_empty() { + return Cow::Borrowed(node); + } + let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else { + warn("overlay effects beyond the work one import may take (left out)"); + return Cow::Borrowed(node); + }; + *work = left; + let mut out = node.clone(); + bake_tree(&mut out, work, 0, warn); + Cow::Owned(out) +} + +/// The visible colour and gradient overlays among `effects`. +fn overlays(effects: &[Effect]) -> Vec> { + effects + .iter() + .filter_map(|e| match e { + Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }), + Effect::GradientOverlay { gradient, opacity, blend } => { + Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend }) + } + _ => None, + }) + .filter(|o| o.opacity > 0.0) + .collect() +} + +fn is_overlay(e: &Effect) -> bool { + matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. }) +} + +/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay +/// lies over what its node shows, its children's overlays included. Each overlay pays for the +/// subtree it is baked into out of `work`; past it, it is left out (and said). +fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) { + if depth > MAX_DEPTH { + return; + } + for c in &mut node.children { + bake_tree(c, work, depth + 1, warn); + } + if !node.effects.iter().any(is_overlay) { + return; + } + let (effects, rest): (Vec, Vec) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay); + node.effects = rest; + let overlays = overlays(&effects); + if overlays.is_empty() { + return; + } + let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX); + let cost = subtree_cost(node, *work / count, 0).saturating_mul(count); + let Some(left) = work.checked_sub(cost) else { + warn("overlay effects beyond the work one import may take (left out)"); + return; + }; + *work = left; + for o in &overlays { + if matches!(o.blend, Blend::Erase | Blend::PassThrough) { + warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)"); + } + bake_node(node, o, 0, warn); + } +} + +/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`. +fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 { + if depth > MAX_DEPTH { + return 0; + } + let mut cost: u64 = match &node.kind { + Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1), + Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1), + Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1), + _ => 1, + }; + for c in &node.children { + if cost > limit { + break; + } + cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1)); + } + cost +} + +fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) { + if depth > MAX_DEPTH { + return; + } + match &mut node.kind { + Kind::Shape { fills, strokes, .. } => { + for f in fills.iter_mut() { + *f = paint(f, o, warn); + } + strokes.iter_mut().for_each(|s| stroke(s, o, warn)); + } + Kind::Artboard { background, strokes, .. } => { + for f in background.iter_mut() { + *f = paint(f, o, warn); + } + strokes.iter_mut().for_each(|s| stroke(s, o, warn)); + } + Kind::Text(t) => { + for r in &mut t.runs { + r.fill = paint(&r.fill, o, warn); + if let Some(s) = &mut r.stroke { + stroke(s, o, warn); + } + } + } + Kind::Table(t) => { + for c in &mut t.cells { + for r in &mut c.runs { + r.fill = paint(&r.fill, o, warn); + if let Some(s) = &mut r.stroke { + stroke(s, o, warn); + } + } + } + for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() { + stroke(s, o, warn); + } + } + Kind::Image(img) => { + if !pixels(img, o) { + warn("overlay effects on images too large to recolour (left out)"); + } + } + Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {} + } + for e in &mut node.effects { + match e { + Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o), + Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {} + Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {} + } + } + for c in &mut node.children { + bake_node(c, o, depth + 1, warn); + } +} + +fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) { + s.paint = paint(&s.paint, o, warn); +} + +/// A paint with the overlay over it. +fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint { + match (p, o.source) { + (Paint::None, _) => Paint::None, + (Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)), + (Paint::Gradient(g), Source::Solid(s)) => { + Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() }) + } + // Over one colour, the result is the overlay's gradient with each stop laid over it. + (Paint::Solid(c), Source::Gradient(g)) => { + Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() }) + } + (Paint::Gradient(base), Source::Gradient(g)) => { + if !(o.blend == Blend::Normal && o.opacity >= 1.0) { + warn("gradient overlays on gradients (approximated)"); + } + // Exact when the overlay covers it; otherwise over the base gradient's average colour. + let avg = average(base); + Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() }) + } + } +} + +impl Source<'_> { + /// One colour for the source (a gradient's average) where only one fits. + fn at_solid(&self) -> Color { + match self { + Source::Solid(c) => *c, + Source::Gradient(g) => average(g), + } + } +} + +fn average(g: &Gradient) -> Color { + let n = g.stops.len().max(1) as f64; + let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0); + for s in &g.stops { + let [sr, sg, sb] = rgb(s.color); + r += sr; + gr += sg; + b += sb; + a += s.color.alpha(); + } + Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n } +} + +/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept. +fn over(base: Color, source: Color, o: &Overlay) -> Color { + let k = (o.opacity * source.alpha()).clamp(0.0, 1.0); + let alpha = base.alpha(); + // Normal mixes within the colour model when both share it, so CMYK stays CMYK. + if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) { + let mix = |a: f64, b: f64| a + (b - a) * k; + match (base, source) { + (Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => { + return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha }; + } + (Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha }, + (_, s) if k >= 1.0 => return with_alpha(s, alpha), + _ => {} + } + } + let b = rgb(base); + let s = rgb(source); + let blended = blend(o.blend, b, s); + let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k); + Color::Rgb { r, g, b: bl, a: alpha } +} + +fn with_alpha(c: Color, a: f64) -> Color { + match c { + Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a }, + Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a }, + Color::Gray { v, .. } => Color::Gray { v, a }, + Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a }, + } +} + +/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the +/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour. +fn rgb(c: Color) -> [f64; 3] { + let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + match c { + Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)], + Color::Gray { v, .. } => [f(v); 3], + Color::Cmyk { c, m, y, k, .. } => { + let w = 1.0 - f(k); + [(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w] + } + Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f), + } +} + +/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65. +fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { + let finite = |v: f64| if v.is_finite() { v } else { 0.0 }; + let (l, a, b) = (finite(l), finite(a), finite(b)); + let fy = (l + 16.0) / 116.0; + let (fx, fz) = (fy + a / 500.0, fy - b / 200.0); + let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) }; + // D50 white. + let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz)); + // XYZ (D50) to linear sRGB, Bradford-adapted. + let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z; + let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z; + let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z; + let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }; + [gamma(r), gamma(g), gamma(bl)] +} + +/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many. +fn pixels(img: &mut Image, o: &Overlay) -> bool { + let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false }; + let Some(len) = n.checked_mul(4) else { return false }; + let mut px = match &img.pixels { + Pixels::Rgba8(p) => match p.get(..len) { + Some(p) => p.to_vec(), + None => return false, + }, + Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) { + Some(p) => p, + None => return false, + }, + // An overlay is blended in RGB: CMYK pixels take the profile-free formula here. + Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) { + Some(p) if p.len() == n * 5 => p + .as_chunks::<5>() + .0 + .iter() + .flat_map(|[c, m, y, k, a]| { + let w = 255 - u16::from(*k); + let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8; + [ch(*c), ch(*m), ch(*y), *a] + }) + .collect(), + _ => return false, + }, + }; + let width = img.width.max(1) as usize; + // A solid source is converted once; a gradient is sampled at each pixel's centre. + let solid = match o.source { + Source::Solid(c) => Some((rgb(c), c.alpha())), + Source::Gradient(_) => None, + }; + let to_unit = match o.source { + Source::Gradient(g) => match invert(g.transform) { + Some(m) => Some((img.transform.then(m), g)), + None => return false, + }, + Source::Solid(_) => None, + }; + for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() { + if p[3] == 0 { + continue; + } + let (s, sa) = match (solid, &to_unit) { + (Some(s), _) => s, + (None, Some((m, g))) => { + let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 }); + let c = sample(g, q); + (rgb(c), c.alpha()) + } + (None, None) => continue, + }; + let k = (o.opacity * sa).clamp(0.0, 1.0); + let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0); + let m = blend(o.blend, b, s); + for c in 0..3 { + p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + img.pixels = Pixels::Rgba8(px); + true +} + +/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says. +fn decode(bytes: &[u8], width: u32, height: u32) -> Option> { + let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + if probe.into_dimensions().ok()? != (width, height) { + return None; + } + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(width); + limits.max_image_height = Some(height); + limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8); + reader.limits(limits); + Some(reader.decode().ok()?.into_rgba8().into_raw()) +} + +/// The gradient's colour at unit-space point `q`. +fn sample(g: &Gradient, q: Point) -> Color { + let t = match g.kind { + GradientKind::Linear => q.x, + GradientKind::Radial => q.x.hypot(q.y), + GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU, + }; + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } }; + if t <= first.offset { + return first.color; + } + for w in g.stops.windows(2) { + let [a, b] = [&w[0], &w[1]]; + if t <= b.offset { + let span = b.offset - a.offset; + let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 }; + // Affinity's midpoint bias (see `Stop::half_point`). + let k = 1.0 + 8.0 * (a.bias - 0.5).abs(); + let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) }; + let (x, y) = (rgb(a.color), rgb(b.color)); + let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u); + return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u }; + } + } + last.color +} + +fn invert(m: Affine) -> Option { + let [a, b, c, d, e, f] = m.0; + let det = a * d - b * c; + if !(det.is_finite() && det.abs() > 1e-12) { + return None; + } + let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); + Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) +} + +/// The blend of backdrop `b` and source `s` (sRGB, 0..1). +fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] { + use Blend::*; + let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0)); + match mode { + Normal | PassThrough | Erase => s, + Darken => sep(f64::min), + Lighten => sep(f64::max), + Multiply => sep(|b, s| b * s), + Screen => sep(screen), + ColorBurn => sep(burn), + ColorDodge => sep(dodge), + Blend::Overlay => sep(|b, s| hard_light(s, b)), + HardLight => sep(hard_light), + SoftLight => sep(soft_light), + Difference => sep(|b, s| (b - s).abs()), + Exclusion => sep(|b, s| b + s - 2.0 * b * s), + Add => sep(|b, s| b + s), + Subtract => sep(|b, s| b - s), + VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }), + LinearLight => sep(|b, s| b + 2.0 * s - 1.0), + PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }), + HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }), + Average => sep(|b, s| (b + s) / 2.0), + Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()), + Reflect => sep(reflect), + Glow => sep(|b, s| reflect(s, b)), + DarkerColor => { + if lum(s) < lum(b) { + s + } else { + b + } + } + LighterColor => { + if lum(s) > lum(b) { + s + } else { + b + } + } + Hue => set_lum(set_sat(s, sat(b)), lum(b)), + Saturation => set_lum(set_sat(b, sat(s)), lum(b)), + Blend::Color => set_lum(s, lum(b)), + Luminosity => set_lum(b, lum(s)), + } +} + +fn screen(b: f64, s: f64) -> f64 { + b + s - b * s +} + +fn burn(b: f64, s: f64) -> f64 { + if b >= 1.0 { + 1.0 + } else if s <= 0.0 { + 0.0 + } else { + 1.0 - ((1.0 - b) / s).min(1.0) + } +} + +fn dodge(b: f64, s: f64) -> f64 { + if b <= 0.0 { + 0.0 + } else if s >= 1.0 { + 1.0 + } else { + (b / (1.0 - s)).min(1.0) + } +} + +fn hard_light(b: f64, s: f64) -> f64 { + if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) } +} + +fn soft_light(b: f64, s: f64) -> f64 { + if s <= 0.5 { + b - (1.0 - 2.0 * s) * b * (1.0 - b) + } else { + let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() }; + b + (2.0 * s - 1.0) * (d - b) + } +} + +fn reflect(b: f64, s: f64) -> f64 { + if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) } +} + +fn lum(c: [f64; 3]) -> f64 { + 0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2] +} + +fn clip_color(c: [f64; 3]) -> [f64; 3] { + let l = lum(c); + let n = c[0].min(c[1]).min(c[2]); + let x = c[0].max(c[1]).max(c[2]); + c.map(|v| { + let mut v = v; + if n < 0.0 && l - n > 1e-12 { + v = l + (v - l) * l / (l - n); + } + if x > 1.0 && x - l > 1e-12 { + v = l + (v - l) * (1.0 - l) / (x - l); + } + v.clamp(0.0, 1.0) + }) +} + +fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] { + let d = l - lum(c); + clip_color(c.map(|v| v + d)) +} + +fn sat(c: [f64; 3]) -> f64 { + c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2]) +} + +fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] { + let max = c[0].max(c[1]).max(c[2]); + let min = c[0].min(c[1]).min(c[2]); + if max - min <= 1e-12 { + return [0.0; 3]; + } + c.map(|v| (v - min) * s / (max - min)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(c: Color, opacity: f64, blend: Blend) -> Node { + Node { + class: crate::stream::Tag::of(b"ShpN"), + name: String::new(), + visible: true, + locked: false, + opacity: 1.0, + blend: Blend::Normal, + kind: Kind::Shape { + path: Default::default(), + fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })], + strokes: vec![], + even_odd: false, + }, + mask: None, + pixel_mask: None, + effects: vec![Effect::ColorOverlay { color: c, opacity, blend }], + children: vec![], + } + } + + fn fill(n: &Node) -> Color { + match &n.kind { + Kind::Shape { fills, .. } => match fills.first() { + Some(Paint::Solid(c)) => *c, + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + } + } + + #[test] + fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() { + let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }; + let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned(); + assert_eq!(fill(&n), white); + assert!(n.effects.is_empty(), "the overlay is baked, not kept"); + // Half way, mixed in RGB. + let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned(); + assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 }); + } + + #[test] + fn blend_modes_follow_their_formulas() { + let b = [0.25, 0.5, 1.0]; + assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]); + assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]); + assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]); + assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]); + // Color keeps the backdrop's luminosity with the source's hue and saturation. + let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]); + assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}"); + } + + #[test] + fn overlays_recolour_pixels_inside_their_alpha() { + let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); + n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY }); + let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned(); + let Kind::Image(img) = &out.kind else { panic!() }; + assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0])); + } + + #[test] + fn a_gradient_overlay_over_one_colour_becomes_that_gradient() { + let g = Gradient { + kind: GradientKind::Linear, + stops: vec![ + Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 }, + Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 }, + ], + transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]), + }; + let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal); + n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }]; + let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned(); + let Kind::Shape { fills, .. } = &out.kind else { panic!() }; + assert_eq!(fills.first(), Some(&Paint::Gradient(g))); + } + + #[test] + fn a_group_overlay_lies_over_its_childrens_overlays() { + let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 }; + let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }; + let mut group = solid(red, 0.5, Blend::Normal); + group.kind = Kind::Group; + group.children = vec![solid(blue, 1.0, Blend::Normal)]; + // The copy (2 nodes), the child's overlay (1) and the group's (2). + let mut work = 5; + let out = apply(&group, &mut work, &mut |_| {}).into_owned(); + assert_eq!(work, 0); + let child = out.children.first().unwrap(); + // Blue first, then half of the group's red over it. + assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 }); + assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked"); + // With no work left for the group's overlay, the child's still applies. + let mut warnings = Vec::new(); + let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned(); + assert_eq!(fill(out.children.first().unwrap()), blue); + assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}"); + } + + #[test] + fn overlays_stop_at_the_work_one_import_may_take() { + let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal); + n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY }); + // 16 pixels and the node, for the copy and for the overlay: 34. + let mut work = 40; + let mut warnings = Vec::new(); + assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_))); + assert_eq!((work, warnings.len()), (6, 0)); + assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_))); + assert_eq!(work, 6); + assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}"); + } +} diff --git a/crates/affinity/src/paint.rs b/crates/affinity/src/paint.rs index c4ebff844..9324b0335 100644 --- a/crates/affinity/src/paint.rs +++ b/crates/affinity/src/paint.rs @@ -126,6 +126,9 @@ pub struct Stroke { /// Gradient stops per gradient. const MAX_STOPS: usize = 1024; +/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs). +const MAX_DASHES: usize = 64; + fn floats(v: &Value) -> Option> { match v { Value::Floats(f) => Some(f.clone()), @@ -136,6 +139,14 @@ fn floats(v: &Value) -> Option> { /// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`). pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { + color_within(r, id, 0) +} + +/// The most spot colours that stand for one another followed. +const MAX_SPOT_CHAIN: u32 = 8; + +/// [`color`], `depth` spot colours deep: one that stands for itself (or a ring of them) ends here. +fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option { let s = r.s; let class = s.class(id)?; let col = || -> Option> { @@ -167,7 +178,10 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option { } b"Pant" => { r.warn("spot colours (imported as their RGB equivalent)"); - return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c))); + if depth >= MAX_SPOT_CHAIN { + return None; + } + return s.obj(id, b"srgb").and_then(|c| color_within(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_within(r, c, depth + 1))); } b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 }, _ => { @@ -283,7 +297,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec Option { +pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option { let s = r.s; let paint = descriptor(r, pen, local)?; if paint == Paint::None { @@ -295,6 +309,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) let scales = s.bool(ldsc, b"LDSc").unwrap_or(true); let width = if scales { weight * local.scale() } else { weight }; let data = s.bytes(lsty, b"Data").unwrap_or_default(); + // Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a + // stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their + // owner made, where every such stroke is invisible in Affinity's own PDF export, and checked + // against the public corpus's thumbnails. + if data.get(10) == Some(&0) { + return None; + } let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0); // Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample; // (2, 2) ends plain lines square and brush lines round; joins import round, which never @@ -312,6 +333,10 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) _ => Align::Center, }; let dash = match s.field(lsty, b"Patn") { + Some(Value::Array(a)) if a.len() > MAX_DASHES => { + r.warn("dash patterns of more than 64 lengths (imported as solid lines)"); + None + } Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => { let mut v: Vec = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect(); while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) { diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 98334717d..d458c08a7 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result {} Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })), Err(Error::Limit(e)) => return Err(Error::Limit(e)), Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"), @@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result Result, Error> { +/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand. +fn is_heif(bytes: &[u8]) -> bool { + bytes.get(4..8) == Some(b"ftyp".as_slice()) + && bytes.get(8..12).is_some_and(|brand| { + [b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice()) + }) +} + +/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read +/// over `over` (document pixels) when given; outside its pixels a mask hides everything. +pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option) -> Result, Error> { let s = r.s; let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) }; let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0); @@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result c, + // What it masks lies outside it: all hidden, which one black pixel says as well. + Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })), + None => (0, 0, width, height), + }; + decode(r, bitmap, crop, world) +} + +/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover +/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap. +fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { + let inv = invert(world)?; + let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y })); + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in pts { + if !(p.x.is_finite() && p.y.is_finite()) { + return Some((0, 0, width, height)); + } + (x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y)); + } + let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32; + let (cx0, cy0, cx1, cy1) = + (clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height)); + (cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1)) +} + +fn invert(m: Affine) -> Option { + let [a, b, c, d, e, f] = m.0; + let det = a * d - b * c; + if !(det.is_finite() && det.abs() > 1e-12) { + return None; + } + let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det); + Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)])) } /// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches. @@ -99,33 +145,41 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result Result, Error> { + decode_within(r, bitmap, crop, world, MAX_PIXELS) +} + +/// [`decode`] with at most `max_pixels` decoded pixels. +fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result, Error> { let s = r.s; let (x0, y0, x1, y1) = crop; if x1 <= x0 || y1 <= y0 { return Ok(None); } let (w, h) = (x1 - x0, y1 - y0); - let pixels = u64::from(w) * u64::from(h); - if pixels > MAX_PIXELS { - r.warn("pixel layers larger than 64 megapixels"); - return Ok(None); + // Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their + // resolution: every `step`th pixel of every `step`th row. + let mut step = 1u32; + while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels { + step *= 2; + if step > 1 << 12 { + r.warn("pixel layers too large to read (left out)"); + return Ok(None); + } } // Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract. - let Some(left) = r.pixels_left.checked_sub(pixels) else { + let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)); + let Some(left) = r.budget.pixels.checked_sub(pixels) else { r.warn("pixel layers beyond the pixels one document may hold"); return Ok(None); }; - r.pixels_left = left; + r.budget.pixels = left; let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f); let (channels, bps) = match format { Some(0) => (4, 1), Some(1) => (4, 2), Some(6) => (1, 1), Some(7) => (1, 2), - Some(4) => { - r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)"); - (5, 1) - } + Some(4) => (5, 1), _ => { r.warn("pixel layers in grey, Lab or 32-bit formats"); return Ok(None); @@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi } else { None }; + // Tiles kept only in an original that can't be decoded here are left empty (transparent). Where + // a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come + // out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said. + if needs_source && source.is_none() && channels > 1 { + let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) { + Some(Value::Array(a)) => a.clone(), + _ => Vec::new(), + }; + let alpha = states(channels); + let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5)))); + if blank { + r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)"); + return Ok(None); + } + } + if step > 1 { + // Its stored tiles must fit what is left of the import's extraction budget. + let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum(); + if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total { + r.warn("pixel layers too large to read (left out)"); + return Ok(None); + } + r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)"); + } let mut cache = CachedPixels { tiles: HashMap::new(), source }; for c in 1..=channels { - planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?); + planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?); } + let (w, h) = (w.div_ceil(step), h.div_ceil(step)); let n = (w as usize) * (h as usize); + let k = f64::from(step); + let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world); + if channels == 5 { + // Ink amounts and alpha as stored (8 bits), interleaved. + let mut cmyka = vec![0u8; n * 5]; + for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() { + for (c, v) in px.iter_mut().enumerate() { + *v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0); + } + } + return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform })); + } let mut rgba = vec![0u8; n * 4]; let sample = |p: &Vec, i: usize| -> f64 { if bps == 2 { @@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi }; for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() { let v: Vec = planes.iter().map(|p| sample(p, i)).collect(); - let (r2, g, b, a) = if channels == 1 { - (v[0], v[0], v[0], 1.0) - } else if channels == 5 { - let k = 1.0 - v[3]; - ((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4]) - } else { - (v[0], v[1], v[2], v[3]) - }; + let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) }; px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8)); } - let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world); Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform })) } @@ -229,28 +312,57 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] { [prefix[0], prefix[1], prefix[2], b'0' + c as u8] } -/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample. +/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state). +fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec) { + let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); + let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); + let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE)); + let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); + let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { + Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), + _ => Vec::new(), + }; + (tw, th, states) +} + +/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`. +fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec { + let (tw, th, states) = grid(s, bitmap, c, bps); + let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); + let (py0, py1) = (y0 as usize, (y0 + h) as usize); + let mut out = Vec::new(); + let mut next = 0usize; + for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) { + if *state != 4 { + continue; + } + let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE); + if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) { + out.push(next); + } + next += 1; + } + out +} + +/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of +/// every `step`th row; `bps` bytes per sample. fn plane( r: &mut Reader, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32), + step: u32, cache: &mut CachedPixels, ) -> Result, Error> { let CachedPixels { tiles: cache, source } = cache; let s = r.s; - let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); - let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0); - let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE)); - let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE)); - let states: Vec = match s.field(bitmap, &tag(b"Sta", c)) { - Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(), - _ => Vec::new(), - }; + let (tw, th, states) = grid(s, bitmap, c, bps); let tiles = s.objs(bitmap, &tag(b"Idx", c)); - let row = (w as usize) * bps; - let mut out = vec![0u8; row * h as usize]; + let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize }; + let row = at.w.div_ceil(at.step) * bps; + let mut out = vec![0u8; row * at.h.div_ceil(at.step)]; let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps); let (py0, py1) = (y0 as usize, (y0 + h) as usize); let mut next = 0usize; @@ -267,7 +379,7 @@ fn plane( continue; } let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string(); - if !cache.contains_key(&name) { + let read = |r: &mut Reader| -> Result, Error> { let mut data = r.archive.read(&name)?; // Older files wrap a tile in a one-field stream: header, then a 64 KiB blob. if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") { @@ -276,10 +388,20 @@ fn plane( if data.len() != TILE_BYTES { return Err(Error::Malformed("pixel tile size")); } + Ok(data) + }; + // A layer read at a lower resolution is too large to keep its tiles around. + if at.step > 1 { + let tile = read(r)?; + copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); + continue; + } + if !cache.contains_key(&name) { + let data = read(r)?; cache.insert(name.clone(), data); } let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?; - copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); + copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0)); continue; } 3 => { @@ -289,9 +411,7 @@ fn plane( 5 => { if let Some((source_width, pixels)) = source.as_ref() { let (ox, oy) = (tx * TILE, ty * TILE); - copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| { - pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0) - }); + copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)); continue; } r.warn("pixel layers drawn from an embedded image (left empty)"); @@ -300,17 +420,38 @@ fn plane( _ => return Err(Error::Malformed("unknown pixel tile state")), }; if let Some(v) = fill { - copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v); + copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v); } } Ok(out) } -/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane. -fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { - for y in oy.max(py0)..(oy + TILE).min(py1) { +/// Where a plane's pixels come from: the crop (pixels) and the sampling step. +struct Crop { + bps: usize, + x0: usize, + y0: usize, + w: usize, + h: usize, + step: usize, +} + +/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every +/// `step`th pixel of every `step`th row. +fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) { + let row = c.w.div_ceil(c.step) * c.bps; + let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps); + for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) { + if !(y - c.y0).is_multiple_of(c.step) { + continue; + } + let out_row = (y - c.y0) / c.step * row; for x in ox.max(bx0)..(ox + TILE).min(bx1) { - if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) { + let px = x / c.bps - c.x0; + if !px.is_multiple_of(c.step) { + continue; + } + if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) { *b = at(x - ox, y - oy); } } @@ -323,6 +464,15 @@ mod tests { use crate::model::Kind; use crate::synth::{self, F, Method, tag}; + #[test] + fn heif_originals_are_recognised_by_their_brand() { + assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0")); + assert!(is_heif(b"\0\0\0\x1cftypavif")); + assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo"); + assert!(!is_heif(&png())); + assert!(!is_heif(b"ftyp")); + } + fn png() -> Vec { let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255])); let mut bytes = std::io::Cursor::new(Vec::new()); @@ -333,18 +483,29 @@ mod tests { /// An original synthetic mixed source/cache bitmap. It exercises reader recovery, /// not a claim that Affinity itself accepts the synthetic container. fn fixture(source: &[u8], origin: i32) -> Vec { + fixture_in(source, origin, false) + } + + /// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored. + fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec { + let channels = if cmyk { 5 } else { 4 }; let mut bitmap = vec![ - (tag(b"Frmt"), F::Enum(0, 0)), + (tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)), (tag(b"BmpW"), F::I32(300)), (tag(b"BmpH"), F::I32(2)), (tag(b"LInf"), F::I32(origin)), (tag(b"TInf"), F::I32(0)), (tag(b"Bckg"), F::Entry("c/1".into())), ]; - for c in 1..=4 { + for c in 1..=channels { + let states = match (cmyk, c) { + (false, _) => vec![5, 4], + (true, 5) => vec![4, 4], + (true, _) => vec![5, 5], + }; bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2))); bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1))); - bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4]))); + bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states))); bitmap.push(( tag(&super::tag(b"Idx", c)), F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]), @@ -369,18 +530,44 @@ mod tests { blob.extend((source.len() as u32).to_le_bytes()); blob.extend(source); let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]); - let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect(); - synth::container( - &[ - ("doc.dat", &doc, Method::Zlib), - ("c/1", &block, Method::Zlib), - ("d/1", &tiles[0], Method::Zlib), - ("d/2", &tiles[1], Method::Zlib), - ("d/3", &tiles[2], Method::Zlib), - ("d/4", &tiles[3], Method::Zlib), - ], - None, - ) + let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect(); + let names: Vec = (1..=channels).map(|c| format!("d/{c}")).collect(); + let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)]; + for (name, tile) in names.iter().zip(&tiles) { + entries.push((name.as_str(), tile.as_slice(), Method::Zlib)); + } + synth::container(&entries, None) + } + + #[test] + fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() { + let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap(); + assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings); + assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind); + } + + #[test] + fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() { + let bytes = fixture(&png(), 0); + let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap(); + let doc = archive.read("doc.dat").unwrap(); + let s = stream::parse(&doc).unwrap(); + let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap(); + let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0); + // 20 × 2 pixels over a budget of 10: every other pixel of every other row. + let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap(); + assert_eq!((image.width, image.height), (10, 1)); + assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two"); + let Pixels::Rgba8(pixels) = &image.pixels else { panic!() }; + for x in 0..10 { + let sx = 2 * x; + let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] }; + assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}"); + } + assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)")); + // Within budget, nothing changes. + let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap(); + assert_eq!((image.width, image.height), (20, 2)); } #[test] diff --git a/crates/affinity/src/stream.rs b/crates/affinity/src/stream.rs index a755747c4..7a3010251 100644 --- a/crates/affinity/src/stream.rs +++ b/crates/affinity/src/stream.rs @@ -303,6 +303,10 @@ impl Parser<'_> { 0x2c => { let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?; let size = usize::from(self.c.u16()?); + // Records of no bytes take nothing from the data, whatever their number. + if n > 0 && size == 0 { + return Err(Error::Malformed("array of records without bytes")); + } if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) { return Err(Error::Malformed("array longer than its data")); } diff --git a/crates/affinity/src/tests.rs b/crates/affinity/src/tests.rs index 006f9e7bd..eeb709d34 100644 --- a/crates/affinity/src/tests.rs +++ b/crates/affinity/src/tests.rs @@ -1,4 +1,4 @@ -//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents. +//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents. mod preview { use crate::container::header; @@ -261,6 +261,32 @@ mod archive { assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_)))); } + // Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's + // window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than + // the format permits for a block. 0.9.1 refuses it. + #[test] + fn a_zstd_block_larger_than_its_frames_window_is_refused() { + const LEN: u32 = 100_000; + let plain = vec![0x61u8; LEN as usize]; + let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None); + // A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE + // block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3. + let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00]; + frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap()); + frame.push(0x61); + // Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72). + let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize; + let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize; + let mut swapped = file[..76].to_vec(); + swapped.extend(&frame); + swapped.extend(&file[76 + old_len..]); + let new_fat = fat - old_len + frame.len(); + swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes()); + swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes()); + let mut a = Archive::open(&swapped, Limits::default()).unwrap(); + assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window"); + } + #[test] fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() { let big = vec![7u8; 1 << 20]; diff --git a/crates/affinity/src/text.rs b/crates/affinity/src/text.rs index 2b632cab3..1a3b6e544 100644 --- a/crates/affinity/src/text.rs +++ b/crates/affinity/src/text.rs @@ -2,7 +2,7 @@ //! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning, //! OpenType features) is left to the importing application. -use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect}; +use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect}; use crate::paint::{self, Color, Paint}; use crate::stream::{ObjId, Value}; @@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20; /// Shared character attributes must not amplify into unbounded font/feature/gradient data. const MAX_ATTRIBUTE_BYTES: usize = 16 << 20; +/// Ends a table cell in a table's story (Affinity's break glyph of kind 4). +pub const CELL_BREAK: char = '\u{1F}'; + pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { let s = r.s; let story = s.obj(id, b"StSt")?; let frame = s.obj(id, b"TxtH")?; let artistic = s.is(frame, b"ArFr"); let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?; - let mut runs = Vec::new(); - let mut align = None; - let mut total = 0usize; - let mut placeholders = false; - let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES); - for block in s.objs(story, b"Blok") { - let Some(glyphs) = s.obj(block, b"Glyp") else { - r.warn("a text block without readable characters (left out)"); - continue; - }; - // Bound collection itself, including placeholders, before allocating the character buffer. - let budget = MAX_CHARS - total; - - let mut chars: Vec = match s.str(glyphs, b"Utf8") { - Some(t) => t.chars().take(budget + 1).collect(), - None => { - // Inline objects (page numbers, anchors) each take one index before the segment text. - let segments = s.objs(glyphs, b"Mixd"); - if segments.is_empty() { - r.warn("a text block without readable characters (left out)"); - continue; - } - let mut v = Vec::new(); - for seg in segments { - let inline = s.objs(seg, b"Glys").len(); - v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len()))); - placeholders |= inline > 0; - v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len())); - if v.len() > budget { - break; - } - } - v - } - }; - let truncated = chars.len() > budget; - if truncated { - chars.truncate(budget); - r.warn("text longer than a million characters (truncated)"); - } - total += chars.len(); - let mut start = 0usize; - for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() { - let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else { - r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); - continue; - }; - let end = index.min(chars.len()); - if index > chars.len() || end < start { - r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); - } - if end <= start { - continue; - } - let text = characters(&chars, start, end); - start = end; - push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget); - } - if start < chars.len() { - let text = characters(&chars, start, chars.len()); - push_run(r, &mut runs, None, text, world, &mut attribute_budget); - } - for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { - if let Some(attrs) = s.obj(paragraph, b"Item") { - let paragraph = paragraph_align(r, attrs); - match align { - None => align = Some(paragraph), - Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"), - _ => {} - } - } - } - if truncated { - break; - } - } - if placeholders { - r.warn("text fields such as page numbers (left out of the text)"); - } - let align = align.unwrap_or(Align::Left); + let (runs, align) = story_runs(r, story, world); let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0); let anchor_x = match align { Align::Center => (bounds.x0 + bounds.x1) / 2.0, @@ -111,19 +35,256 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option { r.warn("text frames with a curved outline (imported as rectangular frames)"); } } - Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world }) + let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) }; + Some(Text { + runs, + align, + anchor: Point { x: anchor_x, y: first_baseline }, + frame: if artistic { None } else { Some(bounds) }, + transform: world, + text_transform, + }) +} + +/// Rows and columns a table may have. +const MAX_TABLE_LINES: usize = 4096; +/// Cells a table may have (rows × columns): each becomes a text object with its edge lines. +const MAX_TABLE_CELLS: usize = 1 << 16; + +/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges. +pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option { + let s = r.s; + let story = s.obj(id, b"StSt")?; + let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?; + let bounds = |pos: Option| -> Option> { + let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None }; + let v: Vec = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::>()?; + (v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v) + }; + let columns = bounds(s.obj(tabl, b"CPos"))?; + let rows = bounds(s.obj(tabl, b"RPos"))?; + let (ncols, nrows) = (columns.len() - 1, rows.len() - 1); + let count = ncols.checked_mul(nrows)?; + if count > MAX_TABLE_CELLS { + r.warn("tables with more than 65536 cells (left out)"); + return None; + } + let Some(left) = r.budget.cells.checked_sub(count) else { + r.warn("tables beyond the cells one document may hold (left out)"); + return None; + }; + r.budget.cells = left; + let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default(); + // Formatting that doesn't match the grid would land on the wrong cells (and merging them, + // move text between cells): only text then. + let cell_objs = if cell_objs.len() == count { + cell_objs + } else { + r.warn("tables whose cells don't match their grid (cell formatting left out)"); + Vec::new() + }; + let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false }; + let mut cells: Vec = (0..count).map(|_| blank()).collect(); + for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() { + if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) { + cell.inset = inset; + } + cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) { + Some(1) => VAlign::Center, + Some(2) => VAlign::Bottom, + _ => VAlign::Top, + }; + // A cell merged into the one on its left; the first column has nothing to its left. + cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true); + } + // The story holds the text of every cell that isn't merged away, row by row, each ended by a + // cell break. + let owner_list: Vec = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect(); + let mut owners = owner_list.into_iter(); + let mut target = owners.next(); + let mut extra = false; + let (runs, _) = story_runs(r, story, world); + for run in runs { + let mut parts = run.text.split(CELL_BREAK).peekable(); + while let Some(part) = parts.next() { + if !part.is_empty() { + match target.and_then(|i| cells.get_mut(i)) { + Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }), + None => extra = true, + } + } + if parts.peek().is_some() { + target = owners.next(); + } + } + } + if extra { + r.warn("table text beyond its last cell (left out)"); + } + let mut edges = |tag: &[u8; 4], expected: usize| -> Vec> { + let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default(); + if objs.len() != expected { + r.warn("tables whose lines don't match their grid (lines left out)"); + return Vec::new(); + } + objs.into_iter() + .map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) { + (Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false), + _ => None, + }) + .collect() + }; + let vertical = edges(b"CEdg", (ncols + 1) * nrows); + let horizontal = edges(b"REdg", ncols * (nrows + 1)); + if let Some(format) = s.obj(tabl, b"TFmt") { + let filled = + s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN"))); + if filled { + r.warn("table cell fills (left out)"); + } + } + let text_transform = text_scale(r, id).then(r.type_base); + Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform }) +} + +/// A story's character runs, each with its paragraph's format, and the first paragraph's +/// alignment. Table cells end with [`CELL_BREAK`]. +pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec, Align) { + let s = r.s; + let mut runs = Vec::new(); + let mut first_align = None; + let mut total = 0usize; + let mut placeholders = false; + let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES); + for block in s.objs(story, b"Blok") { + let Some(glyphs) = s.obj(block, b"Glyp") else { + r.warn("a text block without readable characters (left out)"); + continue; + }; + // Bound collection itself, including placeholders, before allocating the character buffer: + // by the story's limit and by what the document's other stories left. + let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total)); + + let mut chars: Vec = match s.str(glyphs, b"Utf8") { + Some(t) => t.chars().take(budget + 1).collect(), + None => { + // Inline objects (page numbers, anchors, cell ends) each take one index before + // the segment text. + let segments = s.objs(glyphs, b"Mixd"); + if segments.is_empty() { + r.warn("a text block without readable characters (left out)"); + continue; + } + let mut v = Vec::new(); + for seg in segments { + for inline in s.objs(seg, b"Glys") { + if v.len() > budget { + break; + } + if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) { + v.push(CELL_BREAK); + } else { + v.push('\u{FFFC}'); + placeholders = true; + } + } + v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len()))); + if v.len() > budget { + break; + } + } + v + } + }; + let truncated = chars.len() > budget; + if truncated { + chars.truncate(budget); + if budget < MAX_CHARS - total { + r.warn("text beyond the characters one document may hold (truncated)"); + } else { + r.warn("text longer than a million characters (truncated)"); + } + } + total += chars.len(); + // Paragraph formats by end index; a run without an index covers the rest of the block. + let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new(); + let mut open: Option = None; + for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() { + let Some(attrs) = s.obj(run, b"Item") else { continue }; + let a = paragraph(r, attrs); + first_align.get_or_insert(a.align); + match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) { + Some(end) => paragraphs.push((end, a)), + None => match open { + None => open = Some(a), + Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"), + _ => {} + }, + } + } + // Sorted by end (they are in order in every file), so both lookups below are binary + // searches: a block can have as many paragraphs and runs as characters. + paragraphs.sort_by_key(|(end, _)| *end); + let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default(); + let mut cuts: Vec = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect(); + cuts.dedup(); + let push = |r: &mut Reader, runs: &mut Vec, attrs: Option, from: usize, to: usize, budget: &mut Option| { + // Split at paragraph ends so every run has one paragraph format. + let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default(); + let mut a = from; + for &c in inside.iter().chain(std::iter::once(&to)) { + push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a)); + a = c; + } + }; + let mut start = 0usize; + for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() { + let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else { + r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); + continue; + }; + let end = index.min(chars.len()); + if index > chars.len() || end < start { + r.warn("invalid text attribute range (bounded; unassigned characters use defaults)"); + } + if end <= start { + continue; + } + push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget); + start = end; + } + if start < chars.len() { + push(r, &mut runs, None, start, chars.len(), &mut attribute_budget); + } + if truncated { + break; + } + } + r.budget.chars = r.budget.chars.saturating_sub(total); + if placeholders { + r.warn("text fields such as page numbers (left out of the text)"); + } + (runs, first_align.unwrap_or(Align::Left)) } fn characters(chars: &[char], start: usize, end: usize) -> String { chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect() } -fn push_run(r: &mut Reader, runs: &mut Vec, attrs: Option, text: String, world: Affine, attribute_budget: &mut Option) { +fn push_run( + r: &mut Reader, + runs: &mut Vec, + attrs: Option, + text: String, + world: Affine, + attribute_budget: &mut Option, + paragraph: Paragraph, +) { if text.is_empty() { return; } let run = match attrs { - Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget), + Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) }, _ => { // This is a recovery policy, not an assertion about unspecified Affinity attributes. if attrs.is_none() { @@ -137,10 +298,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec, attrs: Option, text: italic: false, features: Vec::new(), all_caps: false, + underline: false, + strikethrough: false, size: 12.0 * r.dpi / 72.0, tracking: 0.0, + h_scale: 1.0, leading: None, fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }), + stroke: None, + paragraph, } } }; @@ -195,13 +361,33 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }); } } - if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None) - { - r.warn("outlined (stroked) text"); - } - if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) { - r.warn("horizontally scaled text"); + // Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes). + let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) { + (true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false), + _ => None, + }; + // Its gradient stops and dashes are owned data too, charged like the fill's. + if let Some(st) = &stroke { + let stops = match &st.paint { + Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::()), + _ => 0, + }; + let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::())); + if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) { + stroke = None; + } } + // Underline (slot 0) and strikethrough (slot 1): 1 is a single line. + let mut decoration = |i: usize| match slot(ints, i, int) { + Some(1) => true, + Some(0) | None => false, + Some(_) => { + r.warn("text decorations of an unknown kind (left out)"); + false + } + }; + let (underline, strikethrough) = (decoration(0), decoration(1)); + let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0); // A resolved font is populated on the emoji run of the public Affinity 3 text-runs file // (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common. // Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af @@ -220,8 +406,8 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) { r.warn("font width variants (exact face depends on the installed PostScript font)"); } - let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default()); - let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default()); + let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default()); + let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default()); let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget); let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400); let weight = if (1..=1000).contains(&weight) { @@ -238,10 +424,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false), features, all_caps, + underline, + strikethrough, size, tracking, + h_scale, leading, fill, + stroke, + paragraph: Paragraph::default(), } } @@ -260,6 +451,17 @@ fn charge_attributes(r: &mut Reader, budget: &mut Option, bytes: usize) - } } +/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it. +const MAX_FONT_NAME: usize = 256; + +fn font_name(r: &mut Reader, budget: &mut Option, value: &str) -> String { + if value.len() > MAX_FONT_NAME { + r.warn("font names longer than 256 bytes (the default font is used)"); + return String::new(); + } + metadata_string(r, budget, value) +} + fn metadata_string(r: &mut Reader, budget: &mut Option, value: &str) -> String { let bytes = value.len().saturating_add(std::mem::size_of::()); if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() } @@ -303,13 +505,18 @@ fn font_features(r: &mut Reader, attrs: Option, attribute_budget: &mut Op (features, all_caps) } -fn paragraph_align(r: &mut Reader, p: ObjId) -> Align { - // Nonzero offsets in these slots are present in the public text-indent and text-para-spacing - // fixtures. Their layout is not represented by this importer; retain text and report the gap. - if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) { - r.warn("paragraph indents or spacing (imported with defaults)"); - } - match slot(r.s.field(p, b"Ints"), 0, int) { +/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic +/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading +/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the +/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space +/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing +/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the +/// thumbnail of a document whose owner made it earlier. +fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph { + let doubles = r.s.field(p, b"Doub"); + let ints = r.s.field(p, b"Ints"); + let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0); + let align = match slot(ints, 0, int) { Some(1) => Align::Center, Some(2) => Align::Right, Some(3) => Align::Justify, @@ -318,5 +525,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align { r.warn("an unknown paragraph alignment (imported as left)"); Align::Left } + }; + let leading = match slot(ints, 1, int) { + Some(2) => Some(at(1)).filter(|l| *l > 0.0), + Some(0) | None => None, + Some(_) => { + r.warn("paragraph leading of an unknown kind (imported as automatic)"); + None + } + }; + let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0); + Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading } +} + +/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set. +/// Neither the frame's own transform nor its groups' scale its type (checked against the +/// thumbnails of tables and frames inside scaled groups). +fn text_scale(r: &mut Reader, id: ObjId) -> Affine { + match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) { + Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]), + Some(_) => { + r.warn("text frames with an invalid text scale (imported unscaled)"); + Affine::IDENTITY + } + None => Affine::IDENTITY, } } diff --git a/crates/affinity/tests/it/import_limits.rs b/crates/affinity/tests/it/import_limits.rs new file mode 100644 index 000000000..088c33912 --- /dev/null +++ b/crates/affinity/tests/it/import_limits.rs @@ -0,0 +1,176 @@ +//! Crafted documents can't make the reader allocate or work without bound: tables, shared +//! stories, paragraphs and embedded documents stay within the whole read's budget, however often +//! a file links or embeds the same content. Original synthetic documents in the reader's +//! accepted schema; no Affinity software or files were used. + +use crate::text_fixture::*; +use vectorcraft_affinity::{ + Document, Error, Limits, + model::{Kind, Node}, + read, + synth::{self, F, Method, tag}, +}; + +type Field = (vectorcraft_affinity::stream::Tag, F); + +/// A document of one spread holding `nodes`, plus these other archive entries. +fn file(nodes: Vec, entries: &[(&str, &[u8])]) -> Vec { + let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]); + let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]; + let mut next_id = 1000; + for (_, value) in &mut root { + shared_objects(value, &mut next_id); + } + let data = synth::stream(&root); + let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)]; + all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored))); + synth::container(&all, None) +} + +fn open(nodes: Vec) -> Document { + read(&file(nodes, &[]), Limits::default()).unwrap() +} + +fn all(nodes: &[Node]) -> Vec<&Node> { + let mut out = Vec::new(); + for n in nodes { + out.push(n); + out.extend(all(&n.children)); + } + out +} + +fn warned(d: &Document, needle: &str) -> bool { + d.warnings.iter().any(|w| w.contains(needle)) +} + +fn story(text: &str) -> F { + F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))]) +} + +fn artistic(story: F) -> F { + F::Obj( + tag(b"TxtA"), + vec![ + (tag(b"StSt"), story), + (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])), + ], + ) +} + +/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines. +fn grid(cols: usize, rows: usize) -> Vec { + let pos = |n: usize| { + let v: Vec = (0..=n).map(|i| i as f64).collect(); + F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))]) + }; + let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]); + vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)] +} + +fn table(frame: F) -> F { + F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)]) +} + +fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> { + all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect() +} + +#[test] +fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() { + // 4096 × 4096 is within each dimension's limit but 16.7 million cells. + let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]); + let t = tables(&d); + assert_eq!(t.len(), 1); + assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match"); + assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings); +} + +#[test] +fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() { + // One 65536-cell grid linked from five tables: four fill the document's 262144 cells. + let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256)); + let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect()); + assert_eq!(tables(&d).len(), 4); + assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings); +} + +#[test] +fn a_shared_story_is_read_within_the_characters_of_one_document() { + // A story of a million characters linked from twenty text layers: 16 million in all. + let text = "x".repeat(1 << 20); + let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]); + let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect()); + let chars: usize = all(&d.spreads[0].nodes) + .into_iter() + .filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::()) } else { None }) + .sum(); + assert_eq!(chars, 16 << 20); + assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings); +} + +#[test] +fn many_paragraphs_and_runs_are_matched_in_about_linear_time() { + // 200 000 paragraphs, each with its own run and format. + let n = 200_000; + let text = "a\u{2029}".repeat(n); + let fmt = + |end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))]; + let blk = F::Obj( + tag(b"StBl"), + vec![ + (tag(b"Glyp"), utf8(&text)), + (tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])), + (tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])), + ], + ); + let started = std::time::Instant::now(); + let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]); + let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") }; + assert_eq!(t.runs.len(), n); + // (About half a second; quadratic matching took over half a minute on the same machine.) + assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed()); +} + +/// A shape node of `size` px. +fn rect(size: f64) -> F { + F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))]) +} + +/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own. +fn embedded(children: Vec) -> F { + let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]); + let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))]; + if !children.is_empty() { + fields.push((tag(b"Chld"), F::Shared(children))); + } + F::Obj(tag(b"EmbN"), fields) +} + +fn embedding(inner: &[u8], nodes: Vec) -> Vec { + let mut entry = b"EmDc\0\0\0\0".to_vec(); + entry.extend_from_slice(inner); + file(nodes, &[("edc/1", &entry)]) +} + +#[test] +fn an_embedded_documents_content_stays_with_its_node() { + // Read before the node's own children, which must not take it. + let inner = file(vec![rect(10.0), rect(20.0)], &[]); + let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap(); + let node = &d.spreads[0].nodes[0]; + assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes"); + assert!(node.children.iter().all(|c| c.children.is_empty())); +} + +#[test] +fn embedding_one_document_many_times_shares_one_budget() { + // 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000 + // a whole read may take (each embedding used to have an allowance of its own). + let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]); + let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect()); + assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_)))); + // Embedded fewer times, it opens. + let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap(); + assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000); +} diff --git a/crates/affinity/tests/it/main.rs b/crates/affinity/tests/it/main.rs new file mode 100644 index 000000000..84b343019 --- /dev/null +++ b/crates/affinity/tests/it/main.rs @@ -0,0 +1,20 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +#[path = "../support/corpus.rs"] +mod corpus; +#[path = "../support/features.rs"] +mod features; +#[cfg(feature = "synth")] +#[path = "../support/text_fixture.rs"] +mod text_fixture; + +#[cfg(feature = "synth")] +mod import_limits; +#[cfg(feature = "synth")] +mod outline_limits; +mod raster; +mod real_files; +#[cfg(feature = "synth")] +mod text; diff --git a/crates/affinity/tests/outline_limits.rs b/crates/affinity/tests/it/outline_limits.rs similarity index 98% rename from crates/affinity/tests/outline_limits.rs rename to crates/affinity/tests/it/outline_limits.rs index b48eb494b..74151c192 100644 --- a/crates/affinity/tests/outline_limits.rs +++ b/crates/affinity/tests/it/outline_limits.rs @@ -1,7 +1,5 @@ //! Original synthetic graph tests for bounded `.af` outline expansion. These model the reader's //! accepted schema, without using Affinity software, third-party parser code or external assets. -#![cfg(feature = "synth")] -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use vectorcraft_affinity::{ Document, Limits, diff --git a/crates/affinity/tests/raster.rs b/crates/affinity/tests/it/raster.rs similarity index 91% rename from crates/affinity/tests/raster.rs rename to crates/affinity/tests/it/raster.rs index e477b4d26..794b4c4f1 100644 --- a/crates/affinity/tests/raster.rs +++ b/crates/affinity/tests/it/raster.rs @@ -1,7 +1,6 @@ //! Pixel retention on current Affinity 3 documents published under MIT by Patchy. //! Assertions use this reader's archive/stream and the original embedded image bytes; //! no third-party Affinity parser, source code or application is used as an oracle. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use vectorcraft_affinity::{ Archive, Limits, @@ -9,8 +8,7 @@ use vectorcraft_affinity::{ stream, }; -#[path = "support/corpus.rs"] -mod corpus; +use crate::corpus; fn images(nodes: &[Node], out: &mut Vec) { for node in nodes { @@ -103,13 +101,16 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() { } #[test] -fn cmyk_and_lab_losses_are_reported() { +fn cmyk_pixels_stay_cmyk_and_lab_losses_are_reported() { + // CMYK layers keep their inks for the importer to convert through a colour profile. let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return }; assert_eq!(images.len(), 1); assert_eq!((images[0].width, images[0].height), (64, 48)); - let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() }; - assert_eq!(crc32fast::hash(pixels), 0x5872bb76); - assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]); + let Pixels::Cmyka8(pixels) = &images[0].pixels else { panic!() }; + assert_eq!(pixels.len(), 64 * 48 * 5); + assert_eq!(crc32fast::hash(pixels), 0x56b1f659); + assert!(cmyk.warnings.is_empty(), "{:?}", cmyk.warnings); + assert_eq!(cmyk.cmyk_profile, None, "this file embeds no profile"); let (_, lab, images) = read_fixture("patchy-lab.af").unwrap(); assert!(images.is_empty()); assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]); diff --git a/crates/affinity/tests/real_files.rs b/crates/affinity/tests/it/real_files.rs similarity index 93% rename from crates/affinity/tests/real_files.rs rename to crates/affinity/tests/it/real_files.rs index 66beca842..a1d72bd01 100644 --- a/crates/affinity/tests/real_files.rs +++ b/crates/affinity/tests/it/real_files.rs @@ -2,16 +2,13 @@ //! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by //! `AFFINITY_SAMPLES` (searched recursively). CI requires a complete, checksum-verified pinned //! corpus via `AFFINITY_CORPUS_REQUIRED=1`; ordinary local runs may skip an absent corpus. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::path::{Path, PathBuf}; use vectorcraft_affinity::{Archive, Limits, stream}; -#[path = "support/corpus.rs"] -mod corpus; -#[path = "support/features.rs"] -mod features; +use crate::corpus; +use crate::features; fn files(dir: &Path, out: &mut Vec) { for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { @@ -90,7 +87,7 @@ fn public_documents_map_to_the_model() { #[test] fn affinity3_feature_retention_matches_pinned_signatures() { let Some(dir) = corpus::pinned() else { return }; - let expected = include_str!("support/affinity3-signatures.txt"); + let expected = include_str!("../support/affinity3-signatures.txt"); let mut checked = std::collections::BTreeSet::new(); for line in expected.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) { let (name, signature) = line.split_once('\t').expect("signature row must have a tab"); @@ -108,7 +105,7 @@ fn affinity3_feature_retention_matches_pinned_signatures() { doc.saved_by ); } - let manifest = include_str!("../../../xtask/affinity-corpus.sha256"); + let manifest = include_str!("../../../../xtask/affinity-corpus.sha256"); let af_names: std::collections::BTreeSet<_> = manifest.lines().filter_map(|l| l.split_once(" ")).map(|(_, n)| n).filter(|n| n.ends_with(".af")).collect(); assert_eq!(checked, af_names, "every pinned .af file needs a reviewed structural baseline"); diff --git a/crates/affinity/tests/text.rs b/crates/affinity/tests/it/text.rs similarity index 79% rename from crates/affinity/tests/text.rs rename to crates/affinity/tests/it/text.rs index 8d6a203e5..35a621f81 100644 --- a/crates/affinity/tests/text.rs +++ b/crates/affinity/tests/it/text.rs @@ -1,17 +1,11 @@ //! Synthetic `.af` version-12 text regression oracles; see support/text_fixture.rs for provenance. //! Unicode ranges below test the scalar-index contract corroborated by the pinned Affinity 3 //! mixed-run fixture. Broad complex typography still needs independent visual validation. -#![cfg(feature = "synth")] -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod support { - pub mod corpus; - pub mod text_fixture; -} -use support::text_fixture::*; +use crate::text_fixture::*; use vectorcraft_affinity::{ Document, Limits, - model::{Align, Kind, Text}, + model::{Align, Kind, Paragraph, Text}, paint::{Color, Paint}, synth::{F, tag}, }; @@ -108,7 +102,7 @@ fn mixed_paragraph_alignments_are_explicitly_reported() { let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]); assert_eq!(content(text(&d)), "one\u{2029}two"); assert_eq!(text(&d).align, Align::Right); - assert!(warning(&d, "mixed paragraph alignments")); + assert!(warning(&d, "mixed paragraph formats")); } #[test] @@ -179,7 +173,7 @@ fn stored_fallback_font_is_used_only_when_it_names_a_font() { #[test] fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() { - let Some(corpus) = support::corpus::pinned() else { return }; + let Some(corpus) = crate::corpus::pinned() else { return }; // Files fetched at the commit and sha256 in xtask/affinity-corpus.sha256. No Patchy parser // source was read; assertions describe this crate's parsing of its published MIT documents. let load = |name: &str| vectorcraft_affinity::read(&std::fs::read(corpus.join(name)).unwrap(), Limits::default()).unwrap(); @@ -194,10 +188,22 @@ fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() { assert_eq!(texts.len(), 2); assert!(texts[0].runs[0].all_caps); assert_eq!(texts[1].runs[0].features, ["smcp"]); - for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] { + // Indents and paragraph spacing are read (values in document pixels, as these fixtures set + // them in Affinity's paragraph panel). + let paragraphs = |name: &str| -> Vec { let d = load(name); - assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings); - } + assert!(!warning(&d, "paragraph"), "{name}: {:?}", d.warnings); + d.spreads[0] + .nodes + .iter() + .filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None }) + .flat_map(|t| t.runs.iter().map(|r| r.paragraph)) + .collect() + }; + let p = paragraphs("patchy-text-indent.af"); + assert_eq!(p.iter().map(|p| (p.left, p.first, p.space_after)).collect::>(), [(40.0, 0.0, 12.0), (0.0, 25.0, 12.0)]); + let p = paragraphs("patchy-text-para-spacing.af"); + assert_eq!(p.iter().map(|p| (p.space_before, p.space_after)).collect::>(), [(9.0, 17.0)]); } proptest::proptest! { @@ -247,9 +253,19 @@ fn excessive_feature_settings_are_bounded_and_reported() { assert!(warning(&d, "more than 256 text feature settings")); } +#[test] +fn font_names_longer_than_256_bytes_are_dropped() { + // Each paragraph and run carries its font's name: a huge one would be copied with each. + let d = read(vec![block(utf8("x"), vec![run(1, Some(attrs(&"F".repeat(257), "Inter-Regular", 400, false, 12.0)))], &[])]); + let r = &text(&d).runs[0]; + assert!(r.family.is_empty() && r.postscript == "Inter-Regular"); + assert!(warning(&d, "font names longer than 256 bytes")); +} + #[test] fn shared_font_metadata_amplification_is_bounded_without_losing_characters() { - let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0); + // The longest font names kept (see `font_names_longer_than_256_bytes_are_dropped`). + let mut a = attrs(&"F".repeat(256), &"P".repeat(256), 400, false, 12.0); if let F::Obj(_, fields) = &mut a { let settings = F::Obj( tag(b"OtAt"), @@ -266,11 +282,12 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() { } let F::Obj(class, fields) = a else { panic!("attributes must be an object") }; let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))]; - runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42))))); - let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]); + // About 7.7 kB of names and features per run: the budget runs out after about 2200 runs. + runs.extend((2..=4000).map(|end| run(end, Some(F::Ref(42))))); + let d = read(vec![block(utf8(&"x".repeat(4000)), runs, &[])]); let t = text(&d); - assert_eq!(content(t), "x".repeat(1000)); - assert_eq!(t.runs.len(), 1000); + assert_eq!(content(t), "x".repeat(4000)); + assert_eq!(t.runs.len(), 4000); let metadata: usize = t .runs .iter() @@ -279,7 +296,7 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() { .map(|s| s.len() + std::mem::size_of::()) .sum(); assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes"); - assert_eq!(t.runs[0].family.len(), 16 * 1024); + assert_eq!(t.runs[0].family.len(), 256); assert_eq!(t.runs[0].features.len(), 256); assert!(t.runs.last().unwrap().family.is_empty()); assert!(t.runs.last().unwrap().features.is_empty()); @@ -320,3 +337,39 @@ fn shared_gradient_attributes_are_bounded_without_losing_characters() { assert!(warning(&d, "text attribute budget exceeded")); assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent"); } + +#[test] +fn shared_gradient_outlines_are_bounded_without_losing_characters() { + let color = + F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]); + let mut colors = vec![color]; + colors.extend((1..1024).map(|_| F::Ref(43))); + let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect(); + let pen = F::Obj( + tag(b"FilG"), + vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))], + ); + let style = F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]); + // `Objs` 1 and 2: the outline's paint and line style, shared by every run. + let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![solid(1.0), pen, style]))]); + let mut runs = vec![run(1, Some(attrs))]; + runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42))))); + let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]); + let t = text(&d); + assert_eq!(content(t), "x".repeat(1000)); + assert_eq!(t.runs.len(), 1000); + let stop_count: usize = t + .runs + .iter() + .filter_map(|r| r.stroke.as_ref()) + .map(|s| match &s.paint { + Paint::Gradient(g) => g.stops.len(), + _ => 0, + }) + .sum(); + assert!(stop_count * std::mem::size_of::() <= 16 << 20); + let Some(Paint::Gradient(first)) = t.runs[0].stroke.as_ref().map(|s| &s.paint) else { panic!("the first outline must fit") }; + assert_eq!(first.stops.len(), 1024); + assert!(t.runs.last().unwrap().stroke.is_none()); + assert!(warning(&d, "text attribute budget exceeded")); +} diff --git a/crates/affinity/tests/support/affinity3-signatures.txt b/crates/affinity/tests/support/affinity3-signatures.txt index fb6c94b29..779a58552 100644 --- a/crates/affinity/tests/support/affinity3-signatures.txt +++ b/crates/affinity/tests/support/affinity3-signatures.txt @@ -1,33 +1,33 @@ -affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"] -affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"] -affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"] -affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"] -patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] -patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] -patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[] -patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"] -patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"] -patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"] -patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"] -patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"] -patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[] -patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"] -patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"] -patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"] -patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"] -patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[] -patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] -patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] -patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"] -patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"] -patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"] -patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"] -patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"] -patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] -patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"] -patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[] +affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"] +affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"] +affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"] +affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"] +patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] +patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"] +patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[] +patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=8497c917;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"] +patchy-cmyk.af dpi=72;spreads=1;model_crc32=56754ce9;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-dpi300.af dpi=300;spreads=1;model_crc32=2561f9d3;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=ab4b6fbf;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-fx-blur.af dpi=72;spreads=1;model_crc32=29b5083b;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["blur effects (left out) (2×)"] +patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=96cd10be;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=[] +patchy-fx.af dpi=72;spreads=1;model_crc32=7c8d4d20;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=[] +patchy-group.af dpi=72;spreads=1;model_crc32=dc37ab6c;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[] +patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=38c9c5f4;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-lab.af dpi=72;spreads=1;model_crc32=df3a4e40;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"] +patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"] +patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"] +patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[] +patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] +patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] +patchy-text-frame.af dpi=72;spreads=1;model_crc32=3bff8ef9;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[] +patchy-text-indent.af dpi=72;spreads=1;model_crc32=dc36dfc5;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"] +patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=6408f39b;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"] +patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"] +patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"] +patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[] +patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"] +patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[] diff --git a/crates/affinity/tests/support/features.rs b/crates/affinity/tests/support/features.rs index 31d3eb53f..f7024feb6 100644 --- a/crates/affinity/tests/support/features.rs +++ b/crates/affinity/tests/support/features.rs @@ -35,7 +35,12 @@ pub fn signature(doc: &Document) -> String { } "texts" } + Kind::Table(t) => { + *counts.entry("table_cells").or_default() += t.cells.len(); + "tables" + } Kind::Image(_) => "images", + Kind::MasterInstance { .. } => "master_instances", Kind::Unsupported => "unsupported", }; *counts.entry(key).or_default() += 1; diff --git a/crates/affinity/tests/support/text_fixture.rs b/crates/affinity/tests/support/text_fixture.rs index d629253d3..574a0ca38 100644 --- a/crates/affinity/tests/support/text_fixture.rs +++ b/crates/affinity/tests/support/text_fixture.rs @@ -140,7 +140,7 @@ pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]; // Shared arrays require definitions/references, whereas inline fields use Obj. Give the original // fixture objects independent shared IDs without making the fixture descriptions noisy. -fn shared_objects(value: &mut F, next_id: &mut u32) { +pub fn shared_objects(value: &mut F, next_id: &mut u32) { match value { F::Shared(items) => { for item in items { diff --git a/crates/brush/src/colorize.rs b/crates/brush/src/colorize.rs index b9c85c12e..80d302b66 100644 --- a/crates/brush/src/colorize.rs +++ b/crates/brush/src/colorize.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Node}; +use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use crate::Colorization; @@ -43,7 +43,8 @@ fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) { } /// Recolour `n` (recursively) for a stroke painted `stroke`, and strip brushes from its strokes so -/// brush output never recurses. Non-solid stroke paints leave the art's colours alone. +/// brush output never recurses. Non-solid stroke paints leave the art's colours alone. Type is +/// recolored like paths: its characters' solid fills and strokes as well as its own. pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) { let key = match (mode, stroke.color()) { (Colorization::None, _) | (_, None) => None, @@ -65,15 +66,43 @@ pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) { } } } + if let (Some(k), NodeKind::Text(t)) = (&key, &mut n.kind) { + for r in &mut t.runs { + map_paint(&mut r.style.fill, mode, k); + map_paint(&mut r.style.stroke, mode, k); + } + } if let Some(ch) = n.children_mut() { for c in ch.iter_mut() { walk(Arc::make_mut(c), mode, key); } } + // A mask's art is drawn too: a brush on its strokes would run this brush again. Its colours + // are the mask's values, so they stay. + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + strip_brushes(Arc::make_mut(&mut m.art)); + } } walk(n, mode, key); } +/// Strip the brushes from every stroke under `n`, its masks' art included. +fn strip_brushes(n: &mut Node) { + for it in &mut n.appearance.items { + if let AppearanceItem::Stroke(s) = it { + s.brush = None; + } + } + if let Some(ch) = n.children_mut() { + for c in ch.iter_mut() { + strip_brushes(Arc::make_mut(c)); + } + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + strip_brushes(Arc::make_mut(&mut m.art)); + } +} + /// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer. pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { let amount = amount.clamp(0.0, 1.0); @@ -88,8 +117,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) { } if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind { for r in &mut t.runs { - if let Paint::Solid { color: c, .. } = &mut r.style.fill { - *c = c.lerp(color, amount); + for p in [&mut r.style.fill, &mut r.style.stroke] { + if let Paint::Solid { color: c, .. } = p { + *c = c.lerp(color, amount); + } } } } @@ -119,6 +150,8 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node { /// The solid colours of the fills and strokes under `nodes`, most used first (then in the order /// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being /// its default. +/// Type's characters count as well: each run's fill, and its stroke when the stroke is wider +/// than 0. pub fn art_colors<'a>(nodes: impl IntoIterator) -> Vec { fn walk(n: &Node, seen: &mut std::collections::HashMap) { for it in &n.appearance.items { @@ -131,6 +164,15 @@ pub fn art_colors<'a>(nodes: impl IntoIterator) -> Vec { seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1; } } + if let NodeKind::Text(t) = &n.kind { + for r in &t.runs { + let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke); + for c in [Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color) { + let order = seen.len(); + seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1; + } + } + } for c in n.children().into_iter().flatten() { walk(c, seen); } diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index a53d20519..1f7af6e60 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -413,6 +413,8 @@ impl Bristle { pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0); pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0); pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0); + /// The most characters `brush.new` and `brush.options` take for a new bristle brush name. + pub const MAX_NAME_CHARS: usize = 31; } impl Default for Bristle { @@ -456,6 +458,31 @@ impl Brush { } } + /// A brush definition from JSON (a stored library entry or a command's params), its values + /// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key` + /// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or + /// black when they use no solid color. + pub fn from_json(mut v: serde_json::Value) -> Result { + let keyless = v.get("colorization").is_some_and(|c| { + c.get("method").and_then(serde_json::Value::as_str) == Some("hueShift") && c.get("key").is_none_or(serde_json::Value::is_null) + }); + if keyless { + // Read the art with the colorization field left out. + let mut plain = v.clone(); + if let Some(o) = plain.as_object_mut() { + o.remove("colorization"); + } + let b: Brush = serde_json::from_value(plain)?; + let key = art_colors(b.art()).first().copied().unwrap_or(vectorcraft_color::Color::BLACK); + if let Some(c) = v.get_mut("colorization").and_then(serde_json::Value::as_object_mut) { + c.insert("key".into(), serde_json::to_value(key)?); + } + } + let mut b: Brush = serde_json::from_value(v)?; + b.sanitize(); + Ok(b) + } + /// Keep every value of the definition within its range (definitions come from files and /// commands, so they are untrusted): numbers are clamped, non-numbers take the default. pub fn sanitize(&mut self) { @@ -517,14 +544,9 @@ pub fn library(doc: &Document) -> Vec { } /// Parse a stored library value (malformed entries are skipped, values kept within their -/// ranges — see [`Brush::sanitize`]). +/// ranges; see [`Brush::from_json`]). pub fn parse_library(v: &serde_json::Value) -> Vec { - let parse = |b: &serde_json::Value| { - let mut b: Brush = serde_json::from_value(b.clone()).ok()?; - b.sanitize(); - Some(b) - }; - v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default() + v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default() } /// Store `lib` as the document's brush library. @@ -667,6 +689,10 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec, known: &mut Known) { /// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed. pub fn node_pieces(doc: &Document, n: &Node) -> Option> { let bp = node_bezpath(n)?; + // The library is parsed for a stroke that names a brush, not for every path. + if !n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some())) { + return None; + } let lib = library(doc); let mut out = vec![]; let mut any = false; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 3dcdbf9bc..8c04e102b 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -308,6 +308,63 @@ fn tints_colorization_uses_stroke_colour() { assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]); } +/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide). +fn type_art(fill: Paint, stroke: Paint) -> Node { + use vectorcraft_doc::{CharStyle, TextObject}; + let style = CharStyle { fill, stroke, stroke_width: 1.0, ..CharStyle::default() }; + Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Ag", style)))) +} + +/// The characters' fills and strokes of every piece of type in `nodes`. +fn run_paints(nodes: &[Node]) -> Vec<(Paint, Paint)> { + let mut out = vec![]; + for n in nodes { + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + out.extend(t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone()))); + } + }); + } + out +} + +/// A scatter brush's colorization recolors the type in its art as it recolors the art's paths. +#[test] +fn scatter_colorization_recolors_type() { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let blue = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 1.0); + let mut path = rect_art(10.0, 4.0); + path.appearance.set_fill(red.clone()); + let art = Node::group(NodeId(0), vec![Arc::new(path), Arc::new(type_art(red.clone(), red.clone()))]); + let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::Tints, ..Default::default() }) }; + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue); + assert!(!out.is_empty()); + let path_fill = out[0].children().unwrap()[0].appearance.fill_paint(); + assert_ne!(path_fill, red); + let paints = run_paints(&out); + assert_eq!(paints.len(), out.len()); + assert!(paints.iter().all(|p| *p == (path_fill.clone(), path_fill.clone())), "{paints:?}"); + // Black type takes the stroke color itself. + let black = Paint::solid(Color::BLACK); + let b = Brush { + name: "s".into(), + kind: BrushKind::Scatter(Scatter { art: type_art(black.clone(), black), colorization: Colorization::Tints, ..Default::default() }), + }; + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue); + assert!(!out.is_empty()); + assert!(run_paints(&out).iter().all(|p| *p == (blue.paint.clone(), blue.paint.clone()))); +} + +/// The Symbol Stainer mixes the characters' strokes of type toward the stain, as it mixes their fills. +#[test] +fn stain_mixes_type_strokes_too() { + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let blue = Color::rgb(0.0, 0.0, 1.0); + let mut art = type_art(red.clone(), red); + tint_node(&mut art, &blue, 1.0); + assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]); +} + #[test] fn library_defaults_store_and_find() { let mut d = Document::new(100.0, 100.0); @@ -557,11 +614,46 @@ fn art_colors_list_the_most_used_first() { |c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0)); let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]); assert_eq!(art_colors([&g]), vec![blue, red]); + // Type counts its characters' fills and drawn strokes. + let text = type_art(Paint::solid(red), Paint::solid(red)); + let g = Node::group(NodeId(0), vec![Arc::new(path(blue)), Arc::new(text.clone())]); + assert_eq!(art_colors([&g]), vec![red, blue]); + let NodeKind::Text(mut t) = text.kind else { panic!("not type") }; + t.runs[0].style.stroke_width = 0.0; + let text = Node::new(NodeId(0), NodeKind::Text(t)); + let g = Node::group(NodeId(0), vec![Arc::new(text), Arc::new(path(blue)), Arc::new(path(blue))]); + assert_eq!(art_colors([&g]), vec![blue, red], "a stroke 0 wide is not counted"); let b = defaults().iter().find(|b| b.name == "Chain").unwrap(); assert_eq!(b.art().len(), 3, "the side and both corner tiles"); assert!(calli(0.0, 100.0, 3.0).art().is_empty()); } +/// A Hue Shift definition without a key color takes the art's most used color, or black when the +/// art has no solid color; a key that is given is kept. +#[test] +fn hue_shift_without_a_key_takes_the_most_used_art_color() { + let red = Color::rgb(1.0, 0.0, 0.0); + let blue = Color::rgb(0.0, 0.0, 1.0); + let path = |fill: Paint| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(fill, Paint::None, 0.0)); + let art = Node::group(NodeId(0), vec![Arc::new(path(Paint::solid(red))), Arc::new(path(Paint::solid(blue))), Arc::new(path(Paint::solid(blue)))]); + let lib = parse_library(&serde_json::json!([ + {"name": "s", "type": "scatter", "art": art, "colorization": {"method": "hueShift"}}, + {"name": "a", "type": "art", "art": art, "colorization": {"method": "hueShift", "key": null}}, + {"name": "p", "type": "pattern", "side": art, "colorization": {"method": "hueShift", "key": red}}, + {"name": "n", "type": "art", "art": path(Paint::None), "colorization": {"method": "hueShift"}}, + ])); + let keys: Vec<(&str, Colorization)> = lib.iter().map(|b| (b.name.as_str(), b.kind.colorization())).collect(); + assert_eq!( + keys, + vec![ + ("s", Colorization::HueShift { key: blue }), + ("a", Colorization::HueShift { key: blue }), + ("p", Colorization::HueShift { key: red }), + ("n", Colorization::HueShift { key: Color::BLACK }), + ] + ); +} + /// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed /// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay. #[test] @@ -595,3 +687,81 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]); } } + +/// Anchors a brushed stroke emitted, which is what the output budget counts. +fn anchors(nodes: &[Node]) -> usize { + nodes + .iter() + .map(|n| match &n.kind { + NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::(), + _ => 0, + }) + .sum() +} + +/// A path of `subpaths` long parallel lines, each long enough to want more tiles than the +/// per-run cap allows. +fn many_long_subpaths(subpaths: usize) -> BezPath { + let mut bp = BezPath::new(); + for i in 0..subpaths { + let y = i as f64 * 50.0; + bp.move_to((0.0, y)); + bp.line_to((300_000.0, y)); + } + bp +} + +/// #3594: the per-factor caps are per track, and a path has as many tracks as it has subpaths, +/// so they multiply instead of bounding. Measured before the budget: 20,000 nodes and 320,000 +/// anchors for one subpath, and exactly N times that for N, so sixteen gave 5,120,000. +#[test] +fn pattern_output_is_capped_across_subpaths_not_only_per_run() { + let brush = pattern_brush(PatternFit::Stretch, false); + let one = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(1), &stroke(1.0)); + assert_eq!(one.len(), 20_000, "one track still fills its own per-run cap"); + let a1 = anchors(&one); + assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}"); + + // Sixteen tracks would be sixteen times that without a total budget. + // Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the + // budget must not bite before the per-factor caps have actually multiplied past it. + let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0)); + assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece"); + assert_eq!(anchors(&four), 4 * a1); + + // Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps. + let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0)); + let an = anchors(&many); + assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}"); + assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}"); + assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len()); +} + +/// The same for scatter, whose per-track guard is also 20,000 copies. +#[test] +fn scatter_output_is_capped_across_subpaths() { + let b = Brush { + name: "s".into(), + kind: BrushKind::Scatter(Scatter { art: rect_art(4.0, 4.0), colorization: Colorization::None, ..Default::default() }), + }; + let few = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(1), &stroke(1.0)); + let per_track = anchors(&few); + assert!(per_track > 0, "one track draws something"); + // Enough tracks that the per-track guards alone would pass the budget. + let tracks = 2_000_000 / per_track + 4; + let many = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(tracks), &stroke(1.0)); + let an = anchors(&many); + assert!(an <= 2_000_000, "{tracks} tracks must not exceed the budget: {an}"); + assert!(an > per_track, "but more is drawn than one track's worth: {an}"); + assert!(many.len() < tracks * few.len().max(1), "fewer copies than the per-track guards alone would allow"); +} + +/// A short, ordinary stroke is untouched by the budget: it is the absurd ones that are cut. +#[test] +fn an_ordinary_stroke_is_unaffected_by_the_budget() { + let brush = pattern_brush(PatternFit::Stretch, false); + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &sq, &stroke(1.0)); + assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget"); + assert!(anchors(&out) < 2_000_000); +} diff --git a/crates/brush/src/track.rs b/crates/brush/src/track.rs index e02bcf48d..9e651a670 100644 --- a/crates/brush/src/track.rs +++ b/crates/brush/src/track.rs @@ -156,6 +156,9 @@ impl Track { } } +/// The most flattened elements of a path kept. +const MAX_FLATTENED: usize = 4_000_000; + /// Flatten `bp` into tracks (one per subpath) with `tol` accuracy. pub fn tracks(bp: &BezPath, tol: f64) -> Vec { let mut out = vec![]; @@ -165,8 +168,18 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec { out.push(t); } }; + // Far outside any drawing a curve flattens to millions of points; past a few million, the + // rest are not kept. + let b = bp.control_box(); + if !(b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs()) <= 1e9) { + return out; + } let mut els = vec![]; - kurbo::flatten(bp.iter(), tol.max(1e-4), |el| els.push(el)); + kurbo::flatten(bp.iter(), tol.max(1e-4), |el| { + if els.len() < MAX_FLATTENED { + els.push(el); + } + }); for el in els { match el { PathEl::MoveTo(p) => { diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index 35031741e..9d61216a1 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -16,6 +16,21 @@ use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Va const MAX_SEG: f64 = 1.5; /// Hard cap on subdivisions per art segment. const MAX_SUB: usize = 4000; +/// Anchors one brushed stroke may emit in total, across every track, tile and copy. +/// +/// Each factor is capped on its own (20,000 tiles a run, [`MAX_SUB`] subdivisions a segment, +/// 20,000 copies a track), but those caps are per track and a path has as many tracks as it has +/// subpaths, so they multiply rather than bound: one track at its cap measures 20,000 nodes and +/// 320,000 anchors, and sixteen subpaths measure exactly sixteen times that (#3594). This is the +/// cap on the product. The per-factor caps still shape each one. +/// +/// The same figure as the stacked-effects budget (`effects::distort::MAX_COPY_ANCHORS`, #3578), +/// and bounded the same way: per brushed stroke rather than per document, so many strokes that +/// each reach it still add up. Six times what one track at its own cap draws, so it bites only +/// where the caps were multiplying. +const MAX_ANCHORS: usize = 2_000_000; +/// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget. +const MAX_WALK: usize = 10_000; /// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and /// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by @@ -235,8 +250,11 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option< let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3)); let mut rng = Rng::new(seed_of(bp, name)); let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes(); - let mut out = vec![]; + let mut out = Budget::default(); for t in tracks(bp, 0.05) { + if out.full() { + break; + } let l = t.len(); // The pen pressure at arc length `at` (half way without a recorded one). let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 })); @@ -270,10 +288,61 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option< let xf = Affine::translate(place.to_vec2()) * Affine::rotate(base - rot) * Affine::scale(size) * Affine::translate(-b.center().to_vec2()); let mut n = sc.art.clone(); n.transform(xf, true); - out.push(n); + out.take(Some(n)); + if out.full() { + break; + } } } - out + out.out +} + +/// Anchors `n` and its children hold. Walked with a stack rather than by recursion, and a tree +/// deeper or wider than [`MAX_WALK`] nodes is charged the whole budget rather than undercounted, +/// so an art tile cannot buy room by nesting. +fn anchors_of(n: &Node) -> usize { + let (mut total, mut seen) = (0usize, 0usize); + let mut stack = vec![n]; + while let Some(n) = stack.pop() { + seen += 1; + if seen > MAX_WALK { + return MAX_ANCHORS; + } + if let NodeKind::Path { path, .. } = &n.kind { + total = total.saturating_add(path.subpaths.iter().map(|s| s.anchors.len()).sum::()); + } + if let Some(c) = n.children() { + stack.extend(c.iter().map(Arc::as_ref)); + } + } + total +} + +/// Brush output collected under [`MAX_ANCHORS`]. +/// +/// A piece is taken whole or not at all, so the art a brush places is never half-drawn; the +/// total is therefore the budget plus at most one piece. +#[derive(Default)] +struct Budget { + out: Vec, + anchors: usize, +} + +impl Budget { + /// Take a piece if there is room. `None` (art that could not be placed) costs nothing. + fn take(&mut self, n: Option) { + let Some(n) = n else { return }; + if self.full() { + return; + } + self.anchors = self.anchors.saturating_add(anchors_of(&n)); + self.out.push(n); + } + + /// Whether the budget is spent, so the caller can stop walking the path. + fn full(&self) -> bool { + self.anchors >= MAX_ANCHORS + } } /// A tile placed on [s0, s1] of the track (stretched along, `cross` across). @@ -291,8 +360,11 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let end = pb.end.as_ref().and_then(orient); let tw = side.1.width() * k; let gap = tw * pb.spacing.max(0.0) / 100.0; - let mut out = vec![]; + let mut out = Budget::default(); for t in tracks(bp, 0.05) { + if out.full() { + break; + } let l = t.len(); if l <= 1e-9 { continue; @@ -314,7 +386,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec * Affine::translate(-bb.center().to_vec2()); let mut n = art.clone(); n.transform(xf, true); - out.push(n); + out.take(Some(n)); } // Runs between corners (closed tracks wrap past the end). let mut bounds: Vec = corners.iter().map(|c| c.1).collect(); @@ -346,7 +418,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { let w = st.1.width() * k; if b - a > w { - out.extend(tile_on(&t, st, a, a + w, k)); + out.take(tile_on(&t, st, a, a + w, k)); a += w; } } @@ -356,7 +428,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec { let w = en.1.width() * k; if b - a > w { - out.extend(tile_on(&t, en, b - w, b, k)); + out.take(tile_on(&t, en, b - w, b, k)); b -= w; } } @@ -372,7 +444,10 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let (w, g) = (tw * f, gap * f); for i in 0..n.min(20000) { let s0 = a + i as f64 * (w + g); - out.extend(tile_on(&t, &side, s0, s0 + w, k)); + out.take(tile_on(&t, &side, s0, s0 + w, k)); + if out.full() { + break; + } } } PatternFit::AddSpace => { @@ -381,7 +456,10 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let lead = if n == 1 { (avail - tw) / 2.0 } else { 0.0 }; for i in 0..n.min(20000) { let s0 = a + lead + i as f64 * (tw + g.max(0.0)); - out.extend(tile_on(&t, &side, s0, s0 + tw, k)); + out.take(tile_on(&t, &side, s0, s0 + tw, k)); + if out.full() { + break; + } } } PatternFit::Approximate => { @@ -390,11 +468,14 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec let lead = (avail - used) / 2.0; for i in 0..n.min(20000) { let s0 = a + lead + i as f64 * unit; - out.extend(tile_on(&t, &side, s0, s0 + tw, k)); + out.take(tile_on(&t, &side, s0, s0 + tw, k)); + if out.full() { + break; + } } } } } } - out + out.out } diff --git a/crates/cad/src/import/build.rs b/crates/cad/src/import/build.rs index b729d8000..c65862e67 100644 --- a/crates/cad/src/import/build.rs +++ b/crates/cad/src/import/build.rs @@ -3,7 +3,7 @@ //! dimensions, as groups), and colours, lineweights, linetypes and transparency resolved through //! layers and blocks. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::rc::Rc; use std::sync::Arc; @@ -23,12 +23,17 @@ const PT_PER_MM: f64 = 72.0 / 25.4; const THINNEST: f64 = 0.25; /// The most objects an import makes. const MAX_NODES: usize = 2_000_000; +/// The most block visits (one per insert copy) an import makes, whatever they produce. +const MAX_VISITS: usize = 10_000_000; +/// The most path elements an import makes (blocks drawn as art are copied for every placement). +const MAX_ELEMENTS: usize = if cfg!(test) { 1_000_000 } else { 4_000_000 }; /// How far from the origin art may lie (points): the canvas. pub(crate) const MAX_EXTENT: f64 = 4.0e6; const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out"; const XREFS: &str = "blocks referencing other drawings (external references) are left out"; const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out"; +const TOO_MANY_POINTS: &str = "the drawing has more than 4,000,000 path points: the rest are left out"; /// What a block's art takes from the insert that places it: its colour, lineweight, linetype /// and opacity where the art says "by block", and its layer for art on layer 0. @@ -52,6 +57,9 @@ pub(crate) struct Build<'d, 'a> { sensitive: HashMap, /// Symbol names by block and what it inherits (`None`: the block has no art). symbols: HashMap<(String, Inherit), Option>, + /// Names of the symbols made so far, and the next suffix to try for each label. + symbol_names: HashSet, + next_suffix: HashMap, /// Blocks being drawn, outermost first. stack: Vec, /// Document space per drawing unit, and its symbols' (no translation). @@ -60,6 +68,9 @@ pub(crate) struct Build<'d, 'a> { /// Lineweights are multiplied by this. lw_scale: f64, nodes: usize, + visits: usize, + /// Path elements made so far. + elements: usize, warnings: Vec, } @@ -108,11 +119,15 @@ impl<'d, 'a> Build<'d, 'a> { bounds: HashMap::new(), sensitive: HashMap::new(), symbols: HashMap::new(), + symbol_names: HashSet::new(), + next_suffix: HashMap::new(), stack: vec![], map: Affine::IDENTITY, symbol_map: Affine::IDENTITY, lw_scale: 1.0, nodes: 0, + visits: 0, + elements: 0, warnings: vec![], } } @@ -362,7 +377,8 @@ impl<'d, 'a> Build<'d, 'a> { let k = self.d.header.ltscale * p.ltscale * scale; // Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap… let mut out: Vec = vec![]; - for v in pattern { + // (A linetype has a few elements; every path keeps its own copy of the dashes.) + for v in pattern.iter().take(64) { let dash = *v >= 0.0; let len = v.abs() * k; match out.len().is_multiple_of(2) { @@ -442,6 +458,12 @@ impl<'d, 'a> Build<'d, 'a> { } fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option { + let n = bp.elements().len(); + if self.elements.saturating_add(n) > MAX_ELEMENTS { + self.warn(TOO_MANY_POINTS); + return None; + } + self.elements += n; let mut bp = bp.clone(); bp.apply_affine(map); super::curve::bounds(&bp)?; @@ -552,6 +574,13 @@ impl<'d, 'a> Build<'d, 'a> { /// The art of block `name` drawn by `map`, as one object. fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option { + self.visits += 1; + if self.visits > MAX_VISITS { + if self.visits == MAX_VISITS + 1 { + self.warn(TOO_MANY); + } + return None; + } let items = self.block(name)?; if !self.enter(name, depth) { return None; @@ -584,7 +613,8 @@ impl<'d, 'a> Build<'d, 'a> { let art = self.art(name, map, &key.1, depth); let made = art.map(|art| { let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str()); - let symbol = unique(label, |n| self.doc.symbols.iter().any(|s| s.name == n)); + let symbol = unique(label, &mut self.next_suffix, &self.symbol_names); + self.symbol_names.insert(symbol.clone()); self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) }); symbol }); @@ -611,10 +641,17 @@ fn rgb_color([r, g, b]: [u8; 3]) -> Color { Color::rgb8(r, g, b) } -/// `base`, or `base 2`, `base 3`… when taken. -fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { - if !taken(base) { +/// `base`, or `base 2`, `base 3`… when taken; `next` remembers where each base left off. +fn unique(base: &str, next: &mut HashMap, taken: &HashSet) -> String { + if !taken.contains(base) { return base.to_string(); } - (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) + let i = next.entry(base.to_string()).or_insert(2); + loop { + let name = format!("{base} {i}"); + *i += 1; + if !taken.contains(&name) { + return name; + } + } } diff --git a/crates/cad/src/import/curve.rs b/crates/cad/src/import/curve.rs index c913a8bd6..a348ce67a 100644 --- a/crates/cad/src/import/curve.rs +++ b/crates/cad/src/import/curve.rs @@ -17,6 +17,8 @@ const MAX_DEPTH: u32 = 12; const MAX_ELEMENTS: usize = 200_000; /// The highest spline degree read. const MAX_DEGREE: usize = 11; +/// Spline weights below this fraction of the largest are read as this fraction. +const MIN_RELATIVE_WEIGHT: f64 = 1e-100; /// A polyline vertex: its point and the bulge of the segment that starts there (the tangent of a /// quarter of the arc's angle; positive turns counter-clockwise). @@ -121,7 +123,9 @@ struct H { impl H { fn lerp(self, o: Self, t: f64) -> Self { - Self { x: self.x + (o.x - self.x) * t, y: self.y + (o.y - self.y) * t, w: self.w + (o.w - self.w) * t } + // Exact at both ends, so a weight far smaller than its neighbour's doesn't cancel to 0. + let s = 1.0 - t; + Self { x: self.x * s + o.x * t, y: self.y * s + o.y * t, w: self.w * s + o.w * t } } fn point(self) -> Option { (self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|p| p.x.is_finite() && p.y.is_finite()) @@ -139,7 +143,11 @@ pub(crate) fn nurbs(degree: usize, knots: &[f64], ctrl: &[Point], weights: Optio if knots.windows(2).any(|w| w[1] < w[0]) { return None; } - let weight = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0); + let raw = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0); + // Scaling all weights alike leaves the curve unchanged: relative to the largest, so tiny ones + // don't underflow, and floored so a weight tiny next to the others can't either. + let max_w = (0..n).map(raw).fold(0.0, f64::max); + let weight = |i: usize| (raw(i) / max_w).max(MIN_RELATIVE_WEIGHT); let hs: Vec = ctrl.iter().enumerate().map(|(i, p)| H { x: p.x * weight(i), y: p.y * weight(i), w: weight(i) }).collect(); let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12); let (lo, hi) = ctrl.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| { @@ -225,6 +233,11 @@ fn fit(bp: &mut BezPath, bez: &[H], t0: f64, t1: f64, tol: f64, depth: u32) -> O let (p3, d3) = eval(bez, t1)?; let h = (t1 - t0) / 3.0; let (c1, c2) = (p0 + d0 * h, p3 - d3 * h); + // Non-finite tangents never fit, so splitting wouldn't help: a line. + if ![c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) { + bp.line_to(p3); + return Some(()); + } let cubic = kurbo::CubicBez::new(p0, c1, c2, p3); let close = (1..8).all(|i| { let s = f64::from(i) / 8.0; @@ -371,6 +384,21 @@ mod tests { } } + #[test] + fn tiny_spline_weights_flatten_to_a_handful_of_pieces() { + // Degree 5, three knot spans: fitted, not exact. + let ctrl: Vec = (0..8).map(|i| Point::new(f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 30.0 })).collect(); + let knots = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0]; + let normal = nurbs(5, &knots, &ctrl, Some(&[1.0; 8])).unwrap().elements().len(); + // Scaling every weight alike leaves the curve as it is. + let tiny = nurbs(5, &knots, &ctrl, Some(&[1e-200; 8])).unwrap().elements().len(); + assert!(tiny <= normal + 8, "normal weights: {normal} elements, 1e-200 weights: {tiny} elements"); + // Weights tiny next to the others don't split every span to the limit either. + let mixed = [1.0, 1e-200, 1.0, 1e-200, 1.0, 1e-200, 1.0, 1e-200]; + let mixed = nurbs(5, &knots, &ctrl, Some(&mixed)).unwrap().elements().len(); + assert!(mixed < 500, "mixed weights: {mixed} elements"); + } + #[test] fn fit_points_make_a_smooth_curve_through_them() { let pts = [Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)]; diff --git a/crates/cad/src/import/entity.rs b/crates/cad/src/import/entity.rs index d11d6a35c..59e1283cc 100644 --- a/crates/cad/src/import/entity.rs +++ b/crates/cad/src/import/entity.rs @@ -17,6 +17,8 @@ const DESCENT: f64 = 0.3; const LINE_SPACING: f64 = 5.0 / 3.0; /// The most copies one array insert (MINSERT) makes. const MAX_CELLS: i64 = 10_000; +/// The most copies all the array inserts of a file make together. +const MAX_TOTAL_CELLS: i64 = 2_000_000; /// Art farther than this from the origin (drawing units) is damaged data, left out. const MAX_COORD: f64 = 1e12; /// How far fit and aligned text may be squeezed or stretched to span their two points: at width @@ -184,12 +186,15 @@ pub(crate) struct Converter { /// Entity types left out, with how many. pub skipped: BTreeMap, pub warnings: Vec, + /// Array insert copies made so far, against [`MAX_TOTAL_CELLS`]. + cells: i64, } const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries"; const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour"; const MESHES: &str = "3D polygon meshes and polyface meshes are left out"; const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each"; +const MANY_ARRAYS: &str = "array inserts are limited to 2,000,000 copies in all"; const FAR_AWAY: &str = "entities with coordinates out of range are left out"; impl Converter { @@ -294,6 +299,15 @@ impl Converter { } else { (cols, rows) }; + // Past the file-wide budget, each insert keeps only its first copy. + let left = (MAX_TOTAL_CELLS - self.cells).max(1); + let (cols, rows) = if cols * rows > left { + self.warn(MANY_ARRAYS); + (cols.min(left), (left / cols.min(left)).max(1).min(rows)) + } else { + (cols, rows) + }; + self.cells = self.cells.saturating_add(cols * rows); let cells = (0..rows) .flat_map(|r| (0..cols).map(move |c| (r, c))) .map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy)) @@ -536,7 +550,9 @@ fn boundary(c: &mut Cursor<'_, '_>) -> Option { // The boundary objects it was made from. if let Some(n) = c.count(97) { for _ in 0..n { - c.opt(330); + if c.opt(330).is_none() { + break; + } } } Some(bp) diff --git a/crates/cad/src/import/reader.rs b/crates/cad/src/import/reader.rs index 1d83475c3..a2d1dc11d 100644 --- a/crates/cad/src/import/reader.rs +++ b/crates/cad/src/import/reader.rs @@ -423,6 +423,7 @@ fn font_family(g: &[Pair<'_>]) -> String { /// objects but with paper-space art has one paper layout. fn layouts(objects: &[Record<'_>], block_records: &HashMap, entities: &[Record<'_>]) -> Vec { let mut paper: Vec<(i64, LayoutDef)> = vec![]; + let mut seen = std::collections::HashSet::new(); for r in objects.iter().filter(|r| r.kind == "LAYOUT") { // The layout's own groups follow its subclass marker. let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]); @@ -432,7 +433,7 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap, enti continue; } let Some(block) = block else { continue }; - if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) { + if seen.insert(name.to_ascii_lowercase()) { paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) })); } } diff --git a/crates/cad/src/import/tests.rs b/crates/cad/src/import/tests.rs index 895b70226..d0dde65c9 100644 --- a/crates/cad/src/import/tests.rs +++ b/crates/cad/src/import/tests.rs @@ -380,6 +380,27 @@ fn solid_hatches_fill_and_pattern_hatches_outline() { assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none())); } +#[test] +fn hatch_declaring_many_absent_source_handles_parses_quickly() { + let base = hatch("1"); + let at = |code: i32, from: usize| from + base[from..].iter().position(|p| p.0 == code).unwrap(); + let first = at(92, 0); + let end = at(97, first) + 1; + let mut boundary = base[first..end].to_vec(); + *boundary.last_mut().unwrap() = (97, "10000000"); + let n = 300; + let mut e: Vec<(i32, &'static str)> = base[..first].to_vec(); + e[at(91, 0)] = (91, "300"); + for _ in 0..n { + e.extend_from_slice(&boundary); + } + e.extend_from_slice(&base[end..]); + let start = std::time::Instant::now(); + let r = open(&entities(&e), &points()); + assert!(start.elapsed() < std::time::Duration::from_millis(400), "took {:?}", start.elapsed()); + assert_eq!(r.document.layers[0].children().unwrap().len(), 1, "{:?}", r.warnings); +} + /// A solid hatch: a loop of a line and a spline edge (`fit` its fit data, if any), followed by /// `source` (the loop's source-object count and handles), and a square hole. fn spline_edged_hatch(fit: &[(i32, &'static str)], source: &[(i32, &'static str)]) -> Vec<(i32, &'static str)> { @@ -712,6 +733,27 @@ fn by_block_colours_make_a_symbol_per_look() { assert_eq!(doc.symbols.len(), 1); } +#[test] +fn many_by_block_looks_import_in_linear_time() { + let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")]; + let n = 4000; + let mut inserts: Vec<(i32, &'static str)> = vec![]; + for i in 0..n { + let colour: &'static str = Box::leak((i + 1).to_string().into_boxed_str()); + inserts.extend([(0, "INSERT"), (2, "Door"), (420, colour), (10, "0"), (20, "0")]); + } + let text = with_door(by_block, &inserts); + let start = std::time::Instant::now(); + let doc = open(&text, &points()).document; + let took = start.elapsed(); + let mut names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names.len(), n); + names.sort_unstable(); + names.dedup(); + assert_eq!(names.len(), n, "names stay unique"); + assert!(took < std::time::Duration::from_secs(4), "import took {took:?}"); +} + #[test] fn array_inserts_attributes_and_dimensions() { let inserts = [ @@ -757,6 +799,48 @@ fn array_inserts_attributes_and_dimensions() { assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0); } +#[test] +fn nested_array_inserts_of_nothing_stop_at_the_work_budget() { + let block = |name: &'static str, inner: Option<&'static str>| { + let mut b = vec![(0, "BLOCK"), (2, name), (70, "1"), (10, "0"), (20, "0")]; + if let Some(i) = inner { + b.extend([(0, "INSERT"), (2, i), (10, "0"), (20, "0"), (70, "300"), (44, "1")]); + } + b.push((0, "ENDBLK")); + b + }; + let mut blocks = block("*A", Some("*B")); + blocks.extend(block("*B", Some("*C"))); + blocks.extend(block("*C", None)); + let top = [(0, "INSERT"), (2, "*A"), (10, "0"), (20, "0"), (70, "300"), (44, "1")]; + let r = open(&drawing(&[], &[], &blocks, &top), &points()); + assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings); +} + +#[test] +fn an_array_of_a_large_anonymous_block_stops_at_the_path_element_budget() { + // A 2,000-vertex polyline in *U1, placed in a 100 × 10 array: 2,000,000 path elements when + // every cell is copied, twice the budget tests import with. + let mut owned: Vec<(i32, String)> = vec![(0, "BLOCK".into()), (2, "*U1".into()), (70, "1".into()), (10, "0".into()), (20, "0".into())]; + owned.extend([(0, "LWPOLYLINE".into()), (90, "2000".into()), (70, "0".into())]); + for i in 0..2000 { + owned.extend([(10, i.to_string()), (20, (i % 2).to_string())]); + } + owned.push((0, "ENDBLK".into())); + let blocks: Vec<(i32, &str)> = owned.iter().map(|(c, v)| (*c, v.as_str())).collect(); + let inserts = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "100"), (71, "10"), (44, "2000"), (45, "2")]; + let r = open(&drawing(&[], &[], &blocks, &inserts), &points()); + let mut elements = 0; + r.document.walk(|n| { + if let NodeKind::Path { path, .. } = &n.kind { + elements += path.to_bezpath().elements().len(); + } + }); + assert!(elements > 0); + assert!(elements <= 1_000_000, "{elements} path elements"); + assert!(r.warnings.iter().any(|w| w.contains("path points")), "{:?}", r.warnings); +} + #[test] fn blocks_inside_themselves_are_left_out_without_hanging() { let blocks = [ @@ -1019,3 +1103,49 @@ fn entities_extruded_along_minus_z_are_mirrored() { let b = bounds(&leaves(&doc)[0]); assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}"); } + +/// A drawing with one paper block record (handle `A`) and a LAYOUT object per name. +fn with_layouts(names: &[String]) -> Vec { + let mut s = String::from( + " 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nBLOCK_RECORD\n 0\nBLOCK_RECORD\n 5\nA\n 2\n*Paper_Space\n 0\nENDTAB\n 0\nENDSEC\n 0\nSECTION\n 2\nOBJECTS\n", + ); + for n in names { + s.push_str(&format!(" 0\nLAYOUT\n100\nAcDbLayout\n 1\n{n}\n330\nA\n")); + } + s.push_str(" 0\nENDSEC\n 0\nEOF\n"); + s.into_bytes() +} + +#[test] +fn many_paper_layouts_are_read_in_linear_time() { + let names: Vec = (0..100_000).map(|i| format!("L{i}")).collect(); + let bytes = with_layouts(&names); + let t = std::time::Instant::now(); + let info = info(&bytes).unwrap(); + assert_eq!(info.layouts.len(), names.len() + 1); + assert!(t.elapsed() < std::time::Duration::from_secs(5), "layout de-duplication is quadratic: {:?}", t.elapsed()); +} + +#[test] +fn paper_layout_names_are_unique_ignoring_case() { + let names = ["Layout1", "LAYOUT1", "Other"].map(String::from); + let info = info(&with_layouts(&names)).unwrap(); + assert_eq!(info.layouts, ["Model", "Layout1", "Other"]); +} + +#[test] +fn many_array_inserts_of_a_missing_block_stay_bounded() { + let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")]; + let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect(); + let bytes = entities(&e); + let text = reader::decode(&bytes); + let (pairs, _) = reader::pairs(&text).unwrap(); + let d = reader::Drawing::read(&pairs); + let model = d.layout("Model").or(d.layouts.first()).unwrap(); + let mut conv = entity::Converter::default(); + let items = conv.items(&d.layout_records(model)); + assert_eq!(items.len(), 3000); + let total: usize = items.iter().map(|i| if let entity::Geom::Insert(ins) = &i.geom { ins.cells.len() } else { 0 }).sum(); + assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}"); + assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings); +} diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 84e4a3aee..ab9137050 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -33,6 +33,9 @@ const WIDE_LINES: &str = const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)"; const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole"; const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)"; +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; /// One segment of a subpath, cubics for every curve. #[derive(Clone, Copy)] @@ -165,12 +168,32 @@ fn subpaths(bp: &BezPath) -> Vec { out } +/// The most pixels of an image written (it is held decoded, as RGBA and encoded at once). +const MAX_IMAGE_PIXELS: u64 = 64 << 20; + +/// The most entities (handles) a drawing is written with. +const MAX_ENTITIES: u32 = 10_000_000; + +/// The largest x or y a path may have to be flattened, and the most points its polygons keep. +const MAX_COORD: f64 = 1e9; +const MAX_POINTS: usize = 4_000_000; + /// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped. fn polygons(bp: &BezPath) -> Vec> { + // Far outside any drawing, a curve flattens to millions of points at this tolerance. + let b = bp.control_box(); + if !matches!( + b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs()).partial_cmp(&MAX_COORD), + Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal) + ) { + return vec![]; + } let mut out: Vec> = vec![]; let mut cur: Vec = vec![]; - let push = |p: Point, cur: &mut Vec| { - if cur.last().is_none_or(|q| q.distance(p) > 1e-9) { + let mut points = 0usize; + let mut push = |p: Point, cur: &mut Vec| { + if points < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) { + points += 1; cur.push(p); } }; @@ -205,21 +228,37 @@ fn symbol_name(name: &str, long: bool) -> String { s.trim().to_string() } -/// `base` made unique among `taken` (any case) by `_2`, `_3`…, within `max` characters. -fn unique(base: &str, taken: &[&str], max: usize) -> String { - let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() }; - let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n)); - if free(&base) { - return base; +/// The names a DXF table holds (compared in any case), and the next suffix to try for each base, +/// so identical names don't each search the suffixes before. +#[derive(Default)] +struct Names { + taken: std::collections::HashSet, + next: HashMap, +} + +impl Names { + /// Names that are there already. + fn with(names: &[&str]) -> Self { + Self { taken: names.iter().map(|n| n.to_lowercase()).collect(), next: HashMap::new() } } - (2..) - .map(|i| { - let suffix = format!("_{i}"); + + /// `base` made unique by `_2`, `_3`…, within `max` characters; the name is taken from now on. + fn unique(&mut self, base: &str, max: usize) -> String { + let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() }; + if self.taken.insert(base.to_lowercase()) { + return base; + } + let next = self.next.entry(base.to_lowercase()).or_insert(2); + loop { + let suffix = format!("_{next}"); + *next += 1; let keep: String = base.chars().take(max.saturating_sub(suffix.len())).collect(); - format!("{keep}{suffix}") - }) - .find(|n| free(n)) - .unwrap_or(base) + let name = format!("{keep}{suffix}"); + if self.taken.insert(name.to_lowercase()) { + return name; + } + } + } } fn overlaps(a: Rect, b: Rect) -> bool { @@ -248,12 +287,14 @@ pub(crate) struct Scene<'a> { e: Codes, next: u32, layers: Vec, + layer_names: Names, /// The DXF layer art goes on, and its flags. layer: String, flags: LayerFlags, linetypes: Vec, /// (style name, font family). styles: Vec<(String, String)>, + style_names: Names, images: Vec, files: Vec, /// Image keys → index into `images` (`None`: left out). @@ -280,10 +321,12 @@ impl<'a> Scene<'a> { e: Codes::new(o.version), next: fixed::FIRST_FREE, layers: vec![], + layer_names: Names::with(&["0", "Defpoints"]), layer: "0".into(), flags: LayerFlags { off: false, locked: false, plot: true }, linetypes: vec![], styles: vec![], + style_names: Names::with(&[crate::writer::STANDARD_STYLE]), images: vec![], files: vec![], by_key: HashMap::new(), @@ -347,6 +390,12 @@ impl<'a> Scene<'a> { if let NodeKind::Layer { template: true, .. } = n.kind { return; } + // Symbols that hold symbols are written in full for each instance: depth alone doesn't + // bound how many entities that is. + if self.next.saturating_sub(fixed::FIRST_FREE) > MAX_ENTITIES { + self.warn("the drawing is too large for DXF: what follows 10 million entities was left out"); + return; + } // Hidden layers are written, switched off. let layer = n.is_layer(); if !force && !n.visible && !layer { @@ -449,9 +498,7 @@ impl<'a> Scene<'a> { fn add_layer(&mut self, n: &Node) -> String { let long = self.o.version.r2000(); let base = symbol_name(n.name.as_deref().unwrap_or("Layer"), long); - let mut taken: Vec<&str> = self.layers.iter().map(|l| l.name.as_str()).collect(); - taken.extend(["0", "Defpoints"]); - let name = unique(&base, &taken, if long { 255 } else { 31 }); + let name = self.layer_names.unique(&base, if long { 255 } else { 31 }); let aci = match n.kind { NodeKind::Layer { color, .. } => nearest_aci(color.rgb(), ColorDepth::Aci256), _ => 7, @@ -852,17 +899,25 @@ impl<'a> Scene<'a> { let angle = b.atan2(a).to_degrees(); for line in &layout.lines { let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue }; - for run in glyphs.chunk_by(|x, y| x.run == y.run) { - let Some(first) = run.first() else { continue }; + // A list marker is a TEXT entity of its own, holding the marker's characters where its + // characters' glyphs are drawn (not its tab's). + let marked: Vec<(usize, &vectorcraft_text::PositionedGlyph, Option<&vectorcraft_text::ListMarker>)> = + glyphs.iter().enumerate().map(|(k, g)| (line.glyph_start + k, g, layout.marker_of(line.glyph_start + k))).collect(); + for chunk in marked.chunk_by(|x, y| x.1.run == y.1.run && x.2.is_some() == y.2.is_some()) { + let run: Vec<&vectorcraft_text::PositionedGlyph> = + chunk.iter().filter(|(k, _, m)| m.is_none_or(|m| m.tab != *k)).map(|(_, g, _)| *g).collect(); + let Some(&first) = run.first() else { continue }; let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo); - let Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue }; + let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| m.text.as_str()); + let Some(s) = marker.or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue }; let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue }; let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill }; let Some(rgb) = self.paint_rgb(paint) else { continue }; let style = self.style(rgb, 1.0, None, None); let text_style = self.text_style(&cs.font_family); - let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across; + // The height is that of the size the layout draws: Shrink Text to Fit scales the size by `layout.fit_scale`. + let height = cs.size * layout.fit_scale * cs.v_scale / 100.0 * CAP_HEIGHT * across; let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 }; let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin); let p = self.pt(t.xf * origin); @@ -893,9 +948,7 @@ impl<'a> Scene<'a> { return name.clone(); } let long = self.o.version.r2000(); - let mut taken: Vec<&str> = self.styles.iter().map(|(n, _)| n.as_str()).collect(); - taken.push(crate::writer::STANDARD_STYLE); - let name = unique(&symbol_name(family, long), &taken, if long { 255 } else { 31 }); + let name = self.style_names.unique(&symbol_name(family, long), if long { 255 } else { 31 }); self.styles.push((name.clone(), family.to_string())); name } @@ -906,7 +959,7 @@ impl<'a> Scene<'a> { self.warn(NO_IMAGES); return; } - let Some(i) = self.image_def(&im.key) else { return }; + let Some(i) = self.image_def(im) else { return }; let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return }; let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1))); let map = self.to_dxf * im.xf; @@ -948,12 +1001,13 @@ impl<'a> Scene<'a> { } } - /// The image file for image `key`, encoded once in the chosen format (`None`: left out). - fn image_def(&mut self, key: &str) -> Option { + /// The image file for image `im`, encoded once in the chosen format (`None`: left out). + fn image_def(&mut self, im: &ImageObject) -> Option { + let key = im.key.as_str(); if let Some(i) = self.by_key.get(key) { return *i; } - let made = self.encode_image(key); + let made = self.encode_image(im); let i = match made { Ok((file, size)) => { let handle = self.handle(); @@ -970,10 +1024,19 @@ impl<'a> Scene<'a> { i } - /// Image `key` as a file in the chosen format, and its size in pixels. - fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> { - let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?; - let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?; + /// Image `im` as a file in the chosen format, and its size in pixels. + fn encode_image(&self, im: &ImageObject) -> Result<(DxfImage, (u32, u32)), String> { + let blob = self.doc.image_blob(im).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?; + // The pixels it converts to (RGBA, 4 bytes each) are read from its header before decoding. + let (w, h) = image::ImageReader::new(Cursor::new(blob.bytes.as_slice())) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .ok_or_else(|| UNDECODED.to_string())?; + if u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS { + return Err("an image is too large for DXF and was left out".to_string()); + } + let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?; let size = (img.width(), img.height()); let mut bytes = vec![]; let written = match self.o.raster { @@ -991,7 +1054,7 @@ impl<'a> Scene<'a> { rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90)) } }; - written.map_err(|e| format!("image '{key}' could not be written: {e}"))?; + written.map_err(|e| format!("an image could not be written: {e}"))?; Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size)) } } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 3701d11a9..92f701d47 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -456,6 +456,63 @@ fn type_is_text_or_outlines() { } } +/// A list paragraph's bullet is a TEXT entity of its own where it is drawn, and the paragraph's +/// text starts after it, at the left indent. +#[test] +fn a_bullet_is_text_of_its_own_before_its_paragraph() { + let mut d = text_doc("Item", 20.0); + let id = d.layers[0].children().unwrap()[0].id; + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + } + let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(kinds(&out), ["TEXT", "TEXT"]); + assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("\u{2022}"), Some("Item"))); + assert_eq!((all(&e[0], 10)[0], all(&e[1], 10)[0]), (10.0, 28.0), "the bullet at the anchor, the text 18 pt on"); + // In a right-to-left paragraph the bullet is drawn after the text, at the right, and its tab + // lies between the two. + let mut d = text_doc("שלום", 20.0); + let id = d.layers[0].children().unwrap()[0].id; + let mut bullet_x = f64::NAN; + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let m = &lay.markers[0]; + let bullet = m.glyphs.clone().find(|&i| i != m.tab).unwrap(); + assert!(lay.glyphs[m.tab].origin.x < lay.glyphs[bullet].origin.x, "the tab before the bullet"); + bullet_x = 10.0 + lay.glyphs[bullet].origin.x; + } + let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert_eq!(kinds(&out), ["TEXT", "TEXT"]); + assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("שלום"), Some("\u{2022}"))); + assert!((all(&e[1], 10)[0] - bullet_x).abs() < 1e-6, "the bullet where it is drawn: {} and {bullet_x}", all(&e[1], 10)[0]); +} + +/// Area type that Shrink Text to Fit scales down is written at the height it draws at. +#[test] +fn shrunk_area_type_is_text_at_the_size_it_draws_at() { + let mut d = text_doc("Plan A, plan B and plan C", 20.0); + let id = d.layers[0].children().unwrap()[0].id; + let mut fit = 1.0; + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 30.0)) }; + t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + assert!(!lay.overflow && lay.fit_scale < 0.9, "the text is shrunk to fit: {}", lay.fit_scale); + fit = lay.fit_scale; + } + let out = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap(); + let e = entities(&out); + assert!(!e.is_empty() && e.iter().all(|e| kind(e) == "TEXT"), "{:?}", kinds(&out)); + for e in &e { + assert!((all(e, 40)[0] - 14.0 * fit).abs() < 1e-6, "the cap height of {} pt type: {:?}", 20.0 * fit, all(e, 40)); + } +} + #[test] fn text_outside_ascii_is_escaped_before_2007() { let d = text_doc("Größe", 12.0); @@ -479,6 +536,7 @@ fn images_link_png_or_jpeg_files() { xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0), link: None, placement: Default::default(), + pixels: None, }; vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))] }); @@ -505,6 +563,32 @@ fn images_link_png_or_jpeg_files() { assert!(r12.warnings.iter().any(|w| w.contains("no images"))); } +#[test] +fn images_without_pixels_are_left_out_with_one_note() { + // One image has no data in the document, and the other image's data is empty. + let mut d = doc_with(|d| { + ["raster-1", "raster-2"] + .into_iter() + .map(|key| { + let im = ImageObject { + key: key.into(), + width: 4, + height: 2, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + }; + Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) + }) + .collect() + }); + d.images.insert("raster-2".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![]), proxy: None }); + let out = export(&d, &opts(&d)).unwrap(); + assert!(entities(&out).is_empty() && out.images.is_empty()); + assert_eq!(out.warnings, ["an image has no pixels and was left out"]); +} + #[test] fn crop_leaves_out_art_outside_the_region() { let d = shapes_doc(); diff --git a/crates/color/src/cms/tests_icc.rs b/crates/color/src/cms/tests_icc.rs index b3796d444..ae3697fe3 100644 --- a/crates/color/src/cms/tests_icc.rs +++ b/crates/color/src/cms/tests_icc.rs @@ -113,11 +113,11 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() { assert_eq!(abs(true), abs(false)); } -/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and -/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space. +/// #1584: every built-in profile (RGB, CMYK and gray) has each tag on a 4-byte boundary and a size +/// that is a multiple of 4 (ICC.1 §7.1.2), and still reads back as the same space. #[test] -fn written_profiles_align_their_tags() { - for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] { +fn builtin_profiles_align_their_sizes_and_tags() { + for name in BUILTIN_RGB.into_iter().chain(BUILTIN_CMYK).chain([GRAY]) { let icc = super::icc_bytes(name).unwrap(); let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize; assert_eq!(be(0), icc.len(), "{name}: header size"); diff --git a/crates/color/src/gradient.rs b/crates/color/src/gradient.rs index 1c2b96c6e..e97ca634a 100644 --- a/crates/color/src/gradient.rs +++ b/crates/color/src/gradient.rs @@ -31,6 +31,75 @@ impl GradientKind { } } +/// How a gradient mixes the colours between its stops (the Gradient panel's Method). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum GradientMethod { + /// Mixed in display RGB, as gradients always were. + #[default] + Classic, + /// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to + /// yellow passes through a light blue-grey rather than a dull mid grey. + Perceptual, +} + +impl GradientMethod { + /// Parse a method name (`classic`, `perceptual`; any case). + pub fn parse(s: &str) -> Option { + [Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + Self::Classic => "Classic", + Self::Perceptual => "Perceptual", + } + } + pub fn is_classic(&self) -> bool { + *self == Self::Classic + } + /// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an + /// RGB colour between the two). + pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color { + match self { + Self::Classic => Color::lerp(a, b, t), + Self::Perceptual => { + use crate::libraries::{oklab_to_srgb, srgb_to_oklab}; + let (p, q, t) = (srgb_to_oklab(a.to_rgb()), srgb_to_oklab(b.to_rgb()), f64::from(t)); + let [r, g, bl] = oklab_to_srgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t); + let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; + Color::rgb(c(r), c(g), c(bl)) + } + } + } +} + +/// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring +/// expanded stops may stray from the perceptual mix: under half a colour level. +const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0; +/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is +/// halved to keep within [`PERCEPTUAL_TOLERANCE`]. +const PERCEPTUAL_DEPTH: u32 = 10; + +/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly +/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved +/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order. +fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec) { + let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb(); + let (c0, c1) = (at(v0), at(v1)); + let stray = [0.25, 0.5, 0.75] + .into_iter() + .flat_map(|u: f32| { + let c = at(v0 + (v1 - v0) * u); + (0..3).map(move |k| (c0[k] + (c1[k] - c0[k]) * u - c[k]).abs()) + }) + .fold(0.0, f32::max); + if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE { + let m = (v0 + v1) / 2.0; + perceptual_steps(a, b, (v0, m), depth + 1, out); + out.push(m); + perceptual_steps(a, b, (m, v1), depth + 1, out); + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct GradientStop { /// 0..=1 along the gradient. @@ -75,19 +144,39 @@ fn half() -> f32 { pub struct Gradient { pub kind: GradientKind, pub stops: Vec, + /// Dither: a fill of this gradient is drawn as pixels with fine noise of up to a colour level + /// added, which hides the steps (banding) between the levels 8-bit colour has. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub dither: bool, + /// How the colours between the stops are mixed (Classic unless set; written only when not). + #[serde(default, skip_serializing_if = "GradientMethod::is_classic")] + pub method: GradientMethod, } impl Default for Gradient { /// Illustrator's default "White, Black" gradient. fn default() -> Self { - Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] } + Self::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]) } } impl Gradient { - /// Colour and opacity at `t` (honours midpoints). + /// A gradient of `kind` through `stops`, with the default options. + pub fn new(kind: GradientKind, stops: Vec) -> Self { + Self { kind, stops, dither: false, method: GradientMethod::Classic } + } + /// Colour and opacity at `t` (honours midpoints and the method). pub fn sample(&self, t: f32) -> (Color, f32) { - self.sample_with(t, Color::lerp) + self.sample_with(t, |a, b, v| self.method.mix(a, b, v)) + } + /// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it + /// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual + /// gradient's as [`Self::sample`] mixes them. + pub fn sample_keep_model(&self, t: f32) -> (Color, f32) { + match self.method { + GradientMethod::Classic => self.sample_with(t, Color::lerp_keep_model), + m => self.sample_with(t, |a, b, v| m.mix(a, b, v)), + } } /// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour /// model rather than display RGB). @@ -115,20 +204,47 @@ impl Gradient { None => (Color::BLACK, 1.0), } } - /// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints). + /// Stops expanded so that midpoints are represented as explicit stops (for renderers without + /// midpoints), and a Perceptual gradient's segments as stops on its curve (for renderers that + /// mix linearly; within [`PERCEPTUAL_TOLERANCE`] of it). pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> { self.expanded().map(|(t, c, o, _)| (t, c, o)).collect() } /// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None` - /// on the gradient's own stops). + /// on the gradient's own stops and a Perceptual gradient's other in-between stops). pub fn expanded(&self) -> impl Iterator)> + '_ { self.stops.iter().enumerate().flat_map(move |(i, s)| { - let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { + let next = self.stops.get(i + 1); + let off_centre = (s.midpoint - 0.5).abs() > 1e-3; + // Classic: a stop at the midpoint, when it is off-centre. + let classic = next.filter(|_| off_centre && self.method.is_classic()).map(|n| { let t = s.offset + (n.offset - s.offset) * s.midpoint; let (c, o) = self.sample(t); (t, c, o, Some(s.midpoint)) }); - std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) + // Perceptual: stops along the mix (`v`) where a straight line would stray from it, the + // midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them. + let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| { + let mut vs = Vec::new(); + perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs); + vs.push(0.5); + perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs); + let m = s.midpoint.clamp(0.01, 0.99); + vs.into_iter().map(move |v| { + let mid = v == 0.5; + let u = if mid { + s.midpoint + } else if v < 0.5 { + 2.0 * v * m + } else { + m + (v - 0.5) * 2.0 * (1.0 - m) + }; + let t = s.offset + (n.offset - s.offset) * u; + let o = s.opacity + (n.opacity - s.opacity) * v; + (t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint)) + }) + }); + std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten()) }) } /// The same ramp the other way round: the stops in reverse order at mirrored offsets, each @@ -172,7 +288,7 @@ impl Gradient { ); } stops.push(stop(1.0, to)); - Gradient { kind: GradientKind::Linear, stops } + Gradient::new(GradientKind::Linear, stops) } } @@ -534,6 +650,117 @@ impl GradientPaint { f.transform(a); } } + /// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill + /// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a + /// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix + /// linearly between the stops, as SVG's and PDF's gradients do)? + pub fn draws_as_pixels(&self, fill: bool) -> bool { + self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic() + } + /// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of + /// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours + /// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a + /// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets + /// [`dither_noise`] before it is rounded to a level. + pub fn pixels( + &self, + bounds: Rect, + area: Rect, + cols: u16, + rows: u16, + fill: bool, + rgb: &dyn Fn(&Color) -> [f32; 3], + ) -> impl Iterator + use<> { + self.pixels_within(bounds, area, cols, rows, [0..cols, 0..rows], fill, rgb) + } + /// [`Self::pixels`] for the cells in columns `cells[0]` and rows `cells[1]` of the grid only, + /// row by row: the same pixels, noise and all, the whole grid has there. + #[allow(clippy::too_many_arguments)] + pub fn pixels_within( + &self, + bounds: Rect, + area: Rect, + cols: u16, + rows: u16, + cells: [std::ops::Range; 2], + fill: bool, + rgb: &dyn Fn(&Color) -> [f32; 3], + ) -> impl Iterator + use<> { + let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither); + let ramp = Ramp::new(&self.gradient, rgb); + let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1))); + let (x0, y0) = (area.x0, area.y0); + let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end)); + down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| { + let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch); + let c = ramp.at(geom.param_at(kind, p) as f32); + let level = |k: usize| { + let noise = if dither { dither_noise(i, j, k as u32) } else { 0.0 }; + (c[k].clamp(0.0, 1.0) * 255.0 + noise).round().clamp(0.0, 255.0) as u8 + }; + [level(0), level(1), level(2), (c[3].clamp(0.0, 1.0) * 255.0).round() as u8] + }) + } +} + +/// A gradient's colours ready to be sampled pixel by pixel, in a colour space of the caller's +/// (display RGB, or an ink's coverage): its stops, midpoints and a Perceptual gradient's +/// in-between colours as stops (as [`Gradient::expanded_stops`]), each turned into that space +/// once, with colour and opacity mixed linearly between them, each on its own (as the renderer's +/// vector gradients mix them). +pub struct Ramp { + stops: Vec<(f32, [f32; 4])>, +} + +impl Ramp { + pub fn new(g: &Gradient, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Self { + let stops = g + .expanded() + .map(|(t, c, o, _)| { + let [r, gr, b] = rgb(&c); + (t, [r, gr, b, o]) + }) + .collect(); + Self { stops } + } + /// Unpremultiplied colour and opacity at `t`, padded past the ends (transparent black + /// without stops). + pub fn at(&self, t: f32) -> [f32; 4] { + let Some(&(t0, first)) = self.stops.first() else { return [0.0; 4] }; + // Not after the first stop (or not a number): its colour. + if t.partial_cmp(&t0) != Some(std::cmp::Ordering::Greater) { + return first; + } + for w in self.stops.windows(2) { + let ((a, ca), (b, cb)) = (w[0], w[1]); + if t <= b { + let u = if b - a > 1e-9 { (t - a) / (b - a) } else { 1.0 }; + return std::array::from_fn(|k| ca[k] + (cb[k] - ca[k]) * u); + } + } + self.stops.last().map_or([0.0; 4], |s| s.1) + } +} + +/// Seed of [`dither_noise`]: fixed, so a dithered gradient draws and exports the same pixels on +/// every run. +const DITHER_SEED: u32 = 0x5eed_d17e; + +/// An integer hash that mixes every input bit into every output bit (the "lowbias32" mix). +fn mix32(mut v: u32) -> u32 { + v ^= v >> 16; + v = v.wrapping_mul(0x7feb_352d); + v ^= v >> 15; + v = v.wrapping_mul(0x846c_a68b); + v ^ (v >> 16) +} + +/// Dither noise for colour channel `channel` of pixel (`x`, `y`), in colour levels: -1..1, +/// densest around 0 (the sum of two uniform values: noise that hides steps between levels without +/// a pattern of its own), the same for the same pixel on every run. +pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 { + let h = mix32(x ^ mix32(y ^ mix32(channel ^ DITHER_SEED))); + ((h & 0xffff) as f32 + (h >> 16) as f32) / 65535.0 - 1.0 } #[cfg(test)] @@ -582,9 +809,8 @@ mod tests { #[test] fn reverse_mirrors_midpoints() { let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) }; - let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] }; - let three = - Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] }; + let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]); + let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]); for g in [two, three] { let mut r = g.clone(); r.reverse(); @@ -825,4 +1051,161 @@ mod tests { h.keep_focal_from(&before); assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}"); } + + #[test] + fn pixels_sample_the_ramp_at_each_cell_centre() { + // White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end. + let mut g = GradientPaint::new(Gradient::default()); + g.gradient.stops[0].midpoint = 0.3; + g.gradient.stops[1].opacity = 0.5; + let b = Rect::new(0.0, 0.0, 10.0, 2.0); + let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect(); + assert_eq!(px.len(), 20); + let ramp = Ramp::new(&g.gradient, &Color::to_rgb); + for (i, p) in px.iter().take(10).enumerate() { + let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8); + assert_eq!(*p, c, "pixel {i}"); + } + assert_eq!(px[3], px[13], "rows alike"); + // The midpoint stop: grey at 30%; padded past the ends. + assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3)); + assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]); + assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]); + assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]); + } + + fn blue_yellow(method: GradientMethod) -> Gradient { + let mut g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))], + ); + g.method = method; + g + } + + /// Illustrator 30's Perceptual method mixes colours in a perceptually even way + /// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html). + /// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey + /// a display RGB mix gives, and its lightness climbs evenly. + #[test] + fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() { + let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb(); + assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}"); + let p = blue_yellow(GradientMethod::Perceptual); + let [r, g, b] = p.sample(0.5).0.to_rgb(); + assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]); + assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]); + let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0]; + let steps: Vec = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect(); + assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}"); + // Ends and opacity as before. + assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff"); + assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00"); + } + + /// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual + /// curve (the midpoint diamond among them), more where it bends, close enough that the linear + /// ramp between them stays within half a colour level of the curve; a segment that hardly + /// bends needs few. + #[test] + fn perceptual_segments_expand_into_stops_on_the_curve() { + let pairs = [ + (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)), + (Color::BLACK, Color::rgb(1.0, 0.0, 0.0)), + (Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)), + (Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE), + ]; + for (a, b) in pairs { + let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]); + p.method = GradientMethod::Perceptual; + p.stops[0].midpoint = 0.3; + p.stops[1].opacity = 0.5; + let ex: Vec<_> = p.expanded().collect(); + assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len()); + assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order"); + assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint"); + assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6)); + let ramp = Ramp::new(&p, &Color::to_rgb); + for k in 0..=2000 { + let t = k as f32 / 2000.0; + let (c, o) = p.sample(t); + let [r, g, b] = c.to_rgb(); + let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}"); + } + } + let mut flat = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))], + ); + flat.method = GradientMethod::Perceptual; + assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint"); + } + + /// Classic gradients expand as they always did: their stops, and a stop at each off-centre + /// midpoint, sampled in display RGB (so the renderer draws the same pixels as before). + #[test] + fn classic_gradients_expand_as_before() { + let mut g = Gradient::new( + GradientKind::Linear, + vec![ + GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) }, + GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) }, + GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) }, + GradientStop::new(1.0, Color::gray(0.3)), + ], + ); + // The expansion gradients had before they had a method. + let before: Vec<_> = g + .stops + .iter() + .enumerate() + .flat_map(|(i, s)| { + let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| { + let t = s.offset + (n.offset - s.offset) * s.midpoint; + let (c, o) = g.sample_with(t, Color::lerp); + (t, c, o, Some(s.midpoint)) + }); + std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid) + }) + .collect(); + assert_eq!(g.expanded().collect::>(), before); + assert_eq!(before.len(), 6); + assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp)); + assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model)); + g.method = GradientMethod::Perceptual; + assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix"); + } + + /// The method is written only when Perceptual, so files from before it (and Classic + /// gradients) read as Classic and new Classic files stay as they were. + #[test] + fn method_is_saved_only_when_perceptual() { + let classic = serde_json::to_value(Gradient::default()).unwrap(); + assert!(classic.get("method").is_none(), "{classic}"); + let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap(); + assert_eq!(old.method, GradientMethod::Classic); + let p = blue_yellow(GradientMethod::Perceptual); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["method"], "Perceptual"); + assert_eq!(serde_json::from_value::(v).unwrap(), p); + assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual)); + assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic)); + assert_eq!(GradientMethod::parse("smooth"), None); + } + + /// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there, + /// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke, + /// dithered or not. + #[test] + fn perceptual_gradients_draw_as_pixels() { + for fill in [true, false] { + assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill)); + assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill)); + let mut radial = blue_yellow(GradientMethod::Perceptual); + radial.kind = GradientKind::Radial; + radial.dither = true; + assert!(GradientPaint::new(radial).draws_as_pixels(fill)); + } + } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 46439d29d..5de064d6b 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -16,7 +16,7 @@ pub mod swatch; pub use blend::BlendMode; pub use freeform::{Freeform, FreeformMode, FreeformPoint}; -pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; +pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop}; pub use libraries::SwatchLibrary; pub use swatch::{Swatch, SwatchGroup, default_swatches}; @@ -111,6 +111,16 @@ impl Color { pub fn out_of_gamut(&self) -> bool { cms::active().out_of_gamut(self) } + /// The colour exactly, as a key: its model and its components' bits (colours that only look + /// alike, as a grey and the same RGB grey, differ). + pub fn key(&self) -> [u32; 5] { + match *self { + Color::Rgb { r, g, b } => [0, r.to_bits(), g.to_bits(), b.to_bits(), 0], + Color::Cmyk { c, m, y, k } => [1, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()], + Color::Gray { k } => [2, k.to_bits(), 0, 0, 0], + Color::Lab { l, a, b } => [3, l.to_bits(), a.to_bits(), b.to_bits(), 0], + } + } pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] { let [r, g, b] = self.to_rgb(); let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; @@ -146,6 +156,8 @@ impl Color { } else { 60.0 * ((r - g) / d + 4.0) }; + // f32 rounding in rem_euclid can yield exactly 360; wrap back to 0. + let h = if h >= 360.0 { 0.0 } else { h }; let s = if max <= 0.0 { 0.0 } else { d / max }; [h, s, max] } @@ -378,6 +390,18 @@ impl Paint { _ => None, } } + /// This stroke paint as the fill of the stroke's outline, so the outline looks as the stroke + /// did: without dither, which strokes ignore ([`GradientPaint::draws_as_pixels`]). + pub fn stroke_as_fill(&self) -> Paint { + match self { + Paint::Gradient(g) if g.gradient.dither => { + let mut g = g.clone(); + g.gradient.dither = false; + Paint::Gradient(g) + } + p => p.clone(), + } + } /// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`]. pub fn registration() -> Self { Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 } @@ -426,6 +450,12 @@ impl Paint { mod tests { use super::*; + #[test] + fn hue_below_360() { + let h = Color::Rgb { r: 1.0, g: 0.0, b: 1e-7 }.to_hsb()[0]; + assert!((0.0..360.0).contains(&h), "hue {h}"); + } + #[test] fn hex_roundtrip() { let c = Color::from_hex("#ff8000").unwrap(); diff --git a/crates/color/src/libraries.rs b/crates/color/src/libraries.rs index 163038777..e96760d5c 100644 --- a/crates/color/src/libraries.rs +++ b/crates/color/src/libraries.rs @@ -98,7 +98,7 @@ pub fn builtin_library(id: &str) -> Option> { // ---------- colour construction ---------- /// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped. -fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { +pub(crate) fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b; let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b; let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b; @@ -113,7 +113,12 @@ fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] { /// OKLab `[l, a, b]` of a colour's display RGB. pub fn to_oklab(c: &Color) -> [f64; 3] { - let [r, g, b] = c.to_rgb_uncalibrated().map(|v| { + srgb_to_oklab(c.to_rgb_uncalibrated()) +} + +/// OKLab `[l, a, b]` of sRGB `rgb` (0..=1, gamma encoded). +pub(crate) fn srgb_to_oklab(rgb: [f32; 3]) -> [f64; 3] { + let [r, g, b] = rgb.map(|v| { let v = f64::from(v); if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } }); @@ -171,7 +176,7 @@ fn group(name: &str, swatches: Vec) -> SwatchGroup { /// A gradient swatch through `stops` of (offset, colour, opacity). pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch { let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect(); - Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false } + Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))), global: false, spot: false } } /// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1), diff --git a/crates/color/src/palette_io.rs b/crates/color/src/palette_io.rs index c8564e052..80f740321 100644 --- a/crates/color/src/palette_io.rs +++ b/crates/color/src/palette_io.rs @@ -181,14 +181,16 @@ fn css_comment(s: &str) -> String { fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { let mut out = format!("/* {} */\n:root {{\n", css_comment(&lib.name)); - let mut taken: Vec = vec![]; + let mut taken: HashSet = HashSet::new(); + // The next number to try for each base, so repeated names don't search the ones used before. + let mut next: HashMap = HashMap::new(); let mut props = |out: &mut String, list: &[Swatch]| { for w in list.iter().filter(&keep) { let value = match &w.paint { Paint::Solid { color, .. } => css_color(color, 1.0), Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => { let stops: Vec = - g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), (s.offset * 100.0).round())).collect(); + g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), s.offset * 100.0)).collect(); match g.gradient.kind { GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")), _ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")), @@ -198,8 +200,18 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String { }; // Names differing only in case or punctuation map to one property: number the others. let base = css_property(&w.name); - let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base}-{i}") }).find(|n| !taken.contains(n)).unwrap_or(base); - taken.push(name.clone()); + let name = if taken.insert(base.clone()) { + base + } else { + let n = next.entry(base.clone()).or_insert(1); + loop { + *n += 1; + let name = format!("{base}-{n}"); + if taken.insert(name.clone()) { + break name; + } + } + }; let note = if w.spot { " /* spot */" } else { "" }; out.push_str(&format!(" {name}: {value};{note}\n")); } @@ -371,7 +383,9 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary { continue; } if let Some(n) = line.strip_prefix("Name:") { - lib.name = n.trim().to_string(); + if !n.trim().is_empty() { + lib.name = n.trim().to_string(); + } continue; } // Comments, headers (`Columns: 4`) and anything else that isn't a colour are skipped. @@ -648,6 +662,14 @@ mod tests { use crate::{Gradient, GradientPaint, GradientStop}; use proptest::prelude::*; + #[test] + fn gpl_blank_name_keeps_fallback() { + let blank = "GIMP Palette\nName: \nColumns: 0\n#\n 1 2 3\tx\n"; + assert_eq!(read(blank, "fallback").unwrap().name, "fallback"); + let named = "GIMP Palette\nName: Warm\n 1 2 3\tx\n"; + assert_eq!(read(named, "fallback").unwrap().name, "Warm"); + } + /// A color book's string: a `u32` count of UTF-16 code units, then the units. fn acb_str(out: &mut Vec, s: &str) { let units: Vec = s.encode_utf16().collect(); @@ -711,13 +733,13 @@ mod tests { } fn sample() -> SwatchLibrary { - let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient { - kind: GradientKind::Linear, - stops: vec![ + let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![ GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) }, GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) }, ], - }))); + )))); SwatchLibrary { name: "Brand */ Colours".into(), swatches: vec![ @@ -805,6 +827,32 @@ mod tests { assert!(read(&text, "x").is_err(), "CSS is written only"); } + #[test] + fn css_preserves_fractional_gradient_stop_positions() { + for kind in [GradientKind::Linear, GradientKind::Radial] { + let lib = SwatchLibrary { + name: "Positions".into(), + swatches: vec![Swatch { + name: "Ramp".into(), + paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + kind, + vec![ + GradientStop::new(0.0, Color::BLACK), + GradientStop::new(0.125, Color::rgb8(255, 0, 0)), + GradientStop::new(0.12890625, Color::WHITE), + GradientStop::new(1.0, Color::rgb8(0, 0, 255)), + ], + )))), + global: false, + spot: false, + }], + groups: vec![], + }; + let text = written(&lib, PaletteFormat::Css); + assert!(text.contains("#000000 0%, #ff0000 12.5%, #ffffff 12.890625%, #0000ff 100%"), "{kind:?}: {text}"); + } + } + #[test] fn holds_counts_the_swatches_each_format_writes() { // `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group. diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index ef63fe845..818a5fbe4 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -264,6 +264,8 @@ pub struct RowMap { mean_l: f32, /// Hue turn from the key colour to the new colour (Hue Shift). turn: f32, + /// Whether colours take the new colour in its own model, not in the model they had. + own_model: bool, } impl RowMap { @@ -274,7 +276,20 @@ impl RowMap { Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::>()), _ => 0.0, }; - Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] } + Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0], own_model: false } + } + + /// With `own`, current colours take the new colour in the new colour's model (an RGB colour + /// replaced by a CMYK one becomes CMYK) instead of keeping the model they had. + pub fn with_own_model(mut self, own: bool) -> Self { + self.own_model = own; + self + } + + /// True when current colour `c` keeps a colour model other than the new colour's (so the new + /// colour is converted into `c`'s model and may look different from what was asked for). + pub fn converts(&self, c: Color) -> bool { + !self.own_model && c.model() != self.to.model() } /// The colour of the row the new colour stands for. @@ -301,7 +316,7 @@ impl RowMap { /// The new colour of current colour `c`, in `c`'s model. pub fn map(&self, c: Color) -> Color { - let to = keep_model(c, self.to); + let to = if self.own_model { self.to } else { keep_model(c, self.to) }; match self.method { Method::Exact => to, Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)), @@ -314,7 +329,13 @@ impl RowMap { Method::HueShift if c == self.key => to, Method::HueShift => { let [h, s, v] = c.to_hsb(); - if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) } + if s <= 0.0 { + if self.own_model { c.in_model(self.to.model()) } else { c } + } else if self.own_model { + Color::from_hsb(h + self.turn, s, v).in_model(self.to.model()) + } else { + keep_model(c, Color::from_hsb(h + self.turn, s, v)) + } } } } @@ -576,6 +597,16 @@ mod tests { assert!(cluster(&[], &[], 3).is_empty()); } + #[test] + fn hue_shift_with_own_model_puts_every_colour_of_the_row_in_the_new_model() { + let to = Color::cmyk(0.0, 1.0, 1.0, 0.0); + let row = [Color::rgb(0.2, 0.2, 0.9), Color::rgb(0.1, 0.1, 0.5), Color::rgb(0.3, 0.3, 0.3)]; + let own = RowMap::new(Method::HueShift, &row, to).with_own_model(true); + assert!(row.iter().all(|c| own.map(*c).model() == to.model()), "key and other colours alike"); + let kept = RowMap::new(Method::HueShift, &row, to); + assert!(row.iter().all(|c| kept.map(*c).model() == c.model())); + } + #[test] fn palette_snaps_to_the_nearest_colour() { let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]); diff --git a/crates/color/src/swatch.rs b/crates/color/src/swatch.rs index 21f9af988..12ae31220 100644 --- a/crates/color/src/swatch.rs +++ b/crates/color/src/swatch.rs @@ -123,13 +123,13 @@ fn base_swatches() -> (Vec, Vec) { .collect(); let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch { name: name.into(), - paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient { + paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient::new( kind, - stops: vec![ + vec![ GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)), GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)), ], - }))), + )))), global: false, spot: false, }; diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 5260be910..0ee88bf3b 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -20,6 +20,7 @@ zune-jpeg = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } +chrono = { workspace = true } [dev-dependencies] jpeg-encoder = { workspace = true } diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index b336ff152..9895cc7f9 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -314,15 +314,15 @@ impl PressureProfile { pub fn at(&self, t: f64) -> f64 { let p = &self.points; let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID }; + // (Positions go forward; a profile read from a file is searched as if they do, in log time.) let v = if t <= first.0 { first.1 } else { - p.windows(2) - .find_map(|w| match w { - [a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)), - _ => None, - }) - .unwrap_or(last.1) + let i = p.partition_point(|q| q.0 < t); + match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) { + (Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0), + _ => last.1, + } }; if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID } } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 119e75abc..7bf88ac16 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -44,23 +44,54 @@ fn lerp_f64(x: f64, y: f64, t: f64) -> f64 { x + (y - x) * t } -fn solid_gradient(c: Color, like: &Gradient) -> Gradient { +/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`). +fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> Gradient { let mut g = like.clone(); for s in &mut g.stops { s.color = c; s.opacity = 1.0; + s.swatch = swatch.map(str::to_string); + s.tint = tint; } g } +/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops +/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks. +fn link_at(g: &Gradient, t: f32) -> (Option, f32) { + let stops = &g.stops; + let link = |s: &GradientStop| (s.swatch.clone(), s.tint); + let Some(first) = stops.first() else { return (None, 1.0) }; + if t <= first.offset { + return link(first); + } + for w in stops.windows(2) { + let (a, b) = (&w[0], &w[1]); + if t <= b.offset { + let span = (b.offset - a.offset).max(1e-6); + let u = (t - a.offset) / span; + let m = a.midpoint.clamp(0.01, 0.99); + let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) }; + return match (&a.swatch, &b.swatch) { + (Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v)), + _ if v <= 0.0 => link(a), + _ if v >= 1.0 => link(b), + _ => (None, 1.0), + }; + } + } + stops.last().map_or((None, 1.0), link) +} + /// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred). fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient { let mut out = g.clone(); out.stops = offsets .iter() .map(|&o| { - let (color, opacity) = g.sample_with(o, lerp_color); - GradientStop { opacity, ..GradientStop::new(o, color) } + let (color, opacity) = g.sample_keep_model(o); + let (swatch, tint) = link_at(g, o); + GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) } }) .collect(); out @@ -106,10 +137,19 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t)), + _ => (None, 1.0), + }; + GradientStop { + opacity: lerp_f32(sa.opacity, sb.opacity, t), + midpoint: lerp_f32(sa.midpoint, sb.midpoint, t), + swatch, + tint, + ..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t)) + } }) .collect(); out.angle = a.angle + (b.angle - a.angle) * t as f64; @@ -131,17 +171,21 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option Paint { match (a, b) { + // Two tints of one swatch blend into tints of that swatch (a spot stays a spot on export). + (Paint::Solid { color: x, swatch: Some(sa), tint: ta }, Paint::Solid { color: y, swatch: Some(sb), tint: tb }) if sa == sb => { + Paint::Solid { color: lerp_color(x, y, t), swatch: Some(sa.clone()), tint: lerp_f32(*ta, *tb, t) } + } (Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)), (Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) { Some(g) => Paint::Gradient(Box::new(g)), None => (if t < 0.5 { a } else { b }).clone(), }, - (Paint::Solid { color, .. }, Paint::Gradient(g)) => { - let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() }; + (Paint::Solid { color, swatch, tint }, Paint::Gradient(g)) => { + let ga = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() }; Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone()))) } - (Paint::Gradient(g), Paint::Solid { color, .. }) => { - let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() }; + (Paint::Gradient(g), Paint::Solid { color, swatch, tint }) => { + let gb = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() }; Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone()))) } _ => (if t < 0.5 { a } else { b }).clone(), @@ -535,11 +579,18 @@ impl Lerp { /// The object at `t` (0 = `a`, 1 = `b`), with id 0. pub fn at(&self, t: f64) -> Node { + self.at_eased(t, t) + } + + /// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text + /// colours) are at `tc`: a blend with its colours eased apart from its spacing. + pub fn at_eased(&self, t: f64, tc: f64) -> Node { let (a, b) = (&self.a, &self.b); let base = if t < 0.5 { a } else { b }; let mut n = match &self.shape { Shape::Group { children, clip } => { - let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip }); + let mut g = + Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip }); g.isolate = base.isolate; g.knockout = base.knockout; g @@ -549,8 +600,8 @@ impl Lerp { if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) { for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) { p.p = x.p.lerp(y.p, t); - p.color = lerp_color(&x.color, &y.color, t as f32); - p.opacity = lerp_f32(x.opacity, y.opacity, t as f32); + p.color = lerp_color(&x.color, &y.color, tc as f32); + p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32); for h in 0..4 { p.handles[h] = x.handles[h].lerp(y.handles[h], t); } @@ -576,7 +627,7 @@ impl Lerp { Shape::Placed => { let mut n = base.clone(); match (&mut n.kind, &a.kind, &b.kind) { - (NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t), + (NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc), (NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => { *o = lerp_affine(*x, *y, t) } @@ -613,8 +664,8 @@ impl Lerp { } }; n.id = NodeId(0); - n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into(); - n.opacity = lerp_f32(a.opacity, b.opacity, t as f32); + n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into(); + n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32); n.blend = base.blend; n.visible = true; n.locked = false; @@ -644,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N /// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an /// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at /// the same place (by character) of both. -fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { +fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) { o.xf = lerp_affine(x.xf, y.xf, t); if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) = (&mut o.kind, &x.kind, &y.kind) @@ -666,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) { } let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::().max(1); let mut start = 0usize; - let tf = t as f32; + let tf = tc as f32; let mut resized = false; for r in &mut o.runs { let len = r.text.chars().count(); @@ -1027,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { let n = blend_step_count(a, b, spec.spacing, len); let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1))); for j in 1..=n { - let t = j as f64 / (n + 1) as f64; - let mut s = lerp.at(t); + // Even position of the step, then where the easing puts its shape and its colours. + let u = j as f64 / (n + 1) as f64; + let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u)); + let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { r.place(&mut s, i, t); } @@ -1090,6 +1143,55 @@ mod tests { assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put"); } + #[test] + fn easing_keeps_the_ends_and_stays_monotone() { + use crate::live::{BlendEase, BlendEaseType as K}; + for kind in [K::In, K::Out, K::InOut, K::OutIn] { + for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] { + let e = BlendEase { kind, ramp }; + assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0)); + let ys: Vec = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect(); + assert!(ys.windows(2).all(|w| w[0] <= w[1])); + assert_eq!(e.apply(f64::NAN), 0.0); + if ramp == 0.0 { + assert_eq!(e.apply(0.25), 0.25); + } + } + } + let e = |kind| BlendEase { kind, ramp: 1.0 }; + assert!(e(K::In).apply(0.25) < 0.25); + assert!(e(K::Out).apply(0.25) > 0.25); + assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75); + assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75); + } + + #[test] + fn colour_easing_moves_colours_apart_from_positions() { + let rect = |x: f64, c: Color| { + let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()); + n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into(); + Arc::new(n) + }; + let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)]; + let mid = |spec: &BlendSpec| { + let out = blend_expand(&keys, spec); + let n = &out[1]; + (n.geometric_bounds().unwrap().x0, n.appearance.fill_paint()) + }; + // Position easing runs along a spine: Align to Path gives the blend one. + let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() }; + let (x0, p0) = mid(&base); + // Colour easing alone leaves the position and moves the colour. + let colour = BlendSpec { color_shift: 50.0, ..base.clone() }; + let (x1, p1) = mid(&colour); + assert!((x0 - x1).abs() < 1e-9 && p0 != p1); + // Position easing alone leaves the colour and moves the position. + let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() }; + let (x2, p2) = mid(&pos); + assert!((x0 - x2).abs() > 1e-3 && p0 == p2); + assert_eq!(mid(&pos).0, x2, "deterministic"); + } + #[test] fn affine_lerp_turns_the_short_way() { let x = Affine::rotate(170f64.to_radians()); @@ -1114,6 +1216,56 @@ mod tests { assert!(pair.at(0.5).bounds().unwrap().height() < 1e-6, "the clicked anchors pair up"); } + #[test] + fn blend_between_tints_of_one_swatch_keeps_the_link() { + let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint }; + let Paint::Solid { swatch, tint, .. } = lerp_paint(&tinted(0.2), &tinted(1.0), 0.5) else { panic!() }; + assert_eq!(swatch.as_deref(), Some("Spot")); + assert!((tint - 0.6).abs() < 1e-6, "{tint}"); + let other = Paint::Solid { color: Color::BLACK, swatch: Some("Other".into()), tint: 1.0 }; + assert!(matches!(lerp_paint(&tinted(0.2), &other, 0.5), Paint::Solid { swatch: None, .. })); + + // Gradient stops on tints of one swatch stay linked too; a process solid unlinks them. + let spot_gradient = |tint: f32| { + let mut stop = GradientStop::new(0.0, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint)); + stop.swatch = Some("Spot".into()); + stop.tint = tint; + let stops = vec![stop.clone(), GradientStop { offset: 1.0, ..stop }]; + Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() }))) + }; + let Paint::Gradient(m) = lerp_paint(&spot_gradient(0.2), &spot_gradient(1.0), 0.5) else { panic!() }; + assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops); + let Paint::Gradient(m) = lerp_paint(&tinted(0.2), &spot_gradient(1.0), 0.5) else { panic!() }; + assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops); + let Paint::Gradient(m) = lerp_paint(&Paint::solid(Color::BLACK), &spot_gradient(1.0), 0.5) else { panic!() }; + assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none()), "{:?}", m.gradient.stops); + } + + #[test] + fn resampled_gradient_stops_keep_the_swatch_link() { + let spot = |offset: f32, tint: f32| { + let mut stop = GradientStop::new(offset, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint)); + stop.swatch = Some("Spot".into()); + stop.tint = tint; + stop + }; + let paint = |stops: Vec| Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() }))); + let two = paint(vec![spot(0.0, 0.2), spot(1.0, 1.0)]); + let three = paint(vec![spot(0.0, 0.2), spot(0.5, 0.6), spot(1.0, 1.0)]); + let Paint::Gradient(m) = lerp_paint(&two, &three, 0.5) else { panic!() }; + assert_eq!(m.gradient.stops.len(), 3); + assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops); + let tints: Vec = m.gradient.stops.iter().map(|s| s.tint).collect(); + assert!((tints[1] - 0.6).abs() < 1e-6 && (tints[2] - 1.0).abs() < 1e-6, "{tints:?}"); + + // Spot on one side, process on the other: the stops are unlinked. + let mut plain = spot(0.5, 1.0); + plain.swatch = None; + let mixed = paint(vec![spot(0.0, 1.0), plain, spot(1.0, 1.0)]); + let Paint::Gradient(m) = lerp_paint(&two, &mixed, 0.5) else { panic!() }; + assert!(m.gradient.stops.iter().any(|s| s.swatch.is_none()), "{:?}", m.gradient.stops); + } + #[test] fn gradients_with_different_stop_counts_interpolate() { let g = |stops: &[(f32, Color)]| { diff --git a/crates/doc/src/cancel.rs b/crates/doc/src/cancel.rs index bff510707..e38b0ab81 100644 --- a/crates/doc/src/cancel.rs +++ b/crates/doc/src/cancel.rs @@ -92,6 +92,15 @@ pub fn check() -> Result<(), Cancelled> { if is_cancelled() { Err(Cancelled) } else { Ok(()) } } +/// Run `f` with this thread's token still in effect and its progress reports dropped: for work +/// that reports its own steps around calls that report theirs (each file of a batch export). +pub fn quiet(f: impl FnOnce() -> T) -> T { + match CURRENT.with(|c| c.borrow().as_ref().map(|s| s.token.clone())) { + Some(token) => scope(&token, None, f), + None => f(), + } +} + /// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`] /// that has a sink. pub fn progress(done: u64, total: u64, what: &str) { @@ -139,4 +148,25 @@ mod tests { progress(2, 3, "after"); assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]); } + + #[test] + fn quiet_work_reports_nothing_and_can_still_be_cancelled() { + let seen = Rc::new(RefCell::new(vec![])); + let sink: ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let t = CancelToken::new(); + scope(&t, Some(sink), || { + progress(0, 2, "file"); + quiet(|| { + progress(0, 1, "artboard"); + t.cancel(); + assert_eq!(check(), Err(Cancelled)); + }); + progress(1, 2, "file"); + }); + assert_eq!(*seen.borrow(), vec![(0, 2, "file".to_string()), (1, 2, "file".to_string())]); + assert_eq!(quiet(check), Ok(()), "outside a scope nothing is cancelled"); + } } diff --git a/crates/doc/src/corners.rs b/crates/doc/src/corners.rs index ccf884fda..24228604a 100644 --- a/crates/doc/src/corners.rs +++ b/crates/doc/src/corners.rs @@ -7,11 +7,68 @@ use std::collections::BTreeSet; use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners}; use vectorcraft_geom::shapes::{self, CornerKind}; -use vectorcraft_geom::{Affine, PathData, Rect}; +use vectorcraft_geom::{Affine, PathData, Point, Rect}; use crate::node::{LiveShape, Node, NodeKind}; use crate::selection::AnchorRef; +/// Stored corner radii as document lengths for cutting and display (fractions when `relative`). +pub fn resolve_stored_radii(base: &PathData, stored: &[f64], relative: bool) -> Vec { + if !relative { + return stored.to_vec(); + } + let corners = path_corners(base); + let mut out = stored.to_vec(); + out.resize(base.anchor_count(), 0.0); + for c in corners { + if let Some(r) = out.get_mut(c.index) { + *r = (*r).clamp(0.0, 1.0) * c.max_radius(); + } + } + out +} + +/// Turn stored absolute radii into fractions of each corner's maximum, or the other way. +pub fn convert_radii_relative(live: &mut LiveShape, relative: bool) { + if live.radii_relative() == relative { + return; + } + let was_relative = live.radii_relative(); + let base = match live { + LiveShape::Rectangle { w, h, .. } => shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)), + LiveShape::Polygon { .. } => live.polygon_outline(), + LiveShape::Path { base, .. } => base.clone(), + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, + }; + let corners = path_corners(&base); + let radii: &mut [f64] = match live { + LiveShape::Rectangle { radii, .. } => radii, + LiveShape::Polygon { radii, .. } => radii, + LiveShape::Path { radii, .. } => radii, + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, + }; + for c in corners { + if let Some(r) = radii.get(c.index) { + let max = c.max_radius(); + radii[c.index] = if relative { + if max > 1e-12 && !was_relative { (*r / max).clamp(0.0, 1.0) } else { *r } + } else if was_relative { + (*r).clamp(0.0, 1.0) * max + } else { + *r + }; + } + } + match live { + LiveShape::Rectangle { radii_relative: rel, .. } + | LiveShape::Polygon { radii_relative: rel, .. } + | LiveShape::Path { radii_relative: rel, .. } => { + *rel = relative; + } + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => {} + } +} + /// The Live Corners of a path. #[derive(Clone, Debug)] pub struct LiveCorners<'a> { @@ -26,6 +83,8 @@ pub struct LiveCorners<'a> { /// The radius each of `corners` is drawn with ([`fitted_radii`]). drawn: Vec, kinds: Vec, + /// Stored radii are fractions of each corner's maximum, not document lengths. + relative: bool, /// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from. sources: Vec>, } @@ -34,16 +93,21 @@ impl<'a> LiveCorners<'a> { /// The Live Corners of `path` and its live shape; `None` for shapes without corners (ellipses, /// lines). pub fn new(path: &'a PathData, live: Option<&'a LiveShape>) -> Option { - let (base, xf, radii, kinds) = match live { - None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![]), + let (base, xf, stored, kinds, relative) = match live { + None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![], false), Some(l @ LiveShape::Rectangle { .. }) => { - let LiveShape::Rectangle { w, h, radii, kinds, xf } = l.folded() else { return None }; - (Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec()) + let LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } = l.folded() else { return None }; + (Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec(), radii_relative) + } + Some(l @ LiveShape::Polygon { radii, kinds, radii_relative, .. }) => { + (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative) + } + Some(LiveShape::Path { base, radii, kinds, radii_relative }) => { + (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative) } - Some(l @ LiveShape::Polygon { radii, kinds, .. }) => (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone()), - Some(LiveShape::Path { base, radii, kinds }) => (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone()), Some(LiveShape::Ellipse { .. } | LiveShape::Line { .. }) => return None, }; + let radii = resolve_stored_radii(&base, &stored, relative); let sources = if radii.iter().any(|r| *r > 0.0) { cut_corners(&base, &radii, &kinds).1 } else { @@ -58,7 +122,7 @@ impl<'a> LiveCorners<'a> { }; let corners = path_corners(&base); let drawn = fitted_radii(&corners, &radii); - Some(Self { corners, base, xf, radii, drawn, kinds, sources }) + Some(Self { corners, base, xf, radii: stored, drawn, kinds, relative, sources }) } /// The Live Corners of a path object. @@ -72,7 +136,13 @@ impl<'a> LiveCorners<'a> { self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.corners.get(i)) } - /// The radius corner `index` is set to, in document units (drawn no larger than fits). + /// Whether corner radii are stored as fractions of each corner's maximum. + pub fn radii_relative(&self) -> bool { + self.relative + } + + /// The radius corner `index` is stored at: document lengths, or a fraction in 0..1 when + /// [`LiveCorners::radii_relative`]. pub fn radius(&self, index: usize) -> f64 { self.radii.get(index).copied().unwrap_or(0.0) } @@ -119,8 +189,9 @@ impl<'a> LiveCorners<'a> { &self.sources } - /// The radius (document units) and kind `corners` share (each `None` when they differ, or - /// for no corners). + /// The radius and kind `corners` share (each `None` when they differ, or for no corners). + /// Radius is what was set ([`LiveCorners::radius`]: document lengths, or a fraction in 0..1 + /// when [`LiveCorners::radii_relative`]), not the fitted draw size ([`LiveCorners::drawn_radius`]). pub fn style(&self, corners: &BTreeSet) -> (Option, Option) { fn shared(mut it: impl Iterator, same: impl Fn(T, T) -> bool) -> Option { let first = it.next()?; @@ -137,29 +208,31 @@ impl<'a> LiveCorners<'a> { /// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they /// are. pub fn set_corners(path: &mut PathData, live: &mut Option>, corners: &BTreeSet, radius: Option, kind: Option) { - fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet, radius: Option, kind: Option) { + fn edit( + base: &PathData, + relative: bool, + radii: &mut [f64], + kinds: &mut [CornerKind], + corners: &BTreeSet, + radius: Option, + kind: Option, + ) { + let corners_on_base = path_corners(base); for k in corners { if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) { - *slot = r.max(0.0); + *slot = if relative { + let max = corners_on_base.iter().find(|c| c.index == *k).map(|c| c.max_radius()).unwrap_or(0.0); + if max > 1e-12 { (r / max).clamp(0.0, 1.0) } else { 0.0 } + } else { + r.max(0.0) + }; } if let (Some(kind), Some(slot)) = (kind, kinds.get_mut(*k)) { *slot = kind; } } } - /// One radius and kind per corner of an outline of `n` anchors while editing; none saved - /// when all are sharp or round. - fn edit_vec( - n: usize, - radii: &mut Vec, - kinds: &mut Vec, - corners: &BTreeSet, - radius: Option, - kind: Option, - ) { - radii.resize(n, 0.0); - kinds.resize(n, CornerKind::Round); - edit(radii, kinds, corners, radius, kind); + fn trim_vec_radii(radii: &mut Vec, kinds: &mut Vec) { if radii.iter().all(|r| *r <= 0.0) { radii.clear(); } @@ -167,18 +240,35 @@ pub fn set_corners(path: &mut PathData, live: &mut Option>, corne kinds.clear(); } } - let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] })); + let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![], radii_relative: false })); // Radii are document lengths and corners circular, also on a rectangle from a file that kept // an uneven scale in its transform (#442). l.fold_scale(); + let relative = l.radii_relative(); match &mut **l { - LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind), - LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind), - LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind), + LiveShape::Rectangle { w, h, radii, kinds, .. } => { + let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)); + edit(&base, relative, radii, kinds, corners, radius, kind); + } + LiveShape::Polygon { radius: poly_r, sides, xf, radii, kinds, .. } => { + let outline = shapes::polygon(Point::ZERO, *poly_r, *sides, 0.0).transformed(*xf); + let n = *sides as usize; + radii.resize(n, 0.0); + kinds.resize(n, CornerKind::Round); + edit(&outline, relative, radii, kinds, corners, radius, kind); + trim_vec_radii(radii, kinds); + } + LiveShape::Path { base, radii, kinds, .. } => { + let n = base.anchor_count(); + radii.resize(n, 0.0); + kinds.resize(n, CornerKind::Round); + edit(base, relative, radii, kinds, corners, radius, kind); + trim_vec_radii(radii, kinds); + } LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return, } match live.as_deref_mut() { - Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => { + Some(LiveShape::Path { base, radii, kinds, .. }) if radii.is_empty() && kinds.is_empty() => { *path = std::mem::take(base); *live = None; } @@ -202,7 +292,14 @@ mod tests { #[test] fn rectangle_corners_map_to_their_anchors() { - let l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY }; + let l = LiveShape::Rectangle { + w: 100.0, + h: 50.0, + radii: [0.0, 10.0, 0.0, 0.0], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; let path = l.to_path(); let c = LiveCorners::new(&path, Some(&l)).unwrap(); // Anchors: top-left, the top-right corner's two, bottom-right, bottom-left. @@ -229,7 +326,7 @@ mod tests { let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0); let (mut path, mut live) = (star.clone(), None); set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None); - let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") }; + let Some(LiveShape::Path { base, radii, kinds, .. }) = live.as_deref() else { panic!("live corners: {live:?}") }; assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0)); assert_eq!(path.anchor_count(), 12, "two corners cut"); let c = LiveCorners::new(&path, live.as_deref()).unwrap(); @@ -245,7 +342,8 @@ mod tests { /// A polygon's corners are cut in the document and survive a change of sides when shared. #[test] fn polygon_corners_follow_their_sides() { - let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] }; + let mut l = + LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![], radii_relative: false }; let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone()))); set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None); assert_eq!(path.anchor_count(), 12); @@ -265,6 +363,53 @@ mod tests { assert!(!p.transform(Affine::translate((5.0, 0.0))), "a move needs no new cuts"); } + #[test] + fn relative_radii_scale_with_rectangle_size() { + let l = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.25, 0.25, 0.25, 0.25], + kinds: Default::default(), + radii_relative: true, + xf: Affine::IDENTITY, + }; + let path = l.to_path(); + let c = LiveCorners::new(&path, Some(&l)).unwrap(); + let r0 = c.drawn_radius(0); + let l2 = LiveShape::Rectangle { + w: 200.0, + h: 200.0, + radii: [0.25, 0.25, 0.25, 0.25], + kinds: Default::default(), + radii_relative: true, + xf: Affine::IDENTITY, + }; + let path2 = l2.to_path(); + let c2 = LiveCorners::new(&path2, Some(&l2)).unwrap(); + assert!((c2.drawn_radius(0) / r0 - 2.0).abs() < 0.05, "{r0} → {}", c2.drawn_radius(0)); + } + + #[test] + fn convert_between_relative_and_absolute_radii() { + let mut l = LiveShape::Rectangle { + w: 100.0, + h: 50.0, + radii: [10.0, 0.0, 0.0, 0.0], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; + let before = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0); + convert_radii_relative(&mut l, true); + assert!(l.radii_relative()); + let mid = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0); + assert!((mid - before).abs() < 0.5); + convert_radii_relative(&mut l, false); + assert!(!l.radii_relative()); + let after = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0); + assert!((after - before).abs() < 0.5); + } + #[test] fn open_paths_and_curves_keep_their_ends_and_smooth_anchors() { let pen = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false)); diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index 81d1d12f6..6f142a584 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -66,11 +66,16 @@ pub struct HitOptions { /// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is /// on or the guides are hidden. pub skip_guides: bool, + /// The selection tools: a clip group's clipping path is hit on its outline, within the + /// tolerance, before the art it clips, so that the mask can be reached where the art covers + /// its edge. Off for the tools that act on what is drawn (sampling, type, gradients…); + /// [`hit_test_visible`] never does it. + pub clip_outlines: bool, } impl Default for HitOptions { fn default() -> Self { - Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None } + Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: false, scope: None } } } @@ -134,6 +139,11 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option, seen: &mut HashSet) -> bool { let Some(children) = layer.children() else { return true }; - if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) { - return true; + if layer.clips() + && let Some(clip) = children.first() + { + // As the hit test does: the clipping mask's outline first, then only inside the mask. + if clip_outline_hit(layer, clip, p, opt, symbols, true).is_some() && seen.insert(clip.id) { + tops.push(clip.id); + } + if !clip_contains(clip, p) { + return true; + } } let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt }; for (i, c) in children.iter().enumerate().rev() { @@ -267,14 +285,24 @@ fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> boo } } -/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are -/// measured by their art ([`Node::geometric_bounds_with`]). +/// [`hit_test`] passing over the nodes `skip` names (and their contents), and with +/// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of +/// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art +/// ([`Node::geometric_bounds_with`]). fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option { let finish = |mut h: Hit| { h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents)); h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count(); h }; + let locks = doc.locked_artboards(); + let on_locked = |id: NodeId| { + editable_only + && !locks.is_empty() + && doc.parent_of(id).and_then(|l| doc.node(l)).is_some_and(Node::is_layer) + && doc.node(id).is_some_and(|n| locks.holds(n)) + }; + let skip = &|id| skip(id) || on_locked(id); let symbols = doc.symbol_boxes(); // Isolation mode: only what is inside the isolated container hits. The art around it is // locked, and clicks pass through it (#833). @@ -283,6 +311,10 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No && let Some(isolated) = doc.node(scope) && let Some(mut chain) = doc.ancestry(scope) { + // Isolated before its artboard was locked: nothing in it hits. + if locks.holds_id(scope) { + return None; + } if isolated.is_container() { return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish); } @@ -363,6 +395,31 @@ fn shaper_hides(parent: &Node, i: usize, scope: Option) -> bool { parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope) } +/// A press on the outline of clipping path `clip` (a path or a compound path; type clips by its +/// glyphs, which this crate cannot outline), within the tolerance. +fn clip_outline(clip: &Node, p: Point, opt: HitOptions) -> Option { + let bp = match &clip.kind { + NodeKind::Path { path, .. } => path.to_bezpath(), + NodeKind::Compound { .. } => clip.stroke_path()?, + _ => return None, + }; + stroke_contains(&bp, 0.0, opt.tol, p).then_some(HitKind::Outline) +} + +/// For the selection tools ([`HitOptions::clip_outlines`]), a press on the outline of `clip`, the +/// clipping path of `parent` (a clip group or a layer with a clipping mask), within the tolerance: +/// it is hit before the art it clips. There Direct Selection takes its segments, and the +/// Selection tool the clip group. The reference app reshapes a clipping path with Direct Selection +/// (); +/// without this, art drawn over the path's edge is hit first and its segments can't be dragged. +/// The clipping path must be one a click may hit ([`may_hit`]: visible, editable, within reach). +fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> Option { + if !opt.clip_outlines || !editable_only || !may_hit(parent, 0, clip, p, opt, symbols, editable_only) { + return None; + } + clip_outline(clip, p, opt) +} + fn hit_children( parent: &Node, p: Point, @@ -373,12 +430,17 @@ fn hit_children( editable_only: bool, ) -> Option { let children = parent.children()?; - // A clip group (or a layer with a clipping mask) only hits inside its clipping path. if parent.clips() && let Some(clip) = children.first() - && !clip_contains(clip, p) { - return None; + if let Some(kind) = clip_outline_hit(parent, clip, p, opt, symbols, editable_only).filter(|_| !skip(clip.id)) { + chain.push(clip.id); + return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 }); + } + // Elsewhere a clip group (or a layer with a clipping mask) only hits inside it. + if !clip_contains(clip, p) { + return None; + } } for i in hit_order(parent, children, p) { let Some(c) = children.get(i) else { continue }; @@ -460,10 +522,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?; on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline) } - // An image is hit within its frame, and where its own fills and strokes paint - // ([`image_paint`]) except in outline mode. - NodeKind::Image(_) => { - if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { + // An image is hit within its frame (on the frame's edge only with Selection by Path Only, + // #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline + // mode. + NodeKind::Image(im) => { + if opt.path_only { + use vectorcraft_geom::kurbo::Shape; + let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1); + frame.apply_affine(im.xf); + if stroke_contains(&frame, 0.0, opt.tol, p) { + return Some(HitKind::Outline); + } + } else if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) { return Some(HitKind::Bounds); } if opt.outline { @@ -477,7 +547,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => { n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds) } - NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)), + NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)), NodeKind::Mesh(m) => { let bp = m.outline().to_bezpath(); if stroke_contains(&bp, 0.0, opt.tol, p) { @@ -533,10 +603,11 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option { parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id) } -/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol -/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group -/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping -/// path clips to, and then only within that region's bounds. +/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving +/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured +/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a +/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then +/// only within that region's bounds. pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> Vec { let mut out = Vec::new(); // `tops` pairs each object with the part of `r` in which the marquee can touch it. @@ -588,7 +659,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r) .is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))), NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)), - NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, s, r, scope)), + NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)), // A box with no width or no height (an instance of a symbol that draws a straight // line) is touched where the marquee crosses it. _ => n.geometric_bounds_with(s).is_some_and(|b| { @@ -615,8 +686,9 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option, leaves: bool) -> } } let symbols = doc.symbol_boxes(); + let locks = doc.locked_artboards(); for (t, r) in tops { - if !t.visible || t.locked { + if !t.visible || t.locked || locks.holds_id(t.id) { continue; } if leaves { @@ -949,6 +1021,81 @@ mod tests { } } + /// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a + /// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the + /// clipping path and the art. + fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art()); + if layer { + d.insert(Some(l), 0, path).unwrap(); + d.insert(Some(l), 1, picture).unwrap(); + d.node_mut(l).unwrap().set_clips(true); + (d, l, clip, art) + } else { + d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap(); + (d, g, clip, art) + } + } + + const SELECTING: HitOptions = + HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None }; + + /// For the selection tools a clip group's clipping path is hit on its outline, within the + /// tolerance, before the art it clips and just outside it too; inside, the art is hit. + #[test] + fn a_clipping_path_is_hit_on_its_outline_before_its_art() { + let (d, g, clip, art) = masked_square(false); + for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] { + let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap(); + assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}"); + } + let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap(); + assert_eq!((h.leaf, h.top_object(None)), (art, g)); + // Beyond the tolerance outside it, the clipped art is not there to hit. + assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none()); + } + + /// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling + /// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints + /// nothing there. + #[test] + fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() { + let (d, _, clip, art) = masked_square(false); + let p = Point::new(138.5, 90.0); + assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art)); + assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); + // A hidden or locked clipping path is passed over. + for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] { + let mut d = d.clone(); + change(d.node_mut(clip).unwrap()); + assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip)); + } + } + + /// A layer's clipping mask is hit on its outline too, and Select Behind lists what the + /// repeated hit test finds: the mask on and just outside its edge, then the art inside it; + /// well inside, the art alone (the mask paints nothing there). + #[test] + fn select_behind_takes_a_layer_masks_outline_first() { + let (d, _, clip, art) = masked_square(true); + for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] { + let p = Point::new(x, 90.0); + assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}"); + assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}"); + } + // Without clip_outlines, as before. + assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]); + } + /// Art in a sublayer is an object of its own: a click or a marquee takes it, not the /// sublayer; hidden, locked and template sublayers block both. #[test] @@ -1016,6 +1163,28 @@ mod tests { } } + /// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so + /// a path under it can be picked through it. + #[test] + fn path_only_picks_paths_through_images() { + let mut d = Document::new(200.0, 200.0); + let l = d.layers[0].id; + let (path, image) = (d.alloc_id(), d.alloc_id()); + d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap(); + // 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path. + let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0); + let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None }; + d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap(); + let path_only = HitOptions { path_only: true, ..Default::default() }; + let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf); + let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5)); + assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path"); + assert_eq!(hit(inside, HitOptions::default()), Some(image)); + assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image"); + assert_eq!(hit(inside, path_only), None, "path only: not inside the image"); + assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame"); + } + /// Type Object Selection by Path Only: point type is picked on its baseline, area type on its /// frame and type on a path on its path, no longer anywhere in its bounds. #[test] @@ -1243,9 +1412,16 @@ world", let mut d = Document::new(120.0, 120.0); let layer = d.layers[0].id; fill(&mut d, layer, 0, &mut next); + // A clipping mask on the layer, or on a sublayer, now and then. + let sublayers: Vec = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect(); + for id in std::iter::once(layer).chain(sublayers) { + if next(3) == 0 { + d.node_mut(id).unwrap().set_clips(true); + } + } for _ in 0..12 { let p = Point::new(f64::from(next(120)), f64::from(next(120))); - let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() }; + let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() }; assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}"); } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 12ecf8570..4e42295f1 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -44,6 +44,7 @@ pub mod perspective; mod pixels; pub use pixels::MAX_RASTER_ALLOC; pub mod placed_document; +pub mod plugin_data; pub mod profiles; pub mod puppet; pub mod range; @@ -99,17 +100,17 @@ pub use graph::{ }; pub use hit::{Hit, HitKind}; pub use links::{LinkInfo, PlacementOptions}; -pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; +pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint}; pub use live_paint::{GapOptions, GapSize}; pub use metadata::{CopyrightStatus, DocMetadata}; pub use node::Knockout; pub use node::Scaling; pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask}; -pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS}; +pub use node::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS}; pub use orient::OrientedBox; pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType}; pub use perspective::PerspectiveAttachment; -pub use placed_document::PlacedDocument; +pub use placed_document::{PageBox, PlacedDocument}; pub use profiles::ColorProfiles; pub use puppet::{PuppetPin, PuppetPins}; pub use rastersettings::{RasterColorModel, RasterEffectsSettings}; @@ -119,8 +120,8 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption pub use style_libs::StyleLibrary; pub use text::{ AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt, - JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics, - SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, + JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, + RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape, }; pub use trace::TraceView; pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables}; @@ -378,7 +379,9 @@ fn parse_measure(s: &str, default: Unit) -> Option { /// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit). fn number_unit(s: &str) -> Option<(f64, Option)> { let s = s.trim(); - let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len()); + // A leading `+` is a sign (`+5`), like `-`. + let sign = usize::from(s.starts_with('+')); + let num_end = s.get(sign..)?.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).map_or(s.len(), |i| i + sign); let v: f64 = s.get(..num_end)?.trim().parse().ok()?; let unit = match s.get(num_end..)?.trim() { "" => None, @@ -438,10 +441,165 @@ pub struct Artboard { pub name: String, /// Artboard rectangle in document points. pub rect: Rect, + /// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands + /// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be + /// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays + /// selected ([`Document::new_art`]). + #[serde(default, skip_serializing_if = "skip::is_default")] + pub locked: bool, + /// Show Center Mark (Artboard Options): the canvas draws a mark at the artboard's center. + /// Exports and print leave it out. Always written, as before it could be set. #[serde(default)] pub show_center_mark: bool, + /// Show Cross Hairs (Artboard Options): the canvas draws a line from the middle of each side + /// through the center. Exports and print leave them out. Always written, as before it could + /// be set. #[serde(default)] pub show_cross_hairs: bool, + /// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and + /// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written + /// only when on, so a document that never shows them saves as before. + #[serde(default, skip_serializing_if = "skip::is_default")] + pub show_video_safe_areas: bool, + /// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its + /// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many + /// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't + /// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is + /// brought into it as it opens. + #[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")] + pub video_ruler_pixel_aspect_ratio: f64, + /// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the + /// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on; + /// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave + /// it out. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times +/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and +/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare. +pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive = 0.1..=10.0; + +/// How far in from each edge of an artboard the video safe areas lie, as a share of its width +/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the +/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television +/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5). +pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05]; + +fn square_pixels() -> f64 { + 1.0 +} + +fn is_square_pixels(v: &f64) -> bool { + *v == 1.0 +} + +/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that +/// isn't a number (NaN, infinite) reads as square pixels. +fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + let v = f64::deserialize(d)?; + let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end()); + Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 }) +} + +impl Artboard { + /// The action-safe and the title-safe area of the artboard, in that order, each inset from its + /// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas). + pub fn video_safe_areas(&self) -> [Rect; 2] { + let r = self.rect; + VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height())) + } +} + +/// A document's locked artboards ([`Document::locked_artboards`]). +pub struct LockedArtboards<'a> { + rects: Vec, + symbols: SymbolBoxes<'a>, + /// The new art, which isn't on them ([`Document::new_art`]). + new_art: Arc>, + /// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no + /// artboard. + pattern_art: std::collections::HashSet, + /// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a + /// group are each answered with one measurement of the group. + held: std::cell::RefCell>, +} + +impl LockedArtboards<'_> { + /// No artboard is locked. + pub fn is_empty(&self) -> bool { + self.rects.is_empty() + } + /// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its + /// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An + /// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or + /// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or + /// new art while it stays selected ([`Document::new_art`]). + pub fn holds(&self, n: &Node) -> bool { + !self.new_art.contains(&n.id) && self.overlaps(n) + } + /// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it + /// is new art? + pub fn overlaps(&self, n: &Node) -> bool { + if self.rects.is_empty() + || !n.visible + || n.is_layer() + || matches!(n.kind, NodeKind::Path { guide: true, .. }) + || self.pattern_art.contains(&n.id) + { + return false; + } + if let Some(held) = self.held.borrow().get(&n.id) { + return *held; + } + let held = + n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0)); + self.held.borrow_mut().insert(n.id, held); + held + } + /// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id` + /// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])? + pub fn holds_id(&self, id: NodeId) -> bool { + !self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n)) + } +} + +/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in +/// place, so a selection change that sets it doesn't replace the document. +#[derive(Default)] +pub struct NewArtIds(std::sync::RwLock>>); + +impl NewArtIds { + /// The objects. + pub fn get(&self) -> Arc> { + self.0.read().map(|g| g.clone()).unwrap_or_default() + } + /// Make `ids` the objects. + pub fn set(&self, ids: Arc>) { + if let Ok(mut g) = self.0.write() { + *g = ids; + } + } +} + +impl Clone for NewArtIds { + fn clone(&self) -> Self { + NewArtIds(std::sync::RwLock::new(self.get())) + } +} + +/// Editing state, not document content: two documents are equal whatever new art they name. +impl PartialEq for NewArtIds { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for NewArtIds { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("NewArtIds").field(&self.get()).finish() + } } /// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing. @@ -795,6 +953,12 @@ pub struct Document { /// state, never saved. #[serde(skip)] pub puppet: Option>, + /// New art on a locked artboard: objects of layers on a locked artboard that an edit made and + /// left selected (a group with selected members counts), kept while they stay selected and on + /// one. They and their contents can be selected and edited ([`LockedArtboards::holds`]). The + /// engine sets it from the selection's ([`Selection::new_art`]). Editing state, never saved. + #[serde(skip)] + pub new_art: NewArtIds, /// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this /// is set, so documents without any pay nothing for them. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -827,6 +991,136 @@ type Places = BTreeMap, Vec<(usize, usize)>>; pub const FORMAT_VERSION: u32 = 1; impl Document { + /// Is everything but the layers the same in `other`, as the next version of the document after + /// an edit of its art? The id counter and the id index are left out, and art outside the layers + /// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it + /// doesn't change: quick for every frame of an animation. + pub fn same_outside_layers(&self, other: &Self) -> bool { + let same_art = |a: &[Arc], b: &[Arc]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b)); + let same_pattern = |a: &PatternDef, b: &PatternDef| { + same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() } + }; + let Self { + version, + title, + template, + units, + ruler_origin, + rulers, + artboard_ruler_origins, + color_mode, + artboards, + layers: _, + swatches, + swatch_groups, + graphic_styles, + char_styles, + para_styles, + text_threads, + symbols, + graph_designs, + guides, + views, + saved_selections, + variables, + grid, + raster_effects_ppi, + images, + patterns, + pattern_edit, + mask_edit, + next_id: _, + unknown, + page_isolate, + page_knockout, + spot_use_lab, + setup, + paste_remembers_layers, + metadata, + raster_effects, + last_view, + transparency_grid, + layers_open, + color_profiles, + export_settings, + extra, + slices, + slices_clip_to_artboard, + print_setup, + assets, + svg_scripts, + puppet, + new_art, + dimensions, + index: _, + } = self; + *version == other.version + && *title == other.title + && *template == other.template + && *units == other.units + && *ruler_origin == other.ruler_origin + && *rulers == other.rulers + && *artboard_ruler_origins == other.artboard_ruler_origins + && *color_mode == other.color_mode + && *artboards == other.artboards + && *swatches == other.swatches + && *swatch_groups == other.swatch_groups + && *graphic_styles == other.graphic_styles + && *char_styles == other.char_styles + && *para_styles == other.para_styles + && *text_threads == other.text_threads + && symbols.len() == other.symbols.len() + && symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && graph_designs.len() == other.graph_designs.len() + && graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art)) + && *guides == other.guides + && *views == other.views + && *saved_selections == other.saved_selections + && *variables == other.variables + && *grid == other.grid + && *raster_effects_ppi == other.raster_effects_ppi + && *images == other.images + && patterns.len() == other.patterns.len() + && patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b)) + && match (pattern_edit, &other.pattern_edit) { + (Some(a), Some(b)) => { + a.pattern == b.pattern + && a.layer == b.layer + && match (&a.original, &b.original) { + (Some(a), Some(b)) => same_pattern(a, b), + (a, b) => a.is_none() && b.is_none(), + } + } + (a, b) => a.is_none() && b.is_none(), + } + && *mask_edit == other.mask_edit + && *unknown == other.unknown + && *page_isolate == other.page_isolate + && *page_knockout == other.page_knockout + && *spot_use_lab == other.spot_use_lab + && *setup == other.setup + && *paste_remembers_layers == other.paste_remembers_layers + && *metadata == other.metadata + && *raster_effects == other.raster_effects + && *last_view == other.last_view + && *transparency_grid == other.transparency_grid + && *layers_open == other.layers_open + && *color_profiles == other.color_profiles + && *export_settings == other.export_settings + && *extra == other.extra + && *slices == other.slices + && *slices_clip_to_artboard == other.slices_clip_to_artboard + && *print_setup == other.print_setup + && *assets == other.assets + && *svg_scripts == other.svg_scripts + && match (puppet, &other.puppet) { + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + (a, b) => a.is_none() && b.is_none(), + } + && *new_art == other.new_art + && *dimensions == other.dimensions + } + /// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value /// isn't a number). pub fn raster_ppi(&self) -> f64 { @@ -882,8 +1176,12 @@ impl Document { id: 1, name: "Artboard 1".into(), rect: Rect::new(0.0, 0.0, width, height), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }], layers: vec![], swatches: swatches.into(), @@ -925,6 +1223,7 @@ impl Document { assets: vec![], svg_scripts: vec![], puppet: None, + new_art: Default::default(), dimensions: false, index: Default::default(), }; @@ -1358,9 +1657,69 @@ impl Document { pub fn node_count(&self) -> usize { self.layers.iter().map(|l| l.count()).sum() } - /// Is the node (and every ancestor) visible and unlocked? + /// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard + /// ([`Document::on_locked_artboard`])? pub fn is_editable(&self, id: NodeId) -> bool { - self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) + self.is_editable_with(&self.locked_artboards(), id) + } + /// [`Document::is_editable`] with the locked artboards `locks` of this document, measured + /// once for many ids. + pub fn is_editable_with(&self, locks: &LockedArtboards, id: NodeId) -> bool { + self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) && !locks.holds_id(id) + } + /// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once + /// for all of them, a group once for all its members. + pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ { + let locks = self.locked_artboards(); + move |id| self.is_editable_with(&locks, id) + } + /// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]). + pub fn locked_artboards(&self) -> LockedArtboards<'_> { + self.locked_artboards_except(usize::MAX) + } + /// [`Document::locked_artboards`] leaving out artboard `index`. + pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> { + // Art flush with an edge from outside can overlap it by a rounding error, as in + // `art_bounds_on` (#1295). + let rects: Vec = + self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect(); + // The objects of pattern editing mode's layer, a sublayer's objects included. + fn objects(n: &Node, out: &mut std::collections::HashSet) { + for c in n.children().into_iter().flatten() { + if c.is_layer() { + objects(c, out); + } else { + out.insert(c.id); + } + } + } + let mut pattern_art = std::collections::HashSet::new(); + if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) { + objects(layer, &mut pattern_art); + } + LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() } + } + /// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none. + pub fn on_locked_artboard(&self, id: NodeId) -> bool { + self.locked_artboards().holds_id(id) + } + /// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else + /// the group or other object around it right inside a layer. None for a layer or sublayer. + pub fn layer_object(&self, id: NodeId) -> Option<&Node> { + self.layer_object_at(&self.index_path(id)?) + } + /// [`Document::layer_object`] for the node at index path `path`. + pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> { + // Down the path to the first node that isn't a layer. + let mut nodes = &self.layers; + for &i in path { + let n = nodes.get(i)?; + if !n.is_layer() { + return Some(n.as_ref()); + } + nodes = n.children()?; + } + None } pub fn is_visible(&self, id: NodeId) -> bool { self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible))) @@ -1371,9 +1730,16 @@ impl Document { a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer)) } /// Every art object the Selection tool can reach (Select All): the visible, unlocked objects - /// of the visible, unlocked, non-template layers, looking through sublayers (bottom first). + /// of the visible, unlocked, non-template layers, looking through sublayers (bottom first), + /// except those on a locked artboard. pub fn selectable_art(&self) -> Vec { - self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect() + let locks = self.locked_artboards(); + self.layers + .iter() + .filter(|l| l.visible && !l.locked && !l.is_template()) + .flat_map(|l| l.layer_art(true)) + .filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n))) + .collect() } /// Colour of the innermost layer or sublayer containing `id` (selection highlight colour). pub fn layer_color(&self, id: NodeId) -> [u8; 3] { @@ -1446,6 +1812,18 @@ impl Document { } art } + /// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`] + /// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as + /// locked art does. + pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec { + let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] }; + let mut art = self.art_on_artboard(rect, locked_and_hidden); + let locks = self.locked_artboards_except(index); + if !locked_and_hidden && !locks.is_empty() { + art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n))); + } + art + } /// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its /// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose /// artboard is gone. @@ -1571,10 +1949,14 @@ impl Document { pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> { self.patterns.iter_mut().find(|p| p.name == name) } - /// Deep-clone `node` with fresh ids for it and all descendants. + /// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken + /// in ([`Self::adopt_pixels`]). pub fn reid(&mut self, node: &Node) -> Node { let mut n = node.clone(); n.id = self.alloc_id(); + if let NodeKind::Image(im) = &mut n.kind { + self.adopt_image_pixels(im); + } // A copied annotation must never bind its source ids to unrelated art in a different // document. Keep the measured geometry; new measurements can be linked after pasting. if let Some(e) = n.extras_if_any() @@ -1599,6 +1981,48 @@ impl Document { } impl Document { + /// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's. + pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> { + im.pixels.as_deref().or_else(|| self.images.get(&im.key)) + } + + /// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art + /// made outside a document) into [`Self::images`]: under the image's key, or a free one when + /// the key shows other pixels. + pub fn adopt_pixels(&mut self, n: &mut Node) { + // Only what holds such pixels is copied out of the art it shares. + fn holds(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some()) + || n.extras().mask.as_ref().is_some_and(|m| holds(&m.art)) + || n.children().into_iter().flatten().any(|c| holds(c)) + } + if let NodeKind::Image(im) = &mut n.kind { + self.adopt_image_pixels(im); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) + && holds(&m.art) + { + self.adopt_pixels(Arc::make_mut(&mut m.art)); + } + for c in n.children_mut().into_iter().flatten() { + if holds(c) { + self.adopt_pixels(Arc::make_mut(c)); + } + } + } + + fn adopt_image_pixels(&mut self, im: &mut ImageObject) { + let Some(blob) = im.pixels.take() else { return }; + let mut key = im.key.clone(); + let mut i = 1; + while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) { + i += 1; + key = format!("{}-{i}", im.key); + } + self.images.entry(key.clone()).or_insert_with(|| (*blob).clone()); + im.key = key; + } + /// Saved selections as a file gives them, made safe to list and recall: at most /// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document /// has, each once. (An id it doesn't have would select whatever object takes that id later.) @@ -1932,6 +2356,101 @@ mod tests { } } + /// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one + /// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with + /// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one. + /// Unlocking the artboard gives the art back. + #[test] + fn art_on_a_locked_artboard_cannot_be_selected_or_edited() { + use crate::hit::{HitOptions, hit_test, marquee, objects_at}; + let (mut d, a, b) = doc_with_rects(); + let layer = d.layers[0].id; + d.artboards.push(Artboard { + id: 2, + name: "Artboard 2".into(), + rect: Rect::new(700.0, 0.0, 900.0, 200.0), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }); + let rect = |d: &mut Document, r: Rect, appearance: Appearance| { + let id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap(); + id + }; + let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art()); + // A group with one member on the artboard and one right of it. + let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let members = [(m1, 750.0), (m2, 950.0)] + .map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art()))); + d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap(); + // Unstroked and flush with the artboard's left edge, from outside. + let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default()); + let opt = HitOptions::default(); + let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None)); + let all = Rect::new(-10.0, -10.0, 1000.0, 800.0); + assert_eq!(top(&d, 720.0, 20.0), Some(c)); + d.artboards[1].locked = true; + for id in [c, g, m1, m2] { + assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard"); + } + for id in [a, b, e, layer] { + assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't"); + } + assert_eq!(d.selectable_art(), [a, b, e]); + assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c"); + assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard"); + let isolated = HitOptions { scope: Some(g), ..opt }; + assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock"); + assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty()); + assert_eq!(marquee(&d, all, None, false), [a, b, e]); + assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too"); + // Deselected in one pass, with the key object and the anchors. + let mut sel = Selection::default(); + sel.set([a, c]); + sel.key = Some(c); + sel.anchors.insert(c, [(0, 0)].into()); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true)); + // A targeted layer stays targeted, with its art off the locked artboard selected. + sel.set_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer))); + // Shift-clicking the layer's target circle removes its art off the locked artboard, and + // a second Shift-click adds it again. + sel.toggle_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!((sel.objects.clone(), sel.target), (vec![], None)); + sel.toggle_target(&d, layer); + sel.deselect_on_locked_artboards(&d); + assert_eq!(sel.objects, [a, b, e]); + // A sublayer with art on both artboards: its art off the locked artboard can still be clicked. + let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap(); + for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] { + d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art())) + .unwrap(); + } + assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None)); + assert!(!d.on_locked_artboard(sub)); + let mut both = Selection::default(); + both.set([s1, s2]); + both.deselect_on_locked_artboards(&d); + assert_eq!(both.objects, [s1]); + // New art on it is editable and can be clicked while it is new art. + d.new_art.set(Arc::new([c].into())); + assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art"); + assert_eq!(top(&d, 720.0, 20.0), Some(c)); + d.new_art.set(Default::default()); + d.artboards[1].locked = false; + assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]); + assert_eq!(top(&d, 955.0, 55.0), Some(g)); + assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]); + } + #[test] fn art_on_artboard_measures_a_symbol_instance_by_its_art() { let mut d = Document::new(612.0, 792.0); diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 7abcec7c1..8632578c2 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -377,6 +377,7 @@ mod tests { xf: Affine::IDENTITY, link, placement: Default::default(), + pixels: None, })), ), ) diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 9175c88c5..b434245cc 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -60,12 +60,75 @@ pub enum BlendOrientation { AlignToPath, } +/// Distribution of blend steps, as offered by the Blend panel's Type menu. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BlendEaseType { + #[default] + In, + Out, + InOut, + OutIn, +} + +impl BlendEaseType { + pub fn id(self) -> &'static str { + match self { + Self::In => "easeIn", + Self::Out => "easeOut", + Self::InOut => "easeInOut", + Self::OutIn => "easeOutIn", + } + } +} + +/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct BlendEase { + #[serde(default)] + pub kind: BlendEaseType, + #[serde(default, deserialize_with = "blend_ramp")] + pub ramp: f64, +} + +impl BlendEase { + pub fn is_linear(&self) -> bool { + self.ramp <= 0.0 + } + + /// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0. + pub fn is_default(&self) -> bool { + self.kind == BlendEaseType::In && self.ramp == 0.0 + } + + /// A monotone polynomial distribution, blended with even spacing by Ramp. Public help + /// describes the four distributions, not their numeric curves; these curves are original. + pub fn apply(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 }; + let eased = match self.kind { + BlendEaseType::In => u * u, + BlendEaseType::Out => u * (2.0 - u), + BlendEaseType::InOut => u * u * (3.0 - 2.0 * u), + BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u), + BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5), + }; + u + r * (eased - u) + } +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct BlendSpec { #[serde(default)] pub spacing: BlendSpacing, #[serde(default)] pub orientation: BlendOrientation, + /// Acceleration of the steps' shapes and positions along the spine between two keys. + #[serde(default, skip_serializing_if = "BlendEase::is_default")] + pub ease: BlendEase, + /// Independent color acceleration. -100..100, with 0 preserving the even color ramp. + #[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")] + pub color_shift: f64, /// Replaced spine. `None` = the straight lines between the key objects' centres. #[serde(default, skip_serializing_if = "Option::is_none")] pub spine: Option, @@ -228,12 +291,15 @@ pub struct GradientMesh { pub points: Vec, } -/// One tessellated piece of a mesh (a small quad with a solid colour). +/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and +/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads +/// meet without a step. #[derive(Clone, Copy, Debug, PartialEq)] pub struct MeshQuad { pub pts: [Point; 4], pub color: Color, pub opacity: f32, + pub corners: [(Color, f32); 4], } /// Create Gradient Mesh → Appearance. @@ -401,11 +467,49 @@ fn cumulative_lengths(pts: &[Point]) -> Vec { cum } +/// Most pieces [`map_nonlinear`] splits one segment into. +const MAX_PIECES: usize = 64; + +/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`. +pub fn pieces(len: f64, piece: f64, most: usize) -> usize { + ((len / piece).ceil() as usize).clamp(1, most.max(1)) +} + +/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so +/// that the pieces past each segment's first number at most `room`: `piece` when they already do, +/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path, +/// so pieces of the total length over `room` fit; with no room every segment stays one piece. +pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 { + let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add); + if added <= room { + return piece; + } + if room == 0 { + return f64::INFINITY; + } + // A NaN length is always one piece. + piece.max(lens.iter().filter(|len| !len.is_nan()).sum::() / room as f64) +} + /// Map every point of `path` through the non-linear function `f`. Segments are split into pieces /// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which /// approximates the image curve to O(h²). Closedness and subpath structure are preserved. pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData { - let max_piece = max_piece.max(1e-3); + map_nonlinear_within(path, max_piece, usize::MAX, f) +} + +/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more: +/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]), +/// a coarser approximation of the image curve. Geometry effects map within their anchor budget. +pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData { + let mut max_piece = max_piece.max(1e-3); + // Each segment adds one anchor per piece past its first. + let room = max.saturating_sub(path.anchor_count()); + let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add); + if segs.saturating_mul(MAX_PIECES - 1) > room { + let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect(); + max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room); + } let mut subs = Vec::with_capacity(path.subpaths.len()); for sp in &path.subpaths { let n = sp.anchors.len(); @@ -421,7 +525,7 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind }); if i < segs { let c = seg_cubic(sp, i); - let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64); + let k = pieces(poly_len(&c), max_piece, MAX_PIECES); for j in 0..k { let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64)); if let Some(last) = res.last_mut() { @@ -769,7 +873,7 @@ impl GradientMesh { (Color::rgb(rgb[0], rgb[1], rgb[2]), op) } - /// Tessellate into `n`×`n` quads per patch, each coloured at its centre. + /// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners. pub fn quads(&self, n: usize) -> Vec { if !self.is_valid() { return vec![]; @@ -777,18 +881,27 @@ impl GradientMesh { let n = n.clamp(1, 64); let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * 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 { for c in 0..self.cols as usize { for j in 0..=n { for i in 0..=n { - grid[j * (n + 1) + i] = self.eval(r, c, i as f64 / n as f64, j as f64 / n as f64); + let (u, v) = (i as f64 / n as f64, j as f64 / n as f64); + grid[j * (n + 1) + i] = self.eval(r, c, u, v); + colors[j * (n + 1) + i] = self.color_at(r, c, u, v); } } for j in 0..n { for i in 0..n { let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64); let g = |a: usize, b: usize| grid[b * (n + 1) + a]; - out.push(MeshQuad { pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], color, opacity }); + let k = |a: usize, b: usize| colors[b * (n + 1) + a]; + out.push(MeshQuad { + pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], + color, + opacity, + corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)], + }); } } } @@ -1363,6 +1476,9 @@ pub struct Hooks<'a> { pub appearance: Outliner<'a>, /// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]). pub pattern: Option>, + /// The document whose symbols' art counts toward the instance budget of a Repeat of their + /// instances ([`crate::RepeatSpec::expand`]). + pub symbols: Option<&'a crate::Document>, } impl<'a> From> for Hooks<'a> { @@ -1679,7 +1795,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec { NodeKind::Blend { children, spec } => blend_expand(children, spec), NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks), NodeKind::Mesh(m) => mesh_quad_nodes(m, 8), - NodeKind::Repeat(r) => r.expand(), + NodeKind::Repeat(r) => r.expand(hooks.symbols), NodeKind::PlacedDocument(p) => p.art(n), NodeKind::Intertwine { children } => crate::intertwine::expand(children), _ => vec![n.clone()], @@ -1757,10 +1873,78 @@ pub(crate) fn max_outset(nodes: &[Arc], symbols: Option<&crate::SymbolBoxe o } +/// Reading a native file never fails over one bad blend number: out-of-range values clamp, +/// and anything that is not a finite number (NaN, null, text) becomes the default. The +/// commands still refuse such input (`ease_param`). +fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result { + let v = serde_json::Value::deserialize(d)?; + Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max))) +} + +fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, 0.0, 1.0) +} + +fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + blend_number(d, -100.0, 100.0) +} + +/// Whether an optional stored color shift has its unchanged default. +fn is_zero(v: &f64) -> bool { + *v == 0.0 +} + +impl BlendSpec { + /// Shift the color transition independently of shape placement. The exponential curve is + /// original; the documented behavior specifies the direction, not numeric sample values. + pub fn color_position(&self, u: f64) -> f64 { + let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 }; + let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 }; + if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) } + } +} + #[cfg(test)] mod tests { use super::*; + /// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of + /// failing the whole document. + #[test] + fn blend_easing_numbers_clamp_when_read() { + use serde::de::value::{Error, F64Deserializer}; + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0)); + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0)); + let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap(); + assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0)); + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!(blend_ramp(F64Deserializer::::new(bad)).unwrap(), 0.0); + assert_eq!(blend_color_shift(F64Deserializer::::new(bad)).unwrap(), 0.0); + } + } + + /// #3578: a non-linear map within an anchor budget splits its segments into longer pieces, + /// down to one a segment, and matches the plain map where the budget leaves room. + #[test] + fn map_nonlinear_within_keeps_to_its_anchor_budget() { + let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)); + let f = |p: Point| Point::new(p.x + (p.y / 10.0).sin(), p.y); + let full = map_nonlinear(&square, 1.0, f); + assert_eq!(full.anchor_count(), 4 * 64); + assert_eq!(map_nonlinear_within(&square, 1.0, 4 * 64, f), full); + // Every side takes the same pieces: of 400 / (max - 4) pt, so 12 a side for 50 anchors. + for (max, want) in [(0, 4), (4, 4), (5, 4), (50, 4 * 12), (255, 4 * 63)] { + assert_eq!(map_nonlinear_within(&square, 1.0, max, f).anchor_count(), want, "{max}"); + } + // Lengths that are not numbers stay one piece; with no room every segment is one piece. + assert_eq!(fit_piece(&[f64::NAN, 10.0], 1.0, 64, 5), 2.0); + assert_eq!(fit_piece(&[f64::INFINITY, 10.0], 1.0, 64, 0), f64::INFINITY); + assert_eq!(pieces(f64::INFINITY, f64::INFINITY, 64), 1); + assert_eq!(pieces(10.0, 1.0, 0), 1); + } + /// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the /// aspect), the top and bottom edges are concentric circles about the bend's centre, at their /// real distance from the centre line; an inner edge that would pass the centre stops there. diff --git a/crates/doc/src/metadata.rs b/crates/doc/src/metadata.rs index 708cb82e8..1a84ef219 100644 --- a/crates/doc/src/metadata.rs +++ b/crates/doc/src/metadata.rs @@ -3,6 +3,7 @@ //! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved //! before File Info existed load unchanged. +use chrono::{Datelike, Timelike}; use serde::{Deserialize, Serialize}; /// File Info → Copyright Status. @@ -118,23 +119,26 @@ pub fn now_unix() -> Option { None } -/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic -/// Gregorian; years clamped to 0–9999). +/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years +/// clamped to 0–9999). pub fn civil(t: i64) -> [i64; 6] { - // Clamp first so every step stays in range for any input. + // Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31), + // exactly as the old code did. The clamped instant is always inside chrono's range, so the + // conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp. let t = t.clamp(-62_167_219_200, 253_402_300_799); - let days = t.div_euclid(86_400); - let secs = t.rem_euclid(86_400); - let z = days + 719_468; - let era = z.div_euclid(146_097); - let doe = z - era * 146_097; - let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = if mp < 10 { mp + 3 } else { mp - 9 }; - let year = yoe + era * 400 + i64::from(month <= 2); - [year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60] + let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400)); + let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097)); + // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400. + let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt); + let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0)); + match (date, time) { + (Some(d), Some(tm)) => { + let year = i128::from(d.year()) + i128::from(cycles) * 400; + let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX }); + [year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())] + } + _ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date + } } /// Unix seconds → `2026-10-05T14:03:00Z`. @@ -151,8 +155,15 @@ mod tests { fn dates_and_statuses() { assert_eq!(iso8601(0), "1970-01-01T00:00:00Z"); assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z"); + // The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999, + // exactly as the old code did: inputs far past chrono's range land on the clamp bounds, + // never on the epoch. assert_eq!(civil(i64::MIN)[0], 0); assert_eq!(civil(i64::MAX)[0], 9999); + assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z"); + assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z"); + assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z"); + assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z"); assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain)); assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain)); assert_eq!(CopyrightStatus::parse("maybe"), None); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 1bfabf9c1..f9a6a11fd 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -88,6 +88,9 @@ pub enum LiveShape { /// Corner kinds, in the same order (Live Corners' corner types). #[serde(default, skip_serializing_if = "all_round")] kinds: [CornerKind; 4], + /// When true, `radii` are fractions in 0..1 of each corner's largest possible radius. + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, /// Maps the untransformed shape (origin at its top-left) into the document. A rotation /// or reflection plus a move, unless a shear (or a file saved before transforms folded /// their scale into `w`/`h`) put more in it. @@ -112,6 +115,8 @@ pub enum LiveShape { /// Corner kinds, in the same order (none: round). #[serde(default, skip_serializing_if = "all_round")] kinds: Vec, + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, }, Line { a: Point, @@ -129,10 +134,24 @@ pub enum LiveShape { /// Corner kinds, in the same order (missing: round). #[serde(default, skip_serializing_if = "all_round")] kinds: Vec, + #[serde(default, skip_serializing_if = "is_false")] + radii_relative: bool, }, } +fn is_false(b: &bool) -> bool { + !*b +} + impl LiveShape { + pub fn radii_relative(&self) -> bool { + match self { + LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => { + *radii_relative + } + LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false, + } + } pub fn label(&self) -> &'static str { match self { LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle", @@ -146,13 +165,23 @@ impl LiveShape { /// Regenerate the path. pub fn to_path(&self) -> PathData { match self { - LiveShape::Rectangle { w, h, radii, kinds, xf } => { - shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf) + LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => { + let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)); + let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative); + let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0)); + shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf) } LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf), - LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0, + LiveShape::Polygon { radii, kinds, radii_relative, .. } => { + let outline = self.polygon_outline(); + let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative); + cut_corners(&outline, &radii, kinds).0 + } LiveShape::Line { a, b } => shapes::line(*a, *b), - LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0, + LiveShape::Path { base, radii, kinds, radii_relative } => { + let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative); + cut_corners(base, &radii, kinds).0 + } } } /// A polygon's outline with its corners uncut, in the document (empty for other shapes). @@ -253,13 +282,13 @@ impl LiveShape { *xf = a * *xf; false } - LiveShape::Polygon { xf, radii, .. } => { + LiveShape::Polygon { xf, radii, radii_relative, .. } => { *xf = a * *xf; - scale_radii(radii, a) + scale_radii(radii, a, *radii_relative) } - LiveShape::Path { base, radii, .. } => { + LiveShape::Path { base, radii, radii_relative, .. } => { base.transform(a); - scale_radii(radii, a) + scale_radii(radii, a, *radii_relative) } LiveShape::Line { a: p, b } => { *p = a * *p; @@ -274,7 +303,7 @@ impl LiveShape { /// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an /// uneven scale in `xf`: their corners are elliptical until this runs. pub fn fold_scale(&mut self) -> bool { - let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false }; + let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false }; let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs(); let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy; @@ -284,7 +313,9 @@ impl LiveShape { return false; } let k = (sx * sy).sqrt(); - radii.iter_mut().for_each(|r| *r *= k); + if !*radii_relative { + radii.iter_mut().for_each(|r| *r *= k); + } *w *= sx; *h *= sy; *xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]); @@ -300,12 +331,13 @@ impl LiveShape { /// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners /// keep their size (Scale Corners off). False when nothing changed. pub fn keep_corners(&mut self, k: f64) -> bool { + let relative = self.radii_relative(); let radii: &mut [f64] = match self { LiveShape::Rectangle { radii, .. } => radii, LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii, LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false, }; - if !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 { + if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 { return false; } radii.iter_mut().for_each(|r| *r /= k); @@ -343,8 +375,8 @@ fn moves_only(a: Affine) -> bool { /// Scale corner radii in document units by the mean scale of `a`, applied to their path. True /// when the path must be cut again: some corner is cut and `a` does more than move it (an /// uneven scale would otherwise make the cuts elliptical). -fn scale_radii(radii: &mut [f64], a: Affine) -> bool { - if moves_only(a) || !radii.iter().any(|r| *r > 0.0) { +fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool { + if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) { return false; } let k = a.determinant().abs().sqrt(); @@ -401,6 +433,11 @@ pub struct ImageObject { /// Links panel → Placement Options: how a file read again takes this image's place. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub placement: crate::links::PlacementOptions, + /// The pixels of an image in art made outside a document (type's outlines, a glyph's art), + /// which has no [`crate::Document::images`] to keep them in. A document takes them in + /// ([`crate::Document::adopt_pixels`]): no image in one holds its own. + #[serde(skip)] + pub pixels: Option>, } /// Written with its `type` first, then its fields (an internally tagged enum); read by @@ -491,8 +528,8 @@ pub enum NodeKind { Mesh(GradientMesh), /// Live Repeat (radial / grid / mirror) of source art. Repeat(RepeatSpec), - /// A placed document: an artboard of another VectorCraft file, linked and locked (see - /// [`crate::placed_document`]). + /// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` + /// file, linked and locked (see [`crate::placed_document`]). PlacedDocument(Box), /// Object › Intertwine: the objects keep their own stacking order, except where one of them /// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see @@ -1159,6 +1196,17 @@ impl Node { pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option { self.geometric(Some(symbols)) } + /// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a + /// hidden bitmap for re-tracing/release; only source-image views measure that bitmap. + pub fn editing_children(&self) -> &[Arc] { + let children = self.children().map(Vec::as_slice).unwrap_or_default(); + let trace_source = self.extras().trace.is_some() + && !self.trace_view().shows_source() + && children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_))); + let skip = usize::from(self.extras().shaper.is_some() || trace_source); + children.get(skip..).unwrap_or_default() + } + /// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols` /// is given. pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option { @@ -1168,9 +1216,9 @@ impl Node { NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { children.first().and_then(|c| c.geometric(symbols)) } - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children + NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self + .editing_children() .iter() - .skip(usize::from(self.extras().shaper.is_some())) .filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. })) .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))), NodeKind::Text(t) => self.projected(t.bounds()), @@ -1184,7 +1232,7 @@ impl Node { NodeKind::Mesh(m) => m.bounds(), // Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the // copies out by the source's bounds measured without the document. - NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?), + NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())), NodeKind::PlacedDocument(p) => Some(p.bounds()), // The members drawn over the others are drawn where they are. NodeKind::Intertwine { children } => union(children), @@ -1223,14 +1271,10 @@ impl Node { } } NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))), - NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + NodeKind::Layer { .. } | NodeKind::Group { .. } => { // The container's own strokes paint around its members. let o = self.appearance.outset(); - children - .iter() - .skip(usize::from(self.extras().shaper.is_some())) - .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))) - .map(|b| b.inflate(o, o)) + self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o)) } NodeKind::Blend { children, spec } => { let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))); @@ -1262,10 +1306,8 @@ impl Node { self.projected(Some(b)).map(|b| b.inflate(o, o)) } NodeKind::Text(t) => { - // The characters' strokes paint around the glyphs, scaled with the type. - let k = Scaling::factor(t.xf).unwrap_or(1.0); - let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k; - let o = self.appearance.outset().max(chars); + // The characters' strokes paint around the glyphs. + let o = self.appearance.outset().max(t.char_stroke_outset()); self.geometric(symbols).map(|b| b.inflate(o, o)) } // An image is measured by its frame, without its own fills and strokes. @@ -1763,6 +1805,28 @@ pub struct ObjectAttributes { /// the element's event attributes; nothing in VectorCraft runs them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub events: Vec<(String, String)>, + /// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG + /// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives + /// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's, + /// as `data` is, so copies, duplicates and pastes keep them and a deleted object takes them + /// with it. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub foreign: Vec, +} + +/// An attribute in another application's XML namespace: `plotter:pen="2"` with `xmlns:plotter= +/// "https://example.org/plotter"` is `{ns: "https://example.org/plotter", prefix: "plotter", name: +/// "pen", value: "2"}`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForeignAttribute { + /// The namespace's URI, which names it. + pub ns: String, + /// The prefix the file gave the namespace, which export reuses where it can. + pub prefix: String, + /// The attribute's local name. + pub name: String, + /// The attribute's value, as the file had it. + pub value: String, } /// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event @@ -2168,14 +2232,28 @@ mod tests { #[test] fn rect_live_shape_roundtrip() { - let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) }; + let l = LiveShape::Rectangle { + w: 10.0, + h: 20.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((5.0, 5.0)), + }; assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0))); assert_eq!(l.label(), "Rectangle"); } #[test] fn corner_kinds_save_only_when_some_corner_isnt_round() { - let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY }; + let mut l = LiveShape::Rectangle { + w: 100.0, + h: 50.0, + radii: [0.0, 10.0, 0.0, 0.0], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; // Older files read as round. let json = serde_json::to_value(&l).unwrap(); assert!(json.get("kinds").is_none(), "{json}"); @@ -2189,9 +2267,9 @@ mod tests { // Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips. let p: LiveShape = serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap(); - assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![] }); + assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false }); let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0); - let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![] }; + let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false }; let json = serde_json::to_value(&path).unwrap(); assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None)); assert_eq!(serde_json::from_value::(json).unwrap(), path); @@ -2221,7 +2299,7 @@ mod tests { let close = |a: f64, b: f64| (a - b).abs() < 1e-9; // A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page. let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0); - let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] }; + let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false }; assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal()); p.set_polygon_angle(45.0); assert!(close(p.polygon_angle().unwrap(), 45.0)); @@ -2246,7 +2324,7 @@ mod tests { /// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path. fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node { - let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf }; + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { *slot = Some(Box::new(live)); @@ -2470,7 +2548,8 @@ mod tests { /// A group holding a path, a compound, an image and a nested group, as a file writes it. fn nested_group() -> Node { let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()); - let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let image = + ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true }); let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd }); let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)]; diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 372fae87a..ca2ec7dd9 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -20,7 +20,7 @@ use vectorcraft_geom::{Affine, PathData, Point}; use crate::blend::Spine; use crate::symbols::SymbolBoxes; -use crate::{Node, NodeId, NodeKind}; +use crate::{Document, Node, NodeId, NodeKind}; /// The center of the nine reference points. pub const CENTER: u8 = 4; @@ -157,6 +157,47 @@ pub fn attachable_path(n: &Node) -> Option<&PathData> { } } +/// What keeps an object from going into an Objects on Path group, as its path or as one of its +/// objects ([`held`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Held { + /// It is inside a compound path, blend, envelope, repeat or Intertwine group, as part of its art. + Live, + /// It is the clipping path of a clip group or of a layer with a clipping mask, or inside one. + Clipping, +} + +/// What keeps the object `id` of `d` where it is ([`Held`]), if anything: in an Objects on Path +/// group it would no longer be a part of the compound path, blend, envelope, repeat or Intertwine +/// group it is in, or of the clipping path it is or is in. The innermost reason counts. `None` for +/// an object that is neither a clipping path nor inside one and is in a layer directly or through +/// plain groups and clip groups, and for an id not in `d`. +pub fn held(d: &Document, id: NodeId) -> Option { + let path = d.index_path(id)?; + let mut chain: Vec<&Node> = Vec::with_capacity(path.len()); + let mut nodes = &d.layers; + for &i in &path { + let n: &Node = nodes.get(i)?; + chain.push(n); + if let Some(children) = n.children() { + nodes = children; + } + } + for (depth, n) in chain.iter().enumerate().rev() { + let Some(parent) = depth.checked_sub(1).and_then(|up| chain.get(up)) else { break }; + if matches!(n.kind, NodeKind::Path { clipping: true, .. }) || (parent.clips() && path.get(depth) == Some(&0)) { + return Some(Held::Clipping); + } + if matches!( + parent.kind, + NodeKind::Compound { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. } + ) { + return Some(Held::Live); + } + } + None +} + /// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation /// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in /// that frame, an object stands square to the path or across it. diff --git a/crates/doc/src/orient.rs b/crates/doc/src/orient.rs index 6f61a09d8..43c67d86f 100644 --- a/crates/doc/src/orient.rs +++ b/crates/doc/src/orient.rs @@ -90,8 +90,8 @@ impl Node { /// ([`Node::geometric_bounds_with`]). pub fn bounds_in(&self, symbols: &SymbolBoxes, a: Affine, visual: bool) -> Option { match &self.kind { - NodeKind::Group { children, clip: false } => { - let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual))); + NodeKind::Group { clip: false, .. } => { + let b = self.editing_children().iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual))); // The group's own strokes paint around its members (as in `visual_bounds`). let o = if visual { self.appearance.outset() } else { 0.0 }; b.map(|b| b.inflate(o, o)) diff --git a/crates/doc/src/pattern.rs b/crates/doc/src/pattern.rs index 1b587e70f..da310eb65 100644 --- a/crates/doc/src/pattern.rs +++ b/crates/doc/src/pattern.rs @@ -11,12 +11,15 @@ //! **Repeats.** A [`RepeatSpec`] holds source art and a radial / grid / mirror arrangement; the //! instances are the source under [`repeat_transforms`] (evaluated on demand like blends). +use std::cell::RefCell; +use std::collections::HashMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; use vectorcraft_color::Paint; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; +use crate::Document; use crate::appearance::AppearanceItem; use crate::node::{Node, NodeId, NodeKind}; @@ -279,7 +282,7 @@ impl PatternDef { out } /// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the - /// centre `(0, 0)` excluded). + /// centre `(0, 0)` excluded), in row-major order (not paint order). pub fn preview_offsets(&self) -> Vec { let k = (self.copies.clamp(1, 15) / 2) as i64; let mut out = vec![]; @@ -292,6 +295,38 @@ impl PatternDef { } out } + /// Offsets of every pattern-editing preview copy, including the centre tile, in the same paint + /// order [`offsets_covering`] uses for [`Overlap`]. + pub fn preview_offsets_in_paint_order(&self) -> Vec { + let k = (self.copies.clamp(1, 15) / 2) as i64; + let (i0, i1, j0, j1) = (-k, k, -k, k); + let mut out = vec![]; + let rows: Vec = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() }; + for &j in &rows { + let cols: Vec = if self.overlap.right_in_front { (i0..=i1).collect() } else { (i0..=i1).rev().collect() }; + for i in cols { + out.push(self.offset(i, j)); + } + } + out + } + /// Preview copies drawn before and after the centre tile in [`Self::preview_offsets_in_paint_order`], + /// for one dimmed composite each (the centre tile is drawn at full strength between them). + pub fn preview_offsets_dimmed_sides(&self) -> (Vec, Vec) { + let mut before = vec![]; + let mut after = vec![]; + let mut past_center = false; + for o in self.preview_offsets_in_paint_order() { + if o.x == 0.0 && o.y == 0.0 { + past_center = true; + } else if past_center { + after.push(o); + } else { + before.push(o); + } + } + (before, after) + } /// Instances covering `region` (document space) as plain nodes for a pattern painted with /// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort /// Pattern Fills; Expand uses [`Self::instances_in_with`]. @@ -487,17 +522,51 @@ pub struct RepeatSpec { /// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps. const MAX_REPEAT_INSTANCES: usize = 250_000; -/// Instances the Repeats in `nodes` make in all (0 when there are none). -fn nested_instances(nodes: &[Arc]) -> usize { - let mut sum = 0usize; - for n in nodes { - sum = sum.saturating_add(match (&n.kind, n.children()) { - (NodeKind::Repeat(r), _) => r.instances_in_all(), - (_, Some(c)) => nested_instances(c), - _ => 0, - }); +/// Counts the instances Repeats make in all, following symbol instances into their symbol's art +/// in `doc`: each symbol's art is counted once, and a missing symbol, or an instance of a symbol +/// inside its own art, counts as none. +/// +/// Limitation: symbols that show each other (A's art holds an instance of B and B's holds one of +/// A) are counted along the first path that reaches them. While A is counted, the instance of A +/// in B's art counts as none, and B keeps that count; had B been counted first, A's count would +/// include B's Repeats and B's would not. So within one count, what such symbols add can depend on +/// which of them came first. A symbol whose art reaches no such loop always counts the same. +struct NestedCount<'a> { + doc: Option<&'a Document>, + /// The counts made so far, by symbol name (`None` while that symbol's art is being counted). + symbols: RefCell>>, +} + +impl<'a> NestedCount<'a> { + fn new(doc: Option<&'a Document>) -> Self { + Self { doc, symbols: RefCell::default() } + } + + /// Instances the Repeats in the art of symbol `name` make in all. + fn symbol(&self, name: &str) -> usize { + if let Some(known) = self.symbols.borrow().get(name) { + return known.unwrap_or(0); + } + let Some(sym) = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == name)) else { return 0 }; + self.symbols.borrow_mut().insert(name.to_owned(), None); + let n = self.nodes(std::slice::from_ref(&sym.art)); + self.symbols.borrow_mut().insert(name.to_owned(), Some(n)); + n + } + + /// Instances the Repeats in `nodes` make in all (0 when there are none). + fn nodes(&self, nodes: &[Arc]) -> usize { + let mut sum = 0usize; + for n in nodes { + sum = sum.saturating_add(match (&n.kind, n.children()) { + (NodeKind::Repeat(r), _) => r.instances_in_all(self), + (NodeKind::SymbolInstance { symbol, .. }, _) => self.symbol(symbol), + (_, Some(c)) => self.nodes(c), + _ => 0, + }); + } + sum } - sum } /// Reflection across the line through `c` with direction angle `deg`. @@ -560,11 +629,13 @@ impl RepeatSpec { pub fn source_bounds(&self) -> Option { crate::live::nodes_bounds(&self.source) } - pub fn transforms(&self) -> Vec { + /// Where the instances go, counting toward the budget the Repeats in the art of the symbols + /// of `doc` that instances in the source show (none without a document). + pub fn transforms(&self, doc: Option<&Document>) -> Vec { let mut v = repeat_transforms(&self.kind, self.source_bounds()); // Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500 // grid of one is 62.5 billion instances): keep the first that fit the budget. - v.truncate(self.kept(nested_instances(&self.source))); + v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source))); v } /// How many of its instances this Repeat keeps when each makes `nested` instances of the @@ -572,14 +643,21 @@ impl RepeatSpec { fn kept(&self, nested: usize) -> usize { self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1) } + /// How many instances this Repeat draws ([`Self::transforms`] for `doc`): as many as its + /// options make, fewer when the Repeats in its source would take it past + /// [`MAX_REPEAT_INSTANCES`]. + pub fn instances(&self, doc: Option<&Document>) -> usize { + self.kept(NestedCount::new(doc).nodes(&self.source)) + } /// Instances made in all, those of the Repeats nested in the source included. - fn instances_in_all(&self) -> usize { - let nested = nested_instances(&self.source); + fn instances_in_all(&self, count: &NestedCount) -> usize { + let nested = count.nodes(&self.source); self.kept(nested).saturating_mul(nested.max(1)) } - /// Evaluated instances: one group of the (transformed) source per instance (ids 0). - pub fn expand(&self) -> Vec { - self.transforms() + /// Evaluated instances: one group of the (transformed) source per instance (ids 0), as many + /// as [`Self::transforms`] keeps for `doc`. + pub fn expand(&self, doc: Option<&Document>) -> Vec { + self.transforms(doc) .into_iter() .map(|m| { let children = self @@ -596,14 +674,14 @@ impl RepeatSpec { }) .collect() } - /// Geometric bounds of all instances. - pub fn bounds(&self) -> Option { - self.copies_bounds(self.source_bounds()?) + /// Geometric bounds of all instances ([`Self::transforms`] for `doc`). + pub fn bounds(&self, doc: Option<&Document>) -> Option { + self.copies_bounds(self.source_bounds()?, doc) } /// The bounds of all instances of a source measuring `source`, each placed by its transform - /// ([`Self::transforms`]). - pub(crate) fn copies_bounds(&self, source: Rect) -> Option { - self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source)))) + /// ([`Self::transforms`] for `doc`). + pub(crate) fn copies_bounds(&self, source: Rect, doc: Option<&Document>) -> Option { + self.transforms(doc).into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source)))) } /// Apply a document transform (source + arrangement parameters). pub fn transform(&mut self, a: Affine, scaling: impl Into) { @@ -739,6 +817,20 @@ mod tests { } } + #[test] + fn preview_paint_order_respects_overlap() { + let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]); + def.copies = 3; + assert!(close(*def.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(-10.0, -10.0))); + let mut d2 = def.clone(); + d2.overlap = Overlap { right_in_front: true, bottom_in_front: true }; + assert!(close(*d2.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(10.0, 10.0))); + assert_eq!(def.preview_offsets_in_paint_order().len(), 9); + let (before, after) = def.preview_offsets_dimmed_sides(); + assert_eq!(before.len() + after.len(), 8); + assert!(before.iter().all(|o| *o != Vec2::ZERO) && after.iter().all(|o| *o != Vec2::ZERO)); + } + #[test] fn covering_offsets_and_overlap_order() { let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]); @@ -813,11 +905,11 @@ mod tests { let r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0)); let RepeatKind::Radial { center, .. } = r.kind else { panic!() }; assert!((center - Point::new(0.0, -0.0)).hypot() < 1e-9, "{center:?}"); - let cs: Vec = r.transforms().iter().map(|m| *m * Point::new(0.0, -100.0)).collect(); + let cs: Vec = r.transforms(None).iter().map(|m| *m * Point::new(0.0, -100.0)).collect(); assert!((cs[0] - Point::new(0.0, -100.0)).hypot() < 1e-9); assert!((cs[1] - Point::new(100.0, 0.0)).hypot() < 1e-9, "{cs:?}"); assert!((cs[2] - Point::new(0.0, 100.0)).hypot() < 1e-9); - assert_eq!(r.expand().len(), 4); + assert_eq!(r.expand(None).len(), 4); } #[test] @@ -826,18 +918,18 @@ mod tests { if let RepeatKind::Radial { radius, .. } = &mut r.kind { *radius = 50.0; } - let m = r.transforms()[0]; + let m = r.transforms(None)[0]; assert!((m * Point::new(0.0, -100.0) - Point::new(0.0, -50.0)).hypot() < 1e-9); } #[test] fn grid_repeat_transforms() { let g = RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 5.0, 2.0); - let t = g.transforms(); + let t = g.transforms(None); assert_eq!(t.len(), 9); assert!((t[1] * Point::ZERO - Point::new(15.0, 0.0)).hypot() < 1e-9); assert!((t[3] * Point::ZERO - Point::new(0.0, 12.0)).hypot() < 1e-9); - assert_eq!(g.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 34.0))); + assert_eq!(g.bounds(None), Some(Rect::new(0.0, 0.0, 40.0, 34.0))); } #[test] @@ -846,7 +938,7 @@ mod tests { if let RepeatKind::Grid { flip_cols, .. } = &mut g.kind { *flip_cols = true; } - let t = g.transforms(); + let t = g.transforms(None); // Column 1 is mirrored about the source centre, then moved one tile right. assert!((t[1] * Point::new(0.0, 0.0) - Point::new(20.0, 0.0)).hypot() < 1e-9); } @@ -854,10 +946,10 @@ mod tests { #[test] fn mirror_transforms() { let m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0); - let t = m.transforms(); + let t = m.transforms(None); assert_eq!(t.len(), 2); assert!((t[1] * Point::new(0.0, 3.0) - Point::new(30.0, 3.0)).hypot() < 1e-9); - let b = m.bounds().unwrap(); + let b = m.bounds(None).unwrap(); assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9 && (b.y1 - 10.0).abs() < 1e-9, "{b:?}"); // Horizontal axis. let h = reflect_about(Point::new(0.0, 10.0), 0.0); @@ -867,9 +959,9 @@ mod tests { #[test] fn repeat_transform_moves_arrangement() { let mut r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0)); - let before = r.bounds().unwrap(); + let before = r.bounds(None).unwrap(); r.transform(Affine::translate((50.0, 0.0)), false); - let after = r.bounds().unwrap(); + let after = r.bounds(None).unwrap(); assert!((after.x0 - before.x0 - 50.0).abs() < 1e-9); let mut m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0); m.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false); @@ -882,7 +974,7 @@ mod tests { // Instances made in all, counted from the transforms without drawing any. fn made(r: &RepeatSpec) -> usize { let each: usize = r.source.iter().map(|s| if let NodeKind::Repeat(n) = &s.kind { made(n) } else { 1 }).sum(); - r.transforms().len().saturating_mul(each) + r.transforms(None).len().saturating_mul(each) } let grid = |source: Arc| { let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0); @@ -899,7 +991,7 @@ mod tests { // A 500 × 500 grid of it made 62.5 billion. let outer = grid(repeat(inner.clone())); assert!(made(&outer) <= 250_000, "{} instances", made(&outer)); - assert_eq!(outer.expand().len(), outer.transforms().len()); + assert_eq!(outer.expand(None).len(), outer.transforms(None).len()); assert!(made(&RepeatSpec::mirror(vec![repeat(inner)], 90.0, 5.0)) <= 250_000); // Small nests are left as they are. let small = RepeatSpec::mirror(vec![repeat(RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 1.0, 1.0))], 90.0, 5.0); diff --git a/crates/doc/src/placed_document.rs b/crates/doc/src/placed_document.rs index 6656e75ae..a94edf42a 100644 --- a/crates/doc/src/placed_document.rs +++ b/crates/doc/src/placed_document.rs @@ -1,4 +1,5 @@ -//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]). +//! Placed documents: another VectorCraft file, or a page of a PDF or `.ai` file, placed linked +//! ([`PlacedDocument`]). //! //! The object keeps the link to the file and the file's bytes, stored like image data in //! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the @@ -6,8 +7,9 @@ //! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting //! expand it like a live object ([`crate::live::expanded`]). //! -//! The file is read by the native format's loader, which lives above this crate and registers -//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting, +//! The file is read by the engine's loader (native documents, PDF and `.ai` pages), which lives +//! above this crate and registers itself ([`set_loader`]); the art read is kept in a small cache +//! by content. Before exporting, //! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses //! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy //! of the document, so they never meet the user's or show in panels. Placed documents inside a @@ -25,16 +27,21 @@ use vectorcraft_color::Paint; use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol}; -/// A placed document: an artboard of another VectorCraft file, shown as one locked object. +/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` file, +/// shown as one locked object. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct PlacedDocument { - /// The file (its `page` is the artboard shown, 1-based). + /// The file (its `page` is the artboard or page shown, 1-based). pub link: LinkInfo, /// Key into [`Document::images`]: the file's bytes (see [`key_for`]). pub key: String, /// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`). #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub bounding: bool, + /// The box of a PDF or `.ai` page shown when not `bounding` (File › Place's `crop`); a + /// VectorCraft document shows its artboard whatever this says. + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + pub page_box: PageBox, /// The artboard's size (or the art's) at 100% (pt). pub width: f64, pub height: f64, @@ -45,11 +52,43 @@ pub struct PlacedDocument { pub placement: PlacementOptions, } +/// The box of a PDF page a placed PDF or `.ai` file shows (File › Place's `crop`, other than +/// `bounding`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PageBox { + /// The visible page area. + #[default] + Crop, + Art, + Trim, + Bleed, + Media, +} + +impl PageBox { + /// The `crop` param value. + pub fn id(self) -> &'static str { + match self { + PageBox::Crop => "crop", + PageBox::Art => "art", + PageBox::Trim => "trim", + PageBox::Bleed => "bleed", + PageBox::Media => "media", + } + } +} + /// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing -/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and -/// its preview. -pub fn key_for(hash: &str, page: u32, bounding: bool) -> String { - format!("{hash}-{page}{}", if bounding { "b" } else { "" }) +/// artboard or page `page` (`bounding`: its art's bounds; else the page's box `page_box`): +/// objects showing the same thing share a blob and its preview. +pub fn key_for(hash: &str, page: u32, bounding: bool, page_box: PageBox) -> String { + let shown = match (bounding, page_box) { + (true, _) => "b", + (false, PageBox::Crop) => "", + (false, b) => b.id(), + }; + format!("{hash}-{page}{shown}") } impl PlacedDocument { @@ -107,7 +146,7 @@ impl PlacedDocument { pub fn preview_image(&self, bytes: &[u8]) -> Option { let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?; let px = Affine::scale_non_uniform(self.width / w as f64, self.height / h as f64); - Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default() }) + Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default(), pixels: None }) } } @@ -249,11 +288,23 @@ pub fn set_file_reader(f: FileReader) { let _ = FILE_READER.set(f); } +/// Whether the caller may read the file at a path (`true`: it may). The engine registers one with +/// [`set_read_check`]: the cache below is shared by every caller, and a cached file is handed out +/// only to a caller that may read the file it came from. +pub type ReadCheck = fn(&str) -> bool; + +static READ_CHECK: OnceLock = OnceLock::new(); + +/// Register how a caller's right to read a file is checked (the first registration stays). +pub fn set_read_check(f: ReadCheck) { + let _ = READ_CHECK.set(f); +} + /// The most bytes of files read again kept. const FULL_BYTES: usize = 512 << 20; -/// Files read again, by key, the most recently read last. -type FullFiles = Vec<(String, Arc>)>; +/// Files read again, by key, with the path each was read from, the most recently read last. +type FullFiles = Vec<(String, String, Arc>)>; static FULL: Mutex = Mutex::new(Vec::new()); @@ -268,7 +319,11 @@ pub fn full_bytes_cached(p: &PlacedDocument, blob: &ImageBlob) -> Option>)> = + full().iter().rev().filter(|(k, _, _)| *k == p.key).map(|(_, path, b)| (path.clone(), b.clone())).collect(); + candidates.into_iter().find(|(path, _)| allowed(path)).map(|(_, b)| b) } /// The file's bytes: stored, read again already, or read again now ([`set_file_reader`]). `None` @@ -279,10 +334,10 @@ pub fn full_bytes(p: &PlacedDocument, blob: &ImageBlob) -> Option>> } let b = Arc::new(FILE_READER.get()?(p)?); let mut c = full(); - c.push((p.key.clone(), b.clone())); - let mut total: usize = c.iter().map(|(_, b)| b.len()).sum(); + c.push((p.key.clone(), p.link.path.clone(), b.clone())); + let mut total: usize = c.iter().map(|(_, _, b)| b.len()).sum(); while total > FULL_BYTES && c.len() > 1 { - total = total.saturating_sub(c.remove(0).1.len()); + total = total.saturating_sub(c.remove(0).2.len()); } Some(b) } @@ -405,7 +460,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc>) -> Option(v).unwrap(), trim); } #[test] diff --git a/crates/doc/src/plugin_data.rs b/crates/doc/src/plugin_data.rs new file mode 100644 index 000000000..f29ab9370 --- /dev/null +++ b/crates/doc/src/plugin_data.rs @@ -0,0 +1,86 @@ +//! What plug-ins keep with a document: settings that belong to the whole document rather than to +//! one object, such as the pens a plotter holds or a machine's profile. +//! +//! They are the document's foreign data ([`Document::unknown`], under [`PLUGIN_DATA`]), as the +//! perspective grid and the brushes are, so the native file has no section of its own for them +//! and a version from before them keeps them when it saves. The host never reads them: a filter +//! plug-in writes its own and reads only its own back, and an exporter, which is given the whole +//! document, can read them all (`vectorcraft-plugins`, which caps what one plug-in keeps). + +use serde_json::{Map, Value}; + +use crate::Document; + +/// `Document::unknown` key: `{"": }`. +pub const PLUGIN_DATA: &str = "pluginData"; + +impl Document { + /// What plug-in `id` keeps with the document, if anything. + pub fn plugin_data(&self, id: &str) -> Option<&Value> { + self.unknown.get(PLUGIN_DATA).and_then(|m| m.get(id)) + } + + /// Keeps `data` for plug-in `id` in place of what it kept; `None` forgets it, and the key goes + /// once no plug-in keeps anything. A value under the key that isn't a map of plug-in ids (a + /// damaged file) is replaced when a plug-in keeps something. + pub fn set_plugin_data(&mut self, id: &str, data: Option) { + let Some(data) = data else { + // Nothing to forget: don't copy the foreign data the undo history shares. + if self.plugin_data(id).is_none() { + return; + } + let Some(m) = self.unknown.get_mut(PLUGIN_DATA).and_then(Value::as_object_mut) else { return }; + m.remove(id); + if m.is_empty() { + self.unknown.remove(PLUGIN_DATA); + } + return; + }; + let kept = self.unknown.entry(PLUGIN_DATA.to_string()).or_insert_with(|| Value::Object(Map::new())); + if !kept.is_object() { + *kept = Value::Object(Map::new()); + } + if let Some(m) = kept.as_object_mut() { + m.insert(id.to_string(), data); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + /// Each plug-in has its own entry, replaced when it keeps something else; forgetting the last + /// one removes the key, and forgetting what isn't kept copies nothing. + #[test] + fn each_plug_in_keeps_its_own_data() { + let mut d = Document::new(10.0, 10.0); + assert_eq!(d.plugin_data("a"), None); + d.set_plugin_data("a", Some(json!({"pens": 2}))); + d.set_plugin_data("b", Some(json!([1, 2]))); + d.set_plugin_data("a", Some(json!({"pens": 3}))); + assert_eq!((d.plugin_data("a"), d.plugin_data("b")), (Some(&json!({"pens": 3})), Some(&json!([1, 2])))); + d.set_plugin_data("a", None); + assert_eq!(d.unknown[PLUGIN_DATA], json!({"b": [1, 2]})); + let before = d.clone(); + d.set_plugin_data("a", None); + assert!(crate::Shared::ptr_eq(&before.unknown, &d.unknown), "still shared with the earlier version"); + d.set_plugin_data("b", None); + assert!(!d.unknown.contains_key(PLUGIN_DATA)); + } + + /// A damaged file's value under the key holds nothing for any plug-in, stays while there is + /// nothing to forget, and is replaced when a plug-in keeps something. + #[test] + fn a_damaged_value_holds_nothing() { + let mut d = Document::new(10.0, 10.0); + d.unknown.insert(PLUGIN_DATA.into(), json!(5)); + assert_eq!(d.plugin_data("a"), None); + d.set_plugin_data("a", None); + assert_eq!(d.unknown[PLUGIN_DATA], json!(5)); + d.set_plugin_data("a", Some(json!(true))); + assert_eq!(d.unknown[PLUGIN_DATA], json!({"a": true})); + } +} diff --git a/crates/doc/src/range.rs b/crates/doc/src/range.rs index 80b4d6d33..eab847204 100644 --- a/crates/doc/src/range.rs +++ b/crates/doc/src/range.rs @@ -9,7 +9,12 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { if t.is_empty() { return Ok(open); } - t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}")) + t.bytes() + .all(|b| b.is_ascii_digit()) + .then(|| t.parse().ok()) + .flatten() + .filter(|n| (1..=count).contains(n)) + .ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}")) }; let mut out = vec![]; for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) { @@ -35,3 +40,16 @@ pub fn parse_range(s: &str, count: usize) -> Result, String> { } Ok(out) } + +#[cfg(test)] +mod tests { + use super::parse_range; + + #[test] + fn sign_is_not_part_of_a_page_number() { + assert!(parse_range("+2", 5).is_err()); + assert!(parse_range("1-+3", 5).is_err()); + assert!(parse_range("-+2", 5).is_err()); + assert_eq!(parse_range("1-3, 5", 5), Ok(vec![0, 1, 2, 4])); + } +} diff --git a/crates/doc/src/recolor.rs b/crates/doc/src/recolor.rs index 715c1aa9a..a5a878710 100644 --- a/crates/doc/src/recolor.rs +++ b/crates/doc/src/recolor.rs @@ -65,7 +65,9 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi } changed += ch as usize; } - NodeKind::Image(im) if reach.images && im.link.is_none() => { + // An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its + // colours, as the glyph does in type. + NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => { if let Some(k) = v.image(&im.key) { im.key = k; changed += 1; diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index 1d45f5d73..f03402791 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -1,6 +1,7 @@ //! Selection state (objects and, for direct selection, anchors and segments; ruler guides). use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -48,6 +49,11 @@ pub struct Selection { /// with `key`; [`Self::prune`] clears it when no object is selected to align to it. #[serde(default, skip_serializing_if = "Option::is_none")] pub key_guide: Option, + /// The new art on a locked artboard that this selection keeps editable + /// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and + /// the engine then sets the document's from it. Editing state, never saved. + #[serde(skip)] + pub new_art: Arc>, } impl Selection { @@ -148,7 +154,11 @@ impl Selection { if self.anchors.is_empty() { return None; } - let mut acc = doc.bounds_of(&self.whole(), visual); + vectorcraft_geom::union_opt(doc.bounds_of(&self.whole(), visual), self.anchors_box(doc)) + } + /// The box around the anchors selected with Direct Selection. `None` without any. + pub fn anchors_box(&self, doc: &Document) -> Option { + let mut acc = None; for (id, set) in &self.anchors { let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue }; for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) { @@ -202,16 +212,36 @@ impl Selection { /// around them (and so the key object when it is one): hidden or locked art can't stay /// selected. pub fn deselect_uneditable(&mut self, doc: &Document) { - let uneditable: HashSet = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect(); - if uneditable.is_empty() { + let locks = doc.locked_artboards(); + let uneditable: HashSet = self.objects.iter().copied().filter(|o| !doc.is_editable_with(&locks, *o)).collect(); + self.deselect_all(&uneditable); + } + /// Deselect the objects on a locked artboard ([`crate::LockedArtboards::holds_id`]), whatever + /// selected them or moved them there (and so the key object when it is one): they act as + /// locked art. A targeted layer stays targeted, as it does with locked objects in it. + pub fn deselect_on_locked_artboards(&mut self, doc: &Document) { + let locks = doc.locked_artboards(); + if locks.is_empty() { + return; + } + let locked: HashSet = self.objects.iter().copied().filter(|o| locks.holds_id(*o)).collect(); + let layer = self.target.filter(|t| doc.node(*t).is_some_and(crate::Node::is_layer)); + self.deselect_all(&locked); + if layer.is_some() { + self.target = layer; + } + } + /// Deselect the objects `gone` in one pass, with their anchors, segments and key. + fn deselect_all(&mut self, gone: &HashSet) { + if gone.is_empty() { return; } self.target = None; self.slices.clear(); - self.objects.retain(|o| !uneditable.contains(o)); - self.anchors.retain(|id, _| !uneditable.contains(id)); - self.segments.retain(|id, _| !uneditable.contains(id)); - if self.key.is_some_and(|k| uneditable.contains(&k)) { + self.objects.retain(|o| !gone.contains(o)); + self.anchors.retain(|id, _| !gone.contains(id)); + self.segments.retain(|id, _| !gone.contains(id)); + if self.key.is_some_and(|k| gone.contains(&k)) { self.key = None; } } @@ -225,12 +255,16 @@ impl Selection { self.target = Some(id); } /// Shift-clicking `id`'s target circle: it (a layer: its art) joins the selection, or leaves it - /// when it is all selected already. Several objects are then selected, none targeted. + /// when it is all selected already. Several objects are then selected, none targeted. Art on a + /// locked artboard is neither counted nor added, since + /// [`Selection::deselect_on_locked_artboards`] deselects it. pub fn toggle_target(&mut self, doc: &Document, id: NodeId) { - let ids = match doc.node(id) { + let mut ids = match doc.node(id) { Some(n) if n.is_layer() => n.layer_art(true), _ => vec![id], }; + let locks = doc.locked_artboards(); + ids.retain(|i| !locks.holds_id(*i)); if ids.iter().all(|i| self.contains(*i)) { ids.iter().for_each(|i| self.remove(*i)); } else { diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 441a64dec..d3860fc58 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -154,6 +154,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[ ("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }), ("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), ("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }), + ("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }), ("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }), ("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }), ]; @@ -238,6 +239,11 @@ pub struct DocSetup { pub export_text: ExportText, /// Background Contents chosen in New Document. pub background: Background, + /// Character panel menu › System Layout: every text object advances by whole points. Off by + /// default and for documents from before it, and saved only when on. + /// + #[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")] + pub system_layout: bool, } impl Default for DocSetup { @@ -260,6 +266,7 @@ impl Default for DocSetup { small_caps_size: 70.0, export_text: ExportText::Editable, background: Background::Transparent, + system_layout: false, } } } diff --git a/crates/doc/src/style_libs.rs b/crates/doc/src/style_libs.rs index 649a5bc1b..30aae49e1 100644 --- a/crates/doc/src/style_libs.rs +++ b/crates/doc/src/style_libs.rs @@ -189,7 +189,7 @@ fn fx(id: &str, params: Value) -> Effect { /// A gradient of `(offset, colour, opacity)` stops. fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient { - Gradient { kind, stops: stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect() } + Gradient::new(kind, stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect()) } /// A linear gradient fitted to each object's box at `angle` degrees (90: upwards). diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 19e36da80..8dbed28ae 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -297,7 +297,7 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> { /// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked /// object, before its children). -fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { +pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) { f(n); if let Some(mask) = n.extras().mask.as_deref() { walk_with_masks(&mask.art, f); diff --git a/crates/doc/src/tests_units.rs b/crates/doc/src/tests_units.rs index a5dbe9fb3..5b3319f18 100644 --- a/crates/doc/src/tests_units.rs +++ b/crates/doc/src/tests_units.rs @@ -23,6 +23,14 @@ fn every_unit_round_trips_through_its_field_text() { } } +#[test] +fn an_explicit_leading_plus_is_accepted() { + assert_eq!(Unit::Points.parse("+5"), Some(5.0)); + assert_eq!(Unit::Millimeters.parse("+5mm"), Some(Unit::Millimeters.to_pt(5.0))); + assert_eq!(Unit::Points.parse("+5+3"), Some(8.0)); + assert_eq!(Unit::Points.parse("5+-3"), Some(2.0)); +} + #[test] fn typed_units_win_over_the_fields_unit() { let mm = 72.0 / 25.4; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index d98e8d4c7..275eea4bb 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -104,15 +104,33 @@ pub struct CharStyle { /// their line line up with it. #[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")] pub char_align: CharAlign, + /// Character Direction (Character panel menu, Middle Eastern options): these characters are + /// set left to right or right to left inside their paragraph, each stretch of them a Unicode + /// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives + /// them. + #[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")] + pub char_direction: Option, /// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the /// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take /// its proportional heights, `vpal`. #[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")] pub proportional_metrics: bool, + /// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters + /// stand side by side across one em of the column. Horizontal type keeps the mark and does not + /// move them. Two or three half-width digits do this without the mark. + /// + #[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")] + pub tate_chu_yoko: bool, /// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of /// these characters are drawn in, whatever digits were typed. #[serde(default, skip_serializing_if = "crate::skip::is_default")] pub digits: Digits, + /// No Break (Character panel menu): these characters are not broken across lines, including + /// at a space or a hyphenation point inside the run. A run longer than the line stays together + /// and can overflow it. + /// + #[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")] + pub no_break: bool, } /// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of @@ -249,6 +267,9 @@ fn regular() -> String { fn hundred() -> f64 { 100.0 } +fn yes() -> bool { + true +} impl Default for CharStyle { fn default() -> Self { @@ -282,9 +303,12 @@ impl Default for CharStyle { stroke_align: StrokeAlign::Center, position: CharPosition::Normal, char_align: CharAlign::RomanBaseline, + char_direction: None, proportional_metrics: false, + tate_chu_yoko: false, small_caps: None, digits: Digits::Default, + no_break: false, } } } @@ -457,6 +481,80 @@ fn default_align_on() -> char { /// Distance between default tab stops when no explicit stop applies (½ inch). pub const DEFAULT_TAB_INTERVAL: f64 = 36.0; +/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Bullet { + /// A filled dot (•). + #[default] + Dot, + /// A dash (–). + Dash, + /// A hollow circle (◦). + Circle, + /// A filled square (▪). + Square, +} + +impl Bullet { + /// The character the layout draws for the bullet. + pub fn character(self) -> char { + match self { + Bullet::Dot => '\u{2022}', + Bullet::Dash => '\u{2013}', + Bullet::Circle => '\u{25E6}', + Bullet::Square => '\u{25AA}', + } + } +} + +/// The list a paragraph is in (Paragraph panel › Bullets). The layout draws the list's marker +/// before the paragraph's first line, in the font, size and color of the paragraph's first +/// character without its baseline shift, rotation, superscript or subscript, tracking, manual +/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after +/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a +/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves +/// out as their defaults, and a build ignores the fields it doesn't know. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct ParaList { + /// The bullet each paragraph of the list starts with. + pub bullet: Bullet, +} + +impl ParaList { + /// A bulleted list with `bullet`. + pub fn bulleted(bullet: Bullet) -> Self { + Self { bullet } + } + + /// The characters of the marker the layout draws before the paragraph. + pub fn marker(&self) -> String { + self.bullet.character().to_string() + } + + /// The style the marker is set in: `first` (the style of the paragraph's first character) + /// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning, + /// tate-chu-yoko, underline and strikethrough. + pub fn marker_style(first: &CharStyle) -> CharStyle { + CharStyle { + baseline_shift: 0.0, + rotation: 0.0, + position: CharPosition::Normal, + tracking: 0.0, + kerning: None, + tate_chu_yoko: false, + underline: false, + strikethrough: false, + ..first.clone() + } + } +} + +/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the +/// marker hangs in the first line's 18 pt before the text. +pub const LIST_HANGING_INDENT: f64 = 18.0; + /// Paragraph composer (Paragraph panel menu): how lines are broken. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -598,6 +696,35 @@ impl Default for JustificationOptions { } } +/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of +/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair +/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter +/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font +/// draws them, at most an em long. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Kashida { + /// No kashidas: justified lines widen their word spaces. + #[default] + None, + Short, + Medium, + Long, + Stylistic, +} + +impl Kashida { + /// The longest kashida, in ems of the letter that follows it in the text; 0 for None. + pub fn max_em(self) -> f64 { + match self { + Kashida::None => 0.0, + Kashida::Short => 0.25, + Kashida::Medium => 0.5, + Kashida::Long | Kashida::Stylistic => 1.0, + } + } +} + /// Paragraph attributes (the Paragraph panel). /// /// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before @@ -621,6 +748,9 @@ pub struct ParaStyle { pub composer: Composer, /// Tab stops (Tabs panel), sorted by position. pub tabs: Vec, + /// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not + /// in a list. + pub list: Option, /// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal. pub style_name: Option, /// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made @@ -645,6 +775,9 @@ pub struct ParaStyle { /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. pub roman_hanging: bool, + /// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with + /// kashidas before they widen their word spaces. + pub kashida: Kashida, } impl ParaStyle { @@ -661,6 +794,19 @@ impl ParaStyle { t.position = within(t.position, 0.0, 100_000.0); } } + + /// The leading of characters styled `st` in this paragraph, as the layout sets it: their own + /// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their + /// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is + /// not a finite number). + pub fn effective_leading(&self, st: &CharStyle) -> f64 { + let auto = self.justification.auto_leading; + if st.leading.is_some() || auto == JustificationOptions::default().auto_leading { + return st.effective_leading(); + } + let l = self.justification.auto_leading_for(st.size); + if l.is_finite() { l } else { st.effective_leading() } + } } /// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph @@ -693,6 +839,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "Vec::is_empty")] tabs: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] + list: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] style_name: Option, #[serde(default, skip_serializing_if = "Mojikumi::is_none")] mojikumi: Mojikumi, @@ -706,6 +854,8 @@ struct ParaStyleFile { kinsoku: Kinsoku, #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] + kashida: Kashida, } impl From for ParaStyleFile { @@ -727,6 +877,7 @@ impl From for ParaStyleFile { justification, composer, tabs, + list, style_name, mojikumi, direction, @@ -734,6 +885,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, roman_hanging, + kashida, .. } = p; Self { @@ -749,6 +901,7 @@ impl From for ParaStyleFile { justification, composer, tabs, + list, style_name, mojikumi, direction, @@ -756,6 +909,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, roman_hanging, + kashida, } } } @@ -775,6 +929,7 @@ impl From for ParaStyle { hyphenation, composer, tabs, + list, style_name, mojikumi, direction, @@ -782,6 +937,7 @@ impl From for ParaStyle { burasagari, kinsoku, roman_hanging, + kashida, .. } = f; Self { @@ -796,6 +952,7 @@ impl From for ParaStyle { justification, composer, tabs, + list, style_name, mojikumi, direction, @@ -803,11 +960,13 @@ impl From for ParaStyle { burasagari, kinsoku, roman_hanging, + kashida, } } } -/// A paragraph's base direction ([`ParaStyle::direction`]). +/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set +/// in ([`CharStyle::char_direction`]). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ParaDirection { @@ -1187,6 +1346,10 @@ fn one() -> f64 { 1.0 } +fn fifty() -> f64 { + 50.0 +} + impl InlineArt { pub fn new(symbol: impl Into) -> Self { Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None } @@ -1252,6 +1415,68 @@ fn reverse_from_end(path: &mut PathData) { } } +/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in +/// vertical type (the Japanese convention), or below (left in vertical type). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RubyPosition { + #[default] + Above, + Below, +} + +/// How ruby text lines up over its base text when the two are different widths. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RubyAlign { + /// Centred over the base text. + #[default] + Center, + /// Starting at the base text's start (left in horizontal type, top in vertical). + Start, + /// Ending at the base text's end (right in horizontal type, bottom in vertical). + End, +} + +/// Ruby (furigana, phonetic guide): a reading set over a range of the text. +/// +/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as +/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text; +/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and +/// allowed to overhang neighbouring characters when the reading is longer than its base. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RubySpan { + /// Byte offset of the first base character in the plain text. + pub start: usize, + /// Byte offset just past the last base character. + pub end: usize, + /// The reading, set in the base characters' font (its size comes from `size_percent`). + pub text: String, + #[serde(default)] + pub position: RubyPosition, + #[serde(default)] + pub align: RubyAlign, + /// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual). + #[serde(default = "fifty")] + pub size_percent: f64, + /// Let a reading wider than its base hang over the neighbouring characters instead of being + /// squeezed to the base's width. + #[serde(default = "yes")] + pub overhang: bool, +} + +impl RubySpan { + /// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging). + pub fn new(start: usize, end: usize, text: impl Into) -> Self { + Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true } + } + /// `size_percent` made finite and usable: 1–100% (untrusted input). + pub fn safe_size_percent(&self) -> f64 { + if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 } + } +} + /// A text object. `runs` split into paragraphs at `\n`. /// /// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every @@ -1267,6 +1492,10 @@ pub struct TextObject { /// Maps text space (origin = first baseline start for point type) to the document. pub xf: Affine, pub runs: Vec, + /// Ruby (furigana / phonetic guide) over byte ranges of the plain text; empty = none. Kept on + /// the object, so the runs (and their styles) stay the text itself. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ruby: Vec, #[serde(default)] pub para: ParaStyle, /// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`. @@ -1291,10 +1520,22 @@ pub struct TextObject { pub wrap: Vec, /// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted /// sides of letters such as A and W at a line's start or end hang outside the margin there - /// (point and area type of left-to-right paragraphs; see the layout's `optical_hangs`). Off - /// for new type and for documents from before it, and saved only when on. + /// (point and area type, and type on a path at its brackets; see the layout's `optical_hangs`). + /// Off for new type and for documents from before it, and saved only when on. #[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")] pub optical_margin: bool, + /// Character panel menu › System Layout, copied from the document: when on, each character + /// that takes space advances by a whole number of points. Off by default, and saved only when + /// on. The document setting is [`crate::DocSetup::system_layout`]. + #[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")] + pub system_layout: bool, + /// Character panel menu › Fractional Widths: spacing between characters uses fractions of a + /// point. On for new type and for documents from before it, and saved only when off. Off, each + /// character that takes any space advances by a whole number of points, at least one, so small + /// type does not run together. The setting is the whole type object, not a range of characters. + /// + #[serde(default = "yes", rename = "fractionalWidths", skip_serializing_if = "crate::skip::is_true")] + pub fractional_widths: bool, /// Cached layout bounds in text space, filled in by the layout engine (not serialized). #[serde(skip)] pub cached_bounds: Option, @@ -1347,12 +1588,43 @@ impl TextObject { Some(style) } + /// Bakes the object's scale ([`style_scale`]) into character attributes and removes that + /// scale from `xf`, so panels show document-point sizes without a separate transform factor. + /// Returns `false` when there is nothing to bake (identity scale or an unrepresentable + /// transform). + pub fn scale_text_attributes(&mut self) -> bool { + let Some(scale) = self.style_scale() else { return false }; + if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 { + return false; + } + let [a, b, c, d, _, _] = self.xf.as_coeffs(); + let (sx, sy) = (a.hypot(b), c.hypot(d)); + if sx <= 1e-12 || sy <= 1e-12 { + return false; + } + let mut baked = Vec::with_capacity(self.runs.len()); + for run in &self.runs { + let Some(eff) = self.effective_char_style(&run.style) else { return false }; + baked.push(eff); + } + for (run, style) in self.runs.iter_mut().zip(baked) { + run.style = style; + } + // Character strokes are drawn in text space: widen them by the vertical scale we remove. + self.scale_char_strokes(1.0 / sy); + self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy); + self.cached_bounds = None; + self.cached_baselines.clear(); + true + } + pub fn point(origin: Point, text: &str, style: CharStyle) -> Self { Self { vertical: false, kind: TextKind::Point, xf: Affine::translate(origin.to_vec2()), runs: vec![TextRun::new(text, style)], + ruby: Vec::new(), para: ParaStyle::default(), paras: Vec::new(), area: AreaOptions::default(), @@ -1361,6 +1633,8 @@ impl TextObject { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + system_layout: false, + fractional_widths: true, cached_bounds: None, cached_baselines: Vec::new(), } @@ -1368,6 +1642,54 @@ impl TextObject { pub fn plain_text(&self) -> String { self.runs.iter().map(|r| r.text.as_str()).collect() } + /// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them. + /// + /// Called after loading and editing text: ranges from a file or an edit are untrusted, and a + /// range past the text or an inverted one would lay out nothing (or panic in a silly place). + pub fn normalize_ruby(&mut self) { + let len: usize = self.runs.iter().map(|r| r.text.len()).sum(); + for r in &mut self.ruby { + r.start = r.start.min(len); + r.end = r.end.min(len); + if r.start > r.end { + std::mem::swap(&mut r.start, &mut r.end); + } + r.text = r.text.trim().to_string(); + } + self.ruby.retain(|r| r.start < r.end && !r.text.is_empty()); + self.ruby.sort_by_key(|r| (r.start, r.end)); + } + /// The ruby span covering byte `byte` of the plain text, if any. + pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> { + self.ruby.iter().find(|r| r.start <= byte && byte < r.end) + } + /// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered. + pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator { + let (a, b) = (start.min(end), start.max(end)); + self.ruby.iter().filter(move |r| r.start < b && a < r.end) + } + /// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with + /// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into + /// keep their start and end up where the replaced part ended. Call it after every edit of the + /// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word. + pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) { + let (start, end) = (start.min(end), start.max(end)); + let delta = inserted as i64 - (end.saturating_sub(start)) as i64; + let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize }; + for r in &mut self.ruby { + if r.end <= start { + continue; + } + if r.start >= end { + r.start = shift(r.start); + r.end = shift(r.end); + } else { + r.start = r.start.min(start); + r.end = shift(r.end).max(r.start); + } + } + self.normalize_ruby(); + } /// Number of paragraphs (`\n`-separated; empty text has one). pub fn paragraph_count(&self) -> usize { 1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::() @@ -1484,6 +1806,21 @@ impl TextObject { TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))), } } + /// How far the characters' strokes paint past their glyphs, in document space: they scale + /// with the type. + pub fn char_stroke_outset(&self) -> f64 { + let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0); + self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k + } + /// How far the characters' strokes can paint past their glyphs, their miter spikes and square + /// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must + /// redraw. They stretch with the type, so this is as far as its transform takes them on either + /// axis. The visual bounds take [`Self::char_stroke_outset`]. + pub fn char_stroke_reach(&self) -> f64 { + let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max); + let [a, b, c, d, ..] = self.xf.as_coeffs(); + reach * (a.abs() + c.abs()).max(b.abs() + d.abs()) + } pub fn transform(&mut self, a: Affine) { self.xf = a * self.xf; } @@ -1658,12 +1995,73 @@ impl crate::Document { } } } + + /// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches + /// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art + /// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry + /// ranges from outside: a range past the text, an inverted one or an empty reading would lay + /// out nothing. It recurses through the art: call it on a document no deeper than + /// [`crate::depth::MAX_DEPTH`]. + pub fn fix_ruby(&mut self) { + let mut fix = |n: &mut crate::Node| { + if let crate::NodeKind::Text(t) = &mut n.kind { + t.normalize_ruby(); + } + }; + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut())) + .chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut())); + for root in roots { + crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix); + } + } } #[cfg(test)] mod tests { use super::*; + /// Character Direction is saved only when set, and styles written before it existed read as + /// Default. + #[test] + fn character_direction_is_saved_only_when_set() { + let st = CharStyle::default(); + assert_eq!(st.char_direction, None); + assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none()); + for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] { + let set = CharStyle { char_direction: Some(d), ..CharStyle::default() }; + assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key); + assert_eq!(serde_json::from_value::(serde_json::to_value(&set).unwrap()).unwrap(), set); + } + let old = serde_json::to_value(&st).unwrap(); + assert_eq!(serde_json::from_value::(old).unwrap().char_direction, None); + } + + /// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none. + #[test] + fn ruby_is_saved_and_missing_ruby_reads_as_none() { + let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default()); + t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }]; + let json = serde_json::to_string(&t).unwrap(); + assert!(json.contains("かんじ")); + let back: TextObject = serde_json::from_str(&json).unwrap(); + assert_eq!(back.ruby, t.ruby); + assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang); + // Old files have no `ruby` key: none. + let mut old: serde_json::Value = serde_json::from_str(&json).unwrap(); + old.as_object_mut().unwrap().remove("ruby"); + assert!(serde_json::from_value::(old).unwrap().ruby.is_empty()); + // A damaged file's range past the text and its empty reading are clamped away. + let mut damaged = t.clone(); + damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")]; + damaged.normalize_ruby(); + assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6)); + } + /// Digits map every digit of the three sets into the chosen one, leave the rest alone, and /// are saved only when set. #[test] @@ -1687,6 +2085,22 @@ mod tests { assert_eq!(serde_json::from_value::(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi); } + /// Auto leading follows the paragraph's Auto Leading; a set leading doesn't. + #[test] + fn paragraph_effective_leading_follows_auto_leading() { + let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() }; + let mut para = ParaStyle::default(); + assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%"); + para.justification.auto_leading = 200.0; + assert_eq!(para.effective_leading(&st), 20.0); + para.justification.auto_leading = 0.0; + assert_eq!(para.effective_leading(&st), 0.0); + let set = CharStyle { leading: Some(14.0), ..st.clone() }; + assert_eq!(para.effective_leading(&set), 14.0); + para.justification.auto_leading = f64::NAN; + assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number"); + } + /// A file can't feed layout justification values `text.setFormat` would refuse: each is /// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and /// Maximum out of order go back to their defaults. diff --git a/crates/doc/src/trace.rs b/crates/doc/src/trace.rs index 5155cc21f..042d6b3ad 100644 --- a/crates/doc/src/trace.rs +++ b/crates/doc/src/trace.rs @@ -89,4 +89,54 @@ mod tests { g.extras_mut().trace = Some(Box::new(serde_json::json!({"view": "sepia"}))); assert_eq!(g.trace_view(), TraceView::Result); } + #[test] + fn live_trace_result_bounds_exclude_the_retained_bitmap_at_any_angle() { + use crate::{Appearance, Document, ImageObject, NodeId, NodeKind}; + use std::sync::Arc; + use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath}; + let mut source = Node::new( + NodeId(2), + NodeKind::Image(Box::new(ImageObject { + key: "synthetic".into(), + width: 200, + height: 200, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + })), + ); + source.visible = false; + let art = Node::path( + NodeId(3), + PathData::single(SubPath::polyline( + &[Point::new(20.0, 30.0), Point::new(80.0, 30.0), Point::new(80.0, 90.0), Point::new(20.0, 90.0)], + true, + )), + Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0), + ); + let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art.clone())]); + group.extras_mut().trace = Some(Box::new(serde_json::json!({}))); + let doc = Document::new(300.0, 300.0); + for view in TraceView::ALL { + group.extras_mut().trace = Some(Box::new(serde_json::json!({"view":view.id()}))); + let want = if view.shows_source() { Rect::new(0.0, 0.0, 200.0, 200.0) } else { Rect::new(20.0, 30.0, 80.0, 90.0) }; + assert_eq!(group.geometric_bounds(), Some(want)); + assert_eq!(group.visual_bounds(), Some(want)); + if !view.shows_source() { + for angle in [0.0_f64, 30.0, 90.0] { + let xf = Affine::rotate(angle.to_radians()); + for visual in [false, true] { + assert_eq!(group.bounds_in(&doc.symbol_boxes(), xf, visual), art.bounds_in(&doc.symbol_boxes(), xf, visual)); + } + } + } + } + group.extras_mut().trace = None; + assert_eq!( + group.geometric_bounds(), + Some(Rect::new(0.0, 0.0, 200.0, 200.0)), + "ordinary hidden group members retain their existing bounds policy" + ); + } } diff --git a/crates/doc/src/xml_id.rs b/crates/doc/src/xml_id.rs index eced91be1..ba454de01 100644 --- a/crates/doc/src/xml_id.rs +++ b/crates/doc/src/xml_id.rs @@ -31,8 +31,12 @@ pub fn to_object_name(id: &str) -> String { /// since a bare `_` would read back as a space. pub fn from_object_name(name: &str) -> String { let mut out = String::with_capacity(name.len().saturating_mul(2)); + let last = name.chars().count().saturating_sub(1); for (i, c) in name.chars().enumerate() { - if c == ' ' { + if c == ' ' && i == last { + // A trailing bare `_` could read back as the end of a uniqueness suffix. + out.push_str("_x20_"); + } else if c == ' ' { out.push('_'); } else if c == '_' { out.push_str("_x5F_"); @@ -69,7 +73,7 @@ fn escaped(s: &str) -> Option<(char, usize)> { let body = s.strip_prefix("_x")?; let end = body.find('_')?; let hex = body.get(..end)?; - if hex.is_empty() || hex.len() > 6 { + if hex.is_empty() || hex.len() > 6 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let c = char::from_u32(u32::from_str_radix(hex, 16).ok()?)?; @@ -109,12 +113,22 @@ mod tests { assert!(!is_valid("My Rect") && !is_valid("1abc") && !is_valid("")); } + #[test] + fn sign_is_not_a_hex_digit_in_escapes() { + assert_eq!(to_object_name("a_x+41_b"), "a x+41 b"); + assert_eq!(to_object_name("a_x-41_b"), "a x-41 b"); + } + #[test] fn round_trip_names_that_cannot_start_an_xml_name() { for name in ["-abc", ".5 mm", "²x", "Ⅷ", "", "1abc", "Layer 1", " lead", "_a", ":a", "#"] { let id = from_object_name(name); assert_eq!(to_object_name(&id), name, "name {name:?} -> id {id:?}"); } + let name = "Scan 12345678901234567890 "; + let id = from_object_name(name); + assert!(id.ends_with("_x20_"), "{id:?}"); + assert_eq!(to_object_name(&id), name); assert_eq!(from_object_name("-abc"), "_x2D_abc"); assert_eq!(from_object_name(".5 mm"), "_x2E_5_mm"); assert_eq!(from_object_name("²x"), "_xB2_x"); diff --git a/crates/effects/src/adjust.rs b/crates/effects/src/adjust.rs index d4f156d39..faa7a5a7c 100644 --- a/crates/effects/src/adjust.rs +++ b/crates/effects/src/adjust.rs @@ -415,7 +415,7 @@ pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option { let src = match &n.kind { NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => { let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()); - art = crate::reshape::as_art(n, symbol.as_deref())?; + art = crate::reshape::as_art(Some(d), n, symbol.as_deref())?; &art } _ => n, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 2bdd0f288..496946e30 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -104,7 +104,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { return Some(bake_pieces(d, m)); } if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { - let m = evaluate_container(&m).unwrap_or(m); + let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); } // Type, images, symbol instances and live objects: reshaped through their outlines, and so are @@ -119,20 +119,21 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, }; - let m = reshape(n, symbol.as_deref())?; + let m = reshape(Some(d), n, symbol.as_deref())?; return Some(bake_node(d, &m).unwrap_or(m)); } - // A path whose Transform effect copies it with a gradient: each copy an object of its own, so - // its gradient fits it rather than spanning every copy. - if crate::has_gradient_copies(n) - && let Some(m) = reshape(n, None) + // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its + // patterns: each copy an object of its own, so its gradient fits it rather than spanning + // every copy and its strokes and patterns can differ from the others'. + if crate::has_transform_art(n) + && let Some(m) = reshape(Some(d), n, None) { return Some(bake_pieces(d, m)); } // Groups and layers: Pathfinder, geometry effects and their own fills and strokes (type that // Transform effects only move stays type). The new pieces (results, outlines, copies, the // fills' and strokes' art) get ids of their own. - if let Some(m) = container_keeping_type(n).or_else(|| evaluate_container(n)) { + if let Some(m) = container_keeping_type(d, n).or_else(|| evaluate_container(Some(d), n)) { return Some(bake_pieces(d, m)); } if let Some(ch) = n.children() { @@ -151,17 +152,19 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { bake_leaf(d, n) } -/// `m`, art evaluated from an object, with its children baked and given ids of their own (the -/// pieces copied from the source keep the source's ids until then). +/// `m`, art evaluated from an object, with its children baked and then given ids of their own (the +/// pieces copied from the source keep the source's ids until then). The pieces are baked with the +/// ids the renderer draws them with: Random in a Transform effect takes its seed from the id. fn bake_pieces(d: &mut Document, mut m: Node) -> Node { let mut seen = std::collections::HashSet::from([m.id]); let children = m.children().cloned().unwrap_or_default(); let children = children .into_iter() .map(|c| { - let mut c = Arc::unwrap_or_clone(c); + let c = Arc::unwrap_or_clone(c); + let mut c = bake_node(d, &c).unwrap_or(c); fresh_ids(d, &mut c, &mut seen); - Arc::new(bake_node(d, &c).unwrap_or(c)) + Arc::new(c) }) .collect(); if let Some(ch) = m.children_mut() { @@ -321,7 +324,7 @@ fn moved_type_group(n: &Node) -> Option { /// objects or inside groups with Pathfinder or geometry effects or fills and strokes that paint, is /// outlined as [`evaluate_container`] outlines it, and the other members become what it makes of /// them. `None` when no type stays type. -fn container_keeping_type(n: &Node) -> Option { +fn container_keeping_type(d: &Document, n: &Node) -> Option { fn keep(n: &mut Node, moves: &[serde_json::Value], clipping: bool, kept: &mut bool) { let clips = n.clips(); let Some(ch) = n.children_mut() else { return }; @@ -346,7 +349,7 @@ fn container_keeping_type(n: &Node) -> Option { } } None if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - None => as_art(c, None), + None => as_art(None, c, None), }; if let Some(o) = art { *c = Arc::new(o); @@ -363,8 +366,7 @@ fn container_keeping_type(n: &Node) -> Option { if !text { return None; } - let hook: &dyn Fn(&Node) -> Option = &outline_text; - let mut m = vectorcraft_doc::live::expand_deep(n, Some(hook)); + let mut m = crate::reshape::evaluated(Some(d), n); let mut kept = false; keep(&mut m, &moves, false, &mut kept); if !kept { @@ -452,7 +454,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, & /// effects are dropped. `None` for other kinds of objects. pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option { if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) { - let m = evaluate_container(&m).unwrap_or(m); + let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); } let (g, rule, ctx, mut m) = leaf_base(n)?; @@ -563,7 +565,7 @@ pub fn bake_appearance(n: &Node) -> Option { } None => { let effects = st.effects.clone(); - let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.clone()) }; + let fill = FillLayer { opacity: st.opacity, blend: st.blend, effects, ..FillLayer::new(st.paint.stroke_as_fill()) }; piece(outline, FillRule::NonZero, AppearanceItem::Fill(fill)) } } @@ -671,6 +673,22 @@ mod tests { assert_eq!(f.effects.iter().map(|e| e.id.as_str()).collect::>(), ["stylize.dropShadow"]); } + #[test] + fn a_baked_stroke_leaves_dither_behind() { + // Strokes ignore dither, so the filled outline goes without it and looks as the stroke did. + let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); + if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] { + let mut g = vectorcraft_color::GradientPaint::new(Default::default()); + g.gradient.dither = true; + s.paint = Paint::Gradient(Box::new(g)); + } + let m = bake_appearance(&n).unwrap(); + let ch = m.children().unwrap(); + let Some(AppearanceItem::Fill(f)) = ch[1].appearance.items.first() else { panic!("the outline is filled") }; + let Paint::Gradient(g) = &f.paint else { panic!("a gradient fill") }; + assert!(!g.gradient.dither); + } + #[test] fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline() { let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()); @@ -736,7 +754,7 @@ mod tests { baked.walk(&mut |n| assert!(ids.insert(n.id), "{params}: duplicate id {}", n.id)); // The renderer and Expand Appearance still draw it as outlines. let mut text = 0; - evaluate_container(&g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); + evaluate_container(None, &g).unwrap().walk(&mut |n| text += usize::from(matches!(n.kind, NodeKind::Text(_)))); assert_eq!(text, 0, "{params}"); } // Stacked effects that only move: every copy of a copy. @@ -1032,7 +1050,7 @@ mod tests { fn gradient_copies(id: u64) -> Node { use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)); n.appearance.effects.push(fx("distort.transform", json!({"moveH": 30, "copies": 2}))); n diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index 502cf962a..b7430e0c7 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -10,8 +10,9 @@ fn diag(b: Rect) -> f64 { b.width().hypot(b.height()).max(1e-6) } -/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates). -pub fn free_distort(path: &PathData, b: Rect, p: &Value) -> PathData { +/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates), +/// making at most `max` anchors ([`map_nonlinear_within`]). +pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let mut c = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)]; if let Some(arr) = p.get("corners").and_then(Value::as_array) { for (i, v) in arr.iter().take(4).enumerate() { @@ -34,7 +35,7 @@ pub fn free_distort(path: &PathData, b: Rect, p: &Value) -> PathData { let bot = c[3].lerp(c[2], u); top.lerp(bot, v) }; - map_nonlinear(path, diag(b) / 16.0, f) + map_nonlinear_within(path, diag(b) / 16.0, max, f) } /// Pucker & Bloat: anchors move toward (bloat) or away from (pucker) the centre, handles the @@ -67,21 +68,29 @@ pub fn pucker_bloat(path: &PathData, b: Rect, amount: f64) -> PathData { } } -/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. -pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData { +/// Most points Roughen resamples one segment into. +const MAX_ROUGHEN_POINTS: usize = 2000; + +/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where +/// that would make more than `max` anchors, the points are spaced further apart until it doesn't +/// (stacked Roughens multiply their points, #3578). +pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let size = num(p, "size", 5.0).clamp(0.0, 1e4); let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size }; let detail = num(p, "detail", 10.0).clamp(0.0, 100.0); let spacing = if detail > 0.0 { 72.0 / detail } else { f64::INFINITY }; + // A segment of k points adds k - 1 anchors to the path. + let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect(); + let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count())); + let mut lens = lens.iter(); let smooth = smooth_points(p); let seed = seed(p); let mut subs = vec![]; for (si, sp) in path.subpaths.iter().enumerate() { let mut pts = vec![]; - for i in 0..sp.segment_count() { + for (i, &len) in (0..sp.segment_count()).zip(&mut lens) { let c = sp.segment(i); - let len = vectorcraft_geom::kurbo::ParamCurveArclen::arclen(&c, 0.1); - let k = if spacing.is_finite() { ((len / spacing).ceil() as usize).clamp(1, 2000) } else { 1 }; + let k = pieces(len, spacing, MAX_ROUGHEN_POINTS); for j in 0..k { pts.push(c.eval(j as f64 / k as f64)); } @@ -105,11 +114,16 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData { /// The move and the number of copies of a Transform effect with params `p` that only moves what /// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects, -/// or moves by a random share. +/// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales +/// strokes while leaving the geometry (Scale Strokes & Effects with Transform Objects off). pub(crate) fn transform_move(p: &Value) -> Option<(Vec2, usize)> { let (m, copies) = transform_map(Rect::ZERO, p, 0); let [a, b, c, d, x, y] = m.as_coeffs(); - (!flag(p, "random", false) && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies)) + // Transform Patterns moves the patterns along; with Transform Objects off, Scale Strokes & + // Effects still scales the strokes by the map the geometry doesn't take. + let o = TransformOptions::of(p); + let plain = !flag(p, "random", false) && !o.patterns && (o.objects || !o.strokes); + (plain && [a, b, c, d] == [1.0, 0.0, 0.0, 1.0]).then_some((Vec2::new(x, y), copies)) } /// The offsets at which Transform effects with params `ps`, applied in order, place `path`, the @@ -161,7 +175,9 @@ pub(crate) fn within_reach(r: Rect) -> bool { /// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With /// `random`, each scale goes a random share of the way from 100 % to its value and each move and /// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result -/// on every redraw while each object varies its own way. +/// on every redraw while each object varies its own way. With Transform Objects off the path stays +/// as it is; Transform Patterns and Scale Strokes & Effects reach the appearance through the +/// object's art ([`crate::has_transform_art`]). pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData { transform_within(path, b, p, seed, MAX_COPY_ANCHORS) } @@ -192,12 +208,49 @@ pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, m out } +/// The Transform effect's options beyond its geometry (#885). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct TransformOptions { + /// Transform Objects (default on): the geometry transforms and is copied. Off, it stays where + /// it is and only the two options below apply, once. + pub objects: bool, + /// Transform Patterns (default off): pattern fills and strokes transform along. + pub patterns: bool, + /// Scale Strokes & Effects (default off): stroke weights and dashes, and the distances of the + /// effects on the fills and strokes, scale by the map's mean scale (`√|det|`). + pub strokes: bool, +} + +impl TransformOptions { + /// The options of a Transform effect with params `p`. Missing keys take their defaults, so + /// effects saved before the options existed draw as they did. + pub fn of(p: &Value) -> Self { + Self { objects: flag(p, "objects", true), patterns: flag(p, "patterns", false), strokes: flag(p, "strokes", false) } + } + + /// Do they reach past the geometry (Transform Patterns or Scale Strokes & Effects)? + pub fn reach_appearance(self) -> bool { + self.patterns || self.strokes + } +} + /// The Transform effect's map and how many copies of what it is applied to it makes, for bounds /// `b` (the reference point lies on them), params `p` and the object's `seed` for Random: the map /// applies to the object itself when `copies` is 0, and once more to each of `copies` copies /// otherwise (see [`transform_within`], and images, whose copies count nodes against -/// [`MAX_COPY_OBJECTS`]). +/// [`MAX_COPY_OBJECTS`]). With Transform Objects off the geometry stays: the identity and no +/// copies ([`transform_affine`] is the map its other options still use). pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) { + if !TransformOptions::of(p).objects { + return (Affine::IDENTITY, 0); + } + (transform_affine(b, p, seed), num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize) +} + +/// The one transformation of the Transform effect with params `p` on an object of bounds `b` +/// (the reference point lies on them; `seed` for Random), whatever Transform Objects says. Each +/// copy applies it once more to the last. +pub(crate) fn transform_affine(b: Rect, p: &Value, seed: u64) -> Affine { let random = flag(p, "random", false); // The share (0..1) of value `k` applied. let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 }; @@ -206,12 +259,10 @@ pub(crate) fn transform_map(b: Rect, p: &Value, seed: u64) -> (Affine, usize) { let reflect = |key: &str| if flag(p, key, false) { -1.0 } else { 1.0 }; let o = vectorcraft_geom::reference_point(b, num(p, "reference", 4.0).clamp(0.0, 8.0) as usize).to_vec2(); // Reflect X flips the x coordinates (left to right), Reflect Y the y coordinates. - let m = Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o) + Affine::translate(Vec2::new(moved("moveH", 2), moved("moveV", 3)) + o) * Affine::rotate(-(num(p, "rotate", 0.0) * share(4)).to_radians()) * Affine::scale_non_uniform(scale("scaleH", 0) * reflect("reflectX"), scale("scaleV", 1) * reflect("reflectY")) - * Affine::translate(-o); - let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize; - (m, copies) + * Affine::translate(-o) } /// Tweak: random displacement of anchors and/or control points. @@ -246,8 +297,9 @@ pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData { out } -/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle. -pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData { +/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle, +/// making at most `max` anchors ([`map_nonlinear_within`]). +pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData { let ang = angle.clamp(-3600.0, 3600.0).to_radians(); if ang == 0.0 { return path.clone(); @@ -261,8 +313,8 @@ pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData { let (s, co) = a.sin_cos(); c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co) }; - let pieces = (r / 8.0).min(r / (1.0 + ang.abs() * 2.0)); - map_nonlinear(path, pieces, f) + let piece = (r / 8.0).min(r / (1.0 + ang.abs() * 2.0)); + map_nonlinear_within(path, piece, max, f) } const ZIGZAG_ARCLEN_ACCURACY: f64 = 1e-4; @@ -280,11 +332,17 @@ struct Ridge { /// displaced along the normal. Corner points join them with straight lines. Smooth points make a /// wave: every peak and valley is a smooth point whose handles run along the path, a third of the /// way to the next ridge, so each span between them follows a half cosine (an S through the path) -/// whatever the size of the ridges (#2343). -pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData { +/// whatever the size of the ridges (#2343). Where `ridges` would make more than `max` anchors, +/// each segment gets as many as fit (stacked Zig Zags multiply their points, #3578). +pub fn zig_zag(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let size = num(p, "size", 10.0).clamp(-1e4, 1e4); let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size }; - let ridges = num(p, "ridges", 4.0).clamp(0.0, 100.0) as usize; + // Each segment makes `ridges + 1` points, and an open path's end one more (all its anchors + // when it has no segment). With no ridges, that is at most the path's own anchors. + let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add); + let ends: usize = path.subpaths.iter().filter(|sp| !sp.closed).map(|sp| if sp.segment_count() > 0 { 1 } else { sp.anchors.len() }).sum(); + let fit = (max.saturating_sub(ends) / segs.max(1)).saturating_sub(1); + let ridges = (num(p, "ridges", 4.0).clamp(0.0, 100.0) as usize).min(fit); let smooth = smooth_points(p); let mut subs = vec![]; for sp in &path.subpaths { diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 2211a4364..5463857ed 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -4,7 +4,8 @@ //! children are the Pathfinder stack (back → front). In the shape modes (Add, Subtract, Intersect, //! Exclude, Minus Back) a child group acts as one shape — the union of its leaves, styled like its //! top leaf — as in the Pathfinder panel. The result replaces the children at render time; each -//! result keeps the appearance of the object whose paint it takes. +//! result keeps the appearance of the object whose paint it takes. Trap ([`crate::trap`]) keeps the +//! children and adds its traps above them. //! //! **Container appearance** ([`evaluate_container`]): a group's or layer's own fills and strokes //! paint every member's geometry, below or above the members as its contents slot says @@ -13,12 +14,14 @@ use std::sync::Arc; -use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeId, NodeKind, StrokeLayer}; +use serde_json::Value; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_pathops::{self as po, PathfinderOp}; /// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order. -pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [ +pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [ ("pathfinder.add", "Add", PathfinderOp::Unite), ("pathfinder.intersect", "Intersect", PathfinderOp::Intersect), ("pathfinder.exclude", "Exclude", PathfinderOp::Exclude), @@ -29,6 +32,8 @@ pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 10] = [ ("pathfinder.merge", "Merge", PathfinderOp::Merge), ("pathfinder.crop", "Crop", PathfinderOp::Crop), ("pathfinder.outline", "Outline", PathfinderOp::Outline), + ("pathfinder.hardMix", "Hard Mix", PathfinderOp::HardMix), + ("pathfinder.softMix", "Soft Mix", PathfinderOp::SoftMix), ]; /// Is `id` an Effect → Pathfinder effect? @@ -100,23 +105,136 @@ fn styled(src: &Node, path: PathData, outline: bool) -> Node { n } +/// Most segments a live Pathfinder effect takes in, as for the Shape Builder. +pub const PATHFINDER_MAX_SEGMENTS: usize = po::SHAPE_BUILDER_MAX_SEGMENTS; + +/// Most pairs of input paths whose bounds overlap a live Pathfinder effect takes in. Crossings, +/// and so the pieces of the result, grow with these pairs (n bars crossing n make n² crossings) +/// and the effect runs on every draw: past this the group draws its art unprocessed (#3591). +pub const PATHFINDER_MAX_OVERLAPS: usize = 16_384; + +/// Fenwick tree of counts over `n` slots, for counting the active boxes of [`within_budget`]. +struct Counts(Vec); + +// `isolate_lowest_one` is still unstable in Rust 1.96, and the workspace builds on 1.95 (rust-version). +#[allow(clippy::manual_isolate_lowest_one)] +impl Counts { + fn add(&mut self, slot: usize, d: isize) { + let mut i = slot + 1; + while let Some(c) = self.0.get_mut(i) { + *c += d; + i += i & i.wrapping_neg(); + } + } + + /// Sum of the slots before `end`. + fn before(&self, end: usize) -> isize { + let (mut i, mut s) = (end.min(self.0.len().saturating_sub(1)), 0); + while i > 0 { + s += self.0.get(i).copied().unwrap_or(0); + i -= i & i.wrapping_neg(); + } + s + } +} + +/// Can a live Pathfinder evaluate `leaves` within [`PATHFINDER_MAX_SEGMENTS`] and +/// [`PATHFINDER_MAX_OVERLAPS`]? The overlapping pairs of bounds are counted exactly by a sweep +/// in x that keeps the active boxes in two Fenwick trees over their y extents, so the check costs +/// O(n log n) however the boxes are laid out. +fn within_budget<'a>(leaves: impl IntoIterator) -> bool { + let mut segments = 0usize; + let mut boxes: Vec = vec![]; + for l in leaves { + let Some((path, _)) = geometry(l) else { continue }; + segments += path.subpaths.iter().map(|sp| sp.segment_count()).sum::(); + if segments > PATHFINDER_MAX_SEGMENTS { + return false; + } + boxes.extend(path.bounds()); + } + let mut ys: Vec = boxes.iter().flat_map(|b| [b.y0, b.y1]).collect(); + ys.sort_by(f64::total_cmp); + ys.dedup_by(|a, b| a.total_cmp(b).is_eq()); + // Slots of the y values below `y`, and of those at or below it. + let below = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_lt()); + let upto = |y: f64| ys.partition_point(|v| v.total_cmp(&y).is_le()); + // (x, is end, box); starts sort before ends at the same x since touching boxes overlap. + let mut events: Vec<(f64, bool, usize)> = boxes.iter().enumerate().flat_map(|(i, b)| [(b.x0, false, i), (b.x1, true, i)]).collect(); + events.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1))); + // Active boxes counted by the slot of their y0 and of their y1. + let mut by_y0 = Counts(vec![0; ys.len() + 1]); + let mut by_y1 = Counts(vec![0; ys.len() + 1]); + let (mut active, mut overlaps) = (0isize, 0usize); + for (_, end, i) in events { + let Some(b) = boxes.get(i) else { continue }; + if end { + by_y0.add(below(b.y0), -1); + by_y1.add(below(b.y1), -1); + active -= 1; + continue; + } + // Active boxes not entirely below b (y1 < b.y0) nor entirely above it (y0 > b.y1). + let above = active - by_y0.before(upto(b.y1)); + let under = by_y1.before(below(b.y0)); + overlaps += usize::try_from(active - above - under).unwrap_or(0); + if overlaps > PATHFINDER_MAX_OVERLAPS { + return false; + } + by_y0.add(below(b.y0), 1); + by_y1.add(below(b.y1), 1); + active += 1; + } + true +} + /// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects. -/// `None` when the group has no Pathfinder effect. +/// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`], +/// [`PATHFINDER_MAX_OVERLAPS`]) it returns the children as they are. pub fn pathfinder_children(group: &Node, hook: Option) -> Option>> { if !has_pathfinder(group) { return None; } - let mut stack: Vec> = group.children()?.to_vec(); + let source: &[Arc] = group.children()?; + let mut stack: Vec> = source.to_vec(); for e in group.appearance.effects.iter().filter(|e| e.visible) { + if e.id == crate::trap::TRAP { + let mut lv = vec![]; + for c in &stack { + leaves(c, hook, &mut lv); + } + if !within_budget(&lv) { + return Some(source.to_vec()); + } + let (ids, srcs): (Vec, Vec) = lv + .iter() + .filter_map(|l| { + let (path, rule) = geometry(l)?; + Some((l.id, crate::trap::TrapSource { path, rule, fill: l.appearance.fill_paint() })) + }) + .unzip(); + let made = crate::trap::traps(&srcs, &crate::trap::TrapOptions::from_params(&e.params)); + stack.extend(made.into_iter().map(|p| Arc::new(crate::trap::trap_node(ids.get(p.from).copied().unwrap_or(NodeId(0)), p)))); + continue; + } let Some(op) = op_of(&e.id) else { continue }; let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack); + let all: Vec> = stack + .iter() + .map(|c| { + let mut lv = vec![]; + leaves(c, hook, &mut lv); + lv + }) + .collect(); + if !within_budget(all.iter().flatten()) { + return Some(source.to_vec()); + } // (style source, shape) per stack entry; keys index `sources`. let mut sources: Vec = vec![]; let mut shapes: Vec = vec![]; - for c in &stack { - let mut lv = vec![]; - leaves(c, hook, &mut lv); + for mut lv in all { if shape_mode && lv.len() > 1 { let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(geometry).collect(); let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect(); @@ -138,15 +256,88 @@ pub fn pathfinder_children(group: &Node, hook: Option) -> Option f64 { + params.get("mixingRate").and_then(|v| v.as_f64()).filter(|r| r.is_finite() && *r > 0.0 && *r <= 100.0).map(|r| r / 100.0).unwrap_or(0.5) +} + +fn fill_cmyk(src: &Node) -> Option<[f32; 4]> { + let paint = src.appearance.fill_paint(); + let color = paint.color()?; + let tinted = match paint { + Paint::Solid { tint, .. } => color.tinted(tint), + _ => color, + }; + Some(tinted.to_cmyk()) +} + +/// One face of a Soft Mix: CMYK blended bottom to top at `rate`; no stroke. +fn soft_mix_face(path: PathData, covering: &[usize], rate: f64, shapes: &[po::Shape], sources: &[Node]) -> Node { + let rate = rate as f32; + let mut cmyk: Option<[f32; 4]> = None; + for &si in covering { + let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue }; + cmyk = Some(match cmyk { + None => ch, + Some(acc) => { + let t = rate; + let u = 1.0 - t; + [acc[0] * u + ch[0] * t, acc[1] * u + ch[1] * t, acc[2] * u + ch[2] * t, acc[3] * u + ch[3] * t] + } + }); + } + let fill = cmyk.map(|c| Paint::solid(Color::cmyk(c[0], c[1], c[2], c[3]))).unwrap_or(Paint::None); + soft_mix_styled(path, covering, shapes, sources, fill) +} + +/// One face of a Hard Mix: the max of each CMYK channel over every covering shape's fill; no stroke. +fn hard_mix_face(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node]) -> Node { + let mut cmyk = [0.0f32; 4]; + let mut painted = false; + for &si in covering { + let Some(ch) = fill_cmyk(&sources[shapes[si].key as usize]) else { continue }; + for (out, v) in cmyk.iter_mut().zip(ch) { + *out = out.max(v); + } + painted = true; + } + let fill = if painted { Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])) } else { Paint::None }; + soft_mix_styled(path, covering, shapes, sources, fill) +} + +fn soft_mix_styled(path: PathData, covering: &[usize], shapes: &[po::Shape], sources: &[Node], fill: Paint) -> Node { + let top = covering.last().and_then(|&si| sources.get(shapes[si].key as usize)); + let mut n = Node::path(top.map_or(NodeId(0), |s| s.id), path, Appearance::basic(fill, Paint::None, 0.0)); + if let Some(src) = top { + n.opacity = src.opacity; + n.blend = src.blend; + } + n +} + fn is_container(n: &Node) -> bool { matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) } @@ -175,9 +366,10 @@ pub fn has_container_appearance(n: &Node) -> bool { /// The regions a group's or layer's own fills and strokes paint, in document space, each with its /// fill rule: every visible member path and compound path, type as its glyph outlines and live /// objects evaluated; the members of a clip group or clipping layer without its clipping path. -/// Images, symbol instances and guides add nothing. -pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> { - fn push(n: &Node, out: &mut Vec<(PathData, FillRule)>) { +/// Images, symbol instances and guides add nothing. A Repeat counts the Repeats in the art of +/// `doc`'s symbols that its instances show toward its instance budget. +pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> { + fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { if !n.visible { return; } @@ -189,19 +381,19 @@ pub fn member_shapes(n: &Node) -> Vec<(PathData, FillRule)> { out.push((PathData::from_bezpath(&all).transformed(t.xf), FillRule::NonZero)); } } - NodeKind::Group { .. } | NodeKind::Layer { .. } => members(n, out), - _ if vectorcraft_doc::live::is_live(n) => members(&crate::reshape::outlined_members(n), out), + NodeKind::Group { .. } | NodeKind::Layer { .. } => members(doc, n, out), + _ if vectorcraft_doc::live::is_live(n) => members(doc, &crate::reshape::outlined_members(doc, n), out), _ => {} } } - fn members(n: &Node, out: &mut Vec<(PathData, FillRule)>) { + fn members(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) { let clip = usize::from(n.clips()); for c in n.children().into_iter().flatten().skip(clip) { - push(c, out); + push(doc, c, out); } } let mut out = vec![]; - members(n, &mut out); + members(doc, n, &mut out); out } @@ -243,8 +435,9 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)], /// reshaping all its members as one piece (the whole art's bounds as their reference box), and its /// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below /// the members (after a clipping path) or above them, as its contents slot says. Its -/// transparency, opacity mask and raster effects stay. `None` when it has none of these. -pub fn evaluate_container(n: &Node) -> Option { +/// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live +/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]). +pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { if !is_container(n) { return None; } @@ -263,12 +456,12 @@ pub fn evaluate_container(n: &Node) -> Option { } } if !fx.is_empty() { - m = crate::reshape::outlined_members(&m); + m = crate::reshape::outlined_members(doc, &m); crate::reshape::reshape_leaves(&mut m, &fx); } m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); if paint { - let shapes = member_shapes(&m); + let shapes = member_shapes(doc, &m); let bounds = m.geometric_bounds(); let art = |items: &[AppearanceItem]| -> Vec> { items.iter().filter_map(|i| item_art(n.id, i, &shapes, bounds)).map(Arc::new).collect() }; @@ -320,6 +513,67 @@ mod tests { po::area(n.path_data().unwrap(), FillRule::NonZero).abs() } + fn overlap_cmyk(g: &Node) -> [f32; 4] { + let faces = pathfinder_children(g, None).unwrap(); + assert_eq!(faces.len(), 3); + let overlap = faces + .iter() + .find(|n| { + let b = n.geometric_bounds().unwrap(); + b.x0 >= 4.9 && b.x1 <= 10.1 && b.y0 <= 0.1 && b.y1 >= 9.9 + }) + .expect("overlap face"); + assert!(overlap.appearance.stroke_paint().is_none()); + overlap.appearance.fill_paint().color().unwrap().to_cmyk() + } + + fn two_overlapping_cmyk_squares() -> Node { + let a = Appearance::basic(Paint::solid(Color::cmyk(0.2, 0.66, 0.4, 0.0)), Paint::solid(Color::BLACK), 2.0); + let b = Appearance::basic(Paint::solid(Color::cmyk(0.4, 0.2, 0.3, 0.1)), Paint::solid(Color::BLACK), 2.0); + Node::group( + NodeId(10), + vec![ + Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), a)), + Arc::new(Node::path(NodeId(2), shapes::rectangle(Rect::new(5.0, 0.0, 15.0, 10.0)), b)), + ], + ) + } + + /// Effect › Pathfinder › Trap keeps the members and adds an overprinting strip of the lighter + /// colour inside the darker one along the edge where they meet. + #[test] + fn trap_effect_adds_an_overprinting_strip_above_the_members() { + let yellow = Color::cmyk(0.0, 0.0, 1.0, 0.0); + let blue = Color::cmyk(1.0, 0.6, 0.0, 0.0); + let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, yellow), sq(2, 10.0, blue)]); + let params = serde_json::json!({"thickness": 1.0, "tintReduction": 100}); + g.appearance.effects.push(Effect { id: crate::trap::TRAP.into(), params, visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 3, "both squares and one trap"); + assert_eq!((out[0].id, out[1].id), (NodeId(1), NodeId(2))); + let b = out[2].geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 0.02 && (b.x1 - 11.0).abs() < 0.02 && b.y0.abs() < 0.02 && (b.y1 - 10.0).abs() < 0.02, "{b:?}"); + let fill = out[2].appearance.fill().unwrap(); + assert!(fill.overprint); + assert_eq!(fill.paint.color(), Some(yellow)); + } + + #[test] + fn hard_mix_takes_the_highest_of_each_cmyk_channel_and_drops_strokes() { + let mut g = two_overlapping_cmyk_squares(); + g.appearance.effects.push(Effect { id: "pathfinder.hardMix".into(), params: serde_json::json!({}), visible: true }); + let [c, m, y, k] = overlap_cmyk(&g); + assert!((c - 0.4).abs() < 0.02 && (m - 0.66).abs() < 0.02 && (y - 0.4).abs() < 0.02 && (k - 0.1).abs() < 0.02, "{c} {m} {y} {k}"); + } + + #[test] + fn soft_mix_blends_cmyk_bottom_to_top_and_drops_strokes() { + let mut g = two_overlapping_cmyk_squares(); + g.appearance.effects.push(Effect { id: "pathfinder.softMix".into(), params: serde_json::json!({"mixingRate": 50.0}), visible: true }); + let [c, m, y, k] = overlap_cmyk(&g); + assert!((c - 0.3).abs() < 0.02 && (m - 0.43).abs() < 0.02 && (y - 0.35).abs() < 0.02 && (k - 0.05).abs() < 0.02, "{c} {m} {y} {k}"); + } + #[test] fn shape_modes_follow_the_pathfinder_panel() { let add = pathfinder_children(&group_with("pathfinder.add"), None).unwrap(); @@ -411,7 +665,7 @@ mod tests { fn a_random_transform_on_a_group_moves_its_members_together() { let mut g = group_with("distort.transform"); g.appearance.effects[0].params = serde_json::json!({"moveH": 100, "moveV": 100, "random": true}); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let corner = |i: usize| art.children().and_then(|c| c.get(i)).and_then(|n| n.geometric_bounds()).map(|b| (b.x0, b.y0)).unwrap(); let (a, b) = (corner(0), corner(1)); assert!(a.0 > 0.0 && a.0 < 100.0, "moved by a share of the value: {a:?}"); @@ -427,7 +681,7 @@ mod tests { style(&mut t); let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), Arc::new(t)]); g.appearance.effects.push(Effect { id: "path.offsetPath".into(), params: serde_json::json!({"offset": 0}), visible: true }); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); (*art.children().unwrap()[1]).clone() } @@ -470,7 +724,7 @@ mod tests { } use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); // The type's characters fit the gradient to its layout; the square has it placed across itself. let style = vectorcraft_doc::CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(ramp.clone())), ..Default::default() }; let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(5.0, 60.0), "IIII", style); @@ -485,7 +739,7 @@ mod tests { let before = start(&crate::outline_text(&t).unwrap().children().unwrap()[0]); let mut g = Node::group(NodeId(10), vec![square, t]); g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true }); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let members = art.children().unwrap(); let after = start(&members[1].children().unwrap()[0]); assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}"); @@ -496,7 +750,7 @@ mod tests { fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None }; let paints = [Paint::Gradient(Box::new(ramp.clone())), Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }))]; for (k, paint) in paints.into_iter().enumerate() { @@ -505,7 +759,7 @@ mod tests { let mut g = Node::group(NodeId(10), vec![square]); let params = serde_json::json!({"moveH": 30, "copies": 2}); g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true }); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own"); assert_eq!(copies.len(), 3); for (i, c) in copies.iter().enumerate() { @@ -519,4 +773,66 @@ mod tests { } } } + + /// A group with `effect` holding `n` vertical then `n` horizontal bars, 2 wide on a 4-unit + /// pitch, each crossing every bar of the other direction: n² crossings. + fn crossing_bars(n: usize, effect: &str) -> Node { + let len = 4.0 * n as f64; + let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let mut children = vec![]; + for i in 0..n { + let at = 4.0 * i as f64; + children.push(Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(at, 0.0, at + 2.0, len)), paint.clone()))); + } + for i in 0..n { + let at = 4.0 * i as f64; + let id = NodeId(100 + (n + i) as u64); + children.push(Arc::new(Node::path(id, shapes::rectangle(Rect::new(0.0, at, len, at + 2.0)), paint.clone()))); + } + let mut g = Node::group(NodeId(10), children); + g.appearance.effects.push(Effect { id: effect.into(), params: serde_json::json!({}), visible: true }); + g + } + + #[test] + fn pathfinder_past_its_budget_draws_the_source_art() { + // n² crossings past the cap: no evaluation (#3591), the source art as it is. + for n in [130, 800] { + let g = crossing_bars(n, "pathfinder.divide"); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 2 * n, "{n} x {n} bars"); + let ids: Vec = out.iter().map(|c| c.id).collect(); + let src: Vec = g.children().unwrap().iter().map(|c| c.id).collect(); + assert_eq!(ids, src, "{n} x {n} bars"); + assert!(out.iter().zip(g.children().unwrap()).all(|(a, b)| a.path_data() == b.path_data())); + } + } + + #[test] + fn pathfinder_within_its_budget_still_divides() { + // Each bar is cut into one piece per crossing plus the n pieces between and past them, so + // n x n bars divide into 3·n² pieces, and 128 x 128 bars, which overlap in exactly + // `PATHFINDER_MAX_OVERLAPS` pairs, must still divide. + for n in [8, 128] { + let out = pathfinder_children(&crossing_bars(n, "pathfinder.divide"), None).unwrap(); + assert_eq!(out.len(), 3 * n * n, "{n} x {n} bars"); + } + } + + #[test] + fn pathfinder_on_many_stacked_bars_still_runs() { + // 3000 bars stacked in y all overlap in x, but each overlaps only the next one in y: 2999 + // overlapping pairs are within the budget, so Add still merges them into one path. + let paint = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let children = (0..3000) + .map(|i| { + let at = 1.5 * i as f64; + Arc::new(Node::path(NodeId(100 + i as u64), shapes::rectangle(Rect::new(0.0, at, 100.0, at + 2.0)), paint.clone())) + }) + .collect(); + let mut g = Node::group(NodeId(10), children); + g.appearance.effects.push(Effect { id: "pathfinder.add".into(), params: serde_json::json!({}), visible: true }); + let out = pathfinder_children(&g, None).unwrap(); + assert_eq!(out.len(), 1); + } } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 45b50d5f9..ce1b1d58d 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -20,6 +20,8 @@ //! art painting its members (its raster effects then apply to the composite). //! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and //! PDF writers. +//! - [`preview`] measures objects as they are drawn, with their geometry effects (Use Preview +//! Bounds). //! - **Plug-in effects** (`plugin.`, Effect › Plug-ins) are geometry effects run by an //! installed WebAssembly plug-in (`vectorcraft-plugins`); [`effect_info`] describes them like //! built-in ones, and an effect whose plug-in isn't installed leaves the geometry as it is. @@ -38,15 +40,18 @@ mod clip; mod distort; mod group; mod lighting; +mod lighting_presets; mod live; mod marks; pub mod pixel; +pub mod preview; mod raster; mod reshape; mod revolve; mod solids; pub mod stroke; mod stylize; +pub mod trap; mod util; mod warp; @@ -54,6 +59,8 @@ mod warp; mod tests; #[cfg(test)] mod tests_scale; +#[cfg(test)] +mod tests_transformfx; use serde_json::{Map, Value, json}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; @@ -69,16 +76,21 @@ pub use group::{ OutlineHook, PATHFINDER_EFFECTS, evaluate_container, has_container_appearance, has_pathfinder, is_pathfinder, member_shapes, paints, pathfinder_children, }; +pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset}; pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects}; -pub use reshape::{expand_outlined, has_gradient_copies, has_item_geometry, image_paint, needs_outline, outline_art, outline_text, reshape}; +pub use reshape::{ + expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, needs_outline, outline_art, + outline_text, reshape, +}; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, - view_options, + validate_three_d, view_options, }; +pub use trap::TRAP; pub use warp::{WarpStyle, warp_point}; /// Catalogue entry for one effect. @@ -143,6 +155,7 @@ fn lengths_of(id: &str) -> Lengths { "stylize.innerGlow" | "stylize.outerGlow" => always(&["blur"]), "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), "brushStrokes.accentedEdges" => always(&["edgeWidth"]), + "artistic.paletteKnife" => always(&["strokeSize"]), "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), "brushStrokes.spatter" => always(&["sprayRadius"]), "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), @@ -156,6 +169,9 @@ fn lengths_of(id: &str) -> Lengths { "sketch.graphicPen" => always(&["strokeLength"]), "sketch.halftonePattern" => always(&["size"]), "sketch.notePaper" => always(&["relief"]), + "sketch.tornEdges" => always(&["smoothness"]), + "sketch.waterPaper" => always(&["fiberLength"]), + TRAP => always(&["thickness", "precision"]), RASTERIZE => always(&["addAround"]), _ => Lengths::default(), } @@ -273,8 +289,8 @@ pub fn effect_catalog() -> Vec { "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw)}", - json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false}), + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( "distort.tweak", @@ -343,6 +359,13 @@ pub fn effect_catalog() -> Vec { ), r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})), r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})), + r( + "artistic.coloredPencil", + "Colored Pencil…", + ARTISTIC, + "{pencilWidth: 1..24 (5; stroke spacing in document units), strokePressure: 0..15 (5; how heavily the strokes darken), paperBrightness: 0..50 (25; lifts lighter areas toward the paper)} redraws the object in coloured pencil strokes along its edges, keeping its hues and coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"pencilWidth": 5.0, "strokePressure": 5.0, "paperBrightness": 25.0}), + ), r( "artistic.cutout", "Cutout…", @@ -350,6 +373,13 @@ pub fn effect_catalog() -> Vec { "{levels: 2..8 (4; steps per colour channel: more levels, more flat colours), edgeSimplicity: 0..10 (4; simpler, smoother edges), edgeFidelity: 1..3 (2; keeps more of the edges' detail)} redraws the object as flat pieces of coloured paper, each piece painted in the average colour that falls into it, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"levels": 4.0, "edgeSimplicity": 4.0, "edgeFidelity": 2.0}), ), + r( + "artistic.dryBrush", + "Dry Brush…", + ARTISTIC, + "{brushSize: 1..10 (2; length of the brush strokes), brushDetail: 0..10 (10; more detail keeps finer structure), texture: 1..3 (1; stronger edges and bristle grain at 3)} repaints the object with short strokes along its form and darkens its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), + ), r( "artistic.filmGrain", "Film Grain…", @@ -357,6 +387,13 @@ pub fn effect_catalog() -> Vec { "{grain: 0..20 (4; monochrome noise in shadows and midtones), highlightArea: 0..20 (0; widens the bright highlight area), intensity: 0..10 (10; highlight strength and quieter highlight grain)}. Smooth document-space grain follows translations; minification uses approximate footprint filtering. Coverage and pure white are preserved; black takes light speckles, and saturated colour channels may clip. Grain 0 with either highlightArea 0 or intensity 0 leaves the art unchanged. Original visual approximation; chosen VectorCraft defaults.", json!({"grain": 4.0, "highlightArea": 0.0, "intensity": 10.0}), ), + r( + "artistic.fresco", + "Fresco…", + ARTISTIC, + "{brushSize: 1..10 (2; wider brush dabs), brushDetail: 0..10 (10; sharper edges and less smoothing), texture: 1..3 (1; deeper plaster relief)} repaints the object as coarse pigment on a textured wall, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 2.0, "brushDetail": 10.0, "texture": 1.0}), + ), r( "artistic.neonGlow", "Neon Glow…", @@ -364,6 +401,20 @@ pub fn effect_catalog() -> Vec { "{glowSize: -24..24 (5; above 0 the light areas glow, below 0 the dark ones; farther from 0 spreads the glow wider), glowBrightness: 0..50 (15; a stronger glow over dimmer greys), color: \"#rrggbb\" (\"#4da6ff\"; the glow's colour)} redraws the object in greys lit by a coloured glow, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"glowSize": 5.0, "glowBrightness": 15.0, "color": "#4da6ff"}), ), + r( + "artistic.paintDaubs", + "Paint Daubs…", + ARTISTIC, + "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), + ), + r( + "artistic.paletteKnife", + "Palette Knife…", + ARTISTIC, + "{strokeSize: 1..50 (10; length of the knife strokes in document units), strokeDetail: 1..3 (2; more detail keeps more of the original), softness: 0..10 (3; softens the strokes)} smears the object's colours with short angled knife strokes, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"strokeSize": 10.0, "strokeDetail": 2.0, "softness": 3.0}), + ), r( "artistic.plasticWrap", "Plastic Wrap…", @@ -378,6 +429,21 @@ pub fn effect_catalog() -> Vec { "{edgeThickness: 0..10 (2; wider lines), edgeIntensity: 0..10 (1; darker lines, on fainter edges too), posterization: 0..6 (2; posterization + 2 levels per colour channel)} reduces the object's colours and draws black lines on its edges, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"edgeThickness": 2.0, "edgeIntensity": 1.0, "posterization": 2.0}), ), + r( + "artistic.roughPastels", + "Rough Pastels…", + ARTISTIC, + "{strokeLength: 0..40 (6; longer pastel strokes), strokeDetail: 1..20 (10; bristle texture along the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false)} repaints the object in soft diagonal pastel strokes over a textured surface, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults.", + json!({ + "strokeLength": 6.0, + "strokeDetail": 10.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), r( "artistic.smudgeStick", "Smudge Stick…", @@ -392,6 +458,21 @@ pub fn effect_catalog() -> Vec { "{brushSize: 0..10 (2; size of the sponge's pores), definition: 0..25 (12; how strongly lighter and darker blobs contrast), smoothness: 1..15 (5; softer blob edges)} paints the object as if dabbed with a sponge, in highly textured areas of contrasting colour. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", json!({"brushSize": 2.0, "definition": 12.0, "smoothness": 5.0}), ), + r( + "artistic.underpainting", + "Underpainting…", + ARTISTIC, + "{brushSize: 0..40 (6; wider, softer strokes), textureCoverage: 0..40 (16; more of the relief texture on the strokes), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object with broad strokes over a textured ground, keeping its coverage. Slider ranges follow the documented controls; numeric defaults are chosen for VectorCraft and are not verified against proprietary defaults. Original visual approximation.", + json!({ + "brushSize": 6.0, + "textureCoverage": 16.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), r( "artistic.watercolor", "Watercolor…", @@ -525,6 +606,21 @@ pub fn effect_catalog() -> Vec { "{detail: 0..10 (4; finer surface texture), smoothness: 0..10 (4; softer relief before the highlights and shadows)} treats the object as polished chrome lit from the top left: bright highlights on high points and dark shadows in low ones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"detail": 4.0, "smoothness": 4.0}), ), + r( + "sketch.conteCrayon", + "Conté Crayon…", + SKETCH, + "{foregroundLevel: 0..20 (12; how far the black crayon reaches from the shadows into the midtones), backgroundLevel: 0..20 (10; how far the white crayon reaches from the highlights into the midtones), texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; the paper's surface, made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} redraws the object in a dense black crayon in its dark areas and a pure white crayon in its light areas, on mid-grey paper whose texture breaks the strokes and stands in relief, keeping its coverage. Public help names Foreground Level, Background Level and the texture options but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", + json!({ + "foregroundLevel": 12.0, + "backgroundLevel": 10.0, + "texture": "canvas", + "scaling": 100.0, + "relief": 4.0, + "lightDirection": "top", + "invert": false + }), + ), r( "sketch.graphicPen", "Graphic Pen…", @@ -574,6 +670,20 @@ pub fn effect_catalog() -> Vec { "{lightDarkBalance: 0..50 (25; the larger, the more of the object prints black), smoothness: 1..50 (5; rounds the shapes)} simplifies the object to solid black and white, as a rubber stamp would print it. Coverage is kept. Original visual approximation; chosen VectorCraft defaults.", json!({"lightDarkBalance": 25.0, "smoothness": 5.0}), ), + r( + "sketch.tornEdges", + "Torn Edges…", + SKETCH, + "{imageBalance: 0..50 (25; lower leaves more midtones as ink, higher as paper), smoothness: 0..15 (11; softens tones and the ragged edges), contrast: 0..20 (17; 20 is pure black and white)} redraws the object in high-contrast black and white with torn, ragged edges along its tones, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + json!({"imageBalance": 25.0, "smoothness": 11.0, "contrast": 17.0}), + ), + r( + "sketch.waterPaper", + "Water Paper…", + SKETCH, + "{fiberLength: pt 3..50 (15; scale of paper fibers and blotchy daubs), brightness: 0..100 (50), contrast: 0..100 (50)} washes the object's colours as if painted on damp fibrous paper, with soft flow and a fibrous modulation. Public helpx names Fiber Length, Brightness and Contrast but not slider ranges or dialog defaults; ranges and defaults here are unverified VectorCraft choices. Original visual approximation, not a proprietary reproduction.", + json!({"fiberLength": 15.0, "brightness": 50.0, "contrast": 50.0}), + ), r( "stylize.glowingEdges", "Glowing Edges…", @@ -648,7 +758,7 @@ pub fn effect_catalog() -> Vec { "texture.texturizer", "Texturizer…", TEXTURE, - "{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\" (\"canvas\"; surfaces made in code), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief", + "{texture: \"brick\"|\"burlap\"|\"canvas\"|\"sandstone\"|\"loaded\" (\"canvas\"; \"loaded\" draws the image in textureImage and draws Canvas when there is none; the others are surfaces made in code), textureImage?: {path} | {name?, dataBase64} | string (Load Texture…: an image file that File › Place reads, within the renderer's pixel allowance, stored as its gray levels in a grayscale PNG in base64; a file sets texture to \"loaded\" unless texture is given, and the stored string, as effect.list gives it, is kept as it is), scaling: % 50..200 (100), relief: 0..50 (4), lightDirection: \"bottom\"|\"bottomLeft\"|\"left\"|\"topLeft\"|\"top\"|\"topRight\"|\"right\"|\"bottomRight\" (\"top\"), invert: bool (false; turns the surface's heights over)} paints the object on a surface in relief. A loaded texture lies one image pixel per point at 100 %, centered on the object's center and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray", json!({"texture": "canvas", "scaling": 100.0, "relief": 4.0, "lightDirection": "top", "invert": false}), ), r( @@ -731,8 +841,19 @@ pub fn effect_catalog() -> Vec { v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"}))); } for (id, label, _) in PATHFINDER_EFFECTS { - v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); + if id == "pathfinder.softMix" { + v.push(g( + id, + label, + PATHFINDER, + "{mixingRate?: 1..100 % (50)} blend overlapping fills in CMYK bottom to top (groups and layers: live Pathfinder over the members)", + json!({"mixingRate": 50.0}), + )); + } else { + v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({}))); + } } + v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); v.push(g( CROP_MARKS, "Crop Marks", @@ -971,15 +1092,80 @@ impl<'a> GeomContext<'a> { } } +/// Most anchors a chain of geometry effects makes of one path, or of all the paths of a type, +/// symbol or image art together; a path that already has more keeps its own count as the limit. +/// Roughen resamples each segment into up to 2000 points, Zig Zag into up to 101 and the warps +/// into up to 64, so stacked effects multiply: three Roughens of a rectangle needed tens of +/// gigabytes (#3578). Each effect builds within it, and one whose result would still pass it is +/// left out. +pub(crate) const MAX_EFFECT_ANCHORS: usize = 2_000_000; + +/// Most lines the renderer is estimated to draw a chain of geometry effects' result with at +/// 1 px/pt ([`util::line_estimate`]), or the path's own estimate when that is more. A large +/// Roughen or Zig Zag makes segments thousands of points long, each drawn as up to hundreds of +/// lines: two million anchors of them came to 141 million lines, 7 GB in the renderer and past +/// the 2²⁷ lines vello takes of one path (#3578). Art of small curves draws its anchors as a few +/// lines each, so this rarely binds before [`MAX_EFFECT_ANCHORS`] does. +pub(crate) const MAX_EFFECT_LINES: usize = 8_000_000; + +/// What geometry effects may make of a path, or what a path costs: its anchors and the lines the +/// renderer is estimated to draw it with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Budget { + pub anchors: usize, + pub lines: usize, +} + +impl Budget { + /// The budget of a chain of geometry effects. + pub(crate) const EFFECTS: Self = Self { anchors: MAX_EFFECT_ANCHORS, lines: MAX_EFFECT_LINES }; + + /// What `path` costs. + pub(crate) fn of(path: &PathData) -> Self { + Self { anchors: path.anchor_count(), lines: util::line_estimate(path) } + } + + /// This budget, raised to `cost` where that is more. + pub(crate) fn at_least(self, cost: Self) -> Self { + Self { anchors: self.anchors.max(cost.anchors), lines: self.lines.max(cost.lines) } + } + + /// Does `cost` fit this budget? + pub(crate) fn fits(self, cost: Self) -> bool { + cost.anchors <= self.anchors && cost.lines <= self.lines + } + + /// What is left of this budget once `cost` is spent. + pub(crate) fn minus(self, cost: Self) -> Self { + Self { anchors: self.anchors.saturating_sub(cost.anchors), lines: self.lines.saturating_sub(cost.lines) } + } + + /// This cost and `cost` together. + pub(crate) fn plus(self, cost: Self) -> Self { + Self { anchors: self.anchors.saturating_add(cost.anchors), lines: self.lines.saturating_add(cost.lines) } + } +} + /// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the /// reference box of the first effect (usually the object's geometric bounds); each later effect -/// uses the bounds of its input. Raster and unknown effects are skipped. +/// uses the bounds of its input. Raster and unknown effects are skipped, and so is an effect +/// whose result would pass 2 million anchors or 8 million lines drawn (or the path's own counts, +/// when they are more). pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData { apply_geometry_with(effects, path, bounds, &GeomContext::default()) } /// [`apply_geometry`] with an explicit [`GeomContext`]. pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData { + apply_geometry_within(effects, path, bounds, ctx, Budget::EFFECTS) +} + +/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that +/// is more. +pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData { + // `budget` raised to what `path` costs, measured once, and only for a path that has geometry + // effects. + let mut limit = None; let mut cur = path.clone(); let mut b = bounds; let mut first = true; @@ -992,7 +1178,10 @@ pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ct break; } let p = merged_params(&e.id, &e.params); - cur = apply_one(&e.id, &p, &cur, b, ctx); + let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path))); + if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) { + cur = next; + } } cur } @@ -1002,20 +1191,44 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() } -pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { +/// Geometry effect `id` with params `p` on `path`, whose reference box is `b`; `None` when its +/// result would not fit `budget` (the effects that multiply anchors build within its anchors). +pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, budget: Budget) -> Option { + let out = apply_unchecked(id, p, path, b, ctx, budget.anchors); + let cost = Budget::of(&out); + if budget.fits(cost) { + return Some(out); + } + if cost.lines <= budget.lines || !builds_within(id) { + return None; + } + // Anchors drawn as many lines each (a large Roughen or Zig Zag): once more with as many fewer + // anchors as there were lines too many, and a tenth to spare, as segments vary in their lines. + let fewer = (cost.anchors as f64 * 0.9 * (budget.lines as f64 / cost.lines as f64)) as usize; + let out = apply_unchecked(id, p, path, b, ctx, fewer.min(budget.anchors)); + budget.fits(Budget::of(&out)).then_some(out) +} + +/// Does geometry effect `id` build within the anchors it is given? +fn builds_within(id: &str) -> bool { + matches!(id, "distort.freeDistort" | "distort.roughen" | "distort.transform" | "distort.twist" | "distort.zigZag") || id.starts_with("warp.") +} + +/// [`apply_one`] whose result may pass `max` when the effect doesn't build within it. +fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData { use util::*; if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) { let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item }; return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints).unwrap_or_else(|| path.clone()); } match id { - "distort.freeDistort" => distort::free_distort(path, b, p), + "distort.freeDistort" => distort::free_distort(path, b, p, max), "distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)), - "distort.roughen" => distort::roughen(path, b, p), - "distort.transform" => distort::transform(path, b, p, ctx.seed), + "distort.roughen" => distort::roughen(path, b, p, max), + "distort.transform" => distort::transform_within(path, b, p, ctx.seed, max), "distort.tweak" => distort::tweak(path, b, p), - "distort.twist" => distort::twist(path, b, num(p, "angle", 10.0)), - "distort.zigZag" => distort::zig_zag(path, b, p), + "distort.twist" => distort::twist(path, b, num(p, "angle", 10.0), max), + "distort.zigZag" => distort::zig_zag(path, b, p, max), "path.offsetPath" => { let off = num(p, "offset", 10.0).clamp(-1e5, 1e5); if off.abs() < 1e-9 { @@ -1048,7 +1261,7 @@ pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &Geo "stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)), "stylize.scribble" => stylize::scribble(path, b, p), _ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) { - Some(style) => warp::warp(path, b, style, p), + Some(style) => warp::warp(path, b, style, p, max), None => path.clone(), }, } diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 6f1242b6e..9a352189d 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -71,7 +71,7 @@ pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color { pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint { use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect(); - let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let mut p = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None }); Paint::Gradient(Box::new(p)) } @@ -334,7 +334,7 @@ mod tests { stop.opacity = 0.4; stop.midpoint = 0.3; let g = GradientPaint { - gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] }, + gradient: Gradient::new(GradientKind::Linear, vec![stop, GradientStop::new(1.0, Color::BLACK)]), geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }), angle: 0.0, swatch: None, diff --git a/crates/effects/src/lighting_presets.rs b/crates/effects/src/lighting_presets.rs new file mode 100644 index 000000000..bf6371224 --- /dev/null +++ b/crates/effects/src/lighting_presets.rs @@ -0,0 +1,112 @@ +//! Original lighting rigs for the publicly documented 3D preset names. +//! Adobe publishes the names, not numeric settings. These values are our own; Standard +//! retains the existing default. A preset replaces the light rig and ambient only. + +use serde_json::{Value, json}; +use vectorcraft_three_d::{DEFAULT_LIGHT, Light}; + +#[derive(Clone, Copy, Debug)] +pub struct LightingPreset { + pub id: &'static str, + pub label: &'static str, + rotation: f64, + height: f64, + softness: f64, + ambient: f64, +} + +pub const LIGHTING_PRESETS: [LightingPreset; 4] = [ + LightingPreset { id: "standard", label: "Standard", rotation: DEFAULT_LIGHT.0, height: DEFAULT_LIGHT.1, softness: 0.0, ambient: 25.0 }, + LightingPreset { id: "diffuse", label: "Diffuse", rotation: DEFAULT_LIGHT.0, height: DEFAULT_LIGHT.1, softness: 100.0, ambient: 35.0 }, + LightingPreset { id: "topLeft", label: "Top Left", rotation: 135.0, height: 45.0, softness: 0.0, ambient: 25.0 }, + LightingPreset { id: "right", label: "Right", rotation: 0.0, height: 30.0, softness: 0.0, ambient: 25.0 }, +]; + +impl LightingPreset { + /// The same patch is used by commands and the dialog's preview transaction. Null + /// `lights` replaces an explicit multi-light rig with this single white light. + pub fn params(self) -> Value { + json!({ + "lights": null, "lightColor": "#ffffff", "lightIntensity": 80.0, + "lightRotation": self.rotation, "lightHeight": self.height, + "lightSoftness": self.softness, "lightBehind": false, "ambient": self.ambient, + }) + } + + fn matches(self, light: Light, ambient: f64) -> bool { + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + !light.behind + && light.color.iter().all(|c| close(*c, 1.0)) + && close(light.intensity, 0.8) + && close(light.rotation, self.rotation) + && close(light.height, self.height) + && close(light.softness, self.softness / 100.0) + && close(ambient, self.ambient / 100.0) + } +} + +pub fn lighting_preset_params(id: &str) -> Option { + LIGHTING_PRESETS.iter().find(|p| p.id == id).map(|p| p.params()) +} + +/// Identify the effective rig, including an equivalent explicit single light. Names +/// are UI state derived from settings, so edits and older files cannot leave stale names. +pub fn matching_lighting_preset(params: &Value) -> Option { + let upgraded = crate::upgrade_params(crate::REVOLVE, params); + let o = crate::revolve_options(&upgraded); + let light = if let Some(lights) = &o.lights { + let mut entries = lights.iter(); + let light = *entries.next()?; + if entries.next().is_some() { + return None; + } + light + } else { + Light { + rotation: o.light_rotation, + height: o.light_height, + behind: o.light_behind, + color: o.light_color, + intensity: o.light_intensity, + softness: o.light_softness, + } + }; + LIGHTING_PRESETS.iter().position(|preset| preset.matches(light, o.ambient)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lighting_presets_are_valid_unique_and_standard_preserves_the_default() { + for (i, preset) in LIGHTING_PRESETS.iter().enumerate() { + let patch = preset.params(); + crate::lighting::validate(&patch).unwrap(); + assert_eq!(lighting_preset_params(preset.id), Some(patch.clone())); + assert_eq!(matching_lighting_preset(&patch), Some(i)); + assert!(!patch.as_object().unwrap().contains_key("shade")); + assert!(!patch.as_object().unwrap().contains_key("rotationX")); + } + assert_eq!(matching_lighting_preset(&json!({})), Some(0)); + assert!(lighting_preset_params("unknown").is_none()); + assert_eq!(crate::revolve_options(&LIGHTING_PRESETS[0].params()).light_direction(), crate::revolve_options(&json!({})).light_direction()); + } + + #[test] + fn lighting_preset_matching_uses_effective_lights_and_detects_custom_edits() { + let mut p = LIGHTING_PRESETS[3].params(); + p["lightColor"] = json!("#ff0000"); + p["lights"] = json!([{"rotation":0,"height":30}]); + assert_eq!(matching_lighting_preset(&p), Some(3), "explicit light overrides stale legacy color"); + p["lights"][0]["color"] = json!("#4080ff"); + assert_eq!(matching_lighting_preset(&p), None); + p["lights"] = json!([{}, {}]); + assert_eq!(matching_lighting_preset(&p), None); + for (key, value) in [("ambient", json!(26)), ("lightIntensity", json!(79)), ("lightBehind", json!(true))] { + let mut p = LIGHTING_PRESETS[0].params(); + p[key] = value; + assert_eq!(matching_lighting_preset(&p), None, "{key}"); + } + } +} diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index 7d03068c0..aad8d8edd 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -37,7 +37,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option { fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { let outline = |n: &Node| outline(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); - f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles) }) + f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc }) } /// One level of evaluation of live object `n` ([`live::expand_live_hooks`]). diff --git a/crates/effects/src/pixel/artistic.rs b/crates/effects/src/pixel/artistic.rs index 0d6764feb..039a0db13 100644 --- a/crates/effects/src/pixel/artistic.rs +++ b/crates/effects/src/pixel/artistic.rs @@ -1,18 +1,78 @@ -//! Effect › Artistic (Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges). Original filters built from +//! Effect › Artistic (Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Plastic Wrap, Poster +//! Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor). Original filters built from //! public behaviour documentation: //! and //! . //! These are visual approximations, not reproductions of a proprietary algorithm. -use super::edges::sobel; -use super::pixelate::{Place, cell, rand}; +use std::f64::consts::TAU; +use super::brushstrokes::{LEFT, RIGHT, Smear, TONE_SOFTNESS, mix, streak, stroke_reach, unit, unpremultiply}; +use super::edges::sobel; +use super::pixelate::{Around, Place, Soft, cell, point_in, rand, value_noise}; +use super::texture::{self, Light, Texture, Tones, luma, premultiply, smooth_noise, straight, texturizer}; +use super::{PixelSpace, blur_plane, pick}; +use vectorcraft_geom::{Point, Vec2}; + +const SALT_DRY: u64 = 264; +const MARGIN: f64 = 1.0; const SALT_PLASTIC: u64 = 512; -use super::texture::{luma, premultiply, straight}; -use super::{PixelSpace, blur_plane}; -use vectorcraft_geom::Point; + +const SALT_PASTEL: u64 = 200; const SALT_FILM: u64 = 256; +const SALT_DAUB: u64 = 272; + +/// Paint Daubs' brush types, in the Brush Type menu's order: (label, parameter value). +/// Illustrator's list: . +pub const PAINT_DAUB_BRUSH_TYPES: [(&str, &str); 6] = [ + ("Simple", "simple"), + ("Light Rough", "lightRough"), + ("Dark Rough", "darkRough"), + ("Wide Sharp", "wideSharp"), + ("Wide Blurry", "wideBlurry"), + ("Sparkle", "sparkle"), +]; + +/// A Paint Daubs brush ([`PAINT_DAUB_BRUSH_TYPES`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PaintDaubBrush { + Simple, + LightRough, + DarkRough, + WideSharp, + WideBlurry, + Sparkle, +} + +impl PaintDaubBrush { + const ALL: [PaintDaubBrush; 6] = [ + PaintDaubBrush::Simple, + PaintDaubBrush::LightRough, + PaintDaubBrush::DarkRough, + PaintDaubBrush::WideSharp, + PaintDaubBrush::WideBlurry, + PaintDaubBrush::Sparkle, + ]; + + /// The brush of Brush Type `value` (a [`PAINT_DAUB_BRUSH_TYPES`] value, any case); Simple otherwise. + pub fn parse(value: &str) -> Self { + if value.eq_ignore_ascii_case("wideRough") { + return PaintDaubBrush::WideSharp; + } + if value.eq_ignore_ascii_case("hue") { + return PaintDaubBrush::WideBlurry; + } + pick(&PAINT_DAUB_BRUSH_TYPES, &Self::ALL, value, PaintDaubBrush::Simple) + } +} + +/// How far (document units) a dab reaches past its centre at `size` (1..50). +pub(super) fn paint_daub_radius(size: f64) -> f64 { + size.clamp(1.0, 50.0) * 0.48 +} +const SALT_PENCIL: u64 = 304; +const SALT_FRESCO: u64 = 320; /// Keep the last cell's four corner values: magnified adjacent pixels often share a cell. /// The cache changes only cost, never the field, and needs constant storage per raster. @@ -161,6 +221,55 @@ fn planes(px: &[[u8; 4]]) -> ([Vec; 3], Vec) { (std::array::from_fn(|k| colours.iter().map(|c| c[k]).collect()), px.iter().map(|p| f32::from(p[3]) / 255.0).collect()) } +/// How far (document units) Colored Pencil reaches at pencil width `width`: soften at +/// `0.12 * width` and the Sobel neighbourhood around it. +pub(super) fn colored_pencil_reach(width: f64) -> f64 { + 3.0 * 0.12 * width.clamp(1.0, 24.0) + 1.0 +} + +/// Colored Pencil: the object redrawn with coloured pencil strokes along local edges, keeping its +/// hues. `width` (1..24) sets the stroke spacing in document units; `pressure` (0..15) darkens +/// along the strokes; `paper` (0..50) lifts lighter areas toward the paper. Coverage is kept. +pub(super) fn colored_pencil(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, width: f64, pressure: f64, paper: f64) { + if !([width, pressure, paper].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) { + return; + } + let width = width.clamp(1.0, 24.0); + let pressure = (pressure / 15.0).clamp(0.0, 1.0) as f32; + let paper_lift = (paper / 50.0).clamp(0.0, 1.0) as f32; + if pressure == 0.0 && paper_lift == 0.0 { + return; + } + let Some(place) = Place::new(space, w, h) else { return }; + let (mut colour, alpha) = planes(px); + soften(&mut colour, &alpha, w, h, (0.12 * width) / space.px); + let tones: Vec = (0..px.len()).map(|k| luma([colour[0][k], colour[1][k], colour[2][k]])).collect(); + let mut edges = vec![0.0f32; px.len()]; + for (e, s) in edges.iter_mut().zip(sobel(&tones, w, h)) { + *e = (s / 4.0).min(1.0); + } + let spacing = (0.35 * width).max(1.0); + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let k = y * w + x; + let c = [colour[0][k], colour[1][k], colour[2][k]]; + let gx = tone_at(&tones, w, h, x as isize + 1, y as isize) - tone_at(&tones, w, h, x as isize - 1, y as isize); + let gy = tone_at(&tones, w, h, x as isize, y as isize + 1) - tone_at(&tones, w, h, x as isize, y as isize - 1); + let angle = gy.atan2(gx) + std::f32::consts::FRAC_PI_2; + let (dx, dy) = (angle.cos() as f64, angle.sin() as f64); + let q = place.at(x, y); + let along = q.x * dx + q.y * dy; + let grain = value_noise(SALT_PENCIL, along / spacing, (q.x * 0.37 + q.y * 0.11) / spacing); + let stroke = (0.5 + 0.5 * (along / spacing * TAU + grain * 3.0).sin()) as f32; + let ink = pressure * (0.3 + 0.55 * stroke) * (0.55 + 0.45 * edges[k]); + let lift = paper_lift * (1.0 - luma(c)) * 0.35; + let c = c.map(|v| (v * (1.0 - ink * 0.45) + lift * (1.0 - v)).clamp(0.0, 1.0)); + *p = premultiply(c, f32::from(p[3]) / 255.0); + }); +} + /// Cutout: the object as flat pieces of coloured paper. Its colours, softened as `simplicity` /// (0..10) asks and less as `fidelity` (1..3) keeps detail, fall into `levels` (2..8) steps per /// colour channel; each step's piece is painted in the average colour of what falls into it, so @@ -241,6 +350,136 @@ pub(super) fn poster_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel } } +/// Paint Daubs: thick round dabs on a grid `size` (1..50) points apart, each dab painted in the +/// local colour around its centre, with edges as sharp as `sharpness` (0..40) and a profile from +/// `kind`. Coverage is kept. +pub(super) fn paint_daubs(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, sharpness: f64, kind: PaintDaubBrush) { + if !([size, sharpness].iter().all(|v| v.is_finite()) && size > 0.0) { + return; + } + let Some(place) = Place::new(space, w, h) else { return }; + let spacing = size.clamp(1.0, 50.0); + let brush_r = paint_daub_radius(size); + let edge = 1.0 + 2.5 * (sharpness.clamp(0.0, 40.0) / 40.0); + let soft = Soft::new(px, w, (spacing * 0.15) / place.px); + let dab = |i: i64, j: i64| { + let at = point_in(SALT_DAUB, i, j); + let center = Point::new(at.x * spacing, at.y * spacing); + (center, soft.at(place.index(center))) + }; + let cover = |q: Point, center: Point, colour: [f32; 4]| -> f32 { + if colour[3] <= 0.0 { + return 0.0; + } + let (dx, dy) = (q.x - center.x, q.y - center.y); + let (dx, dy) = match kind { + PaintDaubBrush::WideSharp | PaintDaubBrush::WideBlurry => (dx * 0.65, dy * 1.35), + _ => (dx, dy), + }; + let t = (dx.hypot(dy) / brush_r).clamp(0.0, 1.0); + let edge_k = match kind { + PaintDaubBrush::WideSharp => edge * 1.35, + PaintDaubBrush::WideBlurry => edge * 0.55, + _ => edge, + }; + let mut c = (1.0 - t).powf(edge_k) as f32; + let tone = luma(unpremultiply(colour).0); + match kind { + PaintDaubBrush::LightRough => c *= 0.7 + 0.35 * rough_bristle(q, 0), + PaintDaubBrush::DarkRough => c *= 0.55 + 0.45 * (1.0 - rough_bristle(q, 1)), + PaintDaubBrush::Sparkle if rough_bristle(center, 2) > 0.92 && tone > 0.55 => c = c.max(0.85), + PaintDaubBrush::Simple | PaintDaubBrush::WideSharp | PaintDaubBrush::WideBlurry | PaintDaubBrush::Sparkle => {} + } + c + }; + let mut around = Around::new(); + place.each(px, |x, y, out| { + if out[3] == 0 { + return; + } + let q = place.at(x, y); + let (u, v) = (q.x / spacing, q.y / spacing); + let (Some(i), Some(j)) = (cell(u), cell(v)) else { return }; + let mut sum = [0.0f32; 3]; + let mut weight = 0.0f32; + for k in 0..9 { + let Some((center, colour)) = around.get((i, j), k, dab) else { continue }; + let w = cover(q, center, colour); + if w <= 0.0 { + continue; + } + let c = unpremultiply(colour).0; + for (s, v) in sum.iter_mut().zip(c) { + *s += v * w; + } + weight += w; + } + if weight <= 1e-6 { + return; + } + let c = sum.map(|v| (v / weight).clamp(0.0, 1.0)); + *out = premultiply(c, f32::from(out[3]) / 255.0); + }); +} + +fn rough_bristle(p: Point, k: u64) -> f32 { + let (Some(i), Some(j)) = (cell(p.x * 3.7), cell(p.y * 3.7)) else { return 0.5 }; + rand(SALT_DAUB + 3, i, j, k) as f32 +} + +/// Fresco: coarse brush dabs on a plaster wall. `brush_size` (1..10) sets dab spacing, +/// `brush_detail` (0..10) keeps sharper edges when high, and `texture` (1..3) deepens the +/// relief. Coverage is kept. +pub(super) fn fresco(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, brush_size: f64, brush_detail: f64, texture: f64) { + if !([brush_size, brush_detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) { + return; + } + let Some(place) = Place::new(space, w, h) else { return }; + let brush_size = brush_size.clamp(1.0, 10.0); + let brush_detail = brush_detail.clamp(0.0, 10.0); + let texture = texture.round().clamp(1.0, 3.0); + let side = 2.0 + 1.8 * brush_size; + let sigma = (0.15 * (10.0 - brush_detail) * brush_size / place.px).max(0.0); + let soft = Soft::new(px, w, sigma); + let tex_strength = 0.08 + 0.07 * (texture - 1.0); + let tex_scale = 3.0 / texture; + let streak_mix = 0.25 * (1.0 - brush_detail / 10.0); + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let (Some(i), Some(j)) = (cell(q.x / side), cell(q.y / side)) else { return }; + let jx = (rand(SALT_FRESCO, i, j, 0) - 0.5) * 0.35 * side; + let jy = (rand(SALT_FRESCO, i, j, 1) - 0.5) * 0.35 * side; + let sample = Point::new(q.x + jx, q.y + jy); + let mut sampled = soft.at(place.index(sample)); + if sampled[3] <= 0.0 { + sampled = soft.at(place.index(q)); + } + if sampled[3] <= 0.0 { + return; + } + let [r, g, b, alpha] = sampled; + let c = [r, g, b].map(|v| v / alpha); + let angle = TAU * rand(SALT_FRESCO, i, j, 2); + let (fx, fy) = (q.x - i as f64 * side, q.y - j as f64 * side); + let along = fx * angle.cos() + fy * angle.sin(); + let streak = (1.0 - streak_mix * (0.5 + 0.5 * (along / side).sin().powi(2))) as f32; + let bump = (1.0 + tex_strength * smooth_noise(SALT_FRESCO + 3, q, tex_scale * side)) as f32; + let lit = c.map(|v| (v * streak * bump).clamp(0.0, 1.0)); + *p = premultiply(lit, f32::from(p[3]) / 255.0); + }); +} + +/// How far (document units) a Fresco dab can reach from a pixel. +pub(super) fn fresco_reach(brush_size: f64, brush_detail: f64) -> f64 { + let brush_size = brush_size.clamp(1.0, 10.0); + let brush_detail = brush_detail.clamp(0.0, 10.0); + let side = 2.0 + 1.8 * brush_size; + side + 0.15 * (10.0 - brush_detail) * brush_size +} + /// How far (document units) Plastic Wrap's softened relief is sampled at `smoothness` (1..15). pub(super) fn plastic_reach(smoothness: f64) -> f64 { 3.0 * (0.15 + 0.35 * smoothness.clamp(1.0, 15.0)) @@ -314,11 +553,144 @@ pub(super) fn plastic_wrap(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel }); } +/// Stroke length (document units) for Dry Brush at Brush Size `size` (1..10). +fn dry_brush_length(size: f64) -> f64 { + 2.0 + 2.8 * (size.clamp(1.0, 10.0) - 1.0) +} + +/// How much Dry Brush softens before smearing at Brush Detail `detail` (0..10; 10 keeps the most detail). +fn dry_brush_sigma(detail: f64) -> f64 { + 0.22 * (10.0 - detail.clamp(0.0, 10.0)) +} + +/// How far (document units) Dry Brush reads from a pixel. +pub(super) fn dry_brush_reach(size: f64, detail: f64) -> f64 { + stroke_reach(dry_brush_length(size)) + 3.0 * dry_brush_sigma(detail) + MARGIN +} + /// How far (document units) Neon Glow's glow spreads at `size` (-24..24). pub(super) fn glow_spread(size: f64) -> f64 { 0.25 * size.abs().min(24.0) } +/// Underpainting: broad brush strokes (`brush_size`, 0..40) over a relief `texture` surface +/// (`scaling`, `relief`, `light`, `invert`, as Texturizer uses), blended in as strongly as +/// `texture_coverage` (0..40) says. Coverage is kept. +#[allow(clippy::too_many_arguments)] +pub(super) fn underpainting( + px: &mut [[u8; 4]], + w: usize, + h: usize, + space: &PixelSpace, + brush_size: f64, + texture_coverage: f64, + texture: &Texture, + scaling: f64, + relief: f64, + light: Light, + invert: bool, +) { + if !(brush_size.is_finite() && texture_coverage.is_finite() && space.px.is_finite() && space.px > 0.0) { + return; + } + let brush_size = brush_size.clamp(0.0, 40.0); + let coverage = (texture_coverage / 40.0).clamp(0.0, 1.0) as f32; + let (mut colour, alpha) = planes(px); + let sigma = (0.1 * brush_size.max(0.25)) / space.px; + if sigma > 0.0 { + soften(&mut colour, &alpha, w, h, sigma); + } + for (k, p) in px.iter_mut().enumerate() { + if p[3] == 0 { + continue; + } + let c = [colour[0][k], colour[1][k], colour[2][k]]; + *p = premultiply(c, f32::from(p[3]) / 255.0); + } + if coverage <= 0.0 { + return; + } + let softened = px.to_vec(); + texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert); + if coverage >= 1.0 { + return; + } + let mix = 1.0 - coverage; + for (k, p) in px.iter_mut().enumerate() { + if p[3] == 0 { + continue; + } + let a = f32::from(p[3]) / 255.0; + let tex = straight(*p); + let soft = straight(softened[k]); + let c = [ + (soft[0] * mix + tex[0] * coverage).clamp(0.0, 1.0), + (soft[1] * mix + tex[1] * coverage).clamp(0.0, 1.0), + (soft[2] * mix + tex[2] * coverage).clamp(0.0, 1.0), + ]; + *p = premultiply(c, a); + } +} + +/// How far (document units) Rough Pastels reaches with strokes `len` long. +pub(super) fn rough_pastels_reach(len: f64) -> f64 { + super::brushstrokes::stroke_reach(len).max(super::brushstrokes::TONE_REACH) +} + +/// Rough Pastels: soft diagonal pastel strokes `length` (0..40) long with bristle `detail` (1..20), +/// then the shared Texturizer surface (`texture`, `scaling`, `relief`, `light`, `invert`). +/// Coverage is kept. +#[allow(clippy::too_many_arguments)] +pub(super) fn rough_pastels( + px: &mut [[u8; 4]], + w: usize, + h: usize, + space: &PixelSpace, + length: f64, + detail: f64, + texture: &Texture, + scaling: f64, + relief: f64, + light: Light, + invert: bool, +) { + if !([length, detail, scaling, relief].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) { + return; + } + let length = length.clamp(0.0, 40.0); + let detail = detail.clamp(1.0, 20.0); + if length > 0.0 { + let Some(place) = Place::new(space, w, h) else { return }; + let smear = Smear::new(px, w, h, &place, length.max(1.0)); + let tones = Tones::new(px, w, h, TONE_SOFTNESS / place.px); + let row = (0.18 * smear.len).max(1.0); + let stroke_mix = (0.35 + 0.0225 * detail).clamp(0.35, 0.8) as f32; + let bristle = (0.015 + 0.0035 * detail) as f32; + let src = px.to_vec(); + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let pm = mix(smear.stroke(SALT_PASTEL, q, RIGHT, row), smear.stroke(SALT_PASTEL + 1, q, LEFT, row), 0.5); + let orig = unit(src.get(y * w + x).copied().unwrap_or([0; 4])); + let pm = mix(orig, pm, stroke_mix); + let (mut c, a) = unpremultiply(pm); + if a <= 0.0 { + *p = [0; 4]; + return; + } + let dir = if tones.get(x, y) > 0.52 { RIGHT } else { LEFT }; + let n = bristle * streak(SALT_PASTEL + 2, q, dir, smear.len.max(1.0), row) as f32; + c = c.map(|v| (v + n).clamp(0.0, 1.0)); + *p = premultiply(c, orig[3]); + }); + } + if relief > 0.0 { + texturizer(px, w, h, space, texture, scaling, relief, light, invert); + } +} + /// Neon Glow: the object in dimmed greys, lit by `colour` glowing from its light areas (`size` /// above 0) or from its dark areas (below 0), spread as far as |`size`| (0..24) says and as /// strong as `brightness` (0..50). Coverage is kept. @@ -343,3 +715,162 @@ pub(super) fn neon_glow(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpa *p = premultiply(c, f32::from(p[3]) / 255.0); } } + +/// The tone at `(x, y)` in `tone`, `w` × `h`, or 0 outside. +fn tone_at(tone: &[f32], w: usize, h: usize, x: isize, y: isize) -> f32 { + if x < 0 || y < 0 { + return 0.0; + } + let (x, y) = (x as usize, y as usize); + if x >= w || y >= h { + return 0.0; + } + tone[y * w + x] +} + +/// A unit vector along the image gradient's tangent at `(x, y)`, or horizontal when flat. +fn dry_brush_dir(tone: &[f32], w: usize, h: usize, x: usize, y: usize) -> Vec2 { + let gx = 0.5 * (tone_at(tone, w, h, x as isize + 1, y as isize) - tone_at(tone, w, h, x as isize - 1, y as isize)); + let gy = 0.5 * (tone_at(tone, w, h, x as isize, y as isize + 1) - tone_at(tone, w, h, x as isize, y as isize - 1)); + let v = Vec2::new(-f64::from(gy), f64::from(gx)); + let len = v.hypot(); + if len > 1e-6 { v / len } else { Vec2::new(1.0, 0.0) } +} + +/// Premultiplied RGBA (0..1) at document `q`, bilinearly from `src`. +fn sample_pm(src: &[[f32; 4]], w: usize, h: usize, place: &Place, q: Point) -> [f32; 4] { + let p = place.to_px * q; + if !(p.x.is_finite() && p.y.is_finite()) { + return [0.0; 4]; + } + let (x, y) = (p.x - 0.5, p.y - 0.5); + let (i, j) = (x.floor() as isize, y.floor() as isize); + let (fx, fy) = (x - i as f64, y - j as f64); + let mut acc = [0.0f32; 4]; + for (row, wy) in [(j, 1.0 - fy), (j + 1, fy)] { + let Some(row) = usize::try_from(row).ok().filter(|r| *r < h) else { continue }; + for (col, wx) in [(i, 1.0 - fx), (i + 1, fx)] { + let Some(col) = usize::try_from(col).ok().filter(|c| *c < w) else { continue }; + let k = (wx * wy) as f32; + if let Some(p) = src.get(row * w + col) { + for (a, v) in acc.iter_mut().zip(p) { + *a += *v * k; + } + } + } + } + acc +} + +/// Dry Brush: the object repainted with short strokes along its form, edges darkened. `size` +/// (1..10) sets the stroke length, `detail` (0..10) how much is softened first (10 keeps the most +/// detail), and `texture` (1..3) how strongly edges and bristle grain contrast. Coverage is kept. +pub(super) fn dry_brush(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, size: f64, detail: f64, texture: f64) { + if !([size, detail, texture].iter().all(|v| v.is_finite()) && space.px.is_finite() && space.px > 0.0) { + return; + } + let len = dry_brush_length(size); + let sigma = dry_brush_sigma(detail); + let texture = texture.clamp(1.0, 3.0); + let edge_gain = (0.25 + 0.2 * (texture - 1.0)) as f32; + let bristle = (0.04 + 0.05 * (texture - 1.0)) as f32; + let (mut colour, alpha) = planes(px); + if sigma > 0.0 { + soften(&mut colour, &alpha, w, h, sigma); + } + let at = |k: usize| [0, 1, 2].map(|i| colour[i].get(k).copied().unwrap_or(0.0)); + let tone: Vec = (0..w * h) + .map(|k| { + let c = at(k); + let a = alpha.get(k).copied().unwrap_or(0.0); + (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) * a + }) + .collect(); + let edges = sobel(&tone, w, h); + let src: Vec<[f32; 4]> = (0..w * h) + .map(|k| { + let c = at(k); + let a = alpha.get(k).copied().unwrap_or(0.0); + [c[0] * a, c[1] * a, c[2] * a, a] + }) + .collect(); + let Some(place) = Place::new(space, w, h) else { return }; + const SAMPLES: usize = 5; + let mut out = px.to_vec(); + for (k, p) in out.iter_mut().enumerate() { + if p[3] == 0 { + continue; + } + let (x, y) = (k % w, k / w); + let q = place.at(x, y); + let dir = dry_brush_dir(&tone, w, h, x, y); + let mut acc = [0.0f32; 4]; + for i in 0..SAMPLES { + let t = (i as f64 / (SAMPLES - 1) as f64 - 0.5) * len; + let s = sample_pm(&src, w, h, &place, q + dir * t); + for (a, v) in acc.iter_mut().zip(s) { + *a += v; + } + } + for a in &mut acc { + *a /= SAMPLES as f32; + } + let a = acc[3].clamp(0.0, 1.0); + let c = if a > 1e-6 { [0, 1, 2].map(|i| (acc[i] / a).clamp(0.0, 1.0)) } else { straight(*p) }; + let edge = edges[k].min(1.0); + let dark = (edge_gain * edge).clamp(0.0, 0.75); + let grain = bristle * (2.0 * value_noise(SALT_DRY, q.x / (5.0 * space.px), q.y / (5.0 * space.px)) - 1.0) as f32; + let c = c.map(|v| (v * (1.0 - dark) + grain).clamp(0.0, 1.0)); + *p = premultiply(c, f32::from(p[3]) / 255.0); + } + px.copy_from_slice(&out); +} + +const SALT_KNIFE: u64 = 208; + +/// How far Palette Knife reaches with strokes `stroke_size` long and Softness `softness` (0..10). +pub(super) fn palette_knife_reach(stroke_size: f64, softness: f64) -> f64 { + stroke_reach(stroke_size) + 3.0 * 0.35 * softness.clamp(0.0, 10.0) + 1.0 +} + +/// Artistic › Palette Knife: the object repainted with short, angled knife strokes that smear its +/// colours; `stroke_size` (1..50) sets their length, `stroke_detail` (1..3) keeps more of the +/// original where it's higher, and `softness` (0..10) softens the strokes. Coverage is kept. +pub(super) fn palette_knife(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, stroke_size: f64, stroke_detail: f64, softness: f64) { + let Some(place) = Place::new(space, w, h) else { return }; + if !(stroke_size.is_finite() && stroke_detail.is_finite() && softness.is_finite()) { + return; + } + let len = stroke_size.clamp(1.0, 50.0); + let keep = ((stroke_detail.clamp(1.0, 3.0) - 1.0) / 2.0) as f32; + let smear = Smear::new(px, w, h, &place, len); + let row = (0.25 * smear.len).max(1.0); + let cell = len * 0.35; + let src = px.to_vec(); + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let angle = TAU * value_noise(SALT_KNIFE, q.x / cell, q.y / cell); + let dir = Vec2::new(angle.cos(), angle.sin()); + let pm = smear.stroke(SALT_KNIFE + 1, q, dir, row); + let orig = unit(src.get(y * w + x).copied().unwrap_or([0; 4])); + let coverage = orig[3]; + let (c, _) = unpremultiply(mix(orig, pm, 1.0 - keep)); + *p = premultiply(c, coverage); + }); + let sigma = 0.35 * softness.clamp(0.0, 10.0) / place.px; + if sigma > 0.0 { + let (mut colour, alpha) = planes(px); + let cover = alpha.clone(); + soften(&mut colour, &alpha, w, h, sigma); + for (k, p) in px.iter_mut().enumerate() { + if cover[k] == 0.0 { + continue; + } + let c = [colour[0][k], colour[1][k], colour[2][k]]; + *p = premultiply(c, cover[k]); + } + } +} diff --git a/crates/effects/src/pixel/brushstrokes.rs b/crates/effects/src/pixel/brushstrokes.rs index d496f294e..100d4e249 100644 --- a/crates/effects/src/pixel/brushstrokes.rs +++ b/crates/effects/src/pixel/brushstrokes.rs @@ -231,7 +231,7 @@ fn frame(q: Point, dir: Vec2) -> (f64, f64) { /// How far along `dir` the stroke through `q` is shifted: strokes `len` long lie in rows `width` /// apart across `dir`, each row starting at random, each stroke shifted by up to [`JITTER`] × /// `len`. -fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 { +pub(super) fn stroke_shift(salt: u64, q: Point, dir: Vec2, len: f64, width: f64) -> f64 { if !(len > 0.0 && width > 0.0) { return 0.0; } diff --git a/crates/effects/src/pixel/mod.rs b/crates/effects/src/pixel/mod.rs index 051278eb1..8885c9def 100644 --- a/crates/effects/src/pixel/mod.rs +++ b/crates/effects/src/pixel/mod.rs @@ -1,9 +1,9 @@ -//! Photoshop-style raster effects (Effect › Artistic › Cutout, Film Grain, Neon Glow, Plastic Wrap and -//! Poster Edges, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes, +//! Photoshop-style raster effects (Effect › Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Plastic Wrap and +//! Poster Edges, Rough Pastels, Smudge Stick, Sponge and Watercolor, Blur › Radial Blur and Smart Blur, Brush Strokes › Accented Edges, Angled Strokes, //! Crosshatch, Dark Strokes, Ink Outlines, Spatter, Sprayed Strokes and Sumi-e, Distort › Diffuse //! Glow, Glass and Ocean Ripple, Pixelate › Color Halftone, Crystallize, Mezzotint and Pointillize, -//! Sharpen › Unsharp Mask, Sketch › Chalk & Charcoal, Graphic Pen, Halftone Pattern, Note Paper, Plaster and -//! Reticulation, Stylize › +//! Sharpen › Unsharp Mask, Sketch › Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Graphic Pen, Halftone Pattern, +//! Note Paper, Photocopy, Plaster, Reticulation, Stamp, Torn Edges and Water Paper, Stylize › //! Glowing Edges, Texture //! › Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass and Texturizer, and Video › //! De-Interlace and NTSC Colors): filters over premultiplied RGBA8 pixels. @@ -34,23 +34,31 @@ use vectorcraft_geom::{Affine, Point, Rect}; use crate::util::{flag, num, text}; +pub use artistic::{PAINT_DAUB_BRUSH_TYPES, PaintDaubBrush}; pub use blur::{SMART_BLUR_MODES, SmartBlurMode}; pub use brushstrokes::{STROKE_DIRECTIONS, StrokeDirection}; pub use distort::{GLASS_TEXTURES, GLASS_TEXTURES_LOADED, GlassTexture}; pub use loaded::{LOADED, LoadedTexture, TEXTURE_IMAGE, texture}; pub use pixelate::{MEZZOTINT_TYPES, Mezzotint}; pub use sketch::{HALFTONE_TYPES, HalftonePattern}; -pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, Texture}; +pub use texture::{GRAIN_TYPES, Grain, LIGHT_DIRECTIONS, Light, TEXTURES, TEXTURES_LOADED, Texture}; /// The Photoshop-style effect ids (all raster effects, see [`crate::is_raster`]). -pub const PIXEL_EFFECTS: [&str; 46] = [ +pub const PIXEL_EFFECTS: [&str; 56] = [ + "artistic.coloredPencil", "artistic.cutout", + "artistic.dryBrush", "artistic.filmGrain", + "artistic.fresco", "artistic.neonGlow", + "artistic.paintDaubs", + "artistic.paletteKnife", "artistic.plasticWrap", "artistic.posterEdges", + "artistic.roughPastels", "artistic.smudgeStick", "artistic.sponge", + "artistic.underpainting", "artistic.watercolor", "blur.radial", "blur.smart", @@ -74,6 +82,7 @@ pub const PIXEL_EFFECTS: [&str; 46] = [ "sketch.chalkCharcoal", "sketch.charcoal", "sketch.chrome", + "sketch.conteCrayon", "sketch.graphicPen", "sketch.halftonePattern", "sketch.notePaper", @@ -81,6 +90,8 @@ pub const PIXEL_EFFECTS: [&str; 46] = [ "sketch.plaster", "sketch.reticulation", "sketch.stamp", + "sketch.tornEdges", + "sketch.waterPaper", "stylize.glowingEdges", "texture.craquelure", "texture.grain", @@ -96,12 +107,22 @@ pub const PIXEL_EFFECTS: [&str; 46] = [ /// are in document units. #[derive(Clone, Debug, PartialEq)] pub enum PixelFx { + /// Artistic › Colored Pencil: coloured strokes `width` (1..24) apart, as heavy as `pressure` + /// (0..15), on paper lifted by `paper` (0..50). + ColoredPencil { width: f64, pressure: f64, paper: f64 }, /// Artistic › Cutout: flat pieces of colour in `levels` (2..8) steps per channel, their /// edges as simple as `simplicity` (0..10) and as faithful as `fidelity` (1..3) say. Cutout { levels: f64, simplicity: f64, fidelity: f64 }, + /// Artistic › Dry Brush: short strokes along the form with darkened edges; `size` (1..10) is the + /// brush length, `detail` (0..10) how much detail is kept, `texture` (1..3) the edge and bristle + /// contrast. + DryBrush { size: f64, detail: f64, texture: f64 }, /// Artistic › Film Grain: monochrome grain in shadows and midtones, a wider bright /// highlight area (0..20), and tonal intensity (0..10), preserving coverage. FilmGrain { grain: f64, highlight: f64, intensity: f64 }, + /// Artistic › Fresco: coarse brush dabs on plaster; `brush_size` (1..10), `brush_detail` + /// (0..10) and `texture` (1..3). + Fresco { brush_size: f64, brush_detail: f64, texture: f64 }, /// Artistic › Smudge Stick: the dark areas smudged along short diagonal strokes `stroke` /// (0..10) long, the light areas (widened by `highlight`, 0..20) smudged longer and lightened /// by up to `intensity` (0..10). @@ -116,12 +137,27 @@ pub enum PixelFx { /// light areas (`size` above 0) or the dark ones (below 0), spread by |`size`| (-24..24), as /// strong as `brightness` (0..50). NeonGlow { size: f64, brightness: f64, colour: [f32; 3] }, + /// Artistic › Paint Daubs: dabs `size` (1..50) points apart, as sharp as `sharpness` (0..40), + /// with brush `kind`. + PaintDaubs { size: f64, sharpness: f64, kind: PaintDaubBrush }, + /// Artistic › Palette Knife: knife strokes `stroke_size` (1..50) long smear the colours; + /// `stroke_detail` (1..3) keeps more original detail when higher; `softness` (0..10) softens + /// the strokes. + PaletteKnife { stroke_size: f64, stroke_detail: f64, softness: f64 }, /// Artistic › Plastic Wrap: specular highlights as strong as `highlight` (0..20) on a relief /// wrinkled by `detail` (0..15) and softened by `smoothness` (1..15). PlasticWrap { highlight: f64, detail: f64, smoothness: f64 }, /// Artistic › Poster Edges: colours posterized to `posterization` + 2 levels (0..6) with black /// lines on the edges, as thick as `thickness` (0..10) and as dark as `intensity` (0..10). PosterEdges { thickness: f64, intensity: f64, posterization: f64 }, + /// Artistic › Underpainting: colours softened as widely as `brush_size` (0..40), then a + /// `texture` surface (`scaling`, `relief`, `light`, `invert`) blended in as strongly as + /// `texture_coverage` (0..40) says. + Underpainting { brush_size: f64, texture_coverage: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool }, + /// Artistic › Rough Pastels: diagonal pastel strokes `length` (0..40) long, bristle detail + /// (1..20), then a [`Texture`] surface scaled by `scaling` (0.5..2), in relief as strong as + /// `relief` (0..50) and lit from `light`; `invert` turns the surface's heights over. + RoughPastels { length: f64, detail: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool }, /// Blur › Radial Blur around the object's centre: along arcs (spin, an arc of `amount`°) or /// along rays (zoom, the content scaled by up to about ±`amount`/2 %), averaging 2^`passes` samples. RadialBlur { amount: f64, zoom: bool, passes: u32 }, @@ -175,6 +211,11 @@ pub enum PixelFx { ChalkCharcoal { chalk: f64, charcoal: f64, pressure: f64 }, /// Sketch › Chrome: polished chrome relief with `detail` (0..10) and `smoothness` (0..10). Chrome { detail: f64, smoothness: f64 }, + /// Sketch › Conté Crayon: dark areas in a black crayon, light areas in a white one, on mid-grey + /// paper; `foreground` and `background` (0..20) set how far each crayon reaches into the + /// midtones, and the paper is a `texture` surface scaled by `scaling` (0.5..2), in relief as + /// strong as `relief` (0..50), lit from `light`, its heights turned over when `invert`. + ConteCrayon { foreground: f64, background: f64, texture: Texture, scaling: f64, relief: f64, light: Light, invert: bool }, /// Sketch › Graphic Pen: fine linear ink strokes along `dir`, each `length` (1..15) points long /// on white paper; `balance` (0..100) sets how much of the tone range becomes black ink. GraphicPen { length: f64, balance: f64, direction: StrokeDirection }, @@ -202,6 +243,14 @@ pub enum PixelFx { /// Sketch › Reticulation: black ink on white paper through a high-contrast film-grain screen; /// `density` (0..50), `foreground` and `background` (0..50) pull tones before the grain. Reticulation { density: f64, foreground: f64, background: f64 }, + /// Sketch › Torn Edges: high-contrast black and white with ragged edges; `image_balance` + /// (0..50) shifts ink versus paper, `smoothness` (0..15) softens tones and fraying, `contrast` + /// (0..20) hardens the split. + TornEdges { image_balance: f64, smoothness: f64, contrast: f64 }, + /// Sketch › Water Paper: colours flow on damp fibrous paper; `fiber_length` (3..50) sets the + /// fibers and daubs, `brightness` and `contrast` (0..100) adjust the wash. Ranges and defaults + /// are unverified VectorCraft choices (helpx documents the controls only). + WaterPaper { fiber_length: f64, brightness: f64, contrast: f64 }, /// Stylize › Glowing Edges: bright coloured Sobel outlines on black. GlowingEdges { width: f64, brightness: f64, smoothness: f64 }, /// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels @@ -282,16 +331,31 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { levels.iter().find(|(name, _)| name.eq_ignore_ascii_case(q)).map_or(default, |(_, v)| *v) }; Some(match id { + "artistic.coloredPencil" => PixelFx::ColoredPencil { + width: num(p, "pencilWidth", 5.0).clamp(1.0, 24.0), + pressure: num(p, "strokePressure", 5.0).clamp(0.0, 15.0), + paper: num(p, "paperBrightness", 25.0).clamp(0.0, 50.0), + }, "artistic.cutout" => PixelFx::Cutout { levels: num(p, "levels", 4.0).round().clamp(2.0, 8.0), simplicity: num(p, "edgeSimplicity", 4.0).clamp(0.0, 10.0), fidelity: num(p, "edgeFidelity", 2.0).round().clamp(1.0, 3.0), }, + "artistic.dryBrush" => PixelFx::DryBrush { + size: num(p, "brushSize", 2.0).clamp(1.0, 10.0), + detail: num(p, "brushDetail", 10.0).clamp(0.0, 10.0), + texture: num(p, "texture", 1.0).round().clamp(1.0, 3.0), + }, "artistic.neonGlow" => PixelFx::NeonGlow { size: num(p, "glowSize", 5.0).clamp(-24.0, 24.0), brightness: num(p, "glowBrightness", 15.0).clamp(0.0, 50.0), colour: crate::raster::color_param(p, vectorcraft_doc::color::Color::rgb(0.3, 0.65, 1.0)).to_rgb(), }, + "artistic.paletteKnife" => PixelFx::PaletteKnife { + stroke_size: num(p, "strokeSize", 10.0).clamp(1.0, 50.0), + stroke_detail: num(p, "strokeDetail", 2.0).clamp(1.0, 3.0), + softness: num(p, "softness", 3.0).clamp(0.0, 10.0), + }, "artistic.posterEdges" => PixelFx::PosterEdges { thickness: num(p, "edgeThickness", 2.0).clamp(0.0, 10.0), intensity: num(p, "edgeIntensity", 1.0).clamp(0.0, 10.0), @@ -302,6 +366,29 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { highlight: num(p, "highlightArea", 0.0).clamp(0.0, 20.0), intensity: num(p, "intensity", 10.0).clamp(0.0, 10.0), }, + "artistic.underpainting" => PixelFx::Underpainting { + brush_size: num(p, "brushSize", 6.0).clamp(0.0, 40.0), + texture_coverage: num(p, "textureCoverage", 16.0).clamp(0.0, 40.0), + texture: Texture::parse(text(p, "texture", "canvas")), + scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0, + relief: num(p, "relief", 4.0).clamp(0.0, 50.0), + light: Light::parse(text(p, "lightDirection", "top")), + invert: flag(p, "invert", false), + }, + "artistic.roughPastels" => PixelFx::RoughPastels { + length: num(p, "strokeLength", 6.0).clamp(0.0, 40.0), + detail: num(p, "strokeDetail", 10.0).clamp(1.0, 20.0), + texture: Texture::parse(text(p, "texture", "canvas")), + scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0, + relief: num(p, "relief", 4.0).clamp(0.0, 50.0), + light: Light::parse(text(p, "lightDirection", "top")), + invert: flag(p, "invert", false), + }, + "artistic.fresco" => PixelFx::Fresco { + brush_size: num(p, "brushSize", 2.0).clamp(1.0, 10.0), + brush_detail: num(p, "brushDetail", 10.0).clamp(0.0, 10.0), + texture: num(p, "texture", 1.0).round().clamp(1.0, 3.0), + }, "artistic.smudgeStick" => PixelFx::SmudgeStick { stroke: num(p, "strokeLength", 2.0).clamp(0.0, 10.0), highlight: num(p, "highlightArea", 12.0).clamp(0.0, 20.0), @@ -317,6 +404,11 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { shadow: num(p, "shadowIntensity", 1.0).clamp(0.0, 10.0), texture: num(p, "texture", 1.0).clamp(1.0, 3.0).round() as u32, }, + "artistic.paintDaubs" => PixelFx::PaintDaubs { + size: num(p, "brushSize", 10.0).clamp(1.0, 50.0), + sharpness: num(p, "sharpness", 25.0).clamp(0.0, 40.0), + kind: PaintDaubBrush::parse(text(p, "brushType", "simple")), + }, "artistic.plasticWrap" => PixelFx::PlasticWrap { highlight: num(p, "highlightStrength", 20.0).clamp(0.0, 20.0), detail: num(p, "detail", 12.0).clamp(0.0, 15.0), @@ -397,6 +489,15 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { pressure: num(p, "strokePressure", 2.0).clamp(0.0, 5.0), }, "sketch.chrome" => PixelFx::Chrome { detail: num(p, "detail", 4.0).clamp(0.0, 10.0), smoothness: num(p, "smoothness", 4.0).clamp(0.0, 10.0) }, + "sketch.conteCrayon" => PixelFx::ConteCrayon { + foreground: num(p, "foregroundLevel", 12.0).clamp(0.0, 20.0), + background: num(p, "backgroundLevel", 10.0).clamp(0.0, 20.0), + texture: Texture::parse(text(p, "texture", "canvas")), + scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0, + relief: num(p, "relief", 4.0).clamp(0.0, 50.0), + light: Light::parse(text(p, "lightDirection", "top")), + invert: flag(p, "invert", false), + }, "sketch.graphicPen" => PixelFx::GraphicPen { length: num(p, "strokeLength", 5.0).clamp(1.0, 15.0), balance: num(p, "lightDarkBalance", 50.0).clamp(0.0, 100.0), @@ -438,6 +539,16 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { foreground: num(p, "foregroundLevel", 6.0).clamp(0.0, 50.0), background: num(p, "backgroundLevel", 6.0).clamp(0.0, 50.0), }, + "sketch.tornEdges" => PixelFx::TornEdges { + image_balance: num(p, "imageBalance", 25.0).clamp(0.0, 50.0), + smoothness: num(p, "smoothness", 11.0).clamp(0.0, 15.0), + contrast: num(p, "contrast", 17.0).clamp(0.0, 20.0), + }, + "sketch.waterPaper" => PixelFx::WaterPaper { + fiber_length: num(p, "fiberLength", 15.0).clamp(3.0, 50.0), + brightness: num(p, "brightness", 50.0).clamp(0.0, 100.0), + contrast: num(p, "contrast", 50.0).clamp(0.0, 100.0), + }, "stylize.glowingEdges" => PixelFx::GlowingEdges { width: num(p, "edgeWidth", 2.0).clamp(1.0, 14.0), brightness: num(p, "edgeBrightness", 6.0).clamp(0.0, 20.0), @@ -475,7 +586,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option { light: num(p, "lightIntensity", 3.0).clamp(0.0, 10.0), }, "texture.texturizer" => PixelFx::Texturizer { - texture: Texture::parse(text(p, "texture", "canvas")), + texture: Texture::parse_with(text(p, "texture", "canvas"), p.get(TEXTURE_IMAGE).and_then(Value::as_str)), scaling: num(p, "scaling", 100.0).clamp(50.0, 200.0) / 100.0, relief: num(p, "relief", 4.0).clamp(0.0, 50.0), light: Light::parse(text(p, "lightDirection", "top")), @@ -503,9 +614,11 @@ impl PixelFx { PixelFx::SmartBlur { radius, .. } => radius, PixelFx::AccentedEdges { .. } => 0.0, // Strokes reach out of the object as far as they reach into it. - PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } | PixelFx::InkOutlines { length, .. } => { - brushstrokes::stroke_reach(length) - } + PixelFx::PaletteKnife { stroke_size, softness, .. } => artistic::palette_knife_reach(stroke_size, softness), + PixelFx::AngledStrokes { length, .. } + | PixelFx::GraphicPen { length, .. } + | PixelFx::InkOutlines { length, .. } + | PixelFx::RoughPastels { length, .. } => brushstrokes::stroke_reach(length), PixelFx::Crosshatch { length, .. } => brushstrokes::stroke_reach(length) + brushstrokes::rough(length), PixelFx::DarkStrokes { .. } => brushstrokes::stroke_reach(brushstrokes::LIGHT_LENGTH), PixelFx::Spatter { radius, .. } => brushstrokes::spatter_shift(radius), @@ -519,19 +632,27 @@ impl PixelFx { PixelFx::UnsharpMask { .. } | PixelFx::ChalkCharcoal { .. } | PixelFx::Chrome { .. } + | PixelFx::ConteCrayon { .. } | PixelFx::HalftonePattern { .. } | PixelFx::NotePaper { .. } | PixelFx::Plaster { .. } - | PixelFx::Reticulation { .. } => 0.0, + | PixelFx::Reticulation { .. } + | PixelFx::TornEdges { .. } => 0.0, PixelFx::BasRelief { .. } | PixelFx::Charcoal { .. } | PixelFx::Photocopy { .. } | PixelFx::Stamp { .. } => 0.0, + PixelFx::WaterPaper { fiber_length, .. } => sketch::water_paper_shift(fiber_length), PixelFx::GlowingEdges { width, smoothness, .. } => width.max(smoothness * 3.0), + PixelFx::PaintDaubs { size, .. } => artistic::paint_daub_radius(size), // These three keep the object's coverage. PixelFx::SmudgeStick { .. } | PixelFx::Sponge { .. } | PixelFx::Watercolor { .. } => 0.0, PixelFx::FilmGrain { .. } + | PixelFx::ColoredPencil { .. } + | PixelFx::Fresco { .. } | PixelFx::Cutout { .. } + | PixelFx::DryBrush { .. } | PixelFx::NeonGlow { .. } | PixelFx::PlasticWrap { .. } | PixelFx::PosterEdges { .. } + | PixelFx::Underpainting { .. } | PixelFx::ColorHalftone { .. } | PixelFx::Mezzotint { .. } | PixelFx::Craquelure { .. } @@ -559,9 +680,11 @@ impl PixelFx { PixelFx::Watercolor { detail, .. } => Some(paint::watercolor_reach(detail)), PixelFx::AccentedEdges { width, smoothness, .. } => Some(brushstrokes::accent_reach(width, smoothness)), // The strokes, and the softened tones that choose them. + PixelFx::PaletteKnife { stroke_size, softness, .. } => Some(artistic::palette_knife_reach(stroke_size, softness)), PixelFx::AngledStrokes { length, .. } | PixelFx::GraphicPen { length, .. } => { Some(brushstrokes::stroke_reach(length).max(brushstrokes::TONE_REACH)) } + PixelFx::RoughPastels { length, .. } => Some(artistic::rough_pastels_reach(length)), PixelFx::Crosshatch { length, .. } => { Some((brushstrokes::stroke_reach(length) + brushstrokes::rough(length)).max(brushstrokes::TONE_REACH)) } @@ -582,21 +705,30 @@ impl PixelFx { // The cells around the pixel's, and the cells around theirs. PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius), PixelFx::Crystallize { cell } => Some(1.5 * cell), + PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)), PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH), PixelFx::Chrome { smoothness, .. } => Some(sketch::chrome_reach(smoothness)), + PixelFx::ConteCrayon { .. } => Some(sketch::CONTE_REACH), PixelFx::FilmGrain { .. } | PixelFx::Mezzotint { .. } | PixelFx::HalftonePattern { .. } | PixelFx::NotePaper { .. } | PixelFx::Reticulation { .. } => Some(0.0), + PixelFx::Fresco { brush_size, brush_detail, .. } => Some(artistic::fresco_reach(brush_size, brush_detail)), + PixelFx::DryBrush { size, detail, .. } => Some(artistic::dry_brush_reach(size, detail)), PixelFx::Plaster { smoothness, .. } => Some(sketch::plaster_reach(smoothness)), - // Cutout's palette comes from the whole object. - PixelFx::Cutout { .. } => None, + PixelFx::PaintDaubs { size, .. } => Some(artistic::paint_daub_radius(size) + size * 0.15), + PixelFx::WaterPaper { fiber_length, .. } => { + Some(sketch::water_paper_shift(fiber_length) + 3.0 * (0.06 * fiber_length.clamp(3.0, 50.0)).max(0.35)) + } + // Cutout's palette and Torn Edges' peak-normalised edges come from the whole object. + PixelFx::Cutout { .. } | PixelFx::TornEdges { .. } => None, // The softened tones around the pixel. PixelFx::NeonGlow { size, .. } => Some(3.0 * artistic::glow_spread(size)), PixelFx::PlasticWrap { smoothness, .. } => Some(artistic::plastic_reach(smoothness)), // The edges' lines, spread round them. PixelFx::PosterEdges { thickness, .. } => Some(3.0 * (0.3 + 0.25 * thickness) + 1.0), + PixelFx::Underpainting { brush_size, .. } => Some(3.0 * (0.1 * brush_size.max(0.25)) + 1.0), // The dots' points, and the softened colour around them. PixelFx::Pointillize { cell } => Some(2.5 * cell), // The softened tones around the pixel. @@ -617,16 +749,29 @@ impl PixelFx { let Some(px) = pixels(data, w, h) else { return }; let to_px = |len: f64| len / space.px.max(1e-9); match *self { + PixelFx::ColoredPencil { width, pressure, paper } => artistic::colored_pencil(px, w, h, space, width, pressure, paper), PixelFx::Cutout { levels, simplicity, fidelity } => artistic::cutout(px, w, h, space, levels, simplicity, fidelity), + PixelFx::DryBrush { size, detail, texture } => artistic::dry_brush(px, w, h, space, size, detail, texture), PixelFx::FilmGrain { grain, highlight, intensity } => artistic::film_grain(px, w, h, space, grain, highlight, intensity), + PixelFx::Fresco { brush_size, brush_detail, texture } => artistic::fresco(px, w, h, space, brush_size, brush_detail, texture), PixelFx::SmudgeStick { stroke, highlight, intensity } => paint::smudge_stick(px, w, h, space, stroke, highlight, intensity), PixelFx::Sponge { brush, definition, smoothness } => paint::sponge(px, w, h, space, brush, definition, smoothness), PixelFx::Watercolor { detail, shadow, texture } => paint::watercolor(px, w, h, space, detail, shadow, texture), + PixelFx::PaintDaubs { size, sharpness, kind } => artistic::paint_daubs(px, w, h, space, size, sharpness, kind), PixelFx::PlasticWrap { highlight, detail, smoothness } => artistic::plastic_wrap(px, w, h, space, highlight, detail, smoothness), PixelFx::NeonGlow { size, brightness, colour } => artistic::neon_glow(px, w, h, space, size, brightness, colour), + PixelFx::PaletteKnife { stroke_size, stroke_detail, softness } => { + artistic::palette_knife(px, w, h, space, stroke_size, stroke_detail, softness) + } PixelFx::PosterEdges { thickness, intensity, posterization } => { artistic::poster_edges(px, w, h, space, thickness, intensity, posterization) } + PixelFx::Underpainting { brush_size, texture_coverage, ref texture, scaling, relief, light, invert } => { + artistic::underpainting(px, w, h, space, brush_size, texture_coverage, texture, scaling, relief, light, invert) + } + PixelFx::RoughPastels { length, detail, ref texture, scaling, relief, light, invert } => { + artistic::rough_pastels(px, w, h, space, length, detail, texture, scaling, relief, light, invert) + } PixelFx::RadialBlur { amount, zoom, passes } => blur::radial(px, w, h, space, amount, zoom, passes), PixelFx::SmartBlur { radius, threshold, samples, mode: SmartBlurMode::Normal } => { blur::smart(px, w, h, to_px(radius), threshold, samples) @@ -650,6 +795,9 @@ impl PixelFx { PixelFx::UnsharpMask { amount, radius, threshold } => sharpen::unsharp(px, w, h, amount, to_px(radius), threshold), PixelFx::ChalkCharcoal { chalk, charcoal, pressure } => sketch::chalk_charcoal(px, w, h, space, chalk, charcoal, pressure), PixelFx::Chrome { detail, smoothness } => sketch::chrome(px, w, h, space, detail, smoothness), + PixelFx::ConteCrayon { foreground, background, ref texture, scaling, relief, light, invert } => { + sketch::conte_crayon(px, w, h, space, foreground, background, texture, scaling, relief, light, invert) + } PixelFx::GraphicPen { length, balance, direction } => brushstrokes::graphic_pen(px, w, h, space, length, balance, direction), PixelFx::HalftonePattern { size, contrast, kind } => sketch::halftone_pattern(px, w, h, space, size, contrast, kind), PixelFx::NotePaper { balance, graininess, relief } => sketch::note_paper(px, w, h, space, balance, graininess, relief), @@ -659,6 +807,8 @@ impl PixelFx { PixelFx::Stamp { balance, smoothness } => sketch_ink::stamp(px, w, h, space, balance, smoothness), PixelFx::Plaster { image_balance, smoothness, light } => sketch::plaster(px, w, h, space, image_balance, smoothness, light), PixelFx::Reticulation { density, foreground, background } => sketch::reticulation(px, w, h, space, density, foreground, background), + PixelFx::TornEdges { image_balance, smoothness, contrast } => sketch::torn_edges(px, w, h, space, image_balance, smoothness, contrast), + PixelFx::WaterPaper { fiber_length, brightness, contrast } => sketch::water_paper(px, w, h, space, fiber_length, brightness, contrast), PixelFx::GlowingEdges { width, brightness, smoothness } => edges::glow(px, w, h, to_px(width), brightness, to_px(smoothness)), PixelFx::ColorHalftone { max_radius, angles } => pixelate::color_halftone(px, w, h, space, max_radius, angles), PixelFx::Crystallize { cell } => pixelate::crystallize(px, w, h, space, cell), @@ -669,7 +819,7 @@ impl PixelFx { PixelFx::MosaicTiles { tile, grout, lighten } => texture::mosaic_tiles(px, w, h, space, tile, grout, lighten), PixelFx::Patchwork { square, relief } => texture::patchwork(px, w, h, space, square, relief), PixelFx::StainedGlass { cell, border, light } => texture::stained_glass(px, w, h, space, cell, border, light), - PixelFx::Texturizer { texture, scaling, relief, light, invert } => { + PixelFx::Texturizer { ref texture, scaling, relief, light, invert } => { texture::texturizer(px, w, h, space, texture, scaling, relief, light, invert) } PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate), @@ -782,22 +932,41 @@ pub fn dilate_plane(a: &mut Vec, w: usize, h: usize, radius: f64) { /// One horizontal pass of [`dilate_plane`] over the rows of `w` values of a plane: the largest /// value within `whole`, blended by `frac` towards the largest within `whole + 1`. fn max_plane(src: &[f32], dst: &mut [f32], w: usize, whole: usize, frac: f32) { + let (mut inner, mut ring) = (vec![0.0f32; w], vec![0.0f32; w]); for (row, out) in src.chunks_exact(w).zip(dst.chunks_exact_mut(w)) { - for (x, o) in out.iter_mut().enumerate() { - let lo = x.saturating_sub(whole + 1); - let hi = (x + whole + 1).min(w - 1); - let (mut inner, mut ring) = (row[x], row[x]); - for (j, v) in row[lo..=hi].iter().enumerate() { - if (lo + j).abs_diff(x) <= whole { - inner = inner.max(*v); - } - ring = ring.max(*v); - } - *o = inner + (ring - inner) * frac; + window_max(row, whole, &mut inner); + window_max(row, whole + 1, &mut ring); + for ((o, i), r) in out.iter_mut().zip(&inner).zip(&ring) { + *o = i + (r - i) * frac; } } } +/// For each `x`, the largest of `row[x - radius..=x + radius]` (cut at the row's ends) into `out`, +/// in time linear in the row (a sliding window, not a scan of the window for each `x`). +fn window_max(row: &[f32], radius: usize, out: &mut [f32]) { + use std::collections::VecDeque; + // Indices of the window's values that can still be its largest, falling in value. + let mut keep: VecDeque = VecDeque::new(); + let mut next = 0; + for (x, o) in out.iter_mut().enumerate().take(row.len()) { + let hi = x.saturating_add(radius).min(row.len().saturating_sub(1)); + while next <= hi { + let v = row[next]; + while keep.back().is_some_and(|&b| row[b] <= v) { + keep.pop_back(); + } + keep.push_back(next); + next += 1; + } + let lo = x.saturating_sub(radius); + while keep.front().is_some_and(|&f| f < lo) { + keep.pop_front(); + } + *o = keep.front().map_or(row[x], |&f| row[f]); + } +} + /// Premultiplied RGBA at 16 bits (×257): blurs and averages that keep their tones. pub(super) type Px16 = [u16; 4]; @@ -911,3 +1080,22 @@ mod tests; mod tests_paint; #[cfg(test)] mod tests_sketch; + +#[cfg(test)] +mod window_max_tests { + use super::window_max; + + #[test] + fn the_sliding_maximum_matches_a_scan_of_each_window() { + let row: Vec = (0..40u32).map(|i| ((i * 7919) % 31) as f32).collect(); + for radius in [0, 1, 2, 5, 39, 100] { + let mut out = vec![0.0; row.len()]; + window_max(&row, radius, &mut out); + for (x, got) in out.iter().enumerate() { + let (lo, hi) = (x.saturating_sub(radius), (x + radius).min(row.len() - 1)); + let want = row[lo..=hi].iter().copied().fold(f32::MIN, f32::max); + assert_eq!(*got, want, "radius {radius} at {x}"); + } + } + } +} diff --git a/crates/effects/src/pixel/sketch.rs b/crates/effects/src/pixel/sketch.rs index 9f04b1277..1085e2120 100644 --- a/crates/effects/src/pixel/sketch.rs +++ b/crates/effects/src/pixel/sketch.rs @@ -1,20 +1,28 @@ -//! Effect › Sketch (Chalk & Charcoal, Chrome, Halftone Pattern, Note Paper, Plaster and Reticulation; Graphic Pen lives in +//! Effect › Sketch (Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Conté Crayon, Halftone Pattern, Note Paper, Photocopy, +//! Plaster, Reticulation, Stamp, Torn Edges and Water Paper; Graphic Pen lives in //! [`super::brushstrokes`]). Original filters built from public behaviour documentation: //! (Sketch effects redraw in -//! black and white) and the public Photoshop Elements Artistic filters guide. Visual -//! approximations, not proprietary reproductions. +//! black and white; Water Paper flows colours on damp fibrous paper; Conté Crayon draws dark areas +//! in black and light areas in white) and the public Photoshop Elements Sketch filters guide +//! (: Conté Crayon's +//! foreground and background levels and texture options). Visual approximations, not proprietary +//! reproductions. use std::f64::consts::{FRAC_1_SQRT_2, TAU}; -use vectorcraft_geom::Vec2; +use vectorcraft_geom::{Point, Vec2}; +use super::distort::displace; use super::edges::sobel; use super::pixelate::{Place, cell, rand, value_noise}; -use super::texture::{Light, Tones, luma, premultiply, smooth_noise, straight}; +use super::texture::{Light, Texture, Tones, luma, premultiply, smooth_noise, straight, texturizer}; use super::{PixelSpace, blur_plane, pick}; const SALT_CHALK: u64 = 216; const SALT_CHROME: u64 = 0xC40_4D45; +const SALT_CONTE: u64 = 0xC047E; +const SALT_TORN: u64 = 0x746f726e5f6564; +const SALT_WATER: u64 = 208; const EDGE_SOFT: f64 = 0.8; const TONE_SOFT: f64 = 1.0; /// Light from the top left (document axes, y down), as on polished relief surfaces. @@ -357,3 +365,194 @@ pub(super) fn reticulation(px: &mut [[u8; 4]], w: usize, h: usize, space: &Pixel *p = premultiply([(1.0 - ink) as f32; 3], f32::from(p[3]) / 255.0); }); } + +/// How far (document units) Conté Crayon samples the softened tones that choose its crayons. +pub(super) const CONTE_REACH: f64 = 3.0 * TONE_SOFT + 1.0; + +/// How much of a crayon is laid down where `amount` (0..1: how dark, for the dark crayon, or how +/// light, for the light one) meets its `level` (0..1) and `grain` (0..1, the paper's tooth and the +/// stroke): at level 0 only the deepest tones take the crayon, at level 1 it reaches past the +/// middle tone; the grain breaks its edge. +fn crayon(amount: f64, level: f64, grain: f64) -> f64 { + ramp((amount - (0.9 - 0.45 * level) + 0.4 * (grain - 0.5)) / 0.12 + 0.5) +} + +/// Conté Crayon: the dark areas drawn in a dense black crayon and the light areas in a pure white +/// one, on mid-grey paper whose tooth (`texture` scaled by `scaling` (0.5..2), its heights turned +/// over when `invert`) and diagonal strokes break the crayons. `foreground` and `background` +/// (0..20) set how far the black and the white crayon reach into the midtones; the paper then +/// stands in relief as strong as `relief` (0..50), lit from `light`, as Texturizer draws it. +/// Coverage is kept. +#[allow(clippy::too_many_arguments)] // the effect's seven options +pub(super) fn conte_crayon( + px: &mut [[u8; 4]], + w: usize, + h: usize, + space: &PixelSpace, + foreground: f64, + background: f64, + texture: &Texture, + scaling: f64, + relief: f64, + light: Light, + invert: bool, +) { + if !([foreground, background, scaling, relief].iter().all(|v| v.is_finite()) && scaling > 0.0) { + return; + } + let Some(place) = Place::new(space, w, h) else { return }; + let fg = foreground.clamp(0.0, 20.0) / 20.0; + let bg = background.clamp(0.0, 20.0) / 20.0; + let tones = Tones::new(px, w, h, TONE_SOFT / place.px); + // The tooth: how far the surface stands above its neighbours a quarter point (or half a pixel) + // away, so that crayon catches on the peaks and leaves the hollows bare. + let step = (0.25f64).max(0.5 * place.px / scaling); + let flip = if invert { -1.0 } else { 1.0 }; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let t = Point::new(q.x / scaling, q.y / scaling); + let tooth = if t.x.is_finite() && t.y.is_finite() { + let at = |dx: f64, dy: f64| texture.height(Point::new(t.x + dx, t.y + dy)); + let ridge = (at(0.0, 0.0) - 0.25 * (at(-step, 0.0) + at(step, 0.0) + at(0.0, -step) + at(0.0, step))) / step; + let tooth = 0.5 + 0.5 * (2.0 * flip * ridge).tanh(); + if tooth.is_finite() { tooth } else { 0.5 } + } else { + 0.5 + }; + // Strokes along the two diagonals, about a point across and several long. + let (u, v) = ((q.x + q.y) * FRAC_1_SQRT_2, (q.x - q.y) * FRAC_1_SQRT_2); + let dark_stroke = value_noise(SALT_CONTE, u * 1.2, v * 0.15); + let light_stroke = value_noise(SALT_CONTE + 1, v * 1.2, u * 0.15); + let tone = tones.get(x, y); + let black = crayon(1.0 - tone, fg, 0.55 * tooth + 0.45 * dark_stroke); + let white = crayon(tone, bg, 0.55 * tooth + 0.45 * light_stroke); + let paper = 0.4 + 0.2 * tone; + let grey = ((paper + (1.0 - paper) * white) * (1.0 - black)) as f32; + *p = premultiply([grey; 3], f32::from(p[3]) / 255.0); + }); + if relief > 0.0 { + texturizer(px, w, h, space, texture, scaling, relief, light, invert); + } +} + +/// Torn Edges: the object in high-contrast black and white with ragged edges. `image_balance` +/// (0..50) shifts whether midtones fall to ink or paper; `smoothness` (0..15) softens tones and +/// the fraying; `contrast` (0..20) hardens the split. Coverage is kept. +pub(super) fn torn_edges(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, image_balance: f64, smoothness: f64, contrast: f64) { + if !(image_balance.is_finite() && smoothness.is_finite() && contrast.is_finite()) { + return; + } + let Some(place) = Place::new(space, w, h) else { return }; + if w == 0 || h == 0 || w.checked_mul(h) != Some(px.len()) { + return; + } + let mut tone: Vec = px.iter().map(|p| luma(straight(*p)) * f32::from(p[3]) / 255.0).collect(); + let cover: Vec = px.iter().map(|p| f32::from(p[3]) / 255.0).collect(); + let sigma = (0.12 * smoothness.clamp(0.0, 15.0)) / place.px.max(1e-9); + if sigma > 0.0 { + blur_plane(&mut tone, w, h, sigma); + } + let mut edges: Vec = sobel(&tone, w, h).into_iter().zip(sobel(&cover, w, h)).map(|(t, c)| t.max(c)).collect(); + let edge_soft = (0.08 * smoothness.clamp(0.0, 15.0)) / place.px.max(1e-9); + if edge_soft > 0.0 { + blur_plane(&mut edges, w, h, edge_soft); + } + let peak = edges.iter().copied().fold(0.0f32, f32::max); + let balance = image_balance.clamp(0.0, 50.0); + // Lower balance favours ink (darker result); higher balance favours paper. + let balance_tone = 0.25 + balance / 50.0 * 0.75; + let steep = 4.0 + contrast.clamp(0.0, 20.0) * 0.45; + let hard = contrast.clamp(0.0, 20.0) >= 18.0; + let fray = 0.25 + 0.05 * smoothness.clamp(0.0, 15.0); + place.each(px, |x, y, p| { + let a = f32::from(p[3]) / 255.0; + if a <= 0.0 { + *p = [0; 4]; + return; + } + let i = y * w + x; + let t = tone.get(i).copied().unwrap_or(0.0); + let e = if peak > 1e-6 { edges.get(i).copied().unwrap_or(0.0) / peak } else { 0.0 }; + let noise = smooth_noise(SALT_TORN, place.at(x, y), 2.5 + 0.3 * smoothness) as f32; + let local = (1.0 - balance_tone) as f32 + e * fray as f32 * noise; + let mut ink = 1.0 / (1.0 + (steep as f32 * (t - local)).exp()); + if hard { + ink = if ink > 0.5 { 1.0 } else { 0.0 }; + } + let paper = 1.0 - ink.clamp(0.0, 1.0); + *p = premultiply([paper, paper, paper], a); + }); +} + +/// How far Water Paper's blotchy displacement can shift content (document units). +pub(super) fn water_paper_shift(fiber_length: f64) -> f64 { + 0.12 * fiber_length.clamp(3.0, 50.0) +} + +/// Water Paper: colours flow on damp fibrous paper. `fiber_length` (3..50) sets the scale of the +/// fibers and daubs; `brightness` and `contrast` (0..100) adjust the washed result. Ranges and +/// defaults are documented in [`crate::effect_catalog`] (unverified; helpx lists the controls only). +pub(super) fn water_paper(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, fiber_length: f64, brightness: f64, contrast: f64) { + if !(fiber_length.is_finite() && brightness.is_finite() && contrast.is_finite()) { + return; + } + let Some(place) = Place::new(space, w, h) else { return }; + let fiber = fiber_length.clamp(3.0, 50.0); + let bright = (brightness / 100.0).clamp(0.0, 1.0); + let cont = (contrast / 100.0).clamp(0.0, 1.0); + let shift = water_paper_shift(fiber); + let wave = fiber * 0.4; + displace(px, w, h, &place, shift, |q| { + let a = 0.65 * smooth_noise(SALT_WATER, q, wave) + 0.35 * smooth_noise(SALT_WATER + 3, q, wave * 1.7); + let b = 0.65 * smooth_noise(SALT_WATER + 1, q, wave * 1.1) + 0.35 * smooth_noise(SALT_WATER + 4, q, wave * 0.85); + Vec2::new(a, b) * shift + }); + let flow = (0.06 * fiber).max(0.35) / place.px.max(1e-9); + let mut planes: [Vec; 3] = std::array::from_fn(|ch| px.iter().map(|p| straight(*p)[ch] * f32::from(p[3]) / 255.0).collect()); + for plane in &mut planes { + blur_plane(plane, w, h, flow); + } + for (i, p) in px.iter_mut().enumerate() { + if p[3] == 0 { + continue; + } + let a = f32::from(p[3]) / 255.0; + if a <= 0.0 { + continue; + } + let rgb = { + let mut rgb = [0.0f32; 3]; + let mut ok = true; + for ch in 0..3 { + let Some(v) = planes[ch].get(i) else { + ok = false; + break; + }; + rgb[ch] = (*v / a).clamp(0.0, 1.0); + } + if !ok { + continue; + } + rgb + }; + *p = premultiply(rgb, a); + } + let lift = (bright - 0.5) * 0.35; + let gain = 0.35 + 0.65 * cont; + place.each(px, |x, y, p| { + if p[3] == 0 { + return; + } + let q = place.at(x, y); + let along = smooth_noise(SALT_WATER + 5, q, fiber * 0.25); + let across = smooth_noise(SALT_WATER + 6, Point::new(q.y, q.x), fiber * 0.22); + let fiber_tex = (0.5 * (along + across) + 0.5).clamp(0.0, 1.0); + let paper = (0.82 + 0.18 * fiber_tex) as f32; + let mut rgb = straight(*p).map(|v| ((v + lift as f32 - 0.5) * gain as f32 + 0.5).clamp(0.0, 1.0)); + rgb = rgb.map(|v| (v * paper).clamp(0.0, 1.0)); + *p = premultiply(rgb, f32::from(p[3]) / 255.0); + }); +} diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index 7d45556c5..fc3497ae6 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -1222,6 +1222,10 @@ fn texture_params_take_defaults_and_stay_in_range() { ("texture.grain", "grainType", GRAIN_TYPES.as_slice()), ("texture.texturizer", "texture", TEXTURES.as_slice()), ("texture.texturizer", "lightDirection", LIGHT_DIRECTIONS.as_slice()), + ("artistic.roughPastels", "texture", TEXTURES.as_slice()), + ("artistic.roughPastels", "lightDirection", LIGHT_DIRECTIONS.as_slice()), + ("sketch.conteCrayon", "texture", TEXTURES.as_slice()), + ("sketch.conteCrayon", "lightDirection", LIGHT_DIRECTIONS.as_slice()), ] { let doc = crate::effect_info(id).unwrap().params; let parsed: Vec = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect(); @@ -1426,6 +1430,29 @@ fn texturizer_lights_each_surface_from_its_side() { assert_eq!(big, run(json!({"texture": "brick", "relief": 20}))); } +#[test] +fn texturizer_draws_a_loaded_texture_as_relief() { + let w = 64; + let mid = grey(150); + let rings = stored(12, 10, |x, y| { + let v = ((x as f64 - 5.5).hypot(y as f64 - 4.5) * 0.9).sin().abs(); + let g = (v * 255.0).round() as u8; + [g, g, g, 255] + }); + let run = |p: serde_json::Value| { + let mut d = flat(w, mid); + fx("texture.texturizer", p).apply(&mut d, w, w, &space(w, w)); + d + }; + let relief = run(json!({"texture": "loaded", "textureImage": rings, "relief": 20})); + assert_ne!(relief, flat(w, mid)); + for (_, builtin) in TEXTURES { + assert_ne!(relief, run(json!({"texture": builtin, "relief": 20})), "{builtin}"); + } + assert_eq!(run(json!({"texture": "loaded", "textureImage": rings, "relief": 0})), flat(w, mid)); + assert_eq!(fx("texture.texturizer", json!({"texture": "LOADED", "textureImage": "not a texture"})), fx("texture.texturizer", json!({}))); +} + /// The Texture filters' patterns lie in document space around the object's centre: the same at /// twice the resolution, and moving with the object. #[test] @@ -2046,6 +2073,20 @@ fn halftone(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace out } +/// Torn Edges of `p` over `w` × `h` pixels of `src`, laid out by `s`. +fn torn(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec { + let mut out = src.to_vec(); + fx("sketch.tornEdges", p).apply(&mut out, w, h, s); + out +} + +/// Water Paper of `p` over `w` × `h` pixels of `src`, laid out by `s`. +fn water_paper(p: serde_json::Value, src: &[u8], w: usize, h: usize, s: &PixelSpace) -> Vec { + let mut out = src.to_vec(); + fx("sketch.waterPaper", p).apply(&mut out, w, h, s); + out +} + /// The smallest period of `values` (their length when they don't repeat). fn period(values: &[u8]) -> usize { (1..values.len()).find(|p| values.iter().zip(&values[*p..]).all(|(a, b)| a == b)).unwrap_or(values.len()) @@ -2144,6 +2185,35 @@ fn note_paper_embosses_a_gradient_and_grain_changes_pixels() { assert_ne!(flat, grainy, "graininess adds fibre"); } +#[test] +fn water_paper_params_take_defaults_and_stay_in_range() { + assert_eq!(fx("sketch.waterPaper", json!({})), PixelFx::WaterPaper { fiber_length: 15.0, brightness: 50.0, contrast: 50.0 }); + assert_eq!( + fx("sketch.waterPaper", json!({"fiberLength": 1, "brightness": -5, "contrast": 200})), + PixelFx::WaterPaper { fiber_length: 3.0, brightness: 0.0, contrast: 100.0 } + ); + let f = fx("sketch.waterPaper", json!({"fiberLength": 40})); + assert!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)) > 0.0); + assert!(f.reach().unwrap_or(0.0) > 0.0); + let doc = crate::effect_info("sketch.waterPaper").unwrap().params; + assert!(doc.contains("unverified"), "{doc}"); +} + +#[test] +fn water_paper_softens_and_textures_a_flat_fill_without_losing_coverage() { + let (w, h) = (64, 64); + let src = image(w, h, |_, _| [200, 80, 40, 255]); + let out = water_paper(json!({"fiberLength": 20, "brightness": 50, "contrast": 40}), &src, w, h, &space(w, h)); + assert!(out.as_chunks::<4>().0.iter().filter(|p| p[3] > 200).count() > w * h / 2, "most of the fill stays visible"); + assert_ne!(out, src, "the wash changes pixels"); + let spread = |d: &[u8]| { + let reds: Vec = d.as_chunks::<4>().0.iter().map(|p| p[0]).collect(); + let (min, max) = (reds.iter().min().unwrap(), reds.iter().max().unwrap()); + max - min + }; + assert!(spread(&out) > spread(&src) / 2, "fibers add tonal variation"); +} + /// Line's rows repeat every Size points: Size pixels at 72 ppi, twice as many at 144 ppi. #[test] fn halftone_line_period_is_the_size_at_any_resolution() { @@ -2343,6 +2413,63 @@ fn reticulation_density_changes_the_grain() { assert_ne!(low, high); } +#[test] +fn torn_edges_params_take_defaults_and_stay_in_range() { + assert_eq!(fx("sketch.tornEdges", json!({})), PixelFx::TornEdges { image_balance: 25.0, smoothness: 11.0, contrast: 17.0 }); + assert_eq!( + fx("sketch.tornEdges", json!({"imageBalance": 0, "smoothness": 99, "contrast": -1})), + PixelFx::TornEdges { image_balance: 0.0, smoothness: 15.0, contrast: 0.0 } + ); + assert_eq!( + fx("sketch.tornEdges", json!({"imageBalance": 1e9, "smoothness": -3, "contrast": 1e9})), + PixelFx::TornEdges { image_balance: 50.0, smoothness: 0.0, contrast: 20.0 } + ); + for junk in [json!({"imageBalance": "x"}), json!({"smoothness": null})] { + assert_eq!(fx("sketch.tornEdges", junk), fx("sketch.tornEdges", json!({}))); + } + assert_eq!(fx("sketch.tornEdges", json!({})).reach(), None); +} + +#[test] +fn torn_edges_draws_in_black_and_white_and_keeps_coverage() { + let (w, h) = (64, 48); + let src = ramp(w, h); + let out = torn(json!({"imageBalance": 12, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h)); + for (p, q) in out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) { + assert_eq!(p[3], q[3], "coverage kept"); + assert!(p[0] == p[1] && p[1] == p[2] && (p[0] == 0 || p[0] == p[3]), "black or white: {p:?}"); + } + assert!(share(&out, |p| p[0] == 0) > 0.05 && share(&out, |p| p[0] == p[3]) > 0.05, "ink and paper"); +} + +#[test] +fn torn_edges_image_balance_shifts_ink_on_a_grey_ramp() { + let (w, h) = (80, 16); + let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8)); + let params = json!({"imageBalance": 25, "contrast": 20, "smoothness": 5}); + let out = torn(params, &src, w, h, &space(w, h)); + let mut dark_ink = 0usize; + let mut bright_ink = 0usize; + for y in 0..h { + for x in 0..w / 4 { + if at(&out, w, x, y)[0] == 0 { + dark_ink += 1; + } + } + for x in 3 * w / 4..w { + if at(&out, w, x, y)[0] == 0 { + bright_ink += 1; + } + } + } + let dark_n = (w / 4) * h; + let bright_n = (w - 3 * w / 4) * h; + assert!(dark_ink as f64 / dark_n as f64 > bright_ink as f64 / bright_n as f64 + 0.2, "dark end of ramp is inkier than bright end"); + let dark = share(&torn(json!({"imageBalance": 0, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h)), |p| p[0] == 0); + let light = share(&torn(json!({"imageBalance": 50, "contrast": 20, "smoothness": 5}), &src, w, h, &space(w, h)), |p| p[0] == 0); + assert!(dark > light + 0.15, "low balance leaves more ink: dark={dark} light={light}"); +} + /// The distinct colours of the covered pixels. fn colours(d: &[u8]) -> std::collections::BTreeSet<[u8; 4]> { d.as_chunks::<4>().0.iter().filter(|p| p[3] > 0).copied().collect() @@ -2360,11 +2487,22 @@ fn ramp(w: usize, h: usize) -> Vec { #[test] fn artistic_params_take_defaults_and_stay_in_range() { + assert_eq!(fx("artistic.coloredPencil", json!({})), PixelFx::ColoredPencil { width: 5.0, pressure: 5.0, paper: 25.0 }); + assert_eq!( + fx("artistic.coloredPencil", json!({"pencilWidth": 0, "strokePressure": 200, "paperBrightness": -5})), + PixelFx::ColoredPencil { width: 1.0, pressure: 15.0, paper: 0.0 } + ); + assert_eq!(fx("artistic.coloredPencil", json!({})).reach(), Some(artistic::colored_pencil_reach(5.0))); assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 }); assert_eq!( fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})), PixelFx::Cutout { levels: 8.0, simplicity: 0.0, fidelity: 2.0 } ); + assert_eq!(fx("artistic.dryBrush", json!({})), PixelFx::DryBrush { size: 2.0, detail: 10.0, texture: 1.0 }); + assert_eq!( + fx("artistic.dryBrush", json!({"brushSize": 1e308, "brushDetail": 0, "texture": -2})), + PixelFx::DryBrush { size: 10.0, detail: 0.0, texture: 1.0 } + ); assert_eq!(fx("artistic.posterEdges", json!({})), PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 }); assert_eq!( fx("artistic.posterEdges", json!({"edgeThickness": 99, "edgeIntensity": -1, "posterization": 6.4})), @@ -2377,14 +2515,96 @@ fn artistic_params_take_defaults_and_stay_in_range() { fx("artistic.neonGlow", json!({"glowSize": -1e308, "glowBrightness": 80, "color": "#ff0000"})), PixelFx::NeonGlow { size: -24.0, brightness: 50.0, colour: [1.0, 0.0, 0.0] } ); + let under = PixelFx::Underpainting { + brush_size: 6.0, + texture_coverage: 16.0, + texture: Texture::Canvas, + scaling: 1.0, + relief: 4.0, + light: Light::Top, + invert: false, + }; + assert_eq!(fx("artistic.underpainting", json!({})), under); + assert_eq!( + fx( + "artistic.underpainting", + json!({"brushSize": 99, "textureCoverage": -1, "texture": "brick", "scaling": 10, "relief": 60, "lightDirection": "bottomRight", "invert": true}) + ), + PixelFx::Underpainting { + brush_size: 40.0, + texture_coverage: 0.0, + texture: Texture::Brick, + scaling: 0.5, + relief: 50.0, + light: Light::BottomRight, + invert: true, + } + ); + assert_eq!( + fx("artistic.roughPastels", json!({})), + PixelFx::RoughPastels { length: 6.0, detail: 10.0, texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false } + ); + assert_eq!( + fx( + "artistic.roughPastels", + json!({"strokeLength": 0, "strokeDetail": 99, "texture": -3, "scaling": 10, "relief": 60, "lightDirection": "up", "invert": true}) + ), + PixelFx::RoughPastels { length: 0.0, detail: 20.0, texture: Texture::Canvas, scaling: 0.5, relief: 50.0, light: Light::Top, invert: true } + ); + assert_eq!(fx("artistic.fresco", json!({})), PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: 1.0 }); + assert_eq!( + fx("artistic.fresco", json!({"brushSize": 0, "brushDetail": 99, "texture": 4.7})), + PixelFx::Fresco { brush_size: 1.0, brush_detail: 10.0, texture: 3.0 } + ); assert_eq!(fx("artistic.plasticWrap", json!({})), PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 }); assert_eq!( fx("artistic.plasticWrap", json!({"highlightStrength": -1, "detail": 99, "smoothness": 0})), PixelFx::PlasticWrap { highlight: 0.0, detail: 15.0, smoothness: 1.0 } ); - for id in ["artistic.cutout", "artistic.neonGlow", "artistic.plasticWrap", "artistic.posterEdges"] { + for id in [ + "artistic.coloredPencil", + "artistic.cutout", + "artistic.dryBrush", + "artistic.fresco", + "artistic.neonGlow", + "artistic.plasticWrap", + "artistic.posterEdges", + "artistic.underpainting", + ] { assert_eq!(fx(id, json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)), 0.0, "{id} keeps the object's shape"); } + assert_eq!(fx("artistic.paintDaubs", json!({})), PixelFx::PaintDaubs { size: 10.0, sharpness: 25.0, kind: PaintDaubBrush::Simple }); + assert_eq!( + fx("artistic.paintDaubs", json!({"brushSize": 0, "sharpness": 99, "brushType": "wideBlurry"})), + PixelFx::PaintDaubs { size: 1.0, sharpness: 40.0, kind: PaintDaubBrush::WideBlurry } + ); + let doc = crate::effect_info("artistic.paintDaubs").unwrap().params; + assert!(PAINT_DAUB_BRUSH_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}"); + assert!(fx("artistic.paintDaubs", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0); + for (id, key, table) in + [("artistic.underpainting", "texture", TEXTURES.as_slice()), ("artistic.underpainting", "lightDirection", LIGHT_DIRECTIONS.as_slice())] + { + let doc = crate::effect_info(id).unwrap().params; + let parsed: Vec = table.iter().map(|(_, v)| fx(id, json!({ key: v }))).collect(); + for (i, (_, v)) in table.iter().enumerate() { + assert!(doc.contains(&format!("\"{v}\"")), "{id} {v}"); + assert!(parsed.iter().skip(i + 1).all(|p| *p != parsed[i]), "{id} {v}"); + } + } + assert!(fx("artistic.roughPastels", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0); +} + +#[test] +fn colored_pencil_strokes_change_pixels_and_keep_coverage() { + let (w, h) = (48, 32); + let src = ramp(w, h); + let mut out = src.clone(); + fx("artistic.coloredPencil", json!({"strokePressure": 12})).apply(&mut out, w, h, &space(w, h)); + assert_ne!(out, src); + assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3])); + let mut mild = src.clone(); + fx("artistic.coloredPencil", json!({"strokePressure": 0, "paperBrightness": 0})).apply(&mut mild, w, h, &space(w, h)); + assert_eq!(mild, src, "zero pressure and paper leaves the art unchanged"); } #[test] @@ -2399,9 +2619,21 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() { let colour = [0.3, 0.65, 1.0]; let filters = [ PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 }, + PixelFx::DryBrush { size: 2.0, detail: 10.0, texture: 1.0 }, + PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: 1.0 }, PixelFx::NeonGlow { size: 5.0, brightness: 15.0, colour }, PixelFx::PlasticWrap { highlight: 20.0, detail: 12.0, smoothness: 15.0 }, PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: 2.0 }, + PixelFx::Underpainting { + brush_size: 6.0, + texture_coverage: 16.0, + texture: Texture::Canvas, + scaling: 1.0, + relief: 4.0, + light: Light::Top, + invert: false, + }, + PixelFx::RoughPastels { length: 6.0, detail: 10.0, texture: Texture::Canvas, scaling: 1.0, relief: 4.0, light: Light::Top, invert: false }, ]; for f in &filters { assert_ne!(run(f, &space(w, h)), src, "{f:?} changes the art in a valid space"); @@ -2423,6 +2655,27 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() { PixelFx::PosterEdges { thickness: f64::NAN, intensity: 1.0, posterization: 2.0 }, PixelFx::PosterEdges { thickness: 2.0, intensity: f64::INFINITY, posterization: 2.0 }, PixelFx::PosterEdges { thickness: 2.0, intensity: 1.0, posterization: f64::NEG_INFINITY }, + PixelFx::Underpainting { + brush_size: f64::NAN, + texture_coverage: 16.0, + texture: Texture::Canvas, + scaling: 1.0, + relief: 4.0, + light: Light::Top, + invert: false, + }, + PixelFx::Underpainting { + brush_size: 6.0, + texture_coverage: f64::INFINITY, + texture: Texture::Canvas, + scaling: 1.0, + relief: 4.0, + light: Light::Top, + invert: false, + }, + PixelFx::Fresco { brush_size: f64::NAN, brush_detail: 10.0, texture: 1.0 }, + PixelFx::Fresco { brush_size: 2.0, brush_detail: f64::INFINITY, texture: 1.0 }, + PixelFx::Fresco { brush_size: 2.0, brush_detail: 10.0, texture: f64::NEG_INFINITY }, ] { if run(&f, &space(w, h)) != src { changed.push(format!("{f:?}")); @@ -2431,6 +2684,43 @@ fn artistic_filters_leave_invalid_coordinate_spaces_alone() { assert!(changed.is_empty(), "directly constructed non-finite parameters and invalid spaces are harmless: {changed:?}"); } +#[test] +fn underpainting_softens_more_with_a_larger_brush_and_keeps_coverage() { + let (w, h) = (48, 32); + let src = ramp(w, h); + let diff = |brush: f64| { + let mut out = src.clone(); + fx("artistic.underpainting", json!({"brushSize": brush, "textureCoverage": 0})).apply(&mut out, w, h, &space(w, h)); + out.as_chunks::<4>() + .0 + .iter() + .zip(src.as_chunks::<4>().0) + .filter(|(_, s)| s[3] == 255) + .map(|(o, s)| (0..3).map(|k| u32::from(o[k].abs_diff(s[k]))).sum::()) + .sum::() + }; + assert!(diff(40.0) > diff(2.0) * 2, "large brush {} vs small {}", diff(40.0), diff(2.0)); + let mut out = src.clone(); + fx("artistic.underpainting", json!({})).apply(&mut out, w, h, &space(w, h)); + assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3])); +} + +#[test] +fn fresco_keeps_coverage_and_responds_to_texture() { + let (w, h) = (48, 32); + let src = ramp(w, h); + let mut low = src.clone(); + let mut high = src.clone(); + fx("artistic.fresco", json!({"texture": 1})).apply(&mut low, w, h, &space(w, h)); + fx("artistic.fresco", json!({"texture": 3})).apply(&mut high, w, h, &space(w, h)); + assert_ne!(low, src); + assert_ne!(high, src); + assert_ne!(low, high, "deeper texture changes the relief"); + for (a, b) in low.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0) { + assert_eq!(a[3], b[3], "alpha is preserved"); + } +} + #[test] fn plastic_wrap_adds_specular_highlights_and_keeps_coverage() { let (w, h) = (48, 32); @@ -2465,6 +2755,20 @@ fn cutout_paints_a_few_flat_colours_and_keeps_coverage() { } } +#[test] +fn rough_pastels_keeps_coverage_and_longer_strokes_smear_more() { + let (w, h) = (48, 48); + let src = ramp(w, h); + let mut short = src.clone(); + let mut long = src.clone(); + fx("artistic.roughPastels", json!({"strokeLength": 8, "strokeDetail": 18, "relief": 12})).apply(&mut short, w, h, &space(w, h)); + fx("artistic.roughPastels", json!({"strokeLength": 32, "strokeDetail": 18, "relief": 12})).apply(&mut long, w, h, &space(w, h)); + assert!(short.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3])); + let changed = |a: &Vec, b: &Vec| a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(o, s)| o != s).count(); + assert!(changed(&short, &src) > w * h / 8, "short strokes repaint many pixels"); + assert!(changed(&long, &short) > w * h / 16, "longer strokes smear differently"); +} + #[test] fn cutout_simplicity_smooths_the_pieces_edges() { // A noisy grey: simpler edges join the speckles into fewer changes between neighbours. @@ -2478,6 +2782,28 @@ fn cutout_simplicity_smooths_the_pieces_edges() { assert!(changes(10.0) * 4 < changes(0.0), "{} vs {}", changes(10.0), changes(0.0)); } +#[test] +fn paint_daubs_coarsens_a_ramp_and_keeps_coverage() { + let (w, h) = (64, 32); + let src = ramp(w, h); + let mut out = src.clone(); + fx("artistic.paintDaubs", json!({"brushSize": 28, "sharpness": 35})).apply(&mut out, w, h, &space(w, h)); + assert_ne!(out, src, "dabbed art differs from the ramp"); + assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3])); +} + +#[test] +fn paint_daubs_larger_brushes_flatten_more() { + let (w, h) = (80, 40); + let src = ramp(w, h); + let dab_colours = |size: f64| { + let mut out = src.clone(); + fx("artistic.paintDaubs", json!({"brushSize": size, "sharpness": 20})).apply(&mut out, w, h, &space(w, h)); + colours(&out).len() + }; + assert!(dab_colours(35.0) < dab_colours(8.0), "{} vs {}", dab_colours(35.0), dab_colours(8.0)); +} + #[test] fn poster_edges_draw_black_lines_on_edges_and_posterize_the_rest() { let (w, h) = (40, 20); @@ -2512,6 +2838,24 @@ fn poster_edges_thickness_widens_the_lines() { assert!(dark(10.0) > dark(0.0), "{} vs {}", dark(10.0), dark(0.0)); } +#[test] +fn dry_brush_repaints_and_preserves_coverage() { + let (w, h) = (48, 24); + let src = ramp(w, h); + let mut out = src.clone(); + fx("artistic.dryBrush", json!({})).apply(&mut out, w, h, &space(w, h)); + assert_ne!(out, src); + for (a, b) in src.as_chunks::<4>().0.iter().zip(out.as_chunks::<4>().0) { + assert_eq!(a[3], b[3], "coverage preserved"); + } + let mut clear = vec![0u8; w * h * 4]; + let before = clear.clone(); + fx("artistic.dryBrush", json!({})).apply(&mut clear, w, h, &space(w, h)); + assert_eq!(clear, before); + let reach = fx("artistic.dryBrush", json!({"brushSize": 10, "brushDetail": 1})).reach(); + assert!(reach.is_some_and(|r| r > 0.0)); +} + #[test] fn neon_glow_lights_the_bright_or_the_dark_areas_in_its_colour() { let (w, h) = (40, 20); @@ -2549,6 +2893,8 @@ fn artistic_filters_look_the_same_at_any_resolution() { ("artistic.cutout", json!({"edgeSimplicity": 6})), ("artistic.neonGlow", json!({"glowSize": 12, "glowBrightness": 30})), ("artistic.posterEdges", json!({"edgeThickness": 6, "edgeIntensity": 6})), + ("artistic.underpainting", json!({"brushSize": 12, "textureCoverage": 20, "relief": 10})), + ("artistic.roughPastels", json!({"strokeLength": 12, "strokeDetail": 15, "texture": "burlap", "relief": 10})), ] { let (mut a, mut b) = (coarse.clone(), fine.clone()); fx(id, p.clone()).apply(&mut a, w, h, &space(w, h)); @@ -2564,3 +2910,30 @@ fn artistic_filters_look_the_same_at_any_resolution() { assert!(diff < 12.0, "{id}: mean difference {diff}"); } } + +#[test] +fn palette_knife_params_take_defaults_and_stay_in_range() { + assert_eq!(fx("artistic.paletteKnife", json!({})), PixelFx::PaletteKnife { stroke_size: 10.0, stroke_detail: 2.0, softness: 3.0 }); + assert_eq!( + fx("artistic.paletteKnife", json!({"strokeSize": 0, "strokeDetail": 0, "softness": 99})), + PixelFx::PaletteKnife { stroke_size: 1.0, stroke_detail: 1.0, softness: 10.0 } + ); + assert!(fx("artistic.paletteKnife", json!({})).outset(Rect::new(0.0, 0.0, 40.0, 30.0)) > 0.0); +} + +#[test] +fn palette_knife_smears_a_ramp_and_keeps_coverage() { + let (w, h) = (48, 24); + let src = ramp(w, h); + let mut out = src.clone(); + fx("artistic.paletteKnife", json!({"strokeSize": 20, "strokeDetail": 1, "softness": 0})).apply(&mut out, w, h, &space(w, h)); + assert_ne!(out, src); + assert!(out.as_chunks::<4>().0.iter().zip(src.as_chunks::<4>().0).all(|(o, s)| o[3] == s[3])); + let mut gentle = src.clone(); + fx("artistic.paletteKnife", json!({"strokeSize": 20, "strokeDetail": 3, "softness": 0})).apply(&mut gentle, w, h, &space(w, h)); + let mean = |buf: &Vec| { + let n = buf.len() / 4; + buf.as_chunks::<4>().0.iter().map(|p| f64::from(p[0])).sum::() / n as f64 + }; + assert!((mean(&gentle) - mean(&src)).abs() < (mean(&out) - mean(&src)).abs()); +} diff --git a/crates/effects/src/pixel/tests_sketch.rs b/crates/effects/src/pixel/tests_sketch.rs index d97cc89f8..2f463f52f 100644 --- a/crates/effects/src/pixel/tests_sketch.rs +++ b/crates/effects/src/pixel/tests_sketch.rs @@ -1,4 +1,4 @@ -//! Sketch › Bas Relief, Charcoal, Photocopy and Stamp. +//! Sketch › Bas Relief, Charcoal, Conté Crayon, Photocopy and Stamp. use serde_json::json; use vectorcraft_doc::Effect; @@ -173,9 +173,123 @@ fn bas_relief_lights_the_surfaces_facing_the_light() { #[test] fn sketch_filters_reach_beyond_nothing_they_do_not_paint() { - for id in ["sketch.basRelief", "sketch.charcoal", "sketch.photocopy", "sketch.stamp"] { + for id in ["sketch.basRelief", "sketch.charcoal", "sketch.conteCrayon", "sketch.photocopy", "sketch.stamp"] { let f = fx(id, json!({})); assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage"); assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach"); } } + +/// The mean of channel 0 over columns `x0..x1` of every row. +fn mean(d: &[u8], w: usize, h: usize, x0: usize, x1: usize) -> f64 { + let sum: u32 = (0..h).flat_map(|y| (x0..x1).map(move |x| (x, y))).map(|(x, y)| u32::from(at(d, w, x, y))).sum(); + f64::from(sum) / ((x1 - x0) * h) as f64 +} + +/// How many pixels of channel 0 are lighter than 192. +fn white(d: &[u8]) -> usize { + d.as_chunks::<4>().0.iter().filter(|p| p[0] > 192).count() +} + +#[test] +fn conte_crayon_params_take_defaults_and_stay_in_range() { + let defaults = PixelFx::ConteCrayon { + foreground: 12.0, + background: 10.0, + texture: Texture::Canvas, + scaling: 1.0, + relief: 4.0, + light: Light::Top, + invert: false, + }; + assert_eq!(fx("sketch.conteCrayon", json!({})), defaults); + assert_eq!( + fx( + "sketch.conteCrayon", + json!({"foregroundLevel": -5, "backgroundLevel": 1e308, "texture": "Brick", "scaling": 10, "relief": 99, "lightDirection": "bottomRight", "invert": true}) + ), + PixelFx::ConteCrayon { + foreground: 0.0, + background: 20.0, + texture: Texture::Brick, + scaling: 0.5, + relief: 50.0, + light: Light::BottomRight, + invert: true + } + ); + for junk in [ + json!({"foregroundLevel": "NaN", "scaling": "1e999"}), + json!({"backgroundLevel": null, "texture": 3, "relief": [1], "lightDirection": "up", "invert": "yes"}), + ] { + assert_eq!(fx("sketch.conteCrayon", junk.clone()), defaults, "{junk}"); + } + let doc = crate::effect_info("sketch.conteCrayon").unwrap().params; + assert!(doc.contains("unverified") && doc.contains("foregroundLevel") && doc.contains("backgroundLevel"), "{doc}"); +} + +/// A grey ramp: its shadows in black crayon, its highlights in white crayon, the midtones on grey +/// paper between them; grey only, coverage kept, the same on every run. +#[test] +fn conte_crayon_draws_the_shadows_black_and_the_highlights_white() { + let (w, h) = (96, 32); + let src = image(w, h, |x, _| grey((x * 255 / (w - 1)) as u8)); + let out = run("sketch.conteCrayon", json!({"relief": 0}), &src, w, h); + for p in out.as_chunks::<4>().0 { + assert!(p[0] == p[1] && p[1] == p[2] && p[3] == 255, "{p:?}"); + } + let (shadows, mids, lights) = (mean(&out, w, h, 0, 12), mean(&out, w, h, w / 2 - 6, w / 2 + 6), mean(&out, w, h, w - 12, w)); + assert!(shadows < 40.0, "shadows {shadows}"); + assert!(lights > 215.0, "highlights {lights}"); + assert!(shadows + 30.0 < mids && mids + 30.0 < lights, "{shadows} {mids} {lights}"); + assert_eq!(out, run("sketch.conteCrayon", json!({"relief": 0}), &src, w, h), "deterministic"); + // The relief shades the paper but keeps it grey. + let lit = run("sketch.conteCrayon", json!({}), &src, w, h); + assert_ne!(lit, out); + assert!(lit.as_chunks::<4>().0.iter().all(|p| p[0] == p[1] && p[1] == p[2])); +} + +/// Foreground Level spreads the black crayon into the midtones, Background Level the white one. +#[test] +fn conte_crayon_levels_spread_each_crayon_into_the_midtones() { + let (w, h) = (64, 64); + let mid = image(w, h, |_, _| grey(128)); + let with = |p: serde_json::Value| run("sketch.conteCrayon", p, &mid, w, h); + let (few, many) = (with(json!({"foregroundLevel": 0, "relief": 0})), with(json!({"foregroundLevel": 20, "relief": 0}))); + assert!(black(&many) > black(&few) + w * h / 4, "{} vs {}", black(&many), black(&few)); + let (few, many) = ( + with(json!({"foregroundLevel": 0, "backgroundLevel": 0, "relief": 0})), + with(json!({"foregroundLevel": 0, "backgroundLevel": 20, "relief": 0})), + ); + assert!(white(&many) > white(&few) + w * h / 4, "{} vs {}", white(&many), white(&few)); +} + +/// The paper's texture, scale and inversion break the crayon differently; the relief is lit from +/// the chosen side. +#[test] +fn conte_crayon_texture_options_change_the_paper() { + let (w, h) = (64, 64); + let mid = image(w, h, |x, y| grey((64 + x + y) as u8)); + let with = |p: serde_json::Value| run("sketch.conteCrayon", p, &mid, w, h); + let canvas = with(json!({"relief": 0})); + for other in [ + json!({"relief": 0, "texture": "brick"}), + json!({"relief": 0, "texture": "sandstone"}), + json!({"relief": 0, "scaling": 200}), + json!({"relief": 0, "invert": true}), + ] { + assert_ne!(with(other.clone()), canvas, "{other}"); + } + let top = with(json!({"texture": "brick", "relief": 20, "lightDirection": "top"})); + let bottom = with(json!({"texture": "brick", "relief": 20, "lightDirection": "bottom"})); + assert_ne!(top, bottom); + assert_ne!(top, with(json!({"texture": "brick", "relief": 0, "lightDirection": "top"}))); + survives( + "sketch.conteCrayon", + &[ + json!({}), + json!({"foregroundLevel": "NaN", "backgroundLevel": 1e308, "scaling": -1e308, "relief": 1e308, "texture": "burlap", "invert": true}), + json!({"foregroundLevel": -1e308, "backgroundLevel": [1], "texture": {}, "lightDirection": 3, "relief": "NaN"}), + ], + ); +} diff --git a/crates/effects/src/pixel/texture.rs b/crates/effects/src/pixel/texture.rs index 18c95681d..a40845453 100644 --- a/crates/effects/src/pixel/texture.rs +++ b/crates/effects/src/pixel/texture.rs @@ -7,9 +7,11 @@ //! whose slopes, taken in document units, are lit from one side. use std::f64::consts::{FRAC_1_SQRT_2, PI}; +use std::sync::Arc; use vectorcraft_geom::{Point, Vec2}; +use super::loaded::{LOADED, LoadedTexture, texture}; use super::pixelate::{AROUND, Around, Place, Soft, cell, gap, point_in, rand, value_noise}; use super::{PixelSpace, blur_plane, pick}; @@ -65,13 +67,19 @@ impl Grain { /// Texturizer's textures, in its Texture menu's order: (label, parameter value). pub const TEXTURES: [(&str, &str); 4] = [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone")]; -/// A Texturizer surface ([`TEXTURES`]), made in code. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Texturizer's Texture menu once a texture is loaded: [`TEXTURES`], then the loaded one. +pub const TEXTURES_LOADED: [(&str, &str); 5] = + [("Brick", "brick"), ("Burlap", "burlap"), ("Canvas", "canvas"), ("Sandstone", "sandstone"), ("Loaded Texture", LOADED)]; + +/// A Texturizer surface ([`TEXTURES`]), made in code or loaded from an image. +#[derive(Clone, Debug, PartialEq, Eq)] pub enum Texture { Brick, Burlap, Canvas, Sandstone, + /// An image the user loaded ([`LoadedTexture`]). + Loaded(Arc), } impl Texture { @@ -82,13 +90,23 @@ impl Texture { pick(&TEXTURES, &Self::ALL, value, Texture::Canvas) } + /// The texture of `value` with `stored` the effect's `textureImage`: the loaded texture for + /// [`LOADED`] (any case) when `stored` holds one, else as [`Texture::parse`]. + pub fn parse_with(value: &str, stored: Option<&str>) -> Self { + match stored.filter(|_| value.eq_ignore_ascii_case(LOADED)).and_then(texture) { + Some(t) => Texture::Loaded(t), + None => Self::parse(value), + } + } + /// The surface's height at `p` (points at 100 %), in points: slopes of about 1 at most. - pub(super) fn height(self, p: Point) -> f64 { + pub(super) fn height(&self, p: Point) -> f64 { match self { Texture::Brick => brick(p), Texture::Burlap => weave(p, 7.0, true), Texture::Canvas => weave(p, 4.0, false), Texture::Sandstone => sandstone(p), + Texture::Loaded(t) => t.height(p), } } } @@ -556,7 +574,7 @@ pub(super) fn texturizer( w: usize, h: usize, space: &PixelSpace, - texture: Texture, + texture: &Texture, scaling: f64, relief: f64, light: Light, diff --git a/crates/effects/src/preview.rs b/crates/effects/src/preview.rs new file mode 100644 index 000000000..a1b8eed98 --- /dev/null +++ b/crates/effects/src/preview.rs @@ -0,0 +1,305 @@ +//! Use Preview Bounds: objects measured as they are drawn, with their geometry effects. +//! +//! [`drawn_art`] gives an object whose geometry effects change what it draws as plain art. A path +//! or compound path is given as its effected geometry, and a fill or stroke with geometry effects +//! of its own paints its own geometry. Type, images, live objects and symbol instances are given as +//! their reshaped art ([`reshape`]), and a symbol instance is also given as its reshaped art when +//! its symbol's art has geometry effects. Groups and layers with geometry or Pathfinder effects of +//! their own, or with a fill or stroke of their own that has geometry effects or paints members +//! with geometry effects, are given as their evaluated art ([`evaluate_container`]). Blends, +//! envelopes and repeats whose objects have geometry effects are given as their evaluated art +//! ([`expanded_live_group`]), with each of those objects given as its own art. A clip group or +//! layer whose clipping path has strokes with geometry effects is given as that path's geometry +//! before its effects, which the renderer clips by, and those strokes as drawn. Fills and +//! strokes that paint nothing (hidden, or painted None) are left out. [`bounds_of`], +//! [`oriented_bounds`] and [`anchor_bounds`] measure the visual bounds of that art, strokes +//! included, where [`Document::bounds_of`], [`Document::oriented_bounds`] and +//! [`Selection::anchor_bounds`] measure the objects themselves. Raster effects (shadows, glows, +//! blurs) are not measured. In one object, the art of at most 4,096 symbol instances is measured +//! with its geometry effects, and the other instances in it are measured without them. + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::sync::Arc; + +use vectorcraft_doc::color::Paint; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, OrientedBox, Selection, SymbolBoxes}; +use vectorcraft_geom::{Affine, PathData, Rect, Shape, union_opt}; + +use crate::group::has_own_paint; +use crate::reshape::{geometry_effects, leaf_geometry}; +use crate::{ + GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, needs_outline, + outline_art, paints, reshape, +}; + +/// Most symbol instances in one object whose art [`Drawn`] measures with its geometry effects: +/// past it, the other instances in the object are measured without geometry effects. +const MAX_INSTANCES: usize = 4096; + +/// The bounds of `ids`: with `preview`, their visual bounds as drawn ([`visual_bounds`]), else +/// their geometric bounds ([`Document::bounds_of`]). +pub fn bounds_of(doc: &Document, ids: &[NodeId], preview: bool) -> Option { + if !preview { + return doc.bounds_of(ids, false); + } + let symbols = doc.symbol_boxes(); + let drawn = Drawn::new(&symbols); + ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, drawn.next().visual_bounds(n))) +} + +/// The bounding box of `ids` square to their shared angle ([`Document::bbox_angle`]), measured as +/// [`bounds_of`] measures them: [`Document::oriented_bounds`] with `preview` false. +pub fn oriented_bounds(doc: &Document, ids: &[NodeId], preview: bool) -> Option { + if !preview { + return doc.oriented_bounds(ids, false); + } + let angle = doc.bbox_angle(ids); + if angle == 0.0 { + return bounds_of(doc, ids, true).map(OrientedBox::aligned); + } + let to_local = OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse(); + let symbols = doc.symbol_boxes(); + let drawn = Drawn::new(&symbols); + let rect = ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, drawn.next().bounds_in(n, to_local)))?; + Some(OrientedBox { angle, rect }) +} + +/// With anchors selected with Direct Selection, the box around those anchors and the objects +/// selected whole, measured by [`bounds_of`]. `None` without any ([`Selection::anchor_bounds`]). +pub fn anchor_bounds(selection: &Selection, doc: &Document, preview: bool) -> Option { + if selection.anchors.is_empty() { + return None; + } + union_opt(bounds_of(doc, &selection.whole(), preview), selection.anchors_box(doc)) +} + +/// The visual bounds of `n` as drawn: those of its [`drawn_art`], else +/// [`Node::visual_bounds_with`]. +pub fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + Drawn::new(symbols).visual_bounds(n) +} + +/// [`visual_bounds`] of `n` as `a` would place it, without changing it ([`Node::bounds_in`]): +/// the art is drawn where `n` is and then placed by `a`. +pub fn bounds_in(symbols: &SymbolBoxes, n: &Node, a: Affine) -> Option { + Drawn::new(symbols).bounds_in(n, a) +} + +/// `n` as plain art with the geometry effects in it applied, at every depth, symbol instances +/// measured by `symbols`. Raster effects stay on the art. A group, layer or intertwine without a +/// clip is rebuilt with the drawn art of each member that has one. A clip group or layer becomes a +/// group of its clipping path without strokes and the drawn art of the path's strokes, when they +/// have one. `None` when no geometry effect in `n` changes what it draws. +pub fn drawn_art(symbols: &SymbolBoxes, n: &Node) -> Option { + Drawn::new(symbols).drawn_art(n) +} + +/// Whether [`drawn_art`] gives `n` art of its own, or would give one to an object in it. A symbol +/// instance in `n` counts when its symbol's art has a geometry effect. +pub fn draws_geometry(symbols: &SymbolBoxes, n: &Node) -> bool { + Drawn::new(symbols).draws_geometry(n) +} + +/// One measurement: whether the art of each symbol checked so far draws geometry, by symbol name, +/// and how many more instances' art it measures with its geometry effects in the current object ([`MAX_INSTANCES`]). +struct Drawn<'s, 'd> { + symbols: &'s SymbolBoxes<'d>, + draws: RefCell>, + left: Cell, +} + +impl<'s, 'd> Drawn<'s, 'd> { + fn new(symbols: &'s SymbolBoxes<'d>) -> Self { + Drawn { symbols, draws: RefCell::default(), left: Cell::new(MAX_INSTANCES) } + } + + /// Sets the count of instances left back to [`MAX_INSTANCES`], for the next object. + fn next(&self) -> &Self { + self.left.set(MAX_INSTANCES); + self + } + + /// See [`visual_bounds`]. + fn visual_bounds(&self, n: &Node) -> Option { + match self.drawn_art(n) { + Some(art) => art.visual_bounds_with(self.symbols), + None => n.visual_bounds_with(self.symbols), + } + } + + /// See [`bounds_in`]. + fn bounds_in(&self, n: &Node, a: Affine) -> Option { + if a == Affine::IDENTITY { + return self.visual_bounds(n); + } + match self.drawn_art(n) { + Some(art) => art.bounds_in(self.symbols, a, true), + None => n.bounds_in(self.symbols, a, true), + } + } + + /// See [`drawn_art`]. + fn drawn_art(&self, n: &Node) -> Option { + let Some(mut art) = self.own_art(n) else { return self.clip_art(n).or_else(|| self.with_drawn_members(n)) }; + // The art's members can carry geometry effects of their own: type's fills and strokes, the + // members of an evaluated group, the clipping path of an evaluated clip group and the + // objects in a symbol's art. + if let Some(m) = self.clip_art(&art).or_else(|| self.with_drawn_members(&art)) { + art = m; + } + Some(art) + } + + /// See [`draws_geometry`]. + fn draws_geometry(&self, n: &Node) -> bool { + let mut found = false; + n.walk(&mut |c| { + found = found + || match &c.kind { + NodeKind::SymbolInstance { symbol, .. } => !geometry_effects(c).is_empty() || self.symbol_draws(symbol), + _ => reshapes(c), + }; + }); + found + } + + /// Whether the art of the symbol named `name` has effects and [`Self::draws_geometry`], worked + /// out once for each name. + fn symbol_draws(&self, name: &str) -> bool { + if let Some(d) = self.draws.borrow().get(name) { + return *d; + } + let d = self.symbols.has_effects(name) && self.symbols.measure_art(name, |art| self.draws_geometry(art)).unwrap_or(false); + self.draws.borrow_mut().insert(name.to_string(), d); + d + } + + /// The art the geometry effects of `n` itself give it, or that a symbol instance gets from the + /// geometry effects in its symbol's art. A group or layer with fills or strokes of its own that + /// paint, around members with geometry effects, is evaluated too: its fills and strokes paint + /// the members' geometry before their effects. A blend, envelope or repeat whose objects have + /// geometry effects is evaluated as the renderer evaluates it ([`expanded_live_group`]), and + /// the objects in its evaluated art keep their geometry effects. `None` when it has none, and + /// for a symbol instance past [`MAX_INSTANCES`]. + fn own_art(&self, n: &Node) -> Option { + let inner = || n.children().is_some_and(|ch| ch.iter().any(|c| self.draws_geometry(c))); + let painted = || has_own_paint(n) && inner(); + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(Some(self.symbols.document()), n), + NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n), + NodeKind::SymbolInstance { symbol, .. } => { + if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) { + return None; + } + let left = self.left.get().checked_sub(1)?; + self.left.set(left); + let art = self.symbols.measure_art(symbol, Arc::clone)?; + // An instance in perspective is projected by `reshape`. + let doc = Some(self.symbols.document()); + reshape(doc, n, Some(&*art)).or_else(|| outline_art(doc, n, Some(&*art))) + } + _ if reshapes(n) => reshape(Some(self.symbols.document()), n, None), + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) if inner() => { + Some(expanded_live_group(Some(self.symbols.document()), n)) + } + _ => None, + } + } + + /// `n`, a group, layer or intertwine without a clip, with each member that has a + /// [`Self::drawn_art`] replaced by it. `None` for any other object and when no member has one. + fn with_drawn_members(&self, n: &Node) -> Option { + if n.clips() || !matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. } | NodeKind::Intertwine { .. }) { + return None; + } + let drawn: Vec<(usize, Node)> = n.children()?.iter().enumerate().filter_map(|(i, c)| Some((i, self.drawn_art(c)?))).collect(); + if drawn.is_empty() { + return None; + } + let mut m = n.clone(); + let children = m.children_mut()?; + for (i, c) in drawn { + if let Some(slot) = children.get_mut(i) { + *slot = Arc::new(c); + } + } + Some(m) + } + + /// Clip group or layer `n` as its clipping path's strokes draw it: a group of the clipping path + /// without its strokes, measured as the region it clips to, and its strokes ([`Node::clip_paint`]) + /// as their [`Self::drawn_art`]. The renderer clips by the clipping path's geometry before its + /// geometry effects ([`crate::clip_outline`]) and paints the path's fills inside that region and + /// its strokes over the clipped art, unclipped. `None` for any other object, when the clipping + /// path is hidden and when its strokes have no drawn art. + fn clip_art(&self, n: &Node) -> Option { + if !n.clips() { + return None; + } + let clip = n.children()?.first().filter(|c| c.visible)?; + let strokes = self.drawn_art(&clip.clip_paint().stroke?)?; + Some(Node::group(n.id, vec![Arc::new(unstroked(clip)), Arc::new(strokes)])) + } +} + +/// Clipping path `clip` without its strokes or its characters' strokes. +fn unstroked(clip: &Node) -> Node { + let mut n = clip.clone(); + n.appearance.items.retain(AppearanceItem::is_fill); + if let NodeKind::Text(t) = &mut n.kind { + for r in &mut t.runs { + r.style.stroke = Paint::None; + } + } + n +} + +/// Whether `n` itself has a geometry effect that [`Drawn::own_art`] applies, its own or one of its +/// fill's or stroke's (a group or layer: also a Pathfinder effect; a symbol instance: only its own). +fn reshapes(n: &Node) -> bool { + match &n.kind { + NodeKind::Group { .. } | NodeKind::Layer { .. } => { + has_pathfinder(n) || !geometry_effects(n).is_empty() || n.appearance.items.iter().any(|i| paints(i) && has_geometry(i.effects())) + } + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => { + has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| paints(i) && has_geometry(i.effects())) + } + _ => needs_outline(n) && (!geometry_effects(n).is_empty() || has_item_geometry(n)), + } +} + +/// Path or compound path `n` as a group of paths: its geometry after its own geometry effects +/// painted by its fills and strokes without geometry effects, and that geometry after the +/// geometry effects of each other fill or stroke that paints, painted by that fill or stroke, as +/// the renderer draws them. The first path is left out when only fills or strokes with geometry +/// effects paint. The paths keep `n`'s id and fill rule and carry no effects. +fn path_art(n: &Node) -> Option { + let (path, rule) = leaf_geometry(n)?; + let ctx = GeomContext::of(n); + let g = apply_geometry_with(&n.appearance.effects, &path, path.to_bezpath().bounding_box(), &ctx); + let piece = |geometry: PathData, items: Vec| { + let mut ap: Appearance = (*n.appearance).clone(); + ap.items = items; + ap.effects.clear(); + for i in &mut ap.items { + i.effects_mut().clear(); + } + let mut p = Node::path(n.id, geometry, ap); + if let NodeKind::Path { rule: r, .. } = &mut p.kind { + *r = rule; + } + Arc::new(p) + }; + let (own, plain): (Vec<&AppearanceItem>, Vec<&AppearanceItem>) = n.appearance.items.iter().partition(|i| paints(i) && has_geometry(i.effects())); + let mut pieces = vec![]; + if own.is_empty() || plain.iter().any(|i| paints(i)) { + pieces.push(piece(g.clone(), plain.into_iter().cloned().collect())); + } + let bounds = g.to_bezpath().bounding_box(); + for item in own { + let geometry = apply_geometry_with(item.effects(), &g, bounds, &ctx.item(item)); + pieces.push(piece(geometry, vec![item.clone()])); + } + Some(Node::group(n.id, pieces)) +} diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ce844a076..0deed993a 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -13,10 +13,11 @@ use std::sync::Arc; -use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind}; +use vectorcraft_doc::live::Hooks; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes}; -use crate::{GeomContext, apply_one, is_geometry, merged_params}; +use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params}; /// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is /// neither a path nor a container)? @@ -36,7 +37,7 @@ pub fn needs_outline(n: &Node) -> bool { /// Is `n` a path or compound path with a visible Transform effect that makes copies and a gradient /// in a fill or stroke? Such an object is drawn through its art ([`outline_art`]) so that each copy -/// paints its gradient by itself ([`copy_with_gradients`]), as a group's copies do. +/// paints its gradient by itself ([`copy_leaf`]), as a group's copies do. pub fn has_gradient_copies(n: &Node) -> bool { matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) && (n.appearance.has_unplaced_gradient() || n.appearance.has_placed_gradient()) @@ -45,6 +46,26 @@ pub fn has_gradient_copies(n: &Node) -> bool { .any(|e| e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0) } +/// Is `n` a path or compound path with a visible Transform effect whose Transform Patterns or +/// Scale Strokes & Effects is on (#885)? Such an effect changes the fills and strokes as well as +/// the geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of +/// its own with its strokes scaled and its patterns moved ([`transform_leaf`]). +pub fn has_transform_options(n: &Node) -> bool { + matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) + && geometry_effects(n) + .iter() + // Both options default to off, so the stored params tell without merging the defaults + // (this runs for every object drawn). + .any(|e| e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance()) +} + +/// Is `n` a path or compound path that its Transform effects make into art of separate objects +/// ([`has_gradient_copies`], [`has_transform_options`])? It is drawn, baked and expanded through +/// that art ([`reshape`]). +pub fn has_transform_art(n: &Node) -> bool { + has_gradient_copies(n) || has_transform_options(n) +} + /// Is `n` type without inline graphics that has a fill or stroke of its own that paints and has a /// visible geometry effect, or an image with a fill or stroke of its own that paints a shape /// ([`image_rows`])? On type, such an effect reshapes the whole outline that its fill or stroke @@ -98,7 +119,7 @@ pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { if !matches!(n.kind, NodeKind::Image(_)) || !has_item_geometry(n) { return vec![]; } - let Some(art) = reshape(n, None) else { return vec![] }; + let Some(art) = reshape(None, n, None) else { return vec![] }; let mut out = vec![]; art.walk(&mut |c| { let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &c.kind else { return }; @@ -120,12 +141,14 @@ pub fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)> { /// The glyph outlines of type `t` in text space with its underline and strikethrough bars: one /// path per run, and all of them as one. pub(crate) fn glyph_outlines(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::BezPath) { - let (runs, all, _) = laid_out(t); + let (runs, all, ..) = laid_out(t); (runs, all) } -/// [`glyph_outlines`] and the bounds of `t`'s layout in text space. -fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::BezPath, Rect) { +/// [`glyph_outlines`], the bounds of `t`'s layout in text space, and its colour glyphs, which have +/// no outlines, placed in the document with their runs: bitmap glyphs (emoji) as images holding +/// their pixels, then COLR and SVG glyphs as their art, as the renderer paints them. +fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::BezPath, Rect, Vec<(usize, Node)>) { let db = vectorcraft_text::FontDb::global(); let layout = vectorcraft_text::layout(db, t); let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; @@ -143,34 +166,37 @@ fn laid_out(t: &vectorcraft_doc::TextObject) -> (Vec, kurbo::Bez } all.extend(bar.iter()); } - (runs, all, layout.bounds) + let images = layout.glyphs.iter().filter_map(|g| Some((g.run, g.placed_image_node(t.xf)?))); + let colours = images.chain(layout.glyphs.iter().filter_map(|g| Some((g.run, g.placed_colour(t.xf)?)))).collect(); + (runs, all, layout.bounds, colours) } /// Type as glyph outlines in document space: one path per run with the run's fill and stroke, -/// and (when the object has its own fills/strokes) the whole outline with the object's items below -/// the characters under them and the others over them, as the renderer paints them. Each run's -/// path is placed by the type's transform, which also scales its stroke weights and dashes and -/// transforms its pattern tiles; its unplaced gradients are first fixed to the layout's bounds in -/// text space. The object's own unplaced gradients are fixed to the bounding box of those bounds -/// in the document. The group is marked as type's outlines +/// over its colour glyphs (images, art), and (when the object has its own fills/strokes) the whole +/// outline with the object's items below the characters under them and the others over them, as +/// the renderer paints them. Each run's path is placed by the type's transform, which also scales its stroke +/// weights and dashes and transforms its pattern tiles; its unplaced gradients are first fixed to +/// the layout's bounds in text space. The object's own unplaced gradients are fixed to the +/// bounding box of those bounds in the document. The group is marked as type's outlines /// ([`vectorcraft_doc::NodeExtras::type_outlines`]). pub fn outline_text(n: &Node) -> Option { let NodeKind::Text(t) = &n.kind else { return None }; - let (runs, all, bounds) = laid_out(t); + let (runs, all, bounds, colours) = laid_out(t); let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf); let scaling = vectorcraft_doc::Scaling { strokes: true, patterns: true, ..Default::default() }; - let mut children: Vec> = runs - .iter() - .zip(&t.runs) - .filter(|(bp, _)| !bp.elements().is_empty()) - .map(|(bp, run)| { - let mut ap = run.style.appearance(); - ap.pin_gradients(bounds); - let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap); - piece.transform(t.xf, scaling); - Arc::new(piece) - }) - .collect(); + let mut children: Vec> = vec![]; + for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { + // Colour glyphs paint themselves, under their run's fill, as the renderer paints them. + children.extend(colours.iter().filter(|(r, _)| *r == i).map(|(_, art)| Arc::new(with_id(art, n.id)))); + if bp.elements().is_empty() { + continue; + } + let mut ap = run.style.appearance(); + ap.pin_gradients(bounds); + let mut piece = Node::path(n.id, PathData::from_bezpath(bp), ap); + piece.transform(t.xf, scaling); + children.push(Arc::new(piece)); + } if !n.appearance.items.is_empty() && !all.elements().is_empty() { let (below, above) = n.appearance.split_contents(); let tb = t.xf.transform_rect_bbox(bounds); @@ -191,12 +217,23 @@ pub fn outline_text(n: &Node) -> Option { Some(g) } +/// `art` with `id` for it and all its members, as the other pieces of outlined type have the type's +/// (Expand Appearance gives them ids of their own). +fn with_id(art: &Node, id: NodeId) -> Node { + let mut art = art.clone(); + art.id = id; + for c in art.children_mut().into_iter().flatten() { + *c = Arc::new(with_id(c, id)); + } + art +} + /// `n` as plain art that geometry effects can reshape, without its transparency and object /// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), /// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by -/// the instance's transform. `None` for other kinds (images, paths, containers). -pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { - let hook: &dyn Fn(&Node) -> Option = &outline_text; +/// the instance's transform. `None` for other kinds (images, paths, containers). A Repeat counts +/// the Repeats in the art of `doc`'s symbols that its instances show toward its instance budget. +pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { let mut art = match &n.kind { NodeKind::Text(_) => outline_text(n)?, NodeKind::SymbolInstance { xf, .. } => { @@ -205,7 +242,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { Node::group(n.id, vec![Arc::new(a)]) } // The object as the one piece the copies of its Transform effect are made from. - _ if has_gradient_copies(n) => { + _ if has_transform_art(n) => { let mut one = n.clone(); one.opacity = 1.0; one.blend = Default::default(); @@ -214,7 +251,7 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { one.appearance.effects.clear(); Node::group(n.id, vec![Arc::new(one)]) } - _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), + _ if vectorcraft_doc::live::is_live(n) => evaluated(doc, n), _ => return None, }; outline_nested_text(&mut art, false); @@ -228,6 +265,14 @@ pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { Some(art) } +/// `n` with every live object in it evaluated, type inside them outlined ([`outline_text`]). The +/// Repeats among them count the Repeats in the art of `doc`'s symbols that their instances show +/// toward their instance budget ([`vectorcraft_doc::RepeatSpec::expand`]). +pub(crate) fn evaluated(doc: Option<&Document>, n: &Node) -> Node { + let hook: &dyn Fn(&Node) -> Option = &outline_text; + vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(hook), symbols: doc, ..Default::default() }) +} + /// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look /// ([`as_art`]). Type in a clip group's clipping path (all of `n` when `clipping` is set) becomes /// its plain outlines ([`outline_text`]) with its visibility, which is what @@ -239,7 +284,7 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { let clipping = clipping || (clips && i == 0); let art = match &c.kind { NodeKind::Text(_) if clipping => outline_text(c).map(|o| Node { visible: c.visible, ..o }), - NodeKind::Text(_) => as_art(c, None), + NodeKind::Text(_) => as_art(None, c, None), _ => None, }; if let Some(o) = art { @@ -259,21 +304,22 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) { /// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective /// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts, /// or has no art (a symbol instance without `symbol_art`). -pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { - if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_gradient_copies(n)) { +pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { + if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) { return None; } - as_art(n, symbol_art) + as_art(doc, n, symbol_art) } -/// `n` (see [`needs_outline`], or a path with [`has_gradient_copies`]) as plain art for Expand +/// `n` (see [`needs_outline`], or a path with [`has_transform_art`]) as plain art for Expand /// Appearance: [`reshape`]d when it has /// geometry effects, else type with fills or strokes of its own outlined, or an image with fills /// or strokes of its own that paint a shape, in the same form. `None` when it has none of these. -pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { - // A path whose Transform effect copies it with a gradient: each copy an object of its own. - if has_gradient_copies(n) { - return as_art(n, symbol_art); +pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { + // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its + // patterns: each copy an object of its own. + if has_transform_art(n) { + return as_art(doc, n, symbol_art); } let own_paint = match n.kind { NodeKind::Text(_) => !n.appearance.items.is_empty(), @@ -282,11 +328,11 @@ pub fn expand_outlined(n: &Node, symbol_art: Option<&Node>) -> Option { if !needs_outline(n) || (geometry_effects(n).is_empty() && !own_paint) { return None; } - as_art(n, symbol_art) + as_art(doc, n, symbol_art) } /// [`reshape`] without its checks (no geometry effects: just the art). -pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { +pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option { let fx = geometry_effects(n); let mut art = match &n.kind { NodeKind::Image(im) => { @@ -314,7 +360,7 @@ pub(crate) fn as_art(n: &Node, symbol_art: Option<&Node>) -> Option { Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect()) } } - _ => outline_art(n, symbol_art)?, + _ => outline_art(doc, n, symbol_art)?, }; reshape_leaves(&mut art, &fx); // Type and symbols in perspective: the art projected onto their plane. @@ -372,65 +418,146 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> { /// Apply `fx` to every path under `art` as one piece: each effect takes the whole art's bounds as /// its reference box and the art's seed (a random Transform moves the pieces together). Images and /// symbol instances follow a Transform effect the same way ([`transform_placed`]); other geometry -/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). +/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The +/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path +/// whose effect would pass what the others leave of it keeps its shape. pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) { + reshape_leaves_within(art, fx, Budget::EFFECTS); +} + +/// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost. +pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) { + if fx.is_empty() { + return; + } let seed = art.id.0; // The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the // pieces, from the outlines' bounds before the effects to their bounds after them. let mut outlines = vec![]; type_outline_bounds(art, &mut outlines); + let mut used = art_cost(art); + let max = budget.at_least(used); for e in fx { let Some(bounds) = art.geometric_bounds() else { break }; let params = merged_params(&e.id, &e.params); - let transform = (e.id == "distort.transform").then(|| crate::distort::transform_map(bounds, ¶ms, seed)); + let transform = (e.id == "distort.transform").then(|| { + let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed); + let options = crate::distort::TransformOptions::of(¶ms); + (m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed)) + }); for_each_leaf(art, &mut |leaf| { let Some((path, rule)) = leaf_geometry(leaf) else { return }; - // A gradient paints each copy by itself, not the one piece the copies make together. - if let Some((m, copies)) = transform - && copies > 0 - && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient()) - { - copy_with_gradients(leaf, &path, m, copies); - return; + // What the other paths leave of the budget. + let before = Budget::of(&path); + let room = max.minus(used.minus(before)); + if let Some((m, copies, options, map)) = transform { + // Transform Patterns and Scale Strokes & Effects change each copy's fills and + // strokes; a gradient paints each copy by itself, not the one piece the copies + // make together. + let gradient = copies > 0 && (leaf.appearance.has_unplaced_gradient() || leaf.appearance.has_placed_gradient()); + if options.reach_appearance() { + let made = transform_leaf(leaf, &path, map, copies, options, room); + used = used.minus(before).plus(made); + return; + } + if gradient { + let made = copy_leaf(leaf, &path, m, copies, vectorcraft_doc::Scaling::default(), room); + used = used.minus(before).plus(made); + return; + } } - let out = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }); + let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { return }; + used = used.minus(before).plus(Budget::of(&out)); set_geometry(leaf, out, rule); }); - if let Some((m, copies)) = transform { + if let Some((m, copies, options, _)) = transform + && options.objects + { transform_placed(art, m, copies); } } - if !fx.is_empty() && outlines.iter().any(Option::is_some) { + if outlines.iter().any(Option::is_some) { rebase_type_outlines(art, &mut outlines.into_iter()); } } +/// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by +/// (its copies stop before one would take more), so stroke weights stay finite where the geometry +/// doesn't grow with them (a horizontal line stretched vertically). +const MAX_STROKE_SCALE: f64 = 1.0e6; + +/// A Transform effect whose Transform Patterns or Scale Strokes & Effects is on (`options`), with +/// map `m` and `copies`, applied to `leaf` (a path or compound path with geometry `path`) as Object +/// › Transform with those options transforms an object (#885): the strokes' weights and dashes +/// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its +/// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them +/// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform +/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after. +fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, options: crate::distort::TransformOptions, budget: Budget) -> Budget { + let scaling = vectorcraft_doc::Scaling { + strokes: options.strokes, + effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)), + patterns: options.patterns, + ..Default::default() + }; + if !options.objects { + if options.strokes + && let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0) + { + leaf.appearance.scale_strokes(k); + leaf.appearance.scale_effects(k, crate::scale_effect); + } + if options.patterns { + vectorcraft_doc::pattern::transform_pattern_paints(leaf, m); + } + return Budget::of(path); + } + if copies == 0 { + // A path scaled up is drawn with more lines; past the budget it keeps its shape. + let moved = path.transformed(m); + if !budget.fits(Budget::of(&moved)) { + return Budget::of(path); + } + leaf.transform(m, scaling); + return Budget::of(&moved); + } + copy_leaf(leaf, path, m, copies, scaling, budget) +} + /// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies` -/// more, each mapped once more by `m` than the last, as a Transform effect copies an object. Each -/// copy is an object of its own, so its gradients fit it (a placed gradient moves with it) rather -/// than spanning every copy. The copies stop where [`crate::distort::transform_within`]'s would. -fn copy_with_gradients(leaf: &mut Node, path: &PathData, m: Affine, copies: usize) { - let each = path.anchor_count(); - let mut anchors = each; +/// more, each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies +/// an object. Each copy is an object of its own, so its gradients fit it (a placed gradient moves +/// with it) rather than spanning every copy, and its strokes and patterns can differ from the +/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass +/// `budget`, and before one's strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the +/// copies cost, the original included. +fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { + let mut made = Budget::of(path); let mut placements: Vec> = vec![Arc::new(leaf.clone())]; let mut xf = Affine::IDENTITY; for _ in 0..copies { - anchors = anchors.saturating_add(each); - if anchors > crate::distort::MAX_COPY_ANCHORS { + xf *= m; + // A copy scaled up is drawn with more lines. + let cost = Budget::of(&path.transformed(xf)); + if !budget.fits(made.plus(cost)) { + break; + } + if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) { break; } - xf *= m; let mut copy = leaf.clone(); - copy.transform(xf, vectorcraft_doc::Scaling::default()); + copy.transform(xf, scaling); if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) { break; } placements.push(Arc::new(copy)); + made = made.plus(cost); } // The copies become nodes of their own under a group with the object's id and name. let mut g = Node::group(leaf.id, placements); g.name = leaf.name.clone(); *leaf = g; + made } /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in @@ -577,9 +704,8 @@ fn placed_count(n: &Node) -> usize { /// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on /// it reach every member. -pub(crate) fn outlined_members(n: &Node) -> Node { - let hook: &dyn Fn(&Node) -> Option = &outline_text; - let mut art = vectorcraft_doc::live::expand_deep(n, Some(hook)); +pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node { + let mut art = evaluated(doc, n); outline_nested_text(&mut art, false); art } @@ -597,6 +723,18 @@ fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) { } } +/// What the paths under `n` that [`for_each_leaf`] visits cost together, as [`leaf_geometry`] +/// gives them. +fn art_cost(n: &Node) -> Budget { + let none = Budget { anchors: 0, lines: 0 }; + match &n.kind { + NodeKind::Path { guide: false, path, .. } => Budget::of(path), + NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).map(Budget::of).fold(none, Budget::plus), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().map(|c| art_cost(c)).fold(none, Budget::plus), + _ => none, + } +} + pub(crate) fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> { match &n.kind { NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 1f17010e3..17507f3ce 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -181,7 +181,7 @@ fn shading_paint(g: vectorcraft_three_d::ShadeGradient, color: impl Fn(f64) -> C use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; let stops = g.brightness.into_iter().enumerate().map(|(i, s)| GradientStop { opacity: opacity(s), ..GradientStop::new(i as f32, color(s)) }).collect(); - let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); paint.geom = Some(GradientGeom { start: g.start, end: g.end, aspect: 1.0, focal: None }); Paint::Gradient(Box::new(paint)) } diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index d4c461f07..cd09153c6 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -143,6 +143,13 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { facets(id, &path, rule, b, p).map(|_| ()) } +/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked +/// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by +/// [`validate_solid`]. +pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> { + if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) } +} + /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and /// blend mode (so overlapping facets don't add up their transparency). fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node { diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index 62d1d536f..d274bdc2a 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -15,7 +15,7 @@ use kurbo::{BezPath, Point, Shape, Vec2}; use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint}; -use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP, lerp_color}; +use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP}; use vectorcraft_doc::{GradientMesh, LineJoin, MeshPoint, StrokeAlign, StrokeGradientMode, StrokeLayer}; use vectorcraft_geom::FillRule; @@ -375,7 +375,7 @@ pub fn written_slices(bp: &BezPath, rule: FillRule, st: &StrokeLayer, outlines: /// stroke's outline), coloured as [`gradient_slices`] paint it. pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { let across = st.gradient_mode == StrokeGradientMode::Across; - let color = |t: f64| g.sample_with(t.clamp(0.0, 1.0) as f32, lerp_color); + let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. let marks: Vec = if across { vec![] } else { g.expanded_stops().iter().map(|s| s.0 as f64).collect() }; let max_dt = match (across, st.profile.is_some()) { diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index a7d8b4031..067064950 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -127,6 +127,25 @@ fn dot_outlines_take_the_cap_shape_and_wind_like_stroked_dashes() { assert!(o.contains(Point::new(10.5, 0.0)), "the dot overlapping the dash end is painted"); } +#[test] +fn expand_stroke_region_of_a_round_line_stays_compact() { + use std::f64::consts::PI; + use vectorcraft_geom::{FillRule, PathData, Point, SubPath}; + let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false)); + let st = stroke(10.0, |s| s.cap = vectorcraft_doc::LineCap::Round); + let region = crate::stroke::outline::outline_region(&path, FillRule::NonZero, &st); + let anchors = region.subpaths.iter().map(|s| s.anchors.len()).sum::(); + assert!(anchors <= 8, "expand stroke should use few anchors for a round line, got {anchors}"); + let area = region.subpaths.iter().map(|s| s.area()).sum::().abs(); + assert!(near(area, 100.0 * 10.0 + PI * 5.0 * 5.0, 1.0), "{area}"); + // Extra colinear anchors along the spine must not multiply outline anchors (#3446). + let dense: Vec = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect(); + let dense_path = PathData::single(SubPath::polyline(&dense, false)); + let dense_region = crate::stroke::outline::outline_region(&dense_path, FillRule::NonZero, &st); + let dense_anchors = dense_region.subpaths.iter().map(|s| s.anchors.len()).sum::(); + assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors"); +} + #[test] fn line_outline_uses_the_profile_dashes_or_a_plain_stroke() { let l = line(0.0, 100.0); diff --git a/crates/effects/src/stylize.rs b/crates/effects/src/stylize.rs index 2db14d10f..bdb5f8623 100644 --- a/crates/effects/src/stylize.rs +++ b/crates/effects/src/stylize.rs @@ -184,9 +184,14 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { row: u64, } let mut chains: Vec = vec![]; - let mut y = y0 + step / 2.0; let mut row = 0u64; - while y < y1 { + // Rows are counted, not summed: adding a step that is below the spacing of the numbers at `y` + // would never move it. + while row <= 2001 { + let y = y0 + step / 2.0 + row as f64 * step; + if y.partial_cmp(&y1) != Some(std::cmp::Ordering::Less) { + break; + } let mut xs: Vec = vec![]; for pl in &polys { for w in pl.windows(2) { @@ -225,7 +230,6 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData { } } row += 1; - y += step; } let lines: Vec = chains .into_iter() diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 34047cc05..ad66a2a97 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -262,6 +262,7 @@ fn stacked_transform_copies_stay_within_the_image_budget() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ) }; @@ -284,7 +285,154 @@ fn stacked_transform_copies_stay_within_the_image_budget() { assert_eq!(count(&art), 25); let mut g = Node::group(id, vec![Arc::new(image())]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 1000}))); - assert_eq!(count(&evaluate_container(&g).unwrap()), 1001); + assert_eq!(count(&evaluate_container(None, &g).unwrap()), 1001); +} + +/// What `effects` make of `path` within `budget`. +fn within(effects: &[Effect], path: &PathData, budget: Budget) -> PathData { + apply_geometry_within(effects, path, path.bounds().unwrap(), &GeomContext::default(), budget) +} + +/// What `effects` make of `path` within a budget of `max` anchors (and any number of lines). +fn anchors_within(effects: &[Effect], path: &PathData, max: usize) -> PathData { + within(effects, path, Budget { anchors: max, lines: usize::MAX }) +} + +/// #3578: stacked Roughens multiply their points (up to 2000 a segment); the anchors they make +/// stay within the budget, each Roughen spacing its points further apart where it must. +#[test] +fn stacked_roughens_stay_within_the_anchor_budget() { + let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100})); + let outs: Vec = (1..=3).map(|n| anchors_within(&vec![rough.clone(); n], &square(), 10_000)).collect(); + let counts: Vec = outs.iter().map(PathData::anchor_count).collect(); + // 100 pt sides at 100 points per inch: 139 points each, within the budget. + assert_eq!(counts[0], 4 * 139); + assert_eq!(outs[0], run("distort.roughen", rough.params.clone(), &square())); + assert!(counts[1..].iter().all(|&c| c > 5_000 && c <= 10_000), "{counts:?}"); + // The third Roughen still roughens, with its points spaced to fit. + assert_ne!(outs[2], outs[1]); + assert!(outs.iter().all(all_finite)); +} + +/// #3578: stacked Zig Zags multiply their points (up to 101 a segment); each segment gets as many +/// ridges as fit the budget. +#[test] +fn stacked_zig_zags_stay_within_the_anchor_budget() { + let zig = fx("distort.zigZag", json!({"ridges": 100, "points": "corner"})); + let counts: Vec = (1..=3).map(|n| anchors_within(&vec![zig.clone(); n], &square(), 10_000).anchor_count()).collect(); + // 4 × 101 points; then 23 ridges a segment fit (404 × 24); then none, each point displaced. + assert_eq!(counts, [404, 9_696, 9_696]); + let twice = anchors_within(&vec![zig.clone(); 2], &square(), 10_000); + assert_ne!(anchors_within(&vec![zig.clone(); 3], &square(), 10_000), twice); + // An open path's end point counts as well. + let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0)], false)); + assert_eq!(anchors_within(&[zig], &line, 30).anchor_count(), 30, "the start, 28 ridges and the end"); +} + +/// #3578: every effect that multiplies anchors builds within the budget, stacked or not, and one +/// that doesn't (Round Corners doubles them) is left out where it would pass it. +#[test] +fn stacked_geometry_effects_stay_within_the_anchor_budget() { + let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 5, 0.0); + for (id, params) in [ + ("distort.roughen", json!({"size": 50, "detail": 100})), + ("distort.zigZag", json!({"ridges": 100, "size": 30})), + ("distort.twist", json!({"angle": 3600})), + ("distort.freeDistort", json!({"corners": [[0.3, -2], [1, 0], [4, 1], [0, 3]]})), + ("warp.twist", json!({"bend": 100})), + ("distort.transform", json!({"copies": 1000, "moveH": 1})), + ] { + for n in 1..=4 { + let out = anchors_within(&vec![fx(id, params.clone()); n], &star, 2_000); + assert!(out.anchor_count() <= 2_000 && all_finite(&out), "{id} × {n}: {}", out.anchor_count()); + } + } + let zig = fx("distort.zigZag", json!({"ridges": 100, "points": "corner"})); + let full = anchors_within(&[zig.clone(), zig.clone()], &square(), 10_000); + let round = fx("stylize.roundCorners", json!({"radius": 1})); + assert_eq!(anchors_within(&[zig.clone(), zig.clone(), round.clone()], &square(), 10_000), full); + assert_ne!(anchors_within(&[zig, round], &square(), 10_000), run("distort.zigZag", json!({"ridges": 100, "points": "corner"}), &square())); +} + +/// #3578: a path with more anchors than the budget keeps its count as its budget: effects that +/// add none apply, and the others add none. +#[test] +fn a_path_past_the_anchor_budget_still_takes_effects() { + let star = shapes::star(Point::new(150.0, 100.0), 70.0, 32.0, 25, 0.0); + let n = star.anchor_count(); + for (id, params) in [ + ("distort.tweak", json!({})), + ("distort.roughen", json!({"detail": 100})), + ("distort.zigZag", json!({"ridges": 100})), + ("distort.twist", json!({"angle": 90})), + ] { + let out = anchors_within(&[fx(id, params)], &star, 10); + assert_eq!(out.anchor_count(), n, "{id}"); + assert_ne!(out, star, "{id}"); + } +} + +/// #3578: the effects on type, symbols and images reshape all their paths within one budget. +#[test] +fn stacked_effects_on_art_stay_within_the_anchor_budget() { + use std::sync::Arc; + + use vectorcraft_doc::NodeId; + let id = NodeId(9); + let leaf = |x: f64| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), Appearance::default())); + let mut art = Node::group(id, (0..10).map(|k| leaf(k as f64 * 120.0)).collect()); + let rough = fx("distort.roughen", json!({"size": 50, "detail": 100})); + crate::reshape::reshape_leaves_within(&mut art, &[&rough, &rough, &rough], Budget { anchors: 5_000, lines: usize::MAX }); + let mut total = 0; + let mut reshaped = 0; + for c in art.children().into_iter().flatten() { + let path = c.path_data().unwrap(); + total += path.anchor_count(); + reshaped += usize::from(path.anchor_count() > 4); + } + // One Roughen alone makes 10 × 556: the budget binds, and goes to the paths in turn. + assert!(total > 4_000 && total <= 5_000, "{total}"); + assert_eq!(reshaped, 10); +} + +/// #3578: a large Roughen draws each of its points as many lines; they stay within the budget, +/// the Roughen built again with fewer points where they wouldn't. +#[test] +fn large_roughens_stay_within_the_line_budget() { + let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100})); + let budget = Budget { anchors: 100_000, lines: 200_000 }; + let once = within(std::slice::from_ref(&rough), &square(), budget); + assert_eq!(once, run("distort.roughen", rough.params.clone(), &square()), "within both budgets"); + let twice = within(&[rough.clone(), rough], &square(), budget); + let cost = Budget::of(&twice); + assert!(budget.fits(cost) && cost.lines > 100_000, "{cost:?}"); + assert!(cost.anchors < 10_000 && twice != once, "the second Roughen with fewer points: {cost:?}"); + // Art of small curves draws its anchors as a few lines each: the anchors bind first. + let zig = fx("distort.zigZag", json!({"ridges": 100, "size": 2})); + let out = within(&[zig.clone(), zig], &square(), Budget { anchors: 10_000, lines: 100_000 }); + assert!(out.anchor_count() > 9_000 && util::line_estimate(&out) < 50_000, "{}", util::line_estimate(&out)); +} + +/// Straight segments are one line each, and a curve as many as Wang's formula takes at 0.25 px. +#[test] +fn line_estimate_counts_the_lines_a_path_is_drawn_with() { + assert_eq!(util::line_estimate(&square()), 4); + let circle = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_eq!(util::line_estimate(&circle), 4 * 9, "a quarter circle of 50 pt in 9 lines"); + let big = shapes::ellipse(Rect::new(0.0, 0.0, 10_000.0, 10_000.0)); + assert_eq!(util::line_estimate(&big), 4 * 84, "100 times as large: about 10 times the lines"); +} + +/// #3578: the issue's input, a large rectangle with three max-detail Roughens of the largest +/// size, at the real budgets: two million anchors of it were 141 million lines to draw. +#[test] +fn three_max_detail_roughens_stay_within_the_effect_budget() { + let rough = fx("distort.roughen", json!({"size": 10000, "relative": false, "detail": 100})); + let rect = shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)); + let out = apply_geometry(&vec![rough.clone(); 3], &rect, rect.bounds().unwrap()); + let cost = Budget::of(&out); + assert!(Budget::EFFECTS.fits(cost) && cost.lines > MAX_EFFECT_LINES / 2, "{cost:?}"); + assert_ne!(out, apply_geometry(&[rough], &rect, rect.bounds().unwrap()), "more than one Roughen applies"); } #[test] @@ -311,11 +459,12 @@ fn transform_on_a_group_moves_and_copies_its_images() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ); let mut g = Node::group(id, vec![Arc::new(sq), Arc::new(image)]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2}))); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let mut xs = vec![]; image_xs(&art, &mut xs); xs.sort_by(f64::total_cmp); @@ -332,11 +481,12 @@ fn transform_on_a_group_moves_and_copies_its_images() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ))], ); g.appearance.effects.push(fx("distort.transform", json!({"scaleH": 50, "scaleV": 50, "reference": 8}))); - let art = evaluate_container(&g).unwrap(); + let art = evaluate_container(None, &g).unwrap(); let mut xs = vec![]; image_xs(&art, &mut xs); // The bounds are the image itself, so the bottom-right reference is its own: it halves in place. @@ -361,11 +511,11 @@ fn transform_on_a_group_moves_and_copies_its_symbol_instances() { }; let mut g = Node::group(id, vec![Arc::new(Node::path(id, square(), Appearance::default())), Arc::new(instance())]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70, "copies": 2}))); - assert_eq!(xs(&evaluate_container(&g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart"); + assert_eq!(xs(&evaluate_container(None, &g).unwrap()), [5.0, 75.0, 145.0], "the instance and its two copies, 70 pt apart"); // Without copies the instance itself takes the transform, as a path does. let mut g = Node::group(id, vec![Arc::new(instance())]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 70}))); - assert_eq!(xs(&evaluate_container(&g).unwrap()), [75.0]); + assert_eq!(xs(&evaluate_container(None, &g).unwrap()), [75.0]); } /// Images and symbol instances share the copy budget: copies of one leave less room for the other. @@ -384,6 +534,7 @@ fn images_and_symbol_instances_share_the_copy_budget() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ); let instance = Node::new(id, NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY }); @@ -413,13 +564,14 @@ fn transform_copies_of_images_stop_at_the_reach_limit() { xf: Affine::translate((10.0, 0.0)), link: None, placement: Default::default(), + pixels: None, })), ); let mut g = Node::group(id, vec![Arc::new(image)]); // The largest move: every copy is 1e7 pt further right, so the 100th would end past 1e9. g.appearance.effects.push(fx("distort.transform", json!({"moveH": 1.0e7, "copies": 1000}))); let mut xs = vec![]; - image_xs(&evaluate_container(&g).unwrap(), &mut xs); + image_xs(&evaluate_container(None, &g).unwrap(), &mut xs); assert_eq!(xs.len(), 100, "the image and the 99 copies that stay within reach"); assert!(xs.iter().all(|x| x + 40.0 <= 1.0e9)); } @@ -914,3 +1066,43 @@ fn bench_catalog_lookup() { } eprintln!("10k lookups: {:?} ({n})", t.elapsed()); } + +#[test] +fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() { + use std::sync::Arc; + use vectorcraft_doc::{Document, NodeId, RepeatKind, RepeatSpec, Symbol}; + let unit = || { + Arc::new(Node::path( + NodeId(0), + PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0)], true)), + Appearance::default(), + )) + }; + let grid = |source: Arc, n: u32| { + let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0); + if let RepeatKind::Grid { rows, cols, .. } = &mut g.kind { + *rows = n; + *cols = n; + } + g + }; + let repeat = |r: RepeatSpec| Arc::new(Node::new(NodeId(0), NodeKind::Repeat(r))); + let instance = |name: &str| Arc::new(Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })); + let mut d = Document::new(100.0, 100.0); + // A 500 × 500 grid as a symbol's art, directly and inside a group; a symbol holding itself. + d.symbols.push(Symbol { name: "Grid".into(), art: repeat(grid(unit(), 500)) }); + d.symbols.push(Symbol { name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) }); + d.symbols.push(Symbol { name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) }); + d.symbols.push(Symbol { name: "Small".into(), art: repeat(grid(unit(), 3)) }); + // A 3 × 3 grid of an instance of each: each copy draws 250,000 instances, so one copy fits. + for name in ["Grid", "Grouped"] { + let outer = Node::new(NodeId(0), NodeKind::Repeat(grid(instance(name), 3))); + let kept = expand_live(Some(&d), &outer).len(); + assert!(kept <= 1, "{name}: {kept} copies of 250,000 instances"); + } + // A symbol holding itself counts no Repeats, and small nests keep every instance. + let looped = Node::new(NodeId(0), NodeKind::Repeat(grid(instance("Loop"), 3))); + assert_eq!(expand_live(Some(&d), &looped).len(), 9); + let small = Node::new(NodeId(0), NodeKind::Repeat(RepeatSpec::mirror(vec![instance("Small")], 90.0, 5.0))); + assert_eq!(expand_live(Some(&d), &small).len(), 2); +} diff --git a/crates/effects/src/tests_solids.rs b/crates/effects/src/tests_solids.rs index 61b5a7f57..9dd72d4b1 100644 --- a/crates/effects/src/tests_solids.rs +++ b/crates/effects/src/tests_solids.rs @@ -286,7 +286,7 @@ fn a_masked_surface_under_a_colored_light_stays_whole() { use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; let mut half = GradientStop::new(1.0, RED); half.opacity = 0.5; - let gradient = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, RED), half] }); + let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), half])); for (lights, surfaces) in [(json!({"lights": [{"color": "#ff8080"}]}), 2), (json!({}), 1)] { let mut n = stroked_rect(100.0); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { diff --git a/crates/effects/src/tests_transformfx.rs b/crates/effects/src/tests_transformfx.rs new file mode 100644 index 000000000..a5fdd674c --- /dev/null +++ b/crates/effects/src/tests_transformfx.rs @@ -0,0 +1,159 @@ +//! The Transform effect's Transform Objects, Transform Patterns and Scale Strokes & Effects +//! options (#885). + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Dash, Effect, Node, NodeId, NodeKind}; +use vectorcraft_geom::{Affine, Rect, shapes}; + +use crate::distort::{TransformOptions, transform_map, transform_move}; +use crate::{has_transform_art, has_transform_options, reshape}; + +fn transform(params: Value) -> Effect { + Effect { id: "distort.transform".into(), params, visible: true } +} + +/// A 100 pt square at the origin with a 2 pt black stroke (dashed 4/2) and the Transform effect +/// `params`. +fn stroked(params: Value) -> Node { + let mut n = + Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + if let Some(s) = n.appearance.stroke_mut() { + s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }); + } + n.appearance.effects.push(transform(params)); + n +} + +/// A 100 pt square at the origin filled with pattern "p" placed at the identity, with the +/// Transform effect `params`. +fn patterned(params: Value) -> Node { + let paint = Paint::Pattern { pattern: "p".into(), xf: Affine::IDENTITY }; + let mut n = Node::path(NodeId(8), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(paint, Paint::None, 0.0)); + n.appearance.effects.push(transform(params)); + n +} + +/// The paths under `n`, in document order. +fn leaves(n: &Node) -> Vec<&Node> { + let mut out = vec![]; + fn walk<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { + match n.children() { + Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().for_each(|c| walk(c, out)), + _ => out.push(n), + } + } + walk(n, &mut out); + out +} + +fn stroke_of(n: &Node) -> (f64, Vec) { + let s = n.appearance.stroke().unwrap(); + (s.width, s.dash.as_ref().map(|d| d.pattern.clone()).unwrap_or_default()) +} + +fn pattern_xf(n: &Node) -> Affine { + match n.appearance.fill_paint() { + Paint::Pattern { xf, .. } => xf, + p => panic!("not a pattern: {p:?}"), + } +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +#[test] +fn the_options_default_to_what_the_effect_did_before() { + let o = TransformOptions::of(&crate::merged_params("distort.transform", &json!({}))); + assert_eq!(o, TransformOptions { objects: true, patterns: false, strokes: false }); + assert!(!o.reach_appearance()); + // An old effect, with no options stored, still draws as geometry alone. + let n = stroked(json!({"scaleH": 50, "scaleV": 50, "copies": 2})); + assert!(!has_transform_options(&n)); + assert!(!has_transform_art(&n)); +} + +#[test] +fn transform_objects_off_maps_nothing_and_makes_no_copies() { + let b = Rect::new(0.0, 0.0, 100.0, 100.0); + let (m, copies) = transform_map(b, &json!({"scaleH": 50, "moveH": 30, "copies": 3, "objects": false}), 0); + assert_eq!((m, copies), (Affine::IDENTITY, 0)); + // A plain move with Transform Patterns on isn't a plain move any more: the pattern moves too. + assert!(transform_move(&json!({"moveH": 30})).is_some()); + assert!(transform_move(&json!({"moveH": 30, "patterns": true})).is_none()); + assert!(transform_move(&json!({"moveH": 30, "strokes": true})).is_some(), "a move scales nothing"); + assert!(transform_move(&json!({"scaleH": 50, "objects": false, "strokes": true})).is_none()); +} + +#[test] +fn scale_strokes_and_effects_scales_the_stroke_and_dash_with_the_object() { + let n = stroked(json!({"scaleH": 50, "scaleV": 50, "strokes": true})); + assert!(has_transform_art(&n)); + let art = reshape(None, &n, None).unwrap(); + let paths = leaves(&art); + assert_eq!(paths.len(), 1); + let (w, dash) = stroke_of(paths[0]); + assert!(close(w, 1.0), "{w}"); + assert_eq!(dash, vec![2.0, 1.0]); + let b = paths[0].geometric_bounds().unwrap(); + assert!(close(b.width(), 50.0), "{b:?}"); +} + +#[test] +fn each_copy_scales_its_stroke_once_more() { + let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true})); + let art = reshape(None, &n, None).unwrap(); + let widths: Vec = leaves(&art).iter().map(|p| stroke_of(p).0).collect(); + assert_eq!(widths, vec![2.0, 4.0, 8.0]); + // Without the option the copies keep the original's weight. + let plain = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2})); + assert!(!has_transform_art(&plain)); +} + +#[test] +fn transform_objects_off_keeps_the_geometry_and_scales_the_stroke_once() { + let n = stroked(json!({"scaleH": 50, "scaleV": 50, "moveH": 40, "copies": 3, "objects": false, "strokes": true})); + let art = reshape(None, &n, None).unwrap(); + let paths = leaves(&art); + assert_eq!(paths.len(), 1, "no copies"); + assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(close(stroke_of(paths[0]).0, 1.0)); +} + +#[test] +fn transform_patterns_moves_each_copys_pattern() { + let n = patterned(json!({"moveH": 30, "copies": 2, "patterns": true})); + assert!(has_transform_options(&n)); + let art = reshape(None, &n, None).unwrap(); + let xs: Vec = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect(); + assert_eq!(xs, vec![0.0, 30.0, 60.0]); + // Without it, every copy's pattern stays put (the tiles don't follow the copies). + let n = patterned(json!({"moveH": 30, "patterns": false, "strokes": false, "objects": true})); + assert!(!has_transform_art(&n)); +} + +#[test] +fn transform_patterns_with_objects_off_moves_only_the_pattern() { + let n = patterned(json!({"rotate": 45, "objects": false, "patterns": true})); + let art = reshape(None, &n, None).unwrap(); + let paths = leaves(&art); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0)); + assert_ne!(pattern_xf(paths[0]), Affine::IDENTITY); +} + +#[test] +fn stroke_scaling_across_copies_stays_finite() { + // A horizontal line stretched vertically barely grows, but its stroke would: the copies stop + // before a stroke weight runs away. + let mut n = + Node::path(NodeId(9), shapes::line((0.0, 0.0).into(), (100.0, 0.0).into()), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)); + n.appearance.effects.push(transform(json!({"scaleH": 4000, "scaleV": 4000, "copies": 1000, "strokes": true}))); + if let Some(art) = reshape(None, &n, None) { + for p in leaves(&art) { + let w = stroke_of(p).0; + assert!(w.is_finite() && w <= 1.0e6, "{w}"); + } + } +} diff --git a/crates/effects/src/trap.rs b/crates/effects/src/trap.rs new file mode 100644 index 000000000..f60232bed --- /dev/null +++ b/crates/effects/src/trap.rs @@ -0,0 +1,469 @@ +//! Pathfinder › Trap, shared by the Pathfinder panel's Trap command (`object.pathfinder.trap`) and +//! the live effect Effect › Pathfinder › Trap ([`TRAP`]). +//! +//! Where two colours printed from separate plates meet, a press that registers the plates slightly +//! off leaves a sliver of paper showing between them. A trap closes that gap: a thin extra shape +//! along the edge where the colours meet, lying over the darker colour, painted with the lighter +//! colour and set to overprint, so the lighter colour seems to spread into the darker one (a +//! lighter object on a darker background) or to choke it (a lighter background around a darker +//! object). The options: +//! +//! - **Thickness** (0.01–5000 pt): how far the lighter colour reaches into the darker one across +//! vertical edges. +//! - **Height/Width** (%): the reach across horizontal edges as a percentage of the vertical one +//! (100%: the same everywhere), for paper that stretches more one way on press. +//! - **Tint Reduction** (%): the trap paints the lighter colour at this percentage of its tint +//! (100: at full strength), so two light colours don't print a dark border where they overlap. +//! - **Traps with Process Color**: a trap of a swatch colour (a spot colour) is painted with the +//! equivalent CMYK process colour instead of the swatch. +//! - **Reverse Traps**: the darker colour spreads into the lighter one instead; not when the +//! darker colour is a rich black (100% K with other inks), which stays where it is. +//! - **Precision** and **Remove Redundant Points** (the effect only): the tolerance in points the +//! trap's outline is refitted to, and whether anchors that don't change its shape are dropped. +//! +//! Only objects filled with a solid colour take part; each object's visible part (what the objects +//! above leave of it) is what traps, so a trap follows the edges that actually show. Two objects +//! filled with the same colour need no trap. The lighter colour is the one with the higher CIE +//! lightness (L*); between colours of equal lightness, the one with less ink. +//! +//! Behaviour from the public descriptions only: +//! and +//! (feature names and +//! option ranges; the defaults of 0.25 pt, 100% and 40% are VectorCraft's own choices, the pages +//! give none). + +use serde_json::{Value, json}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId}; +use vectorcraft_geom::{FillRule, PathData, Rect}; +use vectorcraft_pathops as po; + +use crate::util::{flag, num}; + +/// Effect id of Effect › Pathfinder › Trap. +pub const TRAP: &str = "pathfinder.trap"; + +/// Undo label and name of the Pathfinder panel's Trap. +pub const TRAP_LABEL: &str = "Trap"; + +/// Parameter documentation of the effect (the panel command `object.pathfinder.trap` documents the +/// same keys but precision and removeRedundantPoints). +pub const EFFECT_PARAMS_DOC: &str = "{thickness?: pt 0.01..5000 (0.25; how far the lighter colour reaches into the darker one across vertical edges), heightWidth?: % 1..1000 (100; the reach across horizontal edges as a percentage of the vertical one), tintReduction?: % 0..100 (40; the trap paints the lighter colour at this percentage of its tint), trapsWithProcessColor?: bool (false; a swatch (spot) colour's trap is painted with its CMYK process equivalent), reverseTraps?: bool (false; the darker colour spreads into the lighter one, except a rich black), precision?: pt 0.001..10 (0.01; the tolerance the trap's outline is refitted to), removeRedundantPoints?: bool (true)} adds overprinting traps where the members' visible areas of different solid colours meet (groups and layers: live Pathfinder over the members)"; + +/// The options of a trap; [`TrapOptions::from_params`] reads and bounds them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TrapOptions { + /// Reach across vertical edges, in points (0.01..=5000). + pub thickness: f64, + /// Reach across horizontal edges as a fraction of `thickness` (0.01..=10). + pub height_width: f64, + /// The fraction of the lighter colour's tint the trap paints with (0..=1). + pub tint: f32, + /// Paint traps of swatch colours with their CMYK equivalent. + pub process: bool, + /// Spread the darker colour into the lighter one. + pub reverse: bool, + /// Refit tolerance of the trap outline, in points (0.001..=10). + pub precision: f64, + /// Drop anchors that don't change the trap's shape. + pub remove_redundant: bool, +} + +impl Default for TrapOptions { + fn default() -> Self { + TrapOptions { + thickness: 0.25, + height_width: 1.0, + tint: 0.4, + process: false, + reverse: false, + precision: po::DEFAULT_PRECISION, + remove_redundant: true, + } + } +} + +impl TrapOptions { + /// The options in `p` (keys of [`EFFECT_PARAMS_DOC`]): a missing or non-numeric value takes its + /// default and a value out of range its nearest bound, so a live effect always evaluates. + pub fn from_params(p: &Value) -> Self { + let d = TrapOptions::default(); + let (hw_min, hw_max) = po::TRAP_HEIGHT_WIDTH_RANGE; + TrapOptions { + thickness: num(p, "thickness", d.thickness).clamp(po::TRAP_MIN_WIDTH, po::TRAP_MAX_WIDTH), + height_width: (num(p, "heightWidth", d.height_width * 100.0) / 100.0).clamp(hw_min, hw_max), + tint: (num(p, "tintReduction", f64::from(d.tint) * 100.0) / 100.0).clamp(0.0, 1.0) as f32, + process: flag(p, "trapsWithProcessColor", d.process), + reverse: flag(p, "reverseTraps", d.reverse), + precision: num(p, "precision", d.precision).clamp(0.001, 10.0), + remove_redundant: flag(p, "removeRedundantPoints", d.remove_redundant), + } + } +} + +/// The Pathfinder panel Trap dialog's fields, at their defaults. +pub fn command_defaults() -> Value { + let d = TrapOptions::default(); + json!({ + "thickness": d.thickness, + "heightWidth": d.height_width * 100.0, + "tintReduction": (f64::from(d.tint) * 100.0).round(), + "trapsWithProcessColor": d.process, + "reverseTraps": d.reverse, + }) +} + +/// The Effect › Pathfinder › Trap dialog's fields, at their defaults. +pub fn effect_defaults() -> Value { + let d = TrapOptions::default(); + let mut v = command_defaults(); + if let Some(o) = v.as_object_mut() { + o.insert("precision".into(), json!(d.precision)); + o.insert("removeRedundantPoints".into(), json!(d.remove_redundant)); + } + v +} + +/// One object taking part in a trap, back to front: its outline and its fill. +#[derive(Clone, Debug)] +pub struct TrapSource { + pub path: PathData, + pub rule: FillRule, + pub fill: Paint, +} + +/// One trap: its outline, its paint (the spreading colour, tinted) and the sources it spreads +/// from and into (indices into the sources given to [`traps`]). +#[derive(Clone, Debug)] +pub struct TrapPiece { + pub path: PathData, + pub paint: Paint, + pub from: usize, + pub into: usize, +} + +/// The solid colour a source traps with: none for no fill, gradients, patterns and Registration. +fn trap_color(p: &Paint) -> Option { + if p.is_registration() { None } else { p.color() } +} + +fn finite(p: &PathData) -> bool { + p.subpaths.iter().flat_map(|sp| sp.anchors.iter()).all(|a| a.p.x.is_finite() && a.p.y.is_finite()) +} + +/// Is `a` lighter than `b`? Higher L*; at equal lightness, less ink. +fn lighter(a: Color, b: Color) -> bool { + let (la, lb) = (a.to_lab().l, b.to_lab().l); + if (la - lb).abs() > 1e-3 { + return la > lb; + } + let ink = |c: Color| c.to_cmyk().iter().sum::(); + ink(a) < ink(b) +} + +/// 100% black with other inks under it. +fn rich_black(c: Color) -> bool { + matches!(c, Color::Cmyk { c, m, y, k } if k >= 0.995 && c + m + y > 0.005) +} + +/// Do `a` and `b` print the same (no trap needed)? +fn same_color(a: &Paint, b: &Paint, ca: Color, cb: Color) -> bool { + let link = |p: &Paint| match p { + Paint::Solid { swatch, .. } => swatch.clone(), + _ => None, + }; + link(a) == link(b) && ca.to_cmyk().iter().zip(cb.to_cmyk()).all(|(x, y)| (x - y).abs() < 1e-4) +} + +/// The paint of a trap spreading `p` at tint `t`. +fn trap_paint(p: &Paint, color: Color, o: &TrapOptions) -> Paint { + // A solid colour shows its swatch at `tint` already, so tinting it again gives the swatch at + // `tint × t` ([`Color::tinted`] multiplies in every model). + let shown = color.tinted(o.tint); + match p { + Paint::Solid { swatch: Some(_), .. } if o.process => { + let [c, m, y, k] = shown.to_cmyk(); + Paint::solid(Color::cmyk(c, m, y, k)) + } + Paint::Solid { swatch: Some(s), tint, .. } => Paint::Solid { color: shown, swatch: Some(s.clone()), tint: tint * o.tint }, + _ => Paint::solid(shown), + } +} + +/// The visible part of each source: the faces of their arrangement that it is the top one of. +/// Sources that paint no fill hide nothing and have none. +fn visible(sources: &[TrapSource]) -> Vec { + let mut shapes = vec![]; + for (i, s) in sources.iter().enumerate() { + if !s.fill.is_none() && !s.path.is_empty() && finite(&s.path) { + shapes.push(po::Shape::new(s.path.clone(), s.rule, i as u64)); + } + } + let mut faces: Vec> = vec![vec![]; sources.len()]; + for r in po::regions(&shapes) { + if let Some(face) = r.sources.last().and_then(|&si| shapes.get(si)).and_then(|s| faces.get_mut(s.key as usize)) { + face.push(r.path); + } + } + faces + .into_iter() + .map(|mut f| match f.len() { + 0 => PathData::default(), + 1 => f.pop().unwrap_or_default(), + _ => po::unite_all(&f.iter().map(|p| (p, FillRule::NonZero)).collect::>()), + }) + .collect() +} + +/// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within +/// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their +/// left edge, so shapes spread over the page cost about one check each rather than one per pair. +fn near_pairs(rects: &[Option], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> { + let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect(); + order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0))); + let mut pairs = vec![]; + for (k, &(i, a)) in order.iter().enumerate() { + // Later rects start at or right of `a`, so only how far right they start can rule them out. + for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) { + if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y { + pairs.push((i.min(j), i.max(j))); + } + } + } + pairs.sort_unstable(); + pairs +} + +/// The traps between `sources` (back to front) with options `o`: for each two sources of +/// different solid colours whose visible parts meet, the strip of the darker one's visible part +/// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per +/// [`trap_paint`]. Empty when no visible edges of different colours meet. +pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec { + let colors: Vec> = sources.iter().map(|s| trap_color(&s.fill)).collect(); + if colors.iter().flatten().count() < 2 { + return vec![]; + } + let (w, r) = (o.thickness, o.height_width); + if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) { + return vec![]; // `trap_band` cuts nothing with these. + } + let vis = visible(sources); + // Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects + // the rest by the same bounds), so find those first instead of trying every pair. The hair + // keeps rounding from rejecting a pair `trap_band` would keep. + let rects: Vec> = + vis.iter().zip(&colors).map(|(p, c)| c.and(p.bounds()).filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()))).collect(); + let (w, r) = (w.clamp(po::TRAP_MIN_WIDTH, po::TRAP_MAX_WIDTH), r.clamp(po::TRAP_HEIGHT_WIDTH_RANGE.0, po::TRAP_HEIGHT_WIDTH_RANGE.1)); + let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs())); + let hair = 1e-9 * (1.0 + extent + w * r.max(1.0)); + let mut out = vec![]; + for (i, j) in near_pairs(&rects, w + hair, w * r + hair) { + let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue }; + if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) { + continue; + } + let (mut from, mut into) = if lighter(ci, cj) { (i, j) } else { (j, i) }; + if o.reverse && colors[into].is_some_and(|c| !rich_black(c)) { + (from, into) = (into, from); + } + let mut band = po::trap_band(&vis[from], FillRule::NonZero, &vis[into], FillRule::NonZero, o.thickness, o.height_width, o.precision); + if o.remove_redundant { + band = po::remove_redundant_points(&band, o.precision); + } + if band.is_empty() || po::area(&band, FillRule::NonZero) <= 1e-9 { + continue; + } + let Some(color) = colors[from] else { continue }; + out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into }); + } + out +} + +/// A trap's appearance: one overprinting fill of `paint`, no stroke. +pub fn trap_appearance(paint: Paint) -> Appearance { + Appearance { items: vec![AppearanceItem::Fill(FillLayer { overprint: true, ..FillLayer::new(paint) })], ..Default::default() } +} + +/// A trap as a path object with id `id`. +pub fn trap_node(id: NodeId, piece: TrapPiece) -> Node { + Node::path(id, piece.path, trap_appearance(piece.paint)) +} + +#[cfg(test)] +mod tests { + use vectorcraft_geom::Rect; + use vectorcraft_geom::shapes::rectangle; + + use super::*; + + fn src(r: Rect, c: Color) -> TrapSource { + TrapSource { path: rectangle(r), rule: FillRule::NonZero, fill: Paint::solid(c) } + } + + fn yellow() -> Color { + Color::cmyk(0.0, 0.0, 1.0, 0.0) + } + + fn blue() -> Color { + Color::cmyk(1.0, 0.6, 0.0, 0.0) + } + + fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.02 + } + + fn abutting() -> Vec { + vec![src(Rect::new(0.0, 0.0, 50.0, 40.0), yellow()), src(Rect::new(50.0, 0.0, 100.0, 40.0), blue())] + } + + #[test] + fn the_lighter_colour_spreads_into_the_darker_along_the_shared_edge() { + let o = TrapOptions { thickness: 3.0, tint: 1.0, ..Default::default() }; + let t = traps(&abutting(), &o); + assert_eq!(t.len(), 1); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 50.0) && near(b.x1, 53.0) && near(b.y0, 0.0) && near(b.y1, 40.0), "inside the blue, 3 pt wide: {b:?}"); + assert_eq!((t[0].from, t[0].into), (0, 1)); + assert_eq!(t[0].paint.color(), Some(yellow())); + let n = trap_node(NodeId(7), t[0].clone()); + assert!(n.appearance.fill().is_some_and(|f| f.overprint), "the trap overprints"); + assert!(n.appearance.stroke_paint().is_none()); + } + + #[test] + fn reverse_traps_spread_the_darker_colour() { + let o = TrapOptions { thickness: 2.0, reverse: true, tint: 1.0, ..Default::default() }; + let t = traps(&abutting(), &o); + assert_eq!(t.len(), 1); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 48.0) && near(b.x1, 50.0), "inside the yellow: {b:?}"); + assert_eq!(t[0].paint.color(), Some(blue())); + } + + #[test] + fn reverse_traps_leave_rich_black_alone() { + let rich = Color::cmyk(0.6, 0.4, 0.4, 1.0); + let s = vec![src(Rect::new(0.0, 0.0, 50.0, 40.0), yellow()), src(Rect::new(50.0, 0.0, 100.0, 40.0), rich)]; + let t = traps(&s, &TrapOptions { reverse: true, ..Default::default() }); + assert_eq!(t[0].from, 0, "the yellow still spreads into the rich black"); + } + + #[test] + fn tint_reduction_tints_the_trap_and_keeps_a_swatch_unless_process_colour() { + let mut s = abutting(); + s[0].fill = Paint::Solid { color: yellow(), swatch: Some("PANTONE Yellow".into()), tint: 1.0 }; + let t = traps(&s, &TrapOptions { tint: 0.5, ..Default::default() }); + match &t[0].paint { + Paint::Solid { color, swatch, tint } => { + assert_eq!(swatch.as_deref(), Some("PANTONE Yellow")); + assert!((tint - 0.5).abs() < 1e-6); + assert!((color.to_cmyk()[2] - 0.5).abs() < 1e-4, "{color:?}"); + } + p => panic!("{p:?}"), + } + let t = traps(&s, &TrapOptions { tint: 0.5, process: true, ..Default::default() }); + match &t[0].paint { + Paint::Solid { color, swatch: None, .. } => assert!(matches!(color, Color::Cmyk { .. })), + p => panic!("{p:?}"), + } + } + + #[test] + fn a_lighter_object_on_a_darker_background_spreads_all_round() { + // Yellow square on top of a blue background: a ring around the yellow, inside the blue. + let s = vec![src(Rect::new(0.0, 0.0, 100.0, 100.0), blue()), src(Rect::new(40.0, 40.0, 60.0, 60.0), yellow())]; + let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() }); + assert_eq!(t.len(), 1); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 39.0) && near(b.x1, 61.0) && near(b.y0, 39.0) && near(b.y1, 61.0), "{b:?}"); + assert!(near(po::area(&t[0].path, FillRule::NonZero), 22.0 * 22.0 - 400.0)); + } + + #[test] + fn no_trap_without_adjoining_edges_of_different_colours() { + let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())]; + assert!(traps(&apart, &TrapOptions::default()).is_empty()); + let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())]; + assert!(traps(&same, &TrapOptions::default()).is_empty()); + let mut unfilled = abutting(); + unfilled[1].fill = Paint::None; + assert!(traps(&unfilled, &TrapOptions::default()).is_empty()); + } + + #[test] + fn options_are_bounded_and_bad_numbers_take_defaults() { + let o = TrapOptions::from_params(&json!({"thickness": 1e300, "heightWidth": -5, "tintReduction": 400, "precision": "x"})); + assert_eq!(o.thickness, po::TRAP_MAX_WIDTH); + assert_eq!(o.height_width, po::TRAP_HEIGHT_WIDTH_RANGE.0); + assert_eq!(o.tint, 1.0); + assert_eq!(o.precision, po::DEFAULT_PRECISION); + let o = TrapOptions::from_params(&json!({"thickness": "NaN"})); + assert_eq!(o.thickness, TrapOptions::default().thickness); + // The widest trap still stays inside the darker shape. + let t = traps(&abutting(), &TrapOptions { thickness: f64::NAN, ..Default::default() }); + assert!(t.is_empty(), "a NaN thickness traps nothing"); + let t = traps(&abutting(), &TrapOptions { thickness: po::TRAP_MAX_WIDTH, ..Default::default() }); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 50.0) && near(b.x1, 100.0), "{b:?}"); + } + + #[test] + fn nan_coordinates_are_left_out() { + let mut s = abutting(); + s[1].path.subpaths[0].anchors[0].p.x = f64::NAN; + assert!(traps(&s, &TrapOptions::default()).is_empty()); + } + + /// 2,000 separated 10 pt squares in alternating colours on a 20 pt grid, then a yellow square + /// abutting the first (blue) one's right edge. + fn many_apart_and_one_abutting() -> Vec { + let mut s: Vec = (0..2000) + .map(|k| { + let (x, y) = (f64::from(k % 50) * 20.0, f64::from(k / 50) * 20.0); + src(Rect::new(x, y, x + 10.0, y + 10.0), if (k + k / 50) % 2 == 0 { blue() } else { yellow() }) + }) + .collect(); + s.push(src(Rect::new(10.0, 0.0, 15.0, 10.0), yellow())); + s + } + + #[test] + fn only_shapes_within_reach_are_paired() { + let s = many_apart_and_one_abutting(); + let rects: Vec> = s.iter().map(|t| t.path.bounds()).collect(); + assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired"); + // Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do. + let row: Vec> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect(); + assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]); + let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() }); + assert_eq!(t.len(), 1, "only the abutting pair traps"); + assert_eq!((t[0].from, t[0].into), (2000, 0)); + let b = t[0].path.bounds().unwrap(); + assert!(near(b.x0, 9.0) && near(b.x1, 10.0) && near(b.y0, 0.0) && near(b.y1, 10.0), "{b:?}"); + } + + #[test] + fn pairing_by_bounds_keeps_every_trap_in_order() { + // Shapes abutting across x and y, overlapping, and half a point apart (within a 1 pt trap), + // none of them hiding another's edges: the traps are those of each pair alone. + let s = vec![ + src(Rect::new(0.0, 0.0, 100.0, 100.0), blue()), + src(Rect::new(40.0, 40.0, 60.0, 60.0), yellow()), + src(Rect::new(100.0, 0.0, 120.0, 50.0), yellow()), + src(Rect::new(0.0, 100.5, 50.0, 120.0), yellow()), + src(Rect::new(55.0, 100.0, 80.0, 130.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)), + ]; + let o = TrapOptions { thickness: 1.0, ..Default::default() }; + let got: Vec<(usize, usize)> = traps(&s, &o).iter().map(|t| (t.from, t.into)).collect(); + let mut want = vec![]; + for i in 0..s.len() { + for j in i + 1..s.len() { + if let Some(t) = traps(&[s[i].clone(), s[j].clone()], &o).first() { + want.push(if t.from == 0 { (i, j) } else { (j, i) }); + } + } + } + assert_eq!(want.len(), 4, "{want:?}"); + assert_eq!(got, want); + } +} diff --git a/crates/effects/src/util.rs b/crates/effects/src/util.rs index 336d65199..ec8cb4cba 100644 --- a/crates/effects/src/util.rs +++ b/crates/effects/src/util.rs @@ -1,7 +1,7 @@ //! Parameter parsing, deterministic noise and path-mapping helpers. use serde_json::Value; -use vectorcraft_geom::{Anchor, CubicBez, ParamCurve, Point, Rect, SubPath, Vec2}; +use vectorcraft_geom::{Anchor, CubicBez, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; /// Number param (also accepts numeric strings such as `"10 pt"`); non-finite → default. pub fn num(p: &Value, key: &str, default: f64) -> f64 { @@ -69,8 +69,25 @@ pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez { if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c } } -/// Non-linear path mapping (shared with live envelopes in `vectorcraft-doc`). -pub use vectorcraft_doc::live::map_nonlinear; +/// Non-linear path mapping (shared with live envelopes in `vectorcraft-doc`), and the split of +/// segments into pieces within an anchor budget. +pub use vectorcraft_doc::live::{fit_piece, map_nonlinear_within, pieces}; + +/// About how many lines the renderer flattens `path` into at 1 px/pt: one for a straight +/// segment, and for a curve the count Wang's formula gives at vello's tolerance of 0.25 px, +/// `sqrt(0.75 × d / 0.25)` for the largest second difference `d` of its control points. +pub fn line_estimate(path: &PathData) -> usize { + let curve = |c: CubicBez| { + let d = + (c.p0.to_vec2() - c.p1.to_vec2() * 2.0 + c.p2.to_vec2()).hypot().max((c.p1.to_vec2() - c.p2.to_vec2() * 2.0 + c.p3.to_vec2()).hypot()); + // A NaN casts to 0 and an infinity to the largest count. + ((3.0 * d).sqrt().ceil() as usize).max(1) + }; + path.subpaths + .iter() + .flat_map(|sp| (0..sp.segment_count()).map(move |i| if sp.segment_is_line(i) { 1 } else { curve(sp.segment(i)) })) + .fold(0, usize::saturating_add) +} /// Smooth subpath through `pts` (Catmull-Rom tangents, see [`turn_tangent`] for sharp turns). pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath { diff --git a/crates/effects/src/warp.rs b/crates/effects/src/warp.rs index 35a67ba23..f3b7efea9 100644 --- a/crates/effects/src/warp.rs +++ b/crates/effects/src/warp.rs @@ -10,8 +10,8 @@ use crate::util::*; pub use vectorcraft_doc::live::{WarpStyle, warp_point}; -/// Apply a warp to `path` using box `b`. -pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData { +/// Apply a warp to `path` using box `b`, making at most `max` anchors ([`map_nonlinear_within`]). +pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value, max: usize) -> PathData { let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0; let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0; let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0; @@ -30,5 +30,5 @@ pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData { Point::new(c.x + x2 * hw, c.y + y2 * hh) }; let piece = b.width().hypot(b.height()) / 24.0; - map_nonlinear(path, piece, f) + map_nonlinear_within(path, piece, max, f) } diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index 84dc1f3c0..6fdced122 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -37,7 +37,7 @@ pub fn specs() -> Vec { "appearance.addFill", "Add New Fill", ["Window", "Appearance"], - None, + 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)", has_doc, |s, p| add_item(s, p, true) @@ -46,7 +46,7 @@ pub fn specs() -> Vec { "appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], - None, + Some("Cmd+Alt+/"), "{ids?|id?, target?} add a stroke on top of each selected object's own stack, on an image right below its Contents row (a copy of its top stroke, else the default stroke)", has_doc, |s, p| add_item(s, p, false) @@ -119,7 +119,7 @@ pub fn specs() -> Vec { "Eyedropper", [], None, - "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: the selection), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", + "{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead; `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}", has_doc, copy_from ), @@ -218,7 +218,7 @@ pub(crate) fn item_target_at(s: &Session, p: &Value, key: &str, cmd: &str) -> Re Some(Value::Null) => Ok(ItemTarget::Top), Some(v) => v .as_u64() - .map(|i| ItemTarget::Item { index: i as usize, explicit: true }) + .map(|i| ItemTarget::Item { index: index(i), explicit: true }) .ok_or_else(|| bad(cmd, format!("`{key}` must be an appearance item index (paint order) or null"))), // The active item is a row of the first selected object's own stack. None if p.get("ids").is_some() || p.get("id").is_some() || targets_contents(p)? => Ok(ItemTarget::Top), @@ -372,8 +372,10 @@ pub(crate) fn count_changed(d: &vectorcraft_doc::Document, before: &vectorcraft_ } /// Whether an edit aimed at `item` changes a stroke: the `stroke` param, else the kind of the -/// targeted item (on the first target object), else `default`. -pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool) -> Result { +/// targeted item (on the first target object), else `default`. A `stroke` other than true, false or +/// null is an error of command `cmd`. +pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bool, cmd: &str) -> Result { + check_switches(p, &["stroke"], cmd)?; if let Some(b) = p.get("stroke").and_then(Value::as_bool) { return Ok(b); } @@ -383,8 +385,9 @@ pub(crate) fn edits_stroke(s: &Session, p: &Value, item: ItemTarget, default: bo Ok(ids.first().and_then(|id| d.node(*id)?.appearance.items.get(index)).map_or(default, |it| !it.is_fill())) } +/// The whole number of 0 or more that `key` holds, read with [`index`]. pub(crate) fn index_param(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) + p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) } fn set_active_item(s: &mut Session, p: &Value) -> Result { @@ -485,7 +488,7 @@ fn transfer(s: &mut Session, p: &Value) -> Result { if source == target { return Err(bad(C, "`source` and `target` are the same object")); } - let copy = bool_or(p, "copy", false); + let copy = switch_or(p, "copy", false, C)?; // When both objects come out as they were, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, if copy { "Copy Appearance" } else { "Move Appearance" }, |d| { @@ -547,6 +550,9 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result { fn set_item(s: &mut Session, p: &Value) -> Result { let idx = index_param(p, "index", "appearance.setItem")?; + super::paint::check_keep_model(p, "appearance.setItem")?; + check_numbers(p, &["weight", "opacity"], "appearance.setItem")?; + check_switches(p, &["visible"], "appearance.setItem")?; let paint = paint_from(s, p)?; let ids = appearance_targets(s, p)?; if ids.is_empty() { @@ -603,7 +609,7 @@ fn item_indices(p: &Value, cmd: &str) -> Result> { let mut v = match p.get("indices") { Some(a) => a .as_array() - .and_then(|a| a.iter().map(|x| x.as_u64().map(|i| i as usize)).collect::>>()) + .and_then(|a| a.iter().map(|x| x.as_u64().map(index)).collect::>>()) .filter(|v| !v.is_empty()) .ok_or_else(|| bad(cmd, "`indices` must be a non-empty array of item indices"))?, None => vec![index_param(p, "index", cmd)?], @@ -756,11 +762,13 @@ fn target_contents(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// Sample enabled attributes, refusing object transparency that a text range cannot store. fn copy_from(s: &mut Session, p: &Value) -> Result { const C: &str = "appearance.copyFrom"; let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?; - let (reverse, append) = (bool_or(p, "reverse", false), bool_or(p, "append", false)); + let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?); let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?; + let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None }; let selected = match ids_param(p, "ids") { Some(v) => outermost(&s.doc()?.doc, v), None => selected_roots(s)?, @@ -774,12 +782,38 @@ fn copy_from(s: &mut Session, p: &Value) -> Result { }; targets.retain(|id| *id != src_id); let picked = Picked::of(s.doc()?.doc.node(src_id).ok_or(EngineError::NoNode(src_id))?); + let range = range.filter(|(id, _, _)| targets.contains(id)); + // Character styles cannot represent object transparency. Refuse before even changing + // paint defaults; applying it to the object would also alter the unselected characters. + if let Some((id, _, _)) = range.filter(|_| attrs.appearance.transparency) { + let target = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + if picked.opacity != 1.0 || picked.blend != BlendMode::Normal || target.opacity != 1.0 || target.blend != BlendMode::Normal { + return Err(bad( + C, + "object transparency cannot be sampled onto highlighted characters; disable Transparency in Eyedropper Options or select the whole object", + )); + } + } if !reverse { picked.to_defaults(s, &attrs.appearance); } if targets.is_empty() || attrs.is_empty() { return Ok(json!({ "ids": [] })); } + if let Some((id, start, end)) = range { + let before = s.doc()?.doc.clone(); + super::overprint::edit_counted(s, "Eyedropper", |d| { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; + let placed = picked.placed(Some(t.local_bounds())); + vectorcraft_text::edit::style_range(&mut t.runs, start, end, |st| picked.run(st, &placed, &attrs)); + if let Some((_, para)) = picked.text.as_ref().filter(|_| attrs.paragraph) { + t.edit_paras(Some(t.paragraphs_in(start, end)), |p| *p = para.clone()); + } + super::typecmd::refresh_bounds(t); + Ok(count_changed(d, &before, &[id])) + })?; + return Ok(json!({ "ids": [id.0] })); + } // When every target comes out as it was, the document and its undo history stay as they are. let before = s.doc()?.doc.clone(); super::overprint::edit_counted(s, "Eyedropper", |d| { @@ -841,6 +875,22 @@ impl Picked { } } + /// Copy character attributes and the enabled paint attributes through the same path for + /// whole text objects and highlighted character ranges. + fn run(&self, st: &mut CharStyle, placed: &Appearance, a: &EyedropperAttrs) { + if let Some((cs, _)) = self.text.as_ref().filter(|_| a.character) { + *st = CharStyle { + fill: std::mem::take(&mut st.fill), + stroke: std::mem::take(&mut st.stroke), + stroke_width: st.stroke_width, + overprint_fill: st.overprint_fill, + overprint_stroke: st.overprint_stroke, + ..cs.clone() + }; + } + run_attrs(st, placed, &a.appearance); + } + /// Apply attributes `a` to `n`. `append` adds the fills and strokes (and effects) on top of its /// stack instead. Type takes the paints on its characters, and character and paragraph /// attributes from type. @@ -860,19 +910,7 @@ impl Picked { let placed = self.placed(Some(t.local_bounds())); let text = self.text.as_ref(); for r in &mut t.runs { - if let Some((cs, _)) = text.filter(|_| a.character) { - // The character attributes; the paints follow the appearance attributes. - let st = &mut r.style; - *st = CharStyle { - fill: std::mem::take(&mut st.fill), - stroke: std::mem::take(&mut st.stroke), - stroke_width: st.stroke_width, - overprint_fill: st.overprint_fill, - overprint_stroke: st.overprint_stroke, - ..cs.clone() - }; - } - run_attrs(&mut r.style, &placed, ap); + self.run(&mut r.style, &placed, a); } if let Some((_, para)) = text.filter(|_| a.paragraph) { // The source's first paragraph's attributes, on every paragraph. diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index a137271c5..c560a35c8 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { } /// `e` as an error of command `cmd` (for errors of the export underneath). -fn of_cmd(cmd: &str, e: EngineError) -> EngineError { +pub(crate) fn of_cmd(cmd: &str, e: EngineError) -> EngineError { match e { EngineError::BadParams { msg, .. } => bad(cmd, msg), e => e, diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 61e994993..d27e70f51 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -6,22 +6,23 @@ //! `Node::geometric_bounds` assumes for instances, and each instance's `xf` scales it back. The //! natural size is kept in `Document::unknown["symbolSizes"]` so new instances come out right. +use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_brush::{self as brush, Brush}; +use vectorcraft_brush::{self as brush, Bristle, Brush, BrushKind}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration}; use vectorcraft_geom::{Affine, Point, Vec2}; use super::edit::{outermost, selected_roots}; use super::pathops::{remove_each, separate}; -use super::swatch::names_param; +use super::swatch::{anchor, names_param}; use super::*; use crate::EngineError; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; -const CURRENT_SYMBOL: &str = "currentSymbol"; +pub(crate) const CURRENT_SYMBOL: &str = "currentSymbol"; /// Half the instance box `Node::geometric_bounds` uses for symbol instances. const HALF: f64 = vectorcraft_doc::SYMBOL_HALF; @@ -30,6 +31,15 @@ pub fn specs() -> Vec { // ----- brushes ----- cmd!(query "brush.list", "Brushes", [], None, "{} → {brushes: [{name, type}], current}", has_doc, brush_list), cmd!(query "brush.get", "Brush Definition", [], None, "{name} → the brush definition (JSON)", has_doc, brush_get), + cmd!( + query "brush.selectionArt", + "Brush Art", + [], + None, + "{ids?|id?} → {art: the object brush.new takes as a new scatter or art brush's art and as a new pattern brush's side tile (several objects become one group), or null when nothing is selected or ids name no object}", + has_doc, + brush_selection_art + ), cmd!( "brush.apply", "Apply Brush", @@ -62,7 +72,7 @@ pub fn specs() -> Vec { "New Brush…", [], None, - "{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New Brush), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}", + "{type: calligraphic|scatter|art|pattern|bristle, name? (trimmed; blank: New Brush; a bristle brush name, with the number a taken name gets, has at most 31 characters), params?: {…definition fields}, ids?|id?} new brush; art/scatter/pattern brushes take the selected art (pattern: side tile) unless params give it → {name}", has_doc, brush_new ), @@ -75,6 +85,15 @@ pub fn specs() -> Vec { has_doc, brush_delete ), + cmd!( + "brush.move", + "Move Brush", + [], + None, + "{name? | names?: [brush names of one type, in order], to?: index among the brushes of that type in brush.list order (they go before the brush of that type now there that isn't moving; default: after the last one), type?: calligraphic|scatter|art|pattern|bristle (the type they go into; default: theirs)} reorder brushes within their type, as one undo step (none when the order stays as it was); brushes of different types, or a type that isn't theirs, are refused (brushes move only within their type) → {moved}", + has_doc, + brush_move + ), cmd!( "brush.duplicate", "Duplicate Brush", @@ -84,12 +103,21 @@ pub fn specs() -> Vec { has_doc, brush_duplicate ), + cmd!( + query "brush.unused", + "Select All Unused", + [], + None, + "{} → {names: [the brushes no stroke paints with, in library order]} (the Brushes panel selects them): strokes in the art (opacity masks included), symbols, graph designs, pattern tiles and graphic styles count, and so do strokes in the art of a brush that is used", + has_doc, + brush_unused + ), cmd!( "brush.options", "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour)}}, newName? (trimmed; blank: the name is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), @@ -268,6 +296,12 @@ fn brush_get(s: &mut Session, p: &Value) -> Result { Ok(serde_json::to_value(b).unwrap_or_default()) } +/// The art `brush.new` takes from `ids` or `id`, else from the selection ([`selection_art`]). +fn brush_selection_art(s: &mut Session, p: &Value) -> Result { + let art = selection_art(s, p)?; + Ok(json!({ "art": art.map(|n| serde_json::to_value(&n).unwrap_or_default()) })) +} + fn brush_apply(s: &mut Session, p: &Value) -> Result { const C: &str = "brush.apply"; let name = name_param(p, C)?.to_string(); @@ -348,38 +382,100 @@ pub(crate) fn selection_art(s: &Session, p: &Value) -> Result> { }) } +/// The definition fields that `params` change field by field when the brush has them as objects: +/// `{colorization: {method: "hueShift"}}` keeps the key color, and an art brush's `scale` with only +/// `start` keeps `end`. +const MERGED_FIELDS: [&str; 2] = ["colorization", "scale"]; + fn merge(base: &mut Value, patch: &Value) { if let (Some(b), Some(pt)) = (base.as_object_mut(), patch.as_object()) { for (k, v) in pt { - b.insert(k.clone(), v.clone()); + match (b.get_mut(k).and_then(Value::as_object_mut), v.as_object()) { + (Some(inner), Some(fields)) if MERGED_FIELDS.contains(&k.as_str()) => { + for (f, fv) in fields { + inner.insert(f.clone(), fv.clone()); + } + } + _ => { + b.insert(k.clone(), v.clone()); + } + } } } } -fn save_library(s: &mut Session, label: &str, lib: Vec, renames: &[(String, Option)]) -> Result<()> { +/// Sets brush `to` on the strokes in `items` that paint with brush `from`. +fn set_stroke_brush(items: &mut [AppearanceItem], from: &str, to: Option<&str>) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && st.brush.as_deref() == Some(from) + { + st.brush = to.map(str::to_string); + } + } +} + +/// Whether a stroke in `n` (its opacity masks included) paints with brush `name`. +fn paints_with(n: &Node, name: &str) -> bool { + let mut hit = false; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + hit |= m.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(name))); + }); + hit +} + +/// Applies [`set_stroke_brush`] to every stroke in `n` and its opacity masks. Only the objects that +/// hold such a stroke, and those that hold them, are copied. +fn set_brush_in(n: &mut Node, from: &str, to: Option<&str>) { + set_stroke_brush(&mut n.appearance.items, from, to); + if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_mut()) + && paints_with(&mask.art, from) + { + set_brush_in(Arc::make_mut(&mut mask.art), from, to); + } + for c in n.children_mut().into_iter().flatten() { + if paints_with(c, from) { + set_brush_in(Arc::make_mut(c), from, to); + } + } +} + +/// Returns the art a brush is made of ([`Brush::art`]) for editing. +fn brush_art_mut(b: &mut Brush) -> Vec<&mut Node> { + use vectorcraft_brush::BrushKind; + match &mut b.kind { + BrushKind::Scatter(s) => vec![&mut s.art], + BrushKind::Art(a) => vec![&mut a.art], + BrushKind::Pattern(p) => { + std::iter::once(&mut p.side).chain([&mut p.outer_corner, &mut p.inner_corner, &mut p.start, &mut p.end].into_iter().flatten()).collect() + } + BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => vec![], + } +} + +/// Sets brush `to` (`None`: no brush) on every stroke that paints with brush `from`: in the art +/// (opacity masks included), symbols, graph designs, pattern tiles and the tiles that pattern +/// editing's Cancel puts back, graphic styles and the art of the brushes in `lib`. +fn rename_brush_strokes(d: &mut Document, lib: &mut [Brush], from: &str, to: Option<&str>) { + let trees = d.layers.iter_mut().chain(d.symbols.iter_mut().map(|s| &mut s.art)).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)); + let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())); + for n in trees.chain(patterns.flat_map(|p| &mut p.art)) { + if paints_with(n, from) { + set_brush_in(Arc::make_mut(n), from, to); + } + } + for g in &mut d.graphic_styles { + set_stroke_brush(&mut g.appearance.items, from, to); + } + for n in lib.iter_mut().flat_map(brush_art_mut) { + set_brush_in(n, from, to); + } +} + +fn save_library(s: &mut Session, label: &str, mut lib: Vec, renames: &[(String, Option)]) -> Result<()> { s.edit(label, |d, _| { - brush::store(d, &lib); for (from, to) in renames { - let ids: Vec = { - let mut v = vec![]; - d.walk(|n| { - if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.as_deref() == Some(from.as_str()))) { - v.push(n.id); - } - }); - v - }; - for id in ids { - if let Some(n) = d.node_mut(id) { - for it in &mut n.appearance.items { - if let AppearanceItem::Stroke(st) = it - && st.brush.as_deref() == Some(from.as_str()) - { - st.brush = to.clone(); - } - } - } - } + rename_brush_strokes(d, &mut lib, from, to.as_deref()); if brush::current(d).as_deref() == Some(from.as_str()) { match to { Some(t) => d.unknown.insert(brush::CURRENT_KEY.into(), json!(t)), @@ -387,6 +483,7 @@ fn save_library(s: &mut Session, label: &str, lib: Vec, renames: &[(Strin }; } } + brush::store(d, &lib); Ok(()) }) } @@ -404,6 +501,9 @@ fn brush_new(s: &mut Session, p: &Value) -> Result { let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); let base_name = given.map_or_else(|| format!("New {} Brush", ty[..1].to_uppercase() + &ty[1..]), str::to_string); let name = brush::unique_name(&lib, &base_name); + if ty == "bristle" { + bristle_name_fits(C, &name)?; + } let mut v = json!({"name": name, "type": ty}); if let Some(params) = p.get("params") { merge(&mut v, params); @@ -424,11 +524,18 @@ fn brush_new(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": name })) } -/// A brush definition from command params, its values kept within their ranges. +/// Refuses `name` for a bristle brush when it has more than [`Bristle::MAX_NAME_CHARS`] characters. +fn bristle_name_fits(cmd: &str, name: &str) -> Result<()> { + let (count, max) = (name.chars().count(), Bristle::MAX_NAME_CHARS); + if count > max { + return Err(bad(cmd, format!("a bristle brush name can have at most {max} characters: `{name}` has {count}"))); + } + Ok(()) +} + +/// A brush definition from command params, its values kept within their ranges ([`Brush::from_json`]). fn parse_brush(cmd: &str, v: Value) -> Result { - let mut b: Brush = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid brush: {e}")))?; - b.sanitize(); - Ok(b) + Brush::from_json(v).map_err(|e| bad(cmd, format!("invalid brush: {e}"))) } fn brush_delete(s: &mut Session, p: &Value) -> Result { @@ -444,6 +551,54 @@ fn brush_delete(s: &mut Session, p: &Value) -> Result { ok() } +/// Moves the brushes in `name` or `names`, in that order, to index `to` among the brushes of their +/// type in library order: before the first brush of that type at or after `to` that isn't moving, +/// else after the last brush of that type that isn't moving. Brushes of different types, or a `type` +/// that isn't theirs, are an error. A move that leaves the order as it was records no undo step. +fn brush_move(s: &mut Session, p: &Value) -> Result { + const C: &str = "brush.move"; + const WITHIN: &str = "brushes move only within their type"; + let names = names_param(p, C)?; + check_numbers(p, &["to"], C)?; + let to = match p.get("to").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_u64().map(|i| usize::try_from(i).unwrap_or(usize::MAX)).ok_or_else(|| bad(C, "`to` must be a whole number"))?), + None => None, + }; + let old = brush::library(&s.doc()?.doc); + let mut lib = old.clone(); + // `names_param` refuses an empty list, so the loop sets `kind`. + let mut kind = ""; + for name in &names { + let b = lib.iter().find(|b| &b.name == name).ok_or_else(|| bad(C, format!("no brush named `{name}`")))?; + if !kind.is_empty() && kind != b.kind.type_id() { + return Err(bad(C, WITHIN)); + } + kind = b.kind.type_id(); + } + if p.get("type").filter(|v| !v.is_null()).is_some_and(|t| t.as_str() != Some(kind)) { + return Err(bad(C, WITHIN)); + } + let same: Vec<&Brush> = lib.iter().filter(|b| b.kind.type_id() == kind).collect(); + let before = anchor(&same, to, &names, |b| &b.name); + let first = lib.iter().position(|b| names.contains(&b.name)).unwrap_or(lib.len()); + let mut items = vec![]; + for name in &names { + if let Some(i) = lib.iter().position(|b| &b.name == name) { + items.push(lib.remove(i)); + } + } + let at = match before { + Some(a) => lib.iter().position(|b| b.name == a), + None => lib.iter().rposition(|b| b.kind.type_id() == kind).map(|i| i + 1), + }; + let at = at.unwrap_or(first).min(lib.len()); + lib.splice(at..at, items); + if lib != old { + save_library(s, "Move Brush", lib, &[])?; + } + Ok(json!({ "moved": names.len() })) +} + /// Adds a copy of each brush in `name` or `names` at the end of the library. `newName`, given /// without `names`, names the copy. fn brush_duplicate(s: &mut Session, p: &Value) -> Result { @@ -464,6 +619,44 @@ fn brush_duplicate(s: &mut Session, p: &Value) -> Result { Ok(if p.get("names").is_some() { json!({ "names": out }) } else { json!({ "name": out.first() }) }) } +/// Adds the names of the brushes the strokes of `items` paint with to `out`. +fn stroke_brushes(items: &[AppearanceItem], out: &mut Vec) { + for it in items { + if let AppearanceItem::Stroke(st) = it + && let Some(b) = &st.brush + { + out.push(b.clone()); + } + } +} + +/// Adds the names of the brushes the strokes in `n` (its opacity masks included) paint with to +/// `out`. +fn art_brushes(n: &Node, out: &mut Vec) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| stroke_brushes(&m.appearance.items, out)); +} + +/// The brushes no stroke paints with. Strokes in the art (opacity masks included), symbols, graph +/// designs, pattern tiles and graphic styles count, and so do the strokes in the art of every brush +/// that a counted stroke paints with. +fn brush_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let lib = brush::library(d); + let mut todo = vec![]; + let trees = d.layers.iter().chain(d.symbols.iter().map(|s| &s.art)).chain(d.graph_designs.iter().map(|g| &g.art)); + trees.chain(d.patterns.iter().flat_map(|p| &p.art)).for_each(|n| art_brushes(n, &mut todo)); + d.graphic_styles.iter().for_each(|g| stroke_brushes(&g.appearance.items, &mut todo)); + let mut used = BTreeSet::new(); + while let Some(name) = todo.pop() { + if !used.contains(&name) { + lib.iter().filter(|b| b.name == name).flat_map(Brush::art).for_each(|n| art_brushes(n, &mut todo)); + used.insert(name); + } + } + let names: Vec<&str> = lib.iter().map(|b| b.name.as_str()).filter(|n| !used.contains(*n)).collect(); + Ok(json!({ "names": names })) +} + fn brush_options(s: &mut Session, p: &Value) -> Result { const C: &str = "brush.options"; let name = name_param(p, C)?.to_string(); @@ -478,6 +671,10 @@ fn brush_options(s: &mut Session, p: &Value) -> Result { if lib.iter().any(|b| &b.name == nn) { return Err(bad(C, format!("a brush named `{nn}` exists"))); } + // A `newName` that only trims the white space around the saved name is taken at any length. + if matches!(lib[i].kind, BrushKind::Bristle(_)) && nn != name.trim() { + bristle_name_fits(C, nn)?; + } v["name"] = json!(nn); } else { v["name"] = json!(name); @@ -530,7 +727,7 @@ fn brush_freehand(s: &mut Session, p: &Value) -> Result { // ---------- symbols ---------- -fn natural_size(d: &Document, name: &str) -> (f64, f64) { +pub(crate) fn natural_size(d: &Document, name: &str) -> (f64, f64) { d.unknown .get(SIZES_KEY) .and_then(|m| m.get(name)) @@ -538,7 +735,7 @@ fn natural_size(d: &Document, name: &str) -> (f64, f64) { .unwrap_or((2.0 * HALF, 2.0 * HALF)) } -fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { +pub(crate) fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { let m = d.unknown.entry(SIZES_KEY.to_string()).or_insert_with(|| json!({})); if let Some(o) = m.as_object_mut() { match size { @@ -549,7 +746,7 @@ fn set_natural_size(d: &mut Document, name: &str, size: Option<(f64, f64)>) { } /// Instance transform for a symbol of natural size `(w, h)` whose registration point is at `at`. -fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { +pub(crate) fn place_xf(at: Point, (w, h): (f64, f64), reg: SymbolRegistration) -> Affine { let c = at - reg.offset_from_center(w, h); Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(w / (2.0 * HALF), h / (2.0 * HALF)) } @@ -609,7 +806,7 @@ fn rename_natural_size(d: &mut Document, from: &str, to: &str) { } /// Normalise art for a symbol definition: returns (art, natural size, centre in the document). -fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { +pub(crate) fn normalize(mut art: Node) -> Result<(Node, (f64, f64), Point)> { let b = art.geometric_bounds().ok_or_else(|| EngineError::Other("the art has no bounds".into()))?; let (w, h) = (b.width().max(1.0), b.height().max(1.0)); let c = b.center(); @@ -773,7 +970,7 @@ fn symbol_place(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let name = resolve_symbol(d, p, "symbol.place")?; let centre = d.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); - let at = Point::new(f64_or(p, "x", centre.x), f64_or(p, "y", centre.y)); + let at = Point::new(number_or(p, "x", centre.x, "symbol.place")?, number_or(p, "y", centre.y, "symbol.place")?); let size = natural_size(d, &name); let reg = d.symbol_registration(&name); let parent = s.doc()?.target_parent()?; @@ -902,7 +1099,7 @@ fn symbol_delete(s: &mut Session, p: &Value) -> Result { if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { return Err(bad(C, format!("no symbol named `{name}`"))); } - let expand = bool_or(p, "expandInstances", true); + let expand = switch_or(p, "expandInstances", true, C)?; s.edit("Delete Symbol", |d, _| { let inst = instances_of(d, Some(&name)); if expand { @@ -1019,10 +1216,10 @@ fn symbol_spray(s: &mut Session, p: &Value) -> Result { let pts = points(p, C)?; let st = s.doc()?; let name = resolve_symbol(&st.doc, p, C)?; - let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0); - let density = f64_or(p, "density", 5.0).clamp(1.0, 10.0); - let scale = f64_or(p, "scale", 1.0).clamp(0.01, 100.0); - let alt = bool_or(p, "alt", false); + let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); + let density = number_or(p, "density", 5.0, C)?.clamp(1.0, 10.0); + let scale = number_or(p, "scale", 1.0, C)?.clamp(0.01, 100.0); + let alt = switch_or(p, "alt", false, C)?; let size = natural_size(&st.doc, &name); let set = st.selection.objects.first().copied().filter(|id| st.selection.len() == 1 && st.doc.node(*id).is_some_and(is_symbol_set)); // Only a new Symbol Set needs a layer that takes new art. @@ -1104,10 +1301,11 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result { if !["shift", "scrunch", "size", "spin", "stain", "screen", "style"].contains(&tool.as_str()) { return Err(bad(C, format!("unknown tool `{tool}`"))); } + super::paint::check_keep_model(p, C)?; let pts = points(p, C)?; - let radius = f64_or(p, "radius", 40.0).clamp(1.0, 2000.0); - let intensity = f64_or(p, "intensity", 5.0).clamp(1.0, 10.0) / 10.0; - let alt = bool_or(p, "alt", false); + let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0); + let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0; + let alt = switch_or(p, "alt", false, C)?; let st = s.doc()?; let color = match p.get("color").and_then(color_value) { Some(c) => super::paint::new_color(s, p, c), @@ -1133,7 +1331,8 @@ fn symbol_adjust(s: &mut Session, p: &Value) -> Result { } } } - v.retain(|id| d.is_editable(*id)); + let editable = d.editable(); + v.retain(|id| editable(*id)); v }; let label = match tool.as_str() { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 92d001244..a441d3a07 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -24,6 +24,7 @@ use vectorcraft_tools::builder::{ }; use vectorcraft_trace as tr; +use super::draw2::replace_with_pieces; use super::edit::{outermost, selected_roots}; use super::paint::paint_from; use super::pathops::{Beside, shape_node}; @@ -39,7 +40,7 @@ pub fn specs() -> Vec { "Shape Builder", [], None, - "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of unfilled open paths and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it), tolerance?: pt (2, erase only), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (default: the fill of the object under the first point; the current fill for an area only lines enclose)} → {ids, merged, regions, lines, edges} (counts deleted or merged)", + "{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one", has_selection_or_ids, sb_merge ), @@ -48,7 +49,7 @@ pub fn specs() -> Vec { "Shape Builder Regions", [], None, - "{ids?|id?} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes", + "{ids?|id?, gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (as shapeBuilder.merge)} → {regions: [{bounds: [x0,y0,x1,y1], area, sources: [id…]}], lines: [{bounds, source: id}], edges: [{bounds, source: id}]} faces of the planar arrangement (unfilled open paths cut them, and filled ones too with openFilledAsClosed false; sources is empty for an area only they enclose), the pieces of those open paths and the edges (the other paths' outlines cut where paths meet; source: the front-most path along it), which erasing deletes", has_selection_or_ids, sb_regions ), @@ -57,7 +58,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Live Paint"], Some("Cmd+Alt+X"), - "{ids?|id?} build a Live Paint group from the selected paths → {id, faces, edges}", + "{ids?|id?} build a Live Paint group from the selected paths; with a Live Paint group among them, add the others to it as livePaint.merge does → {id, faces, edges}", has_selection_or_ids, lp_make ), @@ -66,7 +67,7 @@ pub fn specs() -> Vec { "Merge", ["Object", "Live Paint"], None, - "{ids?|id?} add the other selected paths (and Live Paint groups) to the first Live Paint group, keeping its paint → {id, faces, edges}", + "{ids?|id?} add the other selected paths (and Live Paint groups) to the bottom-most Live Paint group, keeping its id, its paint and its own properties (name, lock, visibility, transparency, appearance, opacity mask, graphic style link) → {id, faces, edges}", has_multi_or_ids, lp_merge ), @@ -102,7 +103,7 @@ pub fn specs() -> Vec { "Live Paint Bucket", [], None, - "{group?: id | ids?|id?: [id…] (paths to make live first), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point → {group, face}", + "{group?: id | ids?|id?: [id…] (paths to make live first, as livePaint.make does), point: [x,y], color?|swatch?|gradient?|none? (default: current fill), tint?: 0..100, keepModel?: bool (both as paint.setFill)} fill the face containing point, skipping hidden faces → {group, face}", has_doc, lp_fill ), @@ -236,27 +237,98 @@ fn fill_param(s: &Session, p: &Value, cmd: &str) -> Result> { } } +/// Gap Detection length in points, bounded like Live Paint's custom gap size. +fn gap_param(p: &Value) -> f64 { + let gap = f64_or(p, "gap", 0.0); + if gap.is_finite() { gap.clamp(0.0, po::SHAPE_BUILDER_MAX_GAP) } else { 0.0 } +} + +/// The Shift-drag marquee `rect: [x, y, width, height]`, as the point where the drag started +/// (`x`, `y`) and the opposite corner; the width and height may be negative. +fn marquee_param(p: &Value, cmd: &str) -> Result> { + let Some(v) = p.get("rect").filter(|v| !v.is_null()) else { return Ok(None) }; + let corners = finite_numbers(v).map(|[x, y, w, h]| (Point::new(x, y), Point::new(x + w, y + h))); + match corners { + Some((a, b)) if finite(a) && finite(b) => Ok(Some((a, b))), + _ => Err(bad(cmd, "rect must be [x, y, width, height]")), + } +} + fn sb_merge(s: &mut Session, p: &Value) -> Result { const C: &str = "shapeBuilder.merge"; - let pts = points_param(p, "points").ok_or_else(|| bad(C, "missing points [[x,y]…]"))?; - if pts.is_empty() || !pts.iter().all(|q| finite(*q)) { + let marquee = marquee_param(p, C)?; + let pts = match points_param(p, "points") { + Some(pts) => pts, + // A marquee needs no drag path. + None if marquee.is_some() && p.get("points").is_none_or(Value::is_null) => vec![], + None => return Err(bad(C, "missing points [[x,y]…]")), + }; + if (pts.is_empty() && marquee.is_none()) || !pts.iter().all(|q| finite(*q)) { return Err(bad(C, "points must be a non-empty list of finite [x,y]")); } - let erase = bool_or(p, "erase", false); + let erase = switch_or(p, "erase", false, C)?; + check_numbers(p, &["tolerance", "gap"], C)?; // Line pieces and edges are only deleted (erase mode), never merged. let piece_tol = erase.then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); - let fill = fill_param(s, p, C)?; - let current_fill = s.paint.fill.clone(); + // In Merge Mode, Clicking Stroke Splits the Path: a click (one point) on a stroke splits it; a + // marquee does not. + let split_tol = (switch_or(p, "splitStroke", false, C)? && !erase && pts.len() == 1 && marquee.is_none()) + .then(|| f64_or(p, "tolerance", 2.0).clamp(0.0, 1000.0)); + let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, C)?; + let swatches = match p.get("pickColorFrom") { + None | Some(Value::Null) => false, + Some(v) => match v.as_str() { + Some("artwork") => false, + Some("swatches") => true, + _ => return Err(bad(C, format!("pickColorFrom is \"artwork\" or \"swatches\", not {v}"))), + }, + }; + let fill = match fill_param(s, p, C)? { + None if swatches => Some(s.paint.fill.clone()), + f => f, + }; let roots = root_ids(s, p)?; let label = if erase { "Shape Builder Delete" } else { "Shape Builder" }; - let (ids, merged, touched) = s.edit(label, |d, sel| { - let (map, leaves) = BuilderMap::new(d, &roots, erase); + let (ids, merged, touched, split) = s.edit(label, |d, sel| { + let (map, leaves) = BuilderMap::with_gap(d, &roots, erase || split_tol.is_some(), open_filled_as_closed, gap_param(p)); + let outlines = b::drawn_outlines(d, &leaves); let leaves: Vec = leaves.into_iter().cloned().collect(); if map.shapes.is_empty() { return Err(EngineError::Other("Shape Builder: select paths or compound paths".into())); } let step = (d.bounds_of(&roots, false).map(|r| r.width().max(r.height())).unwrap_or(100.0) / 2000.0).max(0.25); - let mut touched = Touched::default(); + if let Some(tol) = split_tol + && let Some(&q) = pts.first() + && let Some((i, run)) = map.stroke_at(&outlines, q, tol) + && let (Some(leaf), Some(drawn)) = (leaves.get(i), outlines.get(i)) + && let Some(rest) = po::cut_out(drawn, &run, open_filled_as_closed && !leaf.appearance.fill_paint().is_none(), b::outline_snap(drawn)) + && rest.length() > b::outline_snap(drawn) + { + // The path keeps the rest of it, and a copy of it with the run under the click goes + // directly above it; the other objects stay as they are. Gradients fitted to the + // path's bounds keep that fit on both. + if let Some(n) = d.node_mut(leaf.id) { + n.pin_gradients(); + } + let pieces = [rest, PathData::single(run)].into_iter().filter(|pd| !pd.is_empty()).collect(); + let mut replaced = Beside::replacing(d, &[leaf.id]); + let new_ids = replace_with_pieces(d, &mut replaced, leaf.id, pieces)?; + replaced.finish(d)?; + let split = *new_ids.last().ok_or(EngineError::NoNode(leaf.id))?; + // Pick Color From Color Swatches (or `fill`): the stroke takes that paint. + if let Some(f) = &fill + && let Some(n) = d.node_mut(split) + { + n.appearance.set_stroke(f.clone()); + } + // The roots stay selected, with the two paths in place of the path when it was one. + let ids: Vec = roots.iter().flat_map(|r| if *r == leaf.id { new_ids.clone() } else { vec![*r] }).collect(); + sel.set(ids.iter().copied()); + let mut touched = Touched::default(); + touched.add(map.hit(q, Some(tol))); + return Ok((ids, None, touched, Some(split))); + } + let mut touched = Touched { regions: marquee.map(|(a, b)| map.regions_in_marquee(a, b)).unwrap_or_default(), ..Default::default() }; for q in sample_polyline(&pts, step) { touched.add(map.hit(q, piece_tol)); } @@ -265,16 +337,22 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Other("Shape Builder: no region under the pointer".into())); } let cuts: Vec<&SubPath> = touched.edges.iter().filter_map(|&k| map.edges.get(k)?.0.path.subpaths.first()).collect(); - // Edges come back from the planar map within a few hundredths of the outlines they follow. - let cut_tol = step * 0.2; let picked: Vec<&po::Region> = touched.regions.iter().filter_map(|&i| map.regions.get(i).map(|r| &r.0)).collect(); - let faces = po::FaceMerger::new(&map.shapes); + let faces = po::FaceMerger::with_gap(&map.shapes, gap_param(p)); let union = if picked.is_empty() { PathData::default() } else { faces.merge(&picked) }; - // The merged shape takes the look of the front-most object over the first region, or (for an - // area only lines enclose) of the front-most object and the current fill. - let covered = picked.iter().find_map(|r| r.top()); + // Pick Color From Artwork: the merged shape takes the look of the front-most object under the + // drag's first point, else under its last point, else of the front-most object. Color + // Swatches: the current fill, with the rest of the look of the front-most object over the + // first region the drag touched that an object covers, else of the front-most object. A + // marquee's drag starts at its first corner and ends at the opposite one. + let top_at = |q: &Point| map.region_at(*q).and_then(|k| map.regions.get(k)).and_then(|(r, ..)| r.top()); + let (first, last) = match marquee { + Some((a, b)) => (Some(a), Some(b)), + None => (pts.first().copied(), pts.last().copied()), + }; + let covered = + if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) }; let style_src = covered.or(map.shapes.len().checked_sub(1)); - let merged_fill = fill.or_else(|| covered.is_none().then_some(current_fill)); let mut new_nodes: Vec = vec![]; let mut merged_id = None; for (i, (sh, leaf)) in map.shapes.iter().zip(&leaves).enumerate() { @@ -288,7 +366,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { } else { for k in keep { if let Some((l, _)) = map.lines.get(k) { - new_nodes.push(shape_node(d, l.path.clone(), Some(leaf))); + new_nodes.push(remnant(d, leaf, l.path.clone(), None)); } } } @@ -311,7 +389,10 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { } else { b::node_outline(leaf).map_or_else(|| sh.path.clone(), |o| o.0) }; - let fill_closes = !leaf.appearance.fill_paint().is_none(); + let fill_closes = open_filled_as_closed && !leaf.appearance.fill_paint().is_none(); + // Edges come back from the planar map within a few hundredths of the outlines they + // follow, whatever else is selected. + let cut_tol = b::outline_snap(&sh.path); let mut cut = false; for piece in &cuts { if let Some(r) = po::cut_out(&rest, piece, fill_closes, cut_tol) { @@ -322,12 +403,13 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { if !in_union && !cut { new_nodes.push(d.reid(leaf)); } else if !rest.is_empty() { - new_nodes.push(shape_node(d, rest, Some(leaf))); + // What is left of its own outline keeps its fill rule; united faces fill with non-zero. + new_nodes.push(remnant(d, leaf, rest, in_union.then_some(FillRule::NonZero))); } } if !erase && !union.is_empty() && Some(i) == style_src { let mut m = shape_node(d, union.clone(), Some(leaf)); - if let Some(f) = &merged_fill { + if let Some(f) = &fill { m.appearance.set_fill(f.clone()); } merged_id = Some(m.id); @@ -336,15 +418,19 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result { } let new_ids = replace_roots(d, &roots, new_nodes)?; sel.set(new_ids.iter().copied()); - Ok((new_ids, merged_id, touched)) + Ok((new_ids, merged_id, touched, None)) })?; - Ok(json!({ + let mut out = json!({ "ids": ids_json(&ids), "merged": merged.map(|m| m.0), "regions": touched.regions.len(), "lines": touched.lines.len(), "edges": touched.edges.len(), - })) + }); + if let Some(e) = split { + out["split"] = json!(e.0); + } + Ok(out) } /// Remove the roots and insert `nodes` where the front-most root was. Returns the new ids. @@ -363,10 +449,32 @@ fn replace_roots(d: &mut Document, roots: &[NodeId], nodes: Vec) -> Result Ok(ids) } +/// What is left of path or compound path `src`: a copy of it with a fresh id and the geometry `path` +/// (a path for one subpath, else a compound path, as [`shape_node`] makes them). It keeps the rest +/// of `src`: its look, with gradients fitted to its bounds kept where they were on it, its name, +/// opacity mask and attributes, and its fill rule unless `rule` is given. +fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option) -> Node { + let own = match &src.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + }; + let shaped = shape_node(d, path, None); + let mut n = src.clone(); + n.pin_gradients(); + n.id = shaped.id; + n.kind = shaped.kind; + if let NodeKind::Path { rule: r, .. } | NodeKind::Compound { rule: r, .. } = &mut n.kind { + *r = rule.unwrap_or(own); + } + n +} + fn sb_regions(s: &mut Session, p: &Value) -> Result { + let open_filled_as_closed = switch_or(p, "openFilledAsClosed", true, "shapeBuilder.regions")?; + check_numbers(p, &["gap"], "shapeBuilder.regions")?; let roots = root_ids(s, p)?; let d = &s.doc()?.doc; - let (map, leaves) = BuilderMap::new(d, &roots, true); + let (map, leaves) = BuilderMap::with_gap(d, &roots, true, open_filled_as_closed, gap_param(p)); let id_of = |i: usize| leaves.get(i).map(|n| n.id.0); let regions: Vec = map .regions @@ -501,8 +609,12 @@ fn merged_gap_options(groups: &[GapOptions]) -> GapOptions { if !bottom.detect && groups.iter().any(|g| g.detect) { GapOptions { detect: true, size: GapSize::Small, ..bottom } } else { bottom } } -/// Make a Live Paint group from `roots`; returns (group, faces, edges). +/// Make a Live Paint group from `roots`, or add them to the first Live Paint group among them +/// as Merge does; returns (group, faces, edges). fn make_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { + if roots.iter().any(|r| d.node(*r).is_some_and(is_live_paint)) { + return merge_live(d, sel, roots); + } let mut sources = vec![]; for r in roots { let n = d.node(*r).ok_or(EngineError::NoNode(*r))?; @@ -523,71 +635,78 @@ fn lp_make(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } +/// Add the other `roots` (back → front) to the first Live Paint group among them, which keeps its +/// id, the paint of its faces and edges, and its own properties apart from the Gap Options, which +/// are merged; returns (group, faces, edges). +fn merge_live(d: &mut Document, sel: &mut Selection, roots: &[NodeId]) -> Result<(NodeId, usize, usize)> { + let target = roots + .iter() + .copied() + .find(|r| d.node(*r).is_some_and(is_live_paint)) + .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; + let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; + // Paint to carry over: (point inside face, fill) and (point on edge, stroke). + let keep_faces: Vec<(Point, Paint)> = b::faces(&old) + .iter() + .filter(|f| !f.appearance.fill_paint().is_none()) + .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) + .collect(); + let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) + .iter() + .filter_map(|e| { + let pd = e.path_data()?; + let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; + Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone())) + }) + .collect(); + let mut sources = source_nodes(&old); + for r in roots { + if *r != target { + sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?)); + } + } + // Merge applies the Gap Options' merge rules to the groups' options, bottom-most first. + let groups: Vec = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect(); + let options = merged_gap_options(&groups); + let (children, nf, ne) = build_children(d, sources, options.max_gap())?; + // The group keeps its name, lock, visibility, transparency, appearance, opacity mask, graphic + // style link and the rest; its children are made again and its Gap Options set to the merged ones. + let mut g = old; + if let Some(ch) = g.children_mut() { + *ch = children; + } + g.extras_mut().live_paint_gaps = stored_gap_options(options); + for (pt, paint) in keep_faces { + if let Some(fid) = face_at(&g, pt).map(|f| f.id) + && let Some(ch) = g.children_mut() + && let Some(f) = ch.iter_mut().find(|c| c.id == fid) + { + Arc::make_mut(f).appearance.set_fill(paint); + } + } + for (pt, app) in keep_edges { + if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) + && let Some(ch) = g.children_mut() + && let Some(e) = ch.iter_mut().find(|c| c.id == eid) + { + Arc::make_mut(e).appearance = app.into(); + } + } + let mut merged = Beside::replacing(d, roots); + for r in roots.iter().filter(|r| **r != target) { + if d.node(*r).is_some() { + merged.put(d, *r, vec![])?; + } + } + merged.put(d, target, vec![g])?; + merged.finish(d)?; + sel.set([target]); + Ok((target, nf, ne)) +} + fn lp_merge(s: &mut Session, p: &Value) -> Result { let roots = root_ids(s, p)?; - let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| { - let target = roots - .iter() - .copied() - .find(|r| d.node(*r).is_some_and(is_live_paint)) - .ok_or_else(|| EngineError::Other("Live Paint Merge: select a Live Paint group and the paths to add".into()))?; - let old = d.node(target).cloned().ok_or(EngineError::NoNode(target))?; - // Paint to carry over: (point inside face, fill) and (point on edge, stroke). - let keep_faces: Vec<(Point, Paint)> = b::faces(&old) - .iter() - .filter(|f| !f.appearance.fill_paint().is_none()) - .filter_map(|f| Some((po::interior_point(f.path_data()?)?, f.appearance.fill_paint()))) - .collect(); - let keep_edges: Vec<(Point, Appearance)> = b::edges(&old) - .iter() - .filter_map(|e| { - let pd = e.path_data()?; - let sp = pd.subpaths.first().filter(|sp| sp.segment_count() > 0)?; - Some((kurbo::ParamCurve::eval(&sp.segment(0), 0.5), (*e.appearance).clone())) - }) - .collect(); - let mut sources = source_nodes(&old); - for r in &roots { - if *r != target { - sources.extend(source_nodes(d.node(*r).ok_or(EngineError::NoNode(*r))?)); - } - } - // Merge applies the Gap Options' merge rules to the groups' options, bottom-most first. - let groups: Vec = roots.iter().filter_map(|r| d.node(*r)).filter(|n| is_live_paint(n)).map(Node::gap_options).collect(); - let options = merged_gap_options(&groups); - let (children, nf, ne) = build_children(d, sources, options.max_gap())?; - let mut g = Node::group(target, children); - g.name = old.name.clone(); - g.opacity = old.opacity; - g.blend = old.blend; - g.extras_mut().live_paint_gaps = stored_gap_options(options); - for (pt, paint) in keep_faces { - if let Some(fid) = face_at(&g, pt).map(|f| f.id) - && let Some(ch) = g.children_mut() - && let Some(f) = ch.iter_mut().find(|c| c.id == fid) - { - Arc::make_mut(f).appearance.set_fill(paint); - } - } - for (pt, app) in keep_edges { - if let Some(eid) = edge_near(&g, pt, 0.5).map(|e| e.id) - && let Some(ch) = g.children_mut() - && let Some(e) = ch.iter_mut().find(|c| c.id == eid) - { - Arc::make_mut(e).appearance = app.into(); - } - } - let mut merged = Beside::replacing(d, &roots); - for r in roots.iter().filter(|r| **r != target) { - if d.node(*r).is_some() { - merged.put(d, *r, vec![])?; - } - } - merged.put(d, target, vec![g])?; - merged.finish(d)?; - sel.set([target]); - Ok((target, nf, ne)) - })?; + let (id, faces, edges) = s.edit("Merge Live Paint", |d, sel| merge_live(d, sel, &roots))?; Ok(json!({ "id": id.0, "faces": faces, "edges": edges })) } @@ -662,6 +781,12 @@ fn open_gaps(group: &Node, options: GapOptions) -> Vec<[Point; 2]> { po::find_gaps(&source_shapes(&source_nodes(group)).0, options.size.points()) } +/// The gaps View › Show Live Paint Gaps shows on `group`: those up to its Gap Options' size wide, +/// as `livePaint.info` lists them. None when `group` is not a Live Paint group. +pub fn live_paint_gaps(group: &Node) -> Vec<[Point; 2]> { + if is_live_paint(group) { open_gaps(group, group.gap_options()) } else { vec![] } +} + fn lp_gap_options(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.gapOptions"; let detect = match p.get("detect").filter(|v| !v.is_null()) { @@ -679,7 +804,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result { Some(v) => Some(super::layer::layer_color_param(v).map_err(|e| bad(C, e))?.rgb()), None => None, }; - let close = bool_or(p, "closeGaps", false); + let close = switch_or(p, "closeGaps", false, C)?; let targets = live_targets(s, p)?; let (faces, gaps, closed) = s.edit("Gap Options", |d, sel| { let (mut faces, mut gaps, mut closed) = (0, vec![], 0); @@ -723,6 +848,7 @@ fn lp_gap_options(s: &mut Session, p: &Value) -> Result { fn lp_fill(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.fill"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; + super::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.fill.clone()); let group = id_param(p, "group"); let roots = if group.is_none() { root_ids(s, p)? } else { vec![] }; @@ -734,13 +860,24 @@ fn lp_fill(s: &mut Session, p: &Value) -> Result { } g } - None => match roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face_at(n, pt).is_some())) { - Some(g) => g, - None => make_live(d, sel, &roots)?.0, - }, + None => { + let live_at = |face: fn(&Node, Point) -> Option<&Arc>| { + roots.iter().copied().find(|r| d.node(*r).is_some_and(|n| is_live_paint(n) && face(n, pt).is_some())) + }; + match (live_at(b::visible_face_at), live_at(face_at)) { + (Some(g), _) => g, + // At a point on a hidden face of a Live Paint group among the objects, the command returns an error + // and does not merge the objects. + (None, Some(_)) => return Err(EngineError::Other("no Live Paint face at that point".into())), + (None, None) => make_live(d, sel, &roots)?.0, + } + } }; - let fid = - d.node(gid).and_then(|n| face_at(n, pt)).map(|f| f.id).ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; + let fid = d + .node(gid) + .and_then(|n| b::visible_face_at(n, pt)) + .map(|f| f.id) + .ok_or_else(|| EngineError::Other("no Live Paint face at that point".into()))?; d.node_mut(fid).ok_or(EngineError::NoNode(fid))?.appearance.set_fill(paint.clone()); Ok((gid, fid)) })?; @@ -752,15 +889,16 @@ fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result { const C: &str = "livePaint.strokeEdge"; let pt = point_param(p, "point").filter(|q| finite(*q)).ok_or_else(|| bad(C, "missing point [x,y]"))?; let g = id_param(p, "group").ok_or_else(|| bad(C, "missing group"))?; + super::paint::check_keep_model(p, C)?; let paint = paint_from(s, p)?.unwrap_or_else(|| s.paint.stroke.clone()); - let width = p.get("width").and_then(Value::as_f64).unwrap_or(s.paint.stroke_width.max(0.0)); + let width = number_or(p, "width", s.paint.stroke_width.max(0.0), C)?; if !(0.0..=1000.0).contains(&width) { return Err(bad(C, "width out of range")); } - let tol = f64_or(p, "tolerance", 4.0).clamp(0.0, 1000.0); + let tol = number_or(p, "tolerance", 4.0, C)?.clamp(0.0, 1000.0); let edge = s.edit("Live Paint Bucket", |d, _| { let n = d.node(g).filter(|n| is_live_paint(n)).ok_or_else(|| bad(C, format!("{g} is not a Live Paint group")))?; - let eid = edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; + let eid = b::visible_edge_near(n, pt, tol).map(|e| e.id).ok_or_else(|| EngineError::Other("no Live Paint edge near that point".into()))?; let e = d.node_mut(eid).ok_or(EngineError::NoNode(eid))?; e.appearance.set_stroke(paint.clone()); if let Some(st) = e.appearance.stroke_mut() { @@ -815,7 +953,7 @@ fn view_param(cmd: &str, p: &Value) -> Result> { fn trace_params(p: &Value) -> Result { let name = str_param(p, "preset").unwrap_or("Default"); - let enhanced = p.get("enhanced").and_then(Value::as_bool).unwrap_or(false); + let enhanced = switch_or(p, "enhanced", false, "imageTrace.make")?; // (A preset without an enhanced version is traced as it is.) let base = enhanced.then(|| tr::enhanced_preset(name)).flatten().or_else(|| tr::preset(name)); let base = base.ok_or_else(|| bad("imageTrace.make", format!("unknown preset `{name}` (see imageTrace.presets)")))?; @@ -881,13 +1019,14 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha h: height, radii: [radius; 4], kinds: Default::default(), + radii_relative: false, xf: about(center, rotation) * Affine::translate((-width / 2.0, -height / 2.0)), }, tr::TracedShape::Ellipse { center, rx, ry, rotation } => { LiveShape::Ellipse { w: 2.0 * rx, h: 2.0 * ry, pie: (0.0, 360.0), xf: about(center, rotation) * Affine::translate((-rx, -ry)) } } tr::TracedShape::Polygon { center, radius, sides, rotation } => { - LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![] } + LiveShape::Polygon { radius, sides, xf: about(center, rotation), radii: vec![], kinds: vec![], radii_relative: false } } }; // (Whether the move changed the shape's own numbers does not matter: its path is made from them next.) @@ -900,7 +1039,7 @@ fn traced_shape(shape: tr::TracedShape, xf: vectorcraft_geom::Affine) -> LiveSha fn traced_gradient(g: &tr::TracedGradient, xf: vectorcraft_geom::Affine) -> Paint { use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop}; let stops = g.stops.iter().map(|&(at, c)| GradientStop::new(at as f32, Color::rgb8(c[0], c[1], c[2]))).collect(); - let mut paint = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let mut paint = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); paint.geom = Some(GradientGeom { start: xf * g.start, end: xf * g.end, aspect: 1.0, focal: None }); Paint::Gradient(Box::new(paint)) } diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 23a6b9dfa..d51ef6907 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -10,7 +10,7 @@ //! text besides it (see `flavours`). mod flavours; -mod resources; +pub(crate) mod resources; use std::collections::BTreeMap; use std::sync::Arc; diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 1a5650199..08098705f 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -196,7 +196,7 @@ pub fn specs() -> Vec { "Load PDF into Clipboard", [], None, - "{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste", + "{dataBase64, page?: 1, password? (an encrypted PDF), editingData?: false (true: the data is a .ai or .ait file, read as document.open reads it; when document.open would use its editing data, every object of that data comes in, each layer's objects in stacking order and a hidden layer's objects hidden; else page `page`), center?: [x, y]} replace the clipboard with the objects of one PDF page, or of a .ai file's editing data (with the images, patterns and swatches they use; a .ai saved without PDF compatibility: every object of its editing data, as document.open reads it, with or without editingData), centred on `center` (default: the first artboard) → {count, warnings}; then run edit.paste", has_doc, import_pdf ), @@ -421,8 +421,15 @@ fn import_image(s: &mut Session, p: &Value) -> Result { let img = fileio::raster_image(&data_param(p, C)?).map_err(|e| bad(C, e.to_string()))?; let (sx, sy) = pt_per_px(img.ppi); let (w, h) = (img.width, img.height); - let image = - ImageObject { key: img.key.clone(), width: w, height: h, xf: Affine::scale_non_uniform(sx, sy), link: None, placement: Default::default() }; + let image = ImageObject { + key: img.key.clone(), + width: w, + height: h, + xf: Affine::scale_non_uniform(sx, sy), + link: None, + placement: Default::default(), + pixels: None, + }; let mut clip = Clipboard { nodes: vec![Node::new(NodeId(1), NodeKind::Image(Box::new(image)))], ..Default::default() }; clip.images.insert(img.key, img.blob); let count = s.load_clipboard(clip, p)?; @@ -441,6 +448,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the text is empty")); } let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null)); + t.system_layout = s.doc()?.doc.setup.system_layout; crate::cmd::typecmd::refresh_bounds(&mut t); let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t))); let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?; @@ -510,7 +518,15 @@ fn import_pdf(s: &mut Session, p: &Value) -> Result { { return import_loaded(s, p, C, loaded); } - let (mut doc, warnings) = fileio::page_document(&bytes, page - 1, &opts).map_err(|e| bad(C, e.to_string()))?; + let (mut doc, warnings) = match fileio::page_document(&bytes, page - 1, &opts) { + // For a .ai saved without PDF compatibility, the clipboard takes every object of its + // editing data, as document.open reads it. + Err(e) if fileio::is_placeholder(&e) => { + let loaded = fileio::load_with("Clipboard.ai", &bytes, &opts).map_err(|e| bad(C, e.to_string()))?; + return import_loaded(s, p, C, loaded); + } + read => read.map_err(|e| bad(C, e.to_string()))?, + }; doc.drop_edit_modes(); let clip = Clipboard::from_document(&doc); if clip.is_empty() { diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 032ee02ff..ac01a8129 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -500,7 +500,7 @@ impl Imported { impl Clipboard { /// `nodes` (from `src`) and the resources of `src` they use. - pub(super) fn gather(src: &Document, nodes: Vec) -> Self { + pub(crate) fn gather(src: &Document, nodes: Vec) -> Self { let used = Used::of(&Defs::of(src), &nodes); let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::>(); let brushes = match used.names.get(&Res::Brush) { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 5aad77387..b8a844f31 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -30,15 +30,23 @@ pub fn specs() -> Vec { has_doc, flare ), - cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line), + cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2, fill?: bool (Fill Line: the current fill, else none)} → {id}", has_doc, line), cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral), - cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc), + cmd!( + "shape.arc", + "Arc", + [], + None, + "{x1, y1, x2, y2, closed?, slope?} or {x, y, lengthX, lengthY, reference?, baseAlong?: x|y, slope?, closed?, fillArc?} → {id}", + has_doc, + arc + ), cmd!( "shape.rectangularGrid", "Rectangular Grid", [], None, - "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none)} → {id}", + "{x, y, width, height, rows=5, columns=5, rowSkew=0, columnSkew=0 (−500…500 %: + crowds the horizontal dividers toward the top, the vertical ones toward the right), frame=true (the outside as one rectangle, else its four sides), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}", has_doc, rect_grid ), @@ -47,7 +55,7 @@ pub fn specs() -> Vec { "Polar Grid", [], None, - "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none)} → {id}", + "{x, y, width, height, concentric=5, radial=5, concentricSkew=0, radialSkew=0 (−500…500 %: + crowds the ellipses toward the outside, the radial dividers clockwise of the top one), compound=false (the ellipses pair up into compound-path rings), fill=false (the current fill, else none), rotation?: deg (about centre)} → {id}", has_doc, polar_grid ), @@ -65,7 +73,7 @@ pub fn specs() -> Vec { "Drawing Mode", [], Some("Shift+D"), - "{mode?: normal|behind|inside} (no param cycles; inside needs one selected path)", + "{mode?: normal|behind|inside} (inside needs one selected path, compound path or type; no param cycles normal → behind → inside → normal, going from behind back to normal when Draw Inside can't draw into the selection; when the Draw Inside target is isolated, drawing or pasting into it moves isolation to its clip group)", has_doc, draw_mode ), @@ -74,7 +82,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, 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?, 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", has_doc, text_create ), @@ -88,11 +96,6 @@ pub(crate) fn add_art(s: &mut Session, label: &str, kind: NodeKind, name: Option add_look(s, label, kind, look, name) } -/// [`add_look`] with just `appearance`. -pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: Appearance, name: Option) -> Result { - add_look(s, label, kind, NewArt::plain(appearance), name) -} - /// Insert a new object looking like `look` (drawing modes apply); select it. pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option) -> Result { insert_look(s, label, kind, look, name, true) @@ -101,35 +104,63 @@ pub(crate) fn add_look(s: &mut Session, label: &str, kind: NodeKind, look: NewAr /// [`add_look`] that selects the new object only when `select` is set; otherwise the selection /// stays as it was. pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: NewArt, name: Option, select: bool) -> Result { + insert_look_turned(s, label, kind, look, name, select, 0.0) +} + +/// [`add_art`] for an object made turned by a `rotation` param (degrees, clockwise on the page): +/// its bounding box turns with it, as after the Rotate tool. +fn add_art_turned(s: &mut Session, label: &str, kind: NodeKind, rotation: f64) -> Result { + let look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); + insert_look_turned(s, label, kind, look, None, true, bbox_turn(rotation)) +} + +/// The bounding-box angle (counter-clockwise, as `Node::bbox_angle` and the Rotate tool keep it) +/// of an object made with the clockwise `rotation` param: 0 exactly for none, a whole turn or a +/// non-finite angle, so such objects are as they were. +fn bbox_turn(rotation: f64) -> f64 { + if !rotation.is_finite() { + return 0.0; + } + let a = vectorcraft_geom::normalize_deg(-rotation); + if a == 0.0 { 0.0 } else { a } +} + +/// A polygon's or star's `rotation` when it is the base orientation turned by General › Constrain +/// Angle (a Shift-drawn or click-made one), so its bounding box turns with it; else 0: a polygon +/// turned by the drag's direction keeps its box on the page's axes, as before. +fn constrained_turn(s: &Session, rotation: f64) -> f64 { + match vectorcraft_geom::constrain_rotation(s.prefs.constrain_angle) { + Some(r) if rotation.is_finite() && vectorcraft_geom::normalize_deg(r - rotation).abs() < 1e-9 => rotation, + _ => 0.0, + } +} + +/// [`insert_look`] with the new object's bounding box turned to `bbox_angle`. +fn insert_look_turned( + s: &mut Session, + label: &str, + kind: NodeKind, + look: NewArt, + name: Option, + select: bool, + bbox_angle: f64, +) -> Result { let parent = s.doc()?.target_parent()?; let mode = s.draw_mode; let inside = s.draw_inside; let behind_of = s.doc()?.selection.in_paint_order(&s.doc()?.doc).first().copied(); - let id = s.edit(label, |d, sel| { + let (id, into) = s.edit(label, |d, sel| { let id = d.alloc_id(); let mut n = Node::new(id, kind); look.apply(d, &mut n); n.name = name; + n.bbox_angle = bbox_angle; + let mut into = None; match (mode, inside) { (crate::DrawMode::Inside, Some(target)) if d.node(target).is_some() => { - // Wrap the target once in a clip group [mask copy, target, …new art]. - let group = match d.parent_of(target).and_then(|p| d.node(p).map(|pn| (p, pn.kind.clone()))) { - Some((p, NodeKind::Group { clip: true, children })) if children.get(1).is_some_and(|c| c.id == target) => p, - _ => { - let (par, idx, _) = d.position(target).ok_or(crate::EngineError::NoNode(target))?; - let mut mask = d.node(target).cloned().ok_or(crate::EngineError::NoNode(target))?; - mask.id = d.alloc_id(); - mask.appearance = Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0).into(); - if let NodeKind::Path { clipping, .. } = &mut mask.kind { - *clipping = true; - } - let gid = d.alloc_id(); - d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![std::sync::Arc::new(mask)], clip: true }))?; - d.move_node(target, Some(gid), usize::MAX)?; - gid - } - }; + let group = draw_inside_group(d, target)?; d.insert(Some(group), usize::MAX, n)?; + into = Some((target, group)); } (crate::DrawMode::Behind, _) => match behind_of.and_then(|b| d.position(b)) { Some((par, idx, _)) => { @@ -146,32 +177,80 @@ pub(crate) fn insert_look(s: &mut Session, label: &str, kind: NodeKind, look: Ne if select { sel.set([id]); } - Ok(id) + Ok((id, into)) })?; + if let Some((target, group)) = into { + isolate_draw_inside_group(s, target, group)?; + } Ok(json!({ "id": id.0 })) } +/// Draw Inside's clip group for `target`, the object new art goes inside: the clip group +/// [mask copy, target, …] `target` already is the clipped object of, else one made where `target` +/// was, holding an unpainted clipping copy of it and then `target`. New art and pastes go on top +/// of its children. +pub(crate) fn draw_inside_group(d: &mut vectorcraft_doc::Document, target: NodeId) -> Result { + if let Some(p) = d.parent_of(target) + && d.node(p).is_some_and(|pn| matches!(&pn.kind, NodeKind::Group { clip: true, children } if children.get(1).is_some_and(|c| c.id == target))) + { + return Ok(p); + } + let (par, idx, _) = d.position(target).ok_or(crate::EngineError::NoNode(target))?; + // The mask is a copy with ids of its own, its subpaths' too (a compound path's). + let original = d.node(target).cloned().ok_or(crate::EngineError::NoNode(target))?; + let mut mask = d.reid(&original); + mask.appearance = Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::None, 0.0).into(); + if let NodeKind::Path { clipping, .. } = &mut mask.kind { + *clipping = true; + } + let gid = d.alloc_id(); + d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![std::sync::Arc::new(mask)], clip: true }))?; + d.move_node(target, Some(gid), usize::MAX)?; + Ok(gid) +} + +/// Draw Inside into the isolated object (#3705): isolation mode moves to the clip group the art +/// went into ([`draw_inside_group`]), so what is drawn or pasted inside the object is in isolation +/// mode with it, shown undimmed and picked by clicks, instead of locked beside it. Art added in +/// isolation mode belongs with what is isolated: it takes the isolated art's place when the mode +/// ends (). +/// A drag's next preview, replayed on the drag's snapshot, makes the group again under the same +/// id, so isolation stays on it; cancelling the drag restores the isolation it began with. +pub(crate) fn isolate_draw_inside_group(s: &mut Session, target: NodeId, group: NodeId) -> Result<()> { + let st = s.doc_mut()?; + if st.isolation == Some(target) { + st.isolation = Some(group); + } + Ok(()) +} + +/// The object Draw Inside would draw into: the one selected object, when it is a path, a compound +/// path or type. +fn inside_target(st: &crate::DocState) -> Option { + let first = st.selection.objects.first().copied()?; + let fits = matches!(st.doc.node(first).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_))); + (fits && st.selection.len() == 1).then_some(first) +} + fn draw_mode(s: &mut Session, p: &Value) -> Result { let mode = match str_param(p, "mode") { Some("behind") => crate::DrawMode::Behind, Some("inside") => crate::DrawMode::Inside, Some("normal") => crate::DrawMode::Normal, + // Cycling (Shift+D): Draw Behind goes on to Draw Inside only when it can draw inside the + // selection, else back to Draw Normal (#3705: with one group selected it stayed in Draw + // Behind). None => match s.draw_mode { crate::DrawMode::Normal => crate::DrawMode::Behind, - crate::DrawMode::Behind if s.doc()?.selection.len() == 1 => crate::DrawMode::Inside, + crate::DrawMode::Behind if inside_target(s.doc()?).is_some() => crate::DrawMode::Inside, _ => crate::DrawMode::Normal, }, Some(o) => return Err(bad("view.drawMode", format!("unknown mode `{o}`"))), }; if mode == crate::DrawMode::Inside { - let st = s.doc()?; - let target = - st.selection.objects.first().copied().filter(|id| { - matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_))) - }); - match target { - Some(t) if st.selection.len() == 1 => s.draw_inside = Some(t), - _ => return Err(bad("view.drawMode", "Draw Inside needs exactly one selected path, compound path or text")), + match inside_target(s.doc()?) { + Some(t) => s.draw_inside = Some(t), + None => return Err(bad("view.drawMode", "Draw Inside needs exactly one selected path, compound path or text")), } } else { s.draw_inside = None; @@ -184,6 +263,35 @@ fn path_kind(path: PathData, live: Option) -> NodeKind { NodeKind::Path { path, rule: FillRule::NonZero, live: live.map(Box::new), clipping: false, guide: false } } +/// A click-to-size shape dialog's command `cmd` with `params` drawn along General › Constrain +/// Angle, as the reference app draws new objects along it +/// (): +/// a box (rectangle, ellipse, grid) runs along the turned axes from `pivot`, the point the dialog +/// placed at the click (its top-left corner `x`, `y`, or a grid's reference point), which stays +/// put; a polygon or star turns its base orientation about its centre. The params as they are at +/// 0° or a whole turn, for other commands and when they already carry a `rotation`. +pub fn constrain_click(s: &Session, cmd: &str, mut params: Value, pivot: Point) -> Value { + let base = s.prefs.constrain_angle; + let (Some(rotation), Some(axes)) = (vectorcraft_geom::constrain_rotation(base), vectorcraft_geom::constrain_axes(base)) else { return params }; + let Some(o) = params.as_object_mut().filter(|o| !o.contains_key("rotation")) else { return params }; + match cmd { + "shape.rectangle" | "shape.ellipse" | "shape.rectangularGrid" | "shape.polarGrid" => { + let num = |k: &str| o.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + let (Some(x), Some(y), Some(w), Some(h)) = (num("x"), num("y"), num("width"), num("height")) else { return params }; + // The box's centre along the turned axes from the pivot; the command turns it there. + let c = pivot + (axes * Point::new(x + w / 2.0 - pivot.x, y + h / 2.0 - pivot.y)).to_vec2(); + o.insert("x".into(), json!(c.x - w / 2.0)); + o.insert("y".into(), json!(c.y - h / 2.0)); + o.insert("rotation".into(), json!(rotation)); + } + "shape.polygon" | "shape.star" => { + o.insert("rotation".into(), json!(rotation)); + } + _ => {} + } + params +} + fn rect_of(p: &Value, cmd: &str) -> Result { let x = f64_req(p, "x", cmd)?; let y = f64_req(p, "y", cmd)?; @@ -203,10 +311,12 @@ fn rect_transform(r: Rect, p: &Value, cmd: &str) -> Result { if rotation == 0.0 { return Ok(Affine::translate(r.origin().to_vec2())); } - Ok(Affine::translate(r.center().to_vec2()) * Affine::rotate(rotation.to_radians()) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0))) + // Exact at quarter turns (a Constrain Angle of 90°). + Ok(Affine::translate(r.center().to_vec2()) * vectorcraft_geom::rotate_deg(rotation) * Affine::translate((-r.width() / 2.0, -r.height() / 2.0))) } fn rectangle(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["radius"], "shape.rectangle")?; let r = rect_of(p, "shape.rectangle")?; let radius = f64_or(p, "radius", 0.0).max(0.0); let live = LiveShape::Rectangle { @@ -214,35 +324,45 @@ fn rectangle(s: &mut Session, p: &Value) -> Result { h: r.height(), radii: [radius; 4], kinds: Default::default(), + radii_relative: false, xf: rect_transform(r, p, "shape.rectangle")?, }; let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" }; - add_art(s, label, path_kind(live.to_path(), Some(live)), None) + add_art_turned(s, label, path_kind(live.to_path(), Some(live)), f64_or(p, "rotation", 0.0)) } fn ellipse(s: &mut Session, p: &Value) -> Result { let r = rect_of(p, "shape.ellipse")?; let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: rect_transform(r, p, "shape.ellipse")? }; - add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None) + add_art_turned(s, "Ellipse", path_kind(live.to_path(), Some(live)), f64_or(p, "rotation", 0.0)) } fn polygon(s: &mut Session, p: &Value) -> Result { let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?); let r = f64_req(p, "radius", "shape.polygon")?.abs(); + check_numbers(p, &["sides", "rotation"], "shape.polygon")?; let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32; let rot = f64_or(p, "rotation", 0.0); - let xf = Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians()); - let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![] }; - add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None) + let xf = Affine::translate(c.to_vec2()) * vectorcraft_geom::rotate_deg(rot); + let live = LiveShape::Polygon { radius: r, sides, xf, radii: vec![], kinds: vec![], radii_relative: false }; + add_art_turned(s, "Polygon", path_kind(live.to_path(), Some(live)), constrained_turn(s, rot)) } fn star(s: &mut Session, p: &Value) -> Result { let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?); let r1 = f64_req(p, "radius1", "shape.star")?.abs(); + check_numbers(p, &["radius2", "points", "rotation"], "shape.star")?; let r2 = f64_or(p, "radius2", r1 / 2.0).abs(); let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32; - let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0)); - add_art(s, "Star", path_kind(path, None), None) + let rot = f64_or(p, "rotation", 0.0); + let turn = vectorcraft_geom::rotate_deg(rot); + // A quarter or whole turn (a Constrain Angle of 90°, say) turns the upright star exactly. + let path = if rot != 0.0 && turn.as_coeffs()[..4].iter().all(|c| c.fract() == 0.0) { + shapes::star(Point::ZERO, r1, r2, n, 0.0).transformed(Affine::translate(c.to_vec2()) * turn) + } else { + shapes::star(c, r1, r2, n, rot) + }; + add_art_turned(s, "Star", path_kind(path, None), constrained_turn(s, rot)) } /// Flare tool: a lens flare built from radial-gradient paths in Screen mode — a bright centre, a halo @@ -250,6 +370,24 @@ fn star(s: &mut Session, p: &Value) -> Result { fn flare(s: &mut Session, p: &Value) -> Result { use vectorcraft_color::{BlendMode, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; let c = Point::new(f64_req(p, "cx", "shape.flare")?, f64_req(p, "cy", "shape.flare")?); + let numbers = [ + "diameter", + "opacity", + "brightness", + "growth", + "fuzziness", + "rays", + "longest", + "rayFuzziness", + "rings", + "largest", + "x2", + "y2", + "direction", + "pathLength", + "seed", + ]; + check_numbers(p, &numbers, "shape.flare")?; let pct = |k: &str, d: f64| (f64_or(p, k, d) / 100.0).clamp(0.0, 10.0); let r = (f64_or(p, "diameter", 100.0) / 2.0).max(0.5); let opacity = pct("opacity", 50.0).min(1.0) as f32; @@ -278,7 +416,7 @@ fn flare(s: &mut Session, p: &Value) -> Result { }; let stop = |offset: f32, opacity: f32| GradientStop { opacity, ..GradientStop::new(offset, vectorcraft_color::Color::WHITE) }; let radial = |centre: Point, radius: f64, stops: Vec| { - let mut g = GradientPaint::new(Gradient { kind: GradientKind::Radial, stops }); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Radial, stops)); g.geom = Some(GradientGeom { start: centre, end: centre + vectorcraft_geom::Vec2::new(radius, 0.0), aspect: 1.0, focal: None }); Paint::Gradient(Box::new(g)) }; @@ -348,9 +486,10 @@ fn line(s: &mut Session, p: &Value) -> Result { let a = Point::new(f64_req(p, "x1", "shape.line")?, f64_req(p, "y1", "shape.line")?); let b = Point::new(f64_req(p, "x2", "shape.line")?, f64_req(p, "y2", "shape.line")?); let live = LiveShape::Line { a, b }; - // Lines are stroked, never filled (Illustrator ignores the fill for new open lines). + // Lines are stroked and, unless Fill Line asks for the current fill, not filled. + let fill = if switch_or(p, "fill", false, "shape.line")? { s.paint.fill.clone() } else { vectorcraft_color::Paint::None }; let look = s.new_art_look( - vectorcraft_color::Paint::None, + fill, if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() }, s.paint.stroke_width.max(0.1), ); @@ -359,25 +498,52 @@ fn line(s: &mut Session, p: &Value) -> Result { fn spiral(s: &mut Session, p: &Value) -> Result { let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?); + check_numbers(p, &["segments"], "shape.spiral")?; let path = shapes::spiral( c, f64_req(p, "radius", "shape.spiral")?, - f64_or(p, "decay", 80.0), + number_or(p, "decay", 80.0, "shape.spiral")?, p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32, - bool_or(p, "clockwise", true), + switch_or(p, "clockwise", true, "shape.spiral")?, ); add_art(s, "Spiral", path_kind(path, None), None) } fn arc(s: &mut Session, p: &Value) -> Result { - let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?); - let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?); - add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None) + let closed = switch_or(p, "closed", false, "shape.arc")?; + let fill_arc = switch_or(p, "fillArc", false, "shape.arc")?; + let base_x = !matches!(p.get("baseAlong").and_then(Value::as_str), Some("y" | "Y")); + let path = if p.get("lengthX").is_some() || p.get("lengthY").is_some() { + let anchor = Point::new(f64_req(p, "x", "shape.arc")?, f64_req(p, "y", "shape.arc")?); + let reference = p.get("reference").and_then(Value::as_u64).unwrap_or(0) as usize; + shapes::arc_segment( + anchor, + reference, + number_or(p, "lengthX", 100.0, "shape.arc")?, + number_or(p, "lengthY", 100.0, "shape.arc")?, + base_x, + number_or(p, "slope", 50.0, "shape.arc")?, + closed, + ) + } else { + let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?); + let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?); + let slope = number_or(p, "slope", 100.0, "shape.arc")?; + shapes::arc(a, b, slope, closed, base_x) + }; + let fill = if fill_arc { s.paint.fill.clone() } else { vectorcraft_color::Paint::None }; + let look = s.new_art_look( + fill, + if s.paint.stroke.is_none() { vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK) } else { s.paint.stroke.clone() }, + s.paint.stroke_width.max(0.1), + ); + add_look(s, "Arc", path_kind(path, None), look, None) } /// The grid `paths` (bottom first) as one selected group in the new-art stroke, filled with the -/// current fill when `fill`; a path of several subpaths becomes a compound path of them. -fn grid_group(s: &mut Session, label: &str, paths: Vec, fill: bool) -> Result { +/// current fill when `fill`; a path of several subpaths becomes a compound path of them. The group +/// and its members get the bounding-box angle `bbox_angle` (a grid drawn along a Constrain Angle). +fn grid_group(s: &mut Session, label: &str, (paths, bbox_angle): (Vec, f64), fill: bool) -> Result { let parent = s.doc()?.target_parent()?; let fill = if fill { s.paint.fill.clone() } else { vectorcraft_color::Paint::None }; let look = s.new_art_look(fill, s.paint.stroke.clone(), s.paint.stroke_width); @@ -394,20 +560,25 @@ fn grid_group(s: &mut Session, label: &str, paths: Vec, fill: bool) -> Node::path(d.alloc_id(), pd, Appearance::default()) }; look.apply(d, &mut n); + n.bbox_angle = bbox_angle; std::sync::Arc::new(n) }) .collect(); let gid = d.alloc_id(); - d.insert(parent, usize::MAX, Node::group(gid, children))?; + let mut g = Node::group(gid, children); + g.bbox_angle = bbox_angle; + d.insert(parent, usize::MAX, g)?; sel.set([gid]); Ok(gid) })?; Ok(json!({ "id": id.0 })) } -/// A grid's divider count `count` with its skew `key` in percent (0 when absent, clamped to -/// ±[`shapes::GRID_SKEW_MAX`]). +/// A grid's divider count `count` (an error unless it is a number or null; 5 unless it is a JSON +/// integer of 0 or more, so 4.0 and 2.5 give 5; at most 999) with its skew `key` in percent (0 when +/// absent, clamped to ±[`shapes::GRID_SKEW_MAX`]). fn dividers(p: &Value, count: &str, key: &str, cmd: &str) -> Result<(u32, f64)> { + check_numbers(p, &[count], cmd)?; let n = p.get(count).and_then(Value::as_u64).unwrap_or(5).min(999) as u32; let skew = match p.get(key) { None | Some(Value::Null) => 0.0, @@ -429,7 +600,7 @@ fn rect_grid(s: &mut Session, p: &Value) -> Result { let r = rect_of(p, CMD)?; let paths = shapes::rectangular_grid(r, dividers(p, "rows", "rowSkew", CMD)?, dividers(p, "columns", "columnSkew", CMD)?, switch(p, "frame", true, CMD)?); - grid_group(s, "Rectangular Grid", paths, switch(p, "fill", false, CMD)?) + grid_group(s, "Rectangular Grid", turned_grid(p, r, paths, CMD)?, switch(p, "fill", false, CMD)?) } fn polar_grid(s: &mut Session, p: &Value) -> Result { @@ -441,7 +612,24 @@ fn polar_grid(s: &mut Session, p: &Value) -> Result { dividers(p, "radial", "radialSkew", CMD)?, switch(p, "compound", false, CMD)?, ); - grid_group(s, "Polar Grid", paths, switch(p, "fill", false, CMD)?) + grid_group(s, "Polar Grid", turned_grid(p, r, paths, CMD)?, switch(p, "fill", false, CMD)?) +} + +/// A grid's paths in `r` turned by the `rotation` param about the centre of `r` (a grid drawn +/// along a Constrain Angle), and their bounding-box angle; as they are without it or at 0. +fn turned_grid(p: &Value, r: Rect, mut paths: Vec, cmd: &str) -> Result<(Vec, f64)> { + let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", cmd)? } else { 0.0 }; + if !rotation.is_finite() { + return Err(bad(cmd, "rotation must be finite")); + } + if rotation != 0.0 { + let c = r.center().to_vec2(); + let m = Affine::translate(c) * vectorcraft_geom::rotate_deg(rotation) * Affine::translate(-c); + for path in &mut paths { + path.transform(m); + } + } + Ok((paths, bbox_turn(rotation))) } /// An anchor `{x, y, in?, out?, smooth?}` given to `cmd` (handles `[x, y]`, at the anchor when @@ -465,6 +653,8 @@ pub(crate) fn anchor_from_json(v: &Value, cmd: &str) -> Result { } fn path_create(s: &mut Session, p: &Value) -> Result { + // Checked with `d` too, where the path data alone decides whether the path is closed. + let closed = switch_or(p, "closed", false, "path.create")?; let path = if let Some(d) = str_param(p, "d") { let bp = vectorcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?; PathData::from_bezpath(&bp) @@ -479,7 +669,7 @@ fn path_create(s: &mut Session, p: &Value) -> Result { if anchors.is_empty() { return Err(bad("path.create", "need at least one anchor")); } - PathData::single(SubPath::new(anchors, bool_or(p, "closed", false))) + PathData::single(SubPath::new(anchors, closed)) }; let mut look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width); // Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set. @@ -493,8 +683,23 @@ fn path_create(s: &mut Session, p: &Value) -> Result { fn text_create(s: &mut Session, p: &Value) -> Result { let x = f64_req(p, "x", "text.create")?; let y = f64_req(p, "y", "text.create")?; + super::paint::check_keep_model(p, "text.create")?; + check_numbers(p, &["size"], "text.create")?; + check_switches(p, &["vertical", "placeholder"], "text.create")?; + if let Some(a) = p.get("area") { + check_numbers(a, &["width", "height"], "text.create")?; + } let text = str_param(p, "text").unwrap_or(""); let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + // Turned about the point (the first baseline's start, an area's top-left corner): type set + // along a Constrain Angle. + let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 }; + if !rotation.is_finite() { + return Err(bad("text.create", "rotation must be finite")); + } + if rotation != 0.0 { + t.xf *= vectorcraft_geom::rotate_deg(rotation); + } t.vertical = p.get("vertical").and_then(Value::as_bool).unwrap_or(false); t.para = new_type_para(); new_type_alignment(s, p, "text.create", &mut t)?; @@ -504,13 +709,14 @@ fn text_create(s: &mut Session, p: &Value) -> Result { t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) }; t.area.fit = new_area_fit(s, a, "text.create")?; } + t.system_layout = s.doc()?.doc.setup.system_layout; if bool_or(p, "placeholder", false) { super::typemenu::fill_with_placeholder(&mut t); } else { // Lays the text out (and sizes an Auto Size frame). super::typecmd::refresh_bounds(&mut t); } - let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?; + 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)) diff --git a/crates/engine/src/cmd/cut.rs b/crates/engine/src/cmd/cut.rs index 10454562f..7b18289e9 100644 --- a/crates/engine/src/cmd/cut.rs +++ b/crates/engine/src/cmd/cut.rs @@ -130,7 +130,8 @@ fn cut_targets(s: &Session) -> Result> { } } out.dedup(); - out.retain(|id| doc.is_editable(*id)); + let editable = doc.editable(); + out.retain(|id| editable(*id)); Ok(out) } diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index efc3e5fc7..ec1f91f20 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -4,7 +4,7 @@ use serde_json::{Value, json}; use std::sync::Arc; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition}; -use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, Dash, Document, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, ColorMode, Dash, Document, Node, NodeId, NodeKind, TextObject}; use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2}; pub fn specs() -> Vec { @@ -165,11 +165,13 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result { }; let o = dimension_of(&st.doc, from)?.options; let mut ids = vec![]; + let editable = st.doc.editable(); st.doc.walk(|n| { - if n.extras().dimension.is_some() && st.doc.is_editable(n.id) { + if n.extras().dimension.is_some() && editable(n.id) { ids.push(n.id); } }); + drop(editable); s.edit("Apply to All", |doc, _| { for id in &ids { let mut dim = dimension_of(doc, *id)?; @@ -225,15 +227,64 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<()> { next.moved = None; } } - if next != old || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) { + // A new document colour mode (Document Color Mode) redraws every dimension in it. + if next != old + || before.color_mode != doc.color_mode + || before.node(id).and_then(|n| n.extras().dimension.as_deref()).is_some_and(|d| d != &next) + { replace(doc, id, next)?; } } Ok(()) } -fn paint(c: [u8; 3]) -> Paint { - Paint::solid(Color::rgb8(c[0], c[1], c[2])) +/// Redraw the dimensions of an opened document whose art isn't in its colour mode: files saved +/// before dimensions followed the colour mode have RGB dimensions in CMYK documents. A dimension +/// that can't be redrawn keeps the art it has. +pub(crate) fn conform(doc: &mut Document) { + if !doc.dimensions { + return; + } + let mode = doc.color_mode; + let off_mode = |c: &Color| match mode { + ColorMode::Rgb => matches!(c, Color::Cmyk { .. }), + ColorMode::Cmyk => matches!(c, Color::Rgb { .. }), + }; + let mut stale = Vec::new(); + doc.walk(|n| { + if let Some(d) = &n.extras().dimension { + let mut off = false; + n.walk(&mut |c| { + off |= [c.appearance.fill_paint(), c.appearance.stroke_paint()].iter().filter_map(|p| p.color()).any(|c| off_mode(&c)); + // The label's characters carry their own fill. + if let NodeKind::Text(t) = &c.kind { + off |= t.runs.iter().filter_map(|r| r.style.fill.color()).any(|c| off_mode(&c)); + } + }); + if off { + stale.push((n.id, (**d).clone())); + } + } + }); + for (id, d) in stale { + // An error leaves this dimension as it was; the others are still redrawn. + let _ = replace(doc, id, d); + } +} + +/// Option colour `c` as a paint in the document's colour model: in a CMYK document black is K +/// only (a rich four-ink black would print a black dimension in every plate) and other colours +/// convert through the active colour settings. +fn paint(c: [u8; 3], mode: ColorMode) -> Paint { + let rgb = Color::rgb8(c[0], c[1], c[2]); + Paint::solid(match mode { + ColorMode::Rgb => rgb, + ColorMode::Cmyk if c == [0, 0, 0] => Color::cmyk(0.0, 0.0, 0.0, 1.0), + ColorMode::Cmyk => { + let cms = vectorcraft_color::cms::active(); + cms.convert(&rgb, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent) + } + }) } fn line(a: Point, b: Point) -> BezPath { let mut p = BezPath::new(); @@ -242,10 +293,10 @@ fn line(a: Point, b: Point) -> BezPath { p } -fn appearance(o: &DimensionOptions, ext: bool, arrows: bool) -> Appearance { +fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) -> Appearance { let (color, width, kind) = if ext { (o.extension_color, o.extension_weight, o.extension_type) } else { (o.line_color, o.line_weight, o.line_type) }; - let mut a = Appearance::basic(Paint::None, paint(color), width); + let mut a = Appearance::basic(Paint::None, paint(color, mode), width); if let Some(st) = a.stroke_mut() { st.dash = match kind { LineType::Dashed => Some(Dash { pattern: vec![width * 5.0, width * 3.0], ..Default::default() }), @@ -283,6 +334,7 @@ fn along(o: &DimensionOptions) -> f64 { fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result>> { d.validate().map_err(error)?; let o = &d.options; + let mode = doc.color_mode; let label = d.label().map_err(error)?; let mut paths: Vec<(BezPath, Appearance)> = Vec::new(); let mut angle = 0.0; @@ -299,11 +351,11 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result Result Result Result Vec { "Liquify", [], None, - "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure? (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut? (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}", + "{tool: warp|twirl|pucker|bloat|scallop|crystallize|wrinkle, points: [[x,y,pressure?]…] (the brush stroke; pen pressure 0..1, default 1), diameter?|width?, height? (pt, default 100), angle?, intensity? (0..1 or %), usePressure?: bool (each point's pressure is the intensity there), detail? (1..10), simplify?: 0..100, simplifyOn?: bool (default true; both for warp, twirl, pucker, bloat), rate? (twirl °), complexity? (0..15), horizontal?, vertical? (wrinkle, 0..1 or %), affectAnchors?, affectIn?, affectOut?: bool (scallop, crystallize, wrinkle; default true), ids?|id? (default: selection, else every path the brush touches)}", has_doc, liquify ), @@ -300,7 +300,7 @@ fn width_point_set(s: &mut Session, p: &Value) -> Result { if !(left.is_finite() && right.is_finite()) || left < 0.0 || right < 0.0 || left.max(right) > 1.0e5 { return Err(bad(C, "left/right must be non-negative widths")); } - let adjust = bool_or(p, "adjustAdjoining", false); + let adjust = switch_or(p, "adjustAdjoining", false, C)?; // When the profile comes out as it was, within a rounding error (1e-9), the document and its undo // history stay as they are. let mut pos = 0; @@ -405,7 +405,7 @@ fn width_profile_set(s: &mut Session, p: &Value) -> Result { Some(Value::Array(a)) => { let mut pts = vec![]; for q in a { - let v: Vec = q.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default(); + let v: Vec = q.as_array().and_then(|x| x.iter().map(Value::as_f64).collect()).unwrap_or_default(); if v.len() != 3 || v.iter().any(|x| !x.is_finite()) || v[1] < 0.0 || v[2] < 0.0 { return Err(bad(C, "points must be [t, left, right] with non-negative widths")); } @@ -455,7 +455,8 @@ fn samples_of(p: &Value, key: &str) -> Option> { /// What a liquify stroke may reach: the paths it can edit, each with the box no dab outside /// changes it ([`reach_bounds`]), and the objects it leaves as they are (type, symbols, images, /// graphs, meshes, and envelopes, repeats and blends with their contents), each with its bounds -/// and what it is. Hidden and locked objects, and guides, are neither. +/// and what it is. Hidden and locked objects, the art on a locked artboard, and guides are +/// neither. #[derive(Default)] struct Targets { paths: Vec<(NodeId, Rect)>, @@ -484,19 +485,21 @@ impl Targets { /// The targets under `roots` (the selection), or with none every visible object. fn of(d: &Document, roots: &[NodeId]) -> Self { let mut t = Self::default(); + let locks = d.locked_artboards(); if roots.is_empty() { - d.layers.iter().for_each(|l| t.visit(l)); + d.layers.iter().for_each(|l| t.visit(l, &locks)); return t; } + let editable = |id| d.is_editable_with(&locks, id); for r in roots { let (Some(n), Some(chain)) = (d.node(*r), d.ancestry(*r)) else { continue }; - if !d.is_editable(*r) { + if !editable(*r) { continue; } // Inside a graph, an envelope, a repeat or a blend: that object is what is left alone. match chain.iter().filter_map(|a| d.node(*a)).find_map(|a| Some((a, not_liquified(a)?))) { Some((a, what)) => t.block(a, what), - None => t.visit(n), + None => t.visit(n, &locks), } } t @@ -510,7 +513,7 @@ impl Targets { } } - fn visit(&mut self, n: &Node) { + fn visit(&mut self, n: &Node, locks: &vectorcraft_doc::LockedArtboards) { if !n.visible || n.locked { return; } @@ -525,7 +528,8 @@ impl Targets { self.paths.push((n.id, b)); } } - _ => n.children().into_iter().flatten().for_each(|c| self.visit(c)), + // The objects of a layer on a locked artboard, members and all, are left out. + _ => n.children().into_iter().flatten().filter(|c| !(n.is_layer() && locks.holds(c))).for_each(|c| self.visit(c, locks)), } } } @@ -628,9 +632,17 @@ impl LiquifyStroke { } } +/// The number keys of `object.liquify`. [`LiquifyParams::from_json`] reads a value that is not a +/// number as the key left out. +const LIQUIFY_NUMBERS: &[&str] = + &["diameter", "width", "height", "angle", "intensity", "detail", "simplify", "rate", "complexity", "horizontal", "vertical"]; + fn liquify(s: &mut Session, p: &Value) -> Result { const C: &str = "object.liquify"; + check_numbers(p, LIQUIFY_NUMBERS, C)?; let prm = LiquifyParams::from_json(p).ok_or_else(|| bad(C, "missing or unknown `tool`"))?; + // `LiquifyParams::from_json` uses a key's default for a value that is not true or false. + check_switches(p, &["usePressure", "simplifyOn", "affectAnchors", "affectIn", "affectOut"], C)?; let pts = samples_of(p, "points").filter(|v| !v.is_empty()).ok_or_else(|| bad(C, "points must be a non-empty [[x,y,pressure?]…] list"))?; if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) { return Err(bad(C, "points must be finite")); @@ -697,9 +709,10 @@ fn puppet_ids(p: &Value, default: impl FnOnce() -> Result>) -> Resul fn puppet_warp(s: &mut Session, p: &Value) -> Result { const C: &str = "object.puppetWarp"; + check_numbers(p, &["expand"], C)?; let pins = points_of(p, "pins").ok_or_else(|| bad(C, "pins must be [[x,y]…]"))?; let moved = points_of(p, "moved").ok_or_else(|| bad(C, "moved must be [[x,y]…]"))?; - let rest = bool_or(p, "rest", false); + let rest = switch_or(p, "rest", false, C)?; if (pins.is_empty() && !rest) || pins.len() != moved.len() { return Err(bad(C, "pins and moved must be non-empty lists of the same length")); } @@ -711,7 +724,9 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result { let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); let angles: Vec> = match p.get("angles") { None | Some(Value::Null) => vec![None; pins.len()], - Some(Value::Array(a)) if a.len() == pins.len() => a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect(), + Some(Value::Array(a)) if a.len() == pins.len() && a.iter().all(|v| v.is_null() || v.is_number()) => { + a.iter().map(|v| v.as_f64().filter(|d| d.is_finite()).map(f64::to_radians)).collect() + } Some(_) => return Err(bad(C, "angles must be a list as long as pins (degrees or null)")), }; let ids_json = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); @@ -752,6 +767,7 @@ pub(crate) fn drop_puppet_pins(st: &mut crate::DocState) { } fn puppet_pins(s: &mut Session, p: &Value) -> Result { + check_numbers(p, &["expand"], "object.puppetWarp.pins")?; let ids = puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))?; let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0); let set = PinSet::of(&s.doc()?.doc, &ids, expand).ok_or_else(|| EngineError::Other("nothing to warp".into()))?; @@ -794,9 +810,10 @@ pub(crate) fn silent(s: &mut Session, f: impl FnOnce(&mut PerspectiveGrid)) -> R } fn grid_set(s: &mut Session, p: &Value) -> Result { + const C: &str = "perspective.grid.set"; let old = grid_of(&s.doc()?.doc); - let g = old.merged(p).map_err(|e| bad("perspective.grid.set", e))?; - let reproject = bool_or(p, "reproject", false); + let g = old.merged(p).map_err(|e| bad(C, e))?; + let reproject = switch_or(p, "reproject", false, C)?; // When the document holds this grid already, no object moves, and the document and its undo // history stay as they are. if grid_kept(&s.doc()?.doc, &g) { @@ -819,7 +836,12 @@ fn grid_set(s: &mut Session, p: &Value) -> Result { } fn grid_show(s: &mut Session, p: &Value) -> Result { - let want = p.get("visible").and_then(Value::as_bool); + const C: &str = "perspective.grid.show"; + // A missing or null `visible` toggles the grid. + let want = match p.get("visible") { + None | Some(Value::Null) => None, + Some(_) => Some(switch_or(p, "visible", false, C)?), + }; let g = silent(s, |g| g.visible = want.unwrap_or(!g.visible))?; Ok(json!({ "visible": g.visible })) } @@ -1033,7 +1055,9 @@ fn persp_move(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "from and to must be finite")); } let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); - let (perp, copy) = (bool_or(p, "perpendicular", false), bool_or(p, "copy", false)); + let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", 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 { super::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) }; let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| { @@ -1084,13 +1108,13 @@ fn persp_move(s: &mut Session, p: &Value) -> Result { fn persp_transform(s: &mut Session, p: &Value) -> Result { const C: &str = "perspective.transform"; let m = matrix_param(p, "matrix").ok_or_else(|| bad(C, "missing matrix [a,b,c,d,e,f]"))?; - let dz = f64_or(p, "depth", 0.0); + let dz = number_or(p, "depth", 0.0, C)?; if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 || !dz.is_finite() { return Err(bad(C, "matrix must be finite and invertible, depth finite")); } let ids = roots(s, p)?; let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok(); - let copy = bool_or(p, "copy", false); + let copy = switch_or(p, "copy", false, C)?; let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> { let to = depth + dz; if to.abs() > persp::MAX_DEPTH { @@ -1122,6 +1146,7 @@ pub(crate) fn transform_again(s: &mut Session, again: &Value) -> Result { fn plane_move(s: &mut Session, p: &Value) -> Result { const C: &str = "perspective.plane.move"; + check_numbers(p, &["offset", "by"], C)?; let old = grid_of(&s.doc()?.doc); let plane = plane_param(&old, p, C)?; let from = old.offset(plane); @@ -1144,9 +1169,10 @@ fn plane_move(s: &mut Session, p: &Value) -> Result { }; // The objects on the plane where it was (not those moved off it along its normal). let on_plane = |d: &Document| -> Vec { + let editable = d.editable(); persp::attached_roots(d) .into_iter() - .filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && d.is_editable(*id)) + .filter(|(id, pl, depth)| *pl == plane && (depth - from).abs() < 1e-6 && editable(*id)) .map(|(id, ..)| id) .collect() }; @@ -1218,11 +1244,12 @@ fn plane_match(s: &mut Session, p: &Value) -> Result { fn persp_nudge(s: &mut Session, p: &Value) -> Result { const C: &str = "perspective.nudge"; - let (dx, dy) = (f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + let (dx, dy) = (number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); if !(dx.is_finite() && dy.is_finite()) || (dx == 0.0 && dy == 0.0) { return Err(bad(C, "dx and dy give the arrow's direction")); } - let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 }; + let k = s.prefs.keyboard_increment * if switch_or(p, "big", false, C)? { 10.0 } else { 1.0 }; + let copy = switch_or(p, "copy", false, C)?; let ids = selected_roots(s)?; // The step is measured on the page at the centre of the selection's perspective box. let st = s.doc()?; @@ -1230,10 +1257,7 @@ fn persp_nudge(s: &mut Session, p: &Value) -> Result { let (plane, depth, rect) = g.plane_bounds(&st.doc, &ids).ok_or_else(|| EngineError::Other("the selection isn't in perspective".into()))?; let from = g.homography_at(plane, depth).and_then(|h| h.apply(rect.center())).ok_or_else(beyond_horizon)?; let to = from + vectorcraft_geom::Vec2::new(dx.clamp(-1.0, 1.0), dy.clamp(-1.0, 1.0)) * k; - persp_move( - s, - &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": bool_or(p, "copy", false)}), - ) + persp_move(s, &json!({"ids": ids.iter().map(|i| i.0).collect::>(), "from": [from.x, from.y], "to": [to.x, to.y], "copy": copy})) } fn persp_draw(s: &mut Session, p: &Value) -> Result { @@ -1242,11 +1266,14 @@ fn persp_draw(s: &mut Session, p: &Value) -> Result { let params = p.get("params").cloned().unwrap_or_else(|| json!({})); let mut g = grid_of(&s.doc()?.doc); let plane = plane_param(&g, p, C)?; - let snap = bool_or(p, "snap", g.snap); + let snap = switch_or(p, "snap", g.snap, C)?; // A click-to-size dialog's shape keeps its sizes in plane units from the click. - let map = match point_param(p, "at") { - Some(at) => Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?), - None => None, + let map = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => { + let at = finite_numbers(v).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; + Some(g.size_homography(plane, at, snap).ok_or_else(beyond_horizon)?) + } }; let undo_before = s.doc()?.history.undo.len(); let r = s.execute(&command, ¶ms)?; diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 65fc0d2f0..c3407b6b9 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind}; +use vectorcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind, RepeatKind, RepeatSpec}; use vectorcraft_geom::{Affine, Rect}; use vectorcraft_text::embed::Embedding; @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its licence lets it be embedded)}", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}", has_doc, info ), @@ -35,10 +35,11 @@ pub fn specs() -> Vec { } /// Document Info's categories: (id, title), in the order the panel and the report show them. -pub const CATEGORIES: [(&str, &str); 11] = [ +pub const CATEGORIES: [(&str, &str); 13] = [ ("document", "Document"), ("objects", "Objects"), ("graphicStyles", "Graphic Styles"), + ("brushes", "Brushes"), ("spotColors", "Spot Colors"), ("patterns", "Pattern Objects"), ("gradients", "Gradient Swatches"), @@ -47,6 +48,7 @@ pub const CATEGORIES: [(&str, &str); 11] = [ ("fontDetails", "Font Details"), ("linkedImages", "Linked Images"), ("embeddedImages", "Embedded Images"), + ("repeats", "Repeat Objects"), ]; /// The object counts of `document.info` and their labels. @@ -96,9 +98,47 @@ pub fn embedding_label(fs_type: u16) -> &'static str { /// An image object as Document Info lists it: (name, width, height, linked file). type ImageRow = (String, u32, u32, Option); +/// A Repeat object as Document Info lists it: its `repeatObjects` entry, and its row, which gives +/// its name and then its kind with the number of instances it draws (for a grid, its rows and +/// columns, followed by the number it draws when that is fewer). +fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, String)) { + let name = n.display_name(); + let count = |n: usize, one: &str, many: &str| if n == 1 { format!("1 {one}") } else { format!("{n} {many}") }; + let instances = spec.instances(Some(d)); + let mut entry = json!({ "id": n.id.0, "name": name, "instances": instances }); + let kind = &spec.kind; + let detail = match kind { + RepeatKind::Radial { .. } => { + entry["type"] = json!("radial"); + count(instances, "instance", "instances") + } + RepeatKind::Mirror { .. } => { + entry["type"] = json!("mirror"); + count(instances, "instance", "instances") + } + RepeatKind::Grid { rows, cols, .. } => { + // `repeat_transforms` clamps the rows and columns the same way. + let (rows, cols) = ((*rows).clamp(1, 500), (*cols).clamp(1, 500)); + entry["type"] = json!("grid"); + entry["rows"] = json!(rows); + entry["cols"] = json!(cols); + let grid = format!("{} × {}", count(rows as usize, "row", "rows"), count(cols as usize, "column", "columns")); + if instances < (rows * cols) as usize { format!("{grid}, {}", count(instances, "instance", "instances")) } else { grid } + } + }; + (entry, (name, format!("{}, {detail}", kind.label()))) +} + /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts `(family, style)`, image objects and used patterns. -fn sections(d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], category: Option<&str>) -> Vec
{ +/// `document.info` reports), its fonts, its image objects and the rows of its Repeat objects. +fn sections( + d: &Document, + info: &Value, + fonts: &BTreeSet, + images: &[ImageRow], + repeats: &[(String, String)], + category: Option<&str>, +) -> Vec
{ let names = |k: &str| -> Vec<(String, String)> { info[k].as_array().into_iter().flatten().filter_map(Value::as_str).map(|n| (n.to_string(), String::new())).collect() }; @@ -138,6 +178,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet rows } "graphicStyles" => names("graphicStyleNames"), + "brushes" => names("brushNames"), "spotColors" => names("spotColors"), "patterns" => names("patternNames"), "gradients" => d.swatches_iter().filter(|s| matches!(s.paint, Paint::Gradient(_))).map(|s| (s.name.clone(), String::new())).collect(), @@ -174,6 +215,7 @@ fn sections(d: &Document, info: &Value, fonts: &BTreeSet "linkedImages" => { images.iter().filter_map(|(name, w, h, link)| Some((name.clone(), format!("{w} × {h} px — {}", link.as_ref()?)))).collect() } + "repeats" => repeats.to_vec(), _ => images.iter().filter(|i| i.3.is_none()).map(|(name, w, h, _)| (name.clone(), format!("{w} × {h} px"))).collect(), }; Section { id, title, rows } @@ -246,6 +288,11 @@ fn info(s: &mut Session, p: &Value) -> Result { let mut symbols = BTreeSet::new(); let mut spot = BTreeSet::new(); let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() }; + // `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use. + let mut brushes: Vec = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() }; + // `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order. + let mut repeats: Vec = vec![]; + let mut repeat_rows: Vec<(String, String)> = vec![]; for r in roots { r.walk(&mut |n| { let k = match &n.kind { @@ -319,6 +366,21 @@ fn info(s: &mut Session, p: &Value) -> Result { { styles.push(&g.name); } + if selection_only { + for item in &n.appearance.items { + if let vectorcraft_doc::AppearanceItem::Stroke(st) = item + && let Some(b) = &st.brush + && !brushes.contains(b) + { + brushes.push(b.clone()); + } + } + } + if let NodeKind::Repeat(spec) = &n.kind { + let (entry, row) = repeat_entry(d, n, spec); + repeats.push(entry); + repeat_rows.push(row); + } }); } let font_names: Vec = fonts.iter().map(super::fonts::font_label).collect(); @@ -333,6 +395,8 @@ fn info(s: &mut Session, p: &Value) -> Result { "swatches": d.swatches_iter().count(), "graphicStyles": d.graphic_styles.len(), "graphicStyleNames": styles, + "brushNames": brushes, + "repeatObjects": repeats, "characterStyles": d.char_styles.len(), "paragraphStyles": d.para_styles.len(), "patterns": d.patterns.len(), @@ -340,7 +404,7 @@ fn info(s: &mut Session, p: &Value) -> Result { }); // The document's name: its file's, else its title. out["document"]["name"] = json!(st.title()); - let sections = sections(d, &out, &fonts, &image_rows, category); + let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, category); if text { return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) })); } @@ -448,6 +512,109 @@ mod tests { assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0); } + /// Document Info › Brushes lists the Brushes panel's brushes, and with Selection Only it lists + /// the brushes the selected objects' strokes use, each once. + #[test] + fn info_lists_the_brushes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let panel: Vec = + s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().iter().map(|b| b["name"].clone()).collect(); + let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap(); + assert_eq!(i["brushNames"], json!(panel)); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("brushes"), Some("Brushes"))); + let rows: Vec = panel.iter().map(|n| json!([n, ""])).collect(); + assert_eq!(i["sections"][0]["rows"], json!(rows)); + let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let c = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("brush.apply", &json!({"ids": [a], "name": "Charcoal"})).unwrap(); + s.execute("brush.apply", &json!({"ids": [b], "name": "Arrow"})).unwrap(); + s.execute("brush.apply", &json!({"ids": [c], "name": "Charcoal"})).unwrap(); + let names = + |s: &mut Session| s.execute("document.info", &json!({"selectionOnly": true, "category": "brushes"})).unwrap()["brushNames"].clone(); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + assert_eq!(names(&mut s), json!(["Charcoal", "Arrow"])); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + assert_eq!(names(&mut s), json!(["Arrow"])); + // A stroke without a brush lists none. + let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [plain]})).unwrap(); + assert_eq!(names(&mut s), json!([])); + let text = s.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains(&format!("\nBRUSHES\n{}\n", panel[0].as_str().unwrap())), "{text}"); + assert!(text.contains("\nCharcoal\n") && text.contains("\nArrow\n") && !text.contains("OBJECTS"), "{text}"); + } + + /// Document Info › Repeat Objects lists each Repeat object by name with its kind and the number + /// of instances it makes (for a grid, its rows and columns, followed by the number it draws when + /// that is fewer), one in a group included. With Selection Only it lists the Repeat objects in + /// the selection. + #[test] + fn info_lists_the_repeat_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let square = + |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let (a, b, c, d) = (square(&mut s, 0.0), square(&mut s, 100.0), square(&mut s, 200.0), square(&mut s, 300.0)); + let radial = s.execute("object.repeat.radial", &json!({"ids": [a], "instances": 6})).unwrap()["id"].clone(); + let grid = s.execute("object.repeat.grid", &json!({"ids": [b], "rows": 2, "cols": 1})).unwrap()["id"].clone(); + s.execute("layer.setProps", &json!({"id": grid, "name": "Tiles"})).unwrap(); + let mirror = s.execute("object.repeat.mirror", &json!({"ids": [c]})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [mirror, d]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + let i = s.execute("document.info", &json!({"category": "repeats"})).unwrap(); + assert_eq!( + i["repeatObjects"], + json!([ + {"id": radial, "name": "", "type": "radial", "instances": 6}, + {"id": grid, "name": "Tiles", "type": "grid", "rows": 2, "cols": 1, "instances": 2}, + {"id": mirror, "name": "", "type": "mirror", "instances": 2}, + ]) + ); + assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("repeats"), Some("Repeat Objects"))); + assert_eq!( + i["sections"][0]["rows"], + json!([ + ["", "Radial Repeat, 6 instances"], + ["Tiles", "Grid Repeat, 2 rows × 1 column"], + ["", "Mirror Repeat, 2 instances"], + ]) + ); + let names = |s: &mut Session| -> Vec { + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap(); + i["repeatObjects"].as_array().unwrap().iter().map(|r| r["name"].clone()).collect() + }; + s.execute("select.set", &json!({"ids": [grid, group]})).unwrap(); + assert_eq!(names(&mut s), ["Tiles", ""]); + s.execute("select.set", &json!({"ids": [radial]})).unwrap(); + assert_eq!(names(&mut s), [""]); + s.execute("select.set", &json!({"ids": [d]})).unwrap(); + assert!(names(&mut s).is_empty()); + let text = s.execute("document.info", &json!({"category": "repeats", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(text.contains("\nREPEAT OBJECTS\n: Radial Repeat, 6 instances\nTiles: Grid Repeat, 2 rows × 1 column\n"), "{text}"); + assert!(!text.contains("\nOBJECTS\n"), "{text}"); + // A radial of 1,000 copies of a 500 × 500 grid draws one copy within the instance budget, + // and Document Info reports 1 instance. + let e = square(&mut s, 350.0); + let big = s.execute("object.repeat.grid", &json!({"ids": [e], "rows": 500, "cols": 500})).unwrap()["id"].clone(); + let outer = s.execute("object.repeat.radial", &json!({"ids": [big], "instances": 1000})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [outer]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap(); + assert_eq!((i["repeatObjects"][0]["id"].clone(), i["repeatObjects"][0]["instances"].clone()), (outer, json!(1))); + assert_eq!((i["repeatObjects"][1]["id"].clone(), i["repeatObjects"][1]["instances"].clone()), (big, json!(250_000))); + assert_eq!(i["sections"][0]["rows"][0], json!(["", "Radial Repeat, 1 instance"])); + // A 500 × 500 grid of a mirror draws 125,000 copies within the budget, and its row gives + // that number after its rows and columns. + let f = square(&mut s, 380.0); + let pair = s.execute("object.repeat.mirror", &json!({"ids": [f]})).unwrap()["id"].clone(); + let tiles = s.execute("object.repeat.grid", &json!({"ids": [pair], "rows": 500, "cols": 500})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [tiles]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true, "category": "repeats"})).unwrap(); + assert_eq!((i["repeatObjects"][0]["id"].clone(), i["repeatObjects"][0]["instances"].clone()), (tiles, json!(125_000))); + assert_eq!(i["sections"][0]["rows"][0], json!(["", "Grid Repeat, 500 rows × 500 columns, 125000 instances"])); + } + #[test] fn embedding_permissions_read_from_fs_type() { assert_eq!(embedding_label(0), "embedding allowed"); diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index 2cbd4a3b9..cd521a58b 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -83,13 +83,18 @@ pub fn specs() -> Vec { "Bristle Brush Strokes", ["Select", "Object"], None, - "{} objects whose brush name contains \"bristle\" → {count}", + "{} objects whose topmost stroke has a bristle brush (type bristle in brush.list), whatever the brush is named → {count}", has_doc, - |s, _| by_kind(s, |n| n - .appearance - .stroke() - .and_then(|st| st.brush.as_deref()) - .is_some_and(|b| b.to_ascii_lowercase().contains("bristle"))) + |s, _| { + let brushes = vectorcraft_brush::library(&s.doc()?.doc); + by_kind(s, |n| { + n.appearance + .stroke() + .and_then(|st| st.brush.as_deref()) + .and_then(|name| brushes.iter().find(|b| b.name == name)) + .is_some_and(|b| matches!(b.kind, vectorcraft_brush::BrushKind::Bristle(_))) + }) + } ), cmd!( "select.save", @@ -305,7 +310,7 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool) finish_same(s, cmd, ids) } -fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result { +fn by_kind(s: &mut Session, f: impl Fn(&Node) -> bool) -> Result { let ids = candidates(s, f)?; let n = ids.len(); s.select(|_, sel| sel.set(ids))?; @@ -538,10 +543,11 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { let roots = if ids.is_some() { checked_ids_param(s, p, "ids", C)? } else { vec![] }; let d = &s.doc()?.doc; let mut paths = vec![]; + let editable = d.editable(); for r in roots { if let Some(n) = d.node(r) { n.walk(&mut |c| { - if matches!(c.kind, NodeKind::Path { guide: true, .. }) && d.is_editable(c.id) && !paths.contains(&c.id) { + if matches!(c.kind, NodeKind::Path { guide: true, .. }) && editable(c.id) && !paths.contains(&c.id) { paths.push(c.id); } }); @@ -557,7 +563,8 @@ fn release_guides(s: &mut Session, p: &Value) -> Result { } else { st.selection.guides.iter().copied().filter(|i| st.doc.guides.get(*i).is_some_and(|g| st.doc.guide_editable(g))).collect() }; - if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| st.doc.is_editable(*id)).collect()) } else { (rulers, sel) } + let editable = st.doc.editable(); + if sel.is_empty() && rulers.is_empty() { (vec![], all.into_iter().filter(|id| editable(*id)).collect()) } else { (rulers, sel) } }; // Find each ruler guide's line and its parent: the guide's layer, else where new art goes. let st = s.doc()?; diff --git a/crates/engine/src/cmd/draw2.rs b/crates/engine/src/cmd/draw2.rs index e349fb9e9..ca5ef0909 100644 --- a/crates/engine/src/cmd/draw2.rs +++ b/crates/engine/src/cmd/draw2.rs @@ -152,24 +152,40 @@ pub fn specs() -> Vec { // ---------- helpers ---------- -/// `[[x,y]…]` or `[{x,y}…]`; non-finite points are dropped. +/// `[[x,y]…]` or `[{x,y}…]`. fn points_param(p: &Value, key: &str, cmd: &str) -> Result> { Ok(samples_param(p, key, cmd)?.into_iter().map(|(pt, _)| pt).collect()) } +/// The point `[x, y, …]` or `{x, y, …}` that `v` holds, or an error when its x or y is not a finite +/// number. +fn point_entry(v: &Value, cmd: &str) -> Result { + let xy = match v { + Value::Array(xy) => xy.first().and_then(Value::as_f64).zip(xy.get(1).and_then(Value::as_f64)), + Value::Object(_) => v.get("x").and_then(Value::as_f64).zip(v.get("y").and_then(Value::as_f64)), + _ => None, + }; + xy.filter(|(x, y)| x.is_finite() && y.is_finite()).map(|(x, y)| Point::new(x, y)).ok_or_else(|| bad(cmd, "x and y must both be numbers")) +} + /// [`points_param`] with each point's pen pressure, if it has one: `[[x,y,pressure]…]` or -/// `[{x,y,pressure}…]` (0..1). +/// `[{x,y,pressure}…]` (0..1). A point whose x or y is not a number, or whose pressure is neither a +/// number nor null, is an error. fn samples_param(p: &Value, key: &str, cmd: &str) -> Result)>> { let a = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("missing array `{key}`")))?; let pts: Vec<(Point, Option)> = a .iter() - .filter_map(|v| match v { - Value::Array(xy) => Some((Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), xy.get(2).and_then(Value::as_f64))), - Value::Object(_) => Some((Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), v.get("pressure").and_then(Value::as_f64))), - _ => None, + .map(|v| { + let pressure = match v { + Value::Array(xy) => xy.get(2), + _ => v.get("pressure"), + }; + match pressure { + None | Some(Value::Null) | Some(Value::Number(_)) => Ok((point_entry(v, cmd)?, pressure.and_then(Value::as_f64))), + Some(f) => Err(bad(cmd, format!("`pressure` must be a number, not {f}"))), + } }) - .filter(|(p, _)| p.x.is_finite() && p.y.is_finite()) - .collect(); + .collect::>()?; if pts.is_empty() { return Err(bad(cmd, "need at least one point")); } @@ -211,8 +227,9 @@ fn within_input_bound(q: Point) -> bool { q.x.is_finite() && q.y.is_finite() && q.x.abs() < MAX_INPUT && q.y.abs() < MAX_INPUT } +/// The whole number of 0 or more that `key` holds, read with [`index`]. fn usize_req(p: &Value, key: &str, cmd: &str) -> Result { - p.get(key).and_then(Value::as_u64).map(|v| v as usize).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) + p.get(key).and_then(Value::as_u64).map(index).ok_or_else(|| bad(cmd, format!("missing integer `{key}`"))) } /// The whole number of 0 or more that `key` holds, or `None` when `key` is absent or null. Any other @@ -293,7 +310,8 @@ fn target_paths(doc: &Document, sel: &Selection, area: Rect) -> Vec { } } } - out.retain(|id| doc.is_editable(*id)); + let editable = doc.editable(); + out.retain(|id| editable(*id)); out } @@ -646,10 +664,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result { } let pressure = stroke_pressure(&samples); let mut pts: Vec = samples.into_iter().map(|(pt, _)| pt).collect(); - let tol = f64_or(p, "fidelity", 1.5).clamp(0.05, 100.0); - let closed = bool_or(p, "closed", false); + let tol = number_or(p, "fidelity", 1.5, C)?.clamp(0.05, 100.0); + let closed = switch_or(p, "closed", false, C)?; let brush = str_param(p, "style") == Some("brush"); - let keep_selected = bool_or(p, "keepSelected", true); + let keep_selected = switch_or(p, "keepSelected", true, C)?; + let filled = switch_or(p, "fill", false, C)?; if let Some(ext) = p.get("extend").filter(|v| !v.is_null()) { let id = id_param(ext, "id").ok_or_else(|| bad(C, "extend needs id"))?; let at_start = str_param(ext, "end") == Some("start"); @@ -722,7 +741,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result { } if let Some(rd) = p.get("redraw").filter(|v| !v.is_null()) { let id = id_param(rd, "id").ok_or_else(|| bad(C, "redraw needs id"))?; - let within = f64_or(rd, "within", 12.0); + let within = number_or(rd, "within", 12.0, C)?; if !within.is_finite() || within < 0.0 { return Err(bad(C, "redraw within must be a finite distance of 0 or more")); } @@ -746,7 +765,7 @@ fn freehand(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "need at least two points")); } let sp = fit_freehand(&pts, tol, closed); - let fill = if bool_or(p, "fill", false) { s.paint.fill.clone() } else { Paint::None }; + let fill = if filled { s.paint.fill.clone() } else { Paint::None }; // A pencil path with a fill keeps a stroke of None (#1679); only one that would show nothing // gets black. A brush is its stroke. let stroke = if brush || fill.is_none() { stroke_paint(s) } else { s.paint.stroke.clone() }; @@ -762,20 +781,11 @@ fn freehand(s: &mut Session, p: &Value) -> Result { fn curvature(s: &mut Session, p: &Value) -> Result { const C: &str = "path.curvature"; let arr = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(C, "missing points"))?; - let pts: Vec<(Point, bool)> = arr - .iter() - .filter_map(|v| { - let pt = match v { - Value::Array(xy) => Point::new(xy.first()?.as_f64()?, xy.get(1)?.as_f64()?), - _ => Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?), - }; - (pt.x.is_finite() && pt.y.is_finite()).then(|| (pt, v.get("corner").and_then(Value::as_bool).unwrap_or(false))) - }) - .collect(); + let pts: Vec<(Point, bool)> = arr.iter().map(|v| Ok((point_entry(v, C)?, switch_or(v, "corner", false, C)?))).collect::>()?; if pts.is_empty() { return Err(bad(C, "need at least one point")); } - let sp = vectorcraft_tools::draw2::curvature_spline(&pts, bool_or(p, "closed", false)); + let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?); if let Some(id) = id_param(p, "id") { s.edit("Curvature", |d, sel| { *path_mut(d, id)? = PathData::single(sp); @@ -805,6 +815,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result { const C: &str = "path.curvatureEdit"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); + check_numbers(p, &["x", "y", "t"], C)?; let at = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Some(Point::new(x, y)), (None, None) => None, @@ -820,7 +831,7 @@ fn curvature_edit(s: &mut Session, p: &Value) -> Result { None => EndCurve::Keep, Some(f) => EndCurve::parse(f).ok_or_else(|| bad(C, format!("unknown from {f:?}")))?, }; - let corner = bool_or(p, "corner", false); + let corner = switch_or(p, "corner", false, C)?; let op = match str_param(p, "op") { Some("move") => CurvatureOp::Move(usize_req(p, "anchor", C)?, to()?), Some("insert") => CurvatureOp::Insert(usize_req(p, "segment", C)?, f64_or(p, "t", 0.5), at), @@ -978,9 +989,11 @@ fn convert_anchor(s: &mut Session, p: &Value) -> Result { let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let ai = usize_req(p, "anchor", C)?; let smooth = str_param(p, "to") == Some("smooth"); + check_numbers(p, &["x", "y"], C)?; let target = match (p.get("x").and_then(Value::as_f64), p.get("y").and_then(Value::as_f64)) { (Some(x), Some(y)) => Some(Point::new(x, y)), - _ => None, + (None, None) => None, + _ => return Err(bad(C, "x and y must be given together")), }; if target.is_some_and(|q| !within_input_bound(q)) { return Err(bad(C, "x and y must be finite")); @@ -1012,12 +1025,12 @@ fn reshape_segment(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let si = usize_opt(p, "subpath", C)?.unwrap_or(0); let seg = usize_req(p, "segment", C)?; - let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95); - let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); + let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); if !(dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < MAX_INPUT) { return Err(bad(C, "invalid delta")); } - let perpendicular = bool_or(p, "perpendicular", false); + let perpendicular = switch_or(p, "perpendicular", false, C)?; s.edit("Reshape", |d, _| { let path = path_mut(d, id)?; let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; @@ -1130,7 +1143,7 @@ fn knife(s: &mut Session, p: &Value) -> Result { fn erase_region(s: &mut Session, p: &Value) -> Result { const C: &str = "path.eraseRegion"; let pts = points_param(p, "points", C)?; - let size = f64_or(p, "size", 10.0); + let size = number_or(p, "size", 10.0, C)?; if !(size.is_finite() && size > 0.0) { return Err(bad(C, "size must be positive")); } @@ -1172,7 +1185,7 @@ fn apply_plan(s: &mut Session, label: &str, plan: Vec<(NodeId, Vec)>) fn erase_segments(s: &mut Session, p: &Value) -> Result { const C: &str = "path.eraseSegments"; let pts = points_param(p, "points", C)?; - let w = f64_or(p, "width", 8.0); + let w = number_or(p, "width", 8.0, C)?; if !(w.is_finite() && w > 0.0) { return Err(bad(C, "width must be positive")); } @@ -1315,8 +1328,8 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool fn smooth_region(s: &mut Session, p: &Value) -> Result { const C: &str = "path.smoothRegion"; let pts = points_param(p, "points", C)?; - let radius = f64_or(p, "radius", 12.0).max(0.0); - let tol = f64_or(p, "fidelity", 2.5).clamp(0.05, 100.0); + let radius = number_or(p, "radius", 12.0, C)?.max(0.0); + let tol = number_or(p, "fidelity", 2.5, C)?.clamp(0.05, 100.0); let st = s.doc()?; let near = |q: Point| dist_to_polyline(&pts, q) <= radius; let mut plan = vec![]; @@ -1363,7 +1376,7 @@ fn smooth_region(s: &mut Session, p: &Value) -> Result { fn join_scrub(s: &mut Session, p: &Value) -> Result { const C: &str = "path.joinScrub"; let pts = points_param(p, "points", C)?; - let tol = f64_or(p, "tolerance", 12.0).max(0.0); + let tol = number_or(p, "tolerance", 12.0, C)?.max(0.0); let st = s.doc()?; // Open endpoints near the scrub, ordered along it: (order, id, subpath, at_start). let poly = { @@ -1462,7 +1475,7 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result { fn blob(s: &mut Session, p: &Value) -> Result { const C: &str = "path.blob"; let pts = points_param(p, "points", C)?; - let size = f64_or(p, "size", 10.0); + let size = number_or(p, "size", 10.0, C)?; if !(size.is_finite() && size > 0.0) { return Err(bad(C, "size must be positive")); } @@ -1477,11 +1490,12 @@ fn blob(s: &mut Session, p: &Value) -> Result { } else { Paint::solid(Color::BLACK) }; - let keep_selected = bool_or(p, "keepSelected", false); - let only_selection = bool_or(p, "mergeOnlySelection", false); + let keep_selected = switch_or(p, "keepSelected", false, C)?; + let only_selection = switch_or(p, "mergeOnlySelection", false, C)?; + let merge = switch_or(p, "merge", true, C)?; let st = s.doc()?; let mut merge_ids = vec![]; - if bool_or(p, "merge", true) && !(only_selection && st.selection.is_empty()) { + if merge && !(only_selection && st.selection.is_empty()) { let all = Selection::default(); let from = if only_selection { &st.selection } else { &all }; for id in target_paths(&st.doc, from, poly_bounds(&pts, size)) { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7798c229a..1ac71598e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -11,6 +11,7 @@ use super::clipboard::SwatchChoices; use super::*; use crate::{Clipboard, EngineError, inspect}; +/// Register editing commands, including document-to-document artwork drops and their parameter contracts. pub fn specs() -> Vec { vec![ cmd!( @@ -73,6 +74,15 @@ pub fn specs() -> Vec { has_doc, global_rulers ), + cmd!( + "edit.dropSelection", + "Drop Artwork", + [], + None, + "{sourceDocument: uid, ids: [id…], grab: [x,y], at: [x,y]} copy 1–10000 editable source objects from an open document to the active document, preserving the grab point and clipboard, as one undo step", + has_doc, + drop_selection + ), cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo), cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo), cmd!( @@ -109,7 +119,7 @@ pub fn specs() -> Vec { "Paste", ["Edit"], Some("Cmd+V"), - "{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", + "{center?: [x, y], dx?, dy?, swatchConflict?} paste centred on `center` (the app passes the view centre), else offset by dx/dy (default: the Paste Offset preference). Pasting brings the image blobs, symbols, patterns, global and spot swatches (with the tint swatches of the tints used), gradient swatches, graphic styles, character and paragraph styles and brushes the objects use; one of the same name that differs comes in renamed. swatchConflict, for a swatch whose name the document gives another colour (clipboard.conflicts): \"merge\" (default: the objects take the document's swatch) | \"add\" (the pasted swatch comes in renamed) | {name: \"merge\"|\"add\"}. With Paste Remembers Layers on (layer.pasteRemembersLayers), objects go back into the layers they came from (by name; made when missing). Artboards copied with artboard.copy come with their art, together right of the last artboard as they lay (the other paste commands but Paste on All Artboards: where they were), their art back in the layers it came from, and the Artboard tool, when chosen, selects them. In Draw Inside (view.drawMode) the objects go inside the object drawn into, as new art does, and Paste Remembers Layers doesn't apply; edit.pasteInPlace and edit.pasteOnAllArtboards do the same, and in Draw Behind they go behind the selection as edit.pasteInBack puts them; edit.pasteInFront and edit.pasteInBack ignore the drawing mode → {ids, artboard: the first new artboard's index or null, artboards: the new artboards' indices, added: resources added, merged: conflicts merged, renamed: [{kind, from, to}]}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset) ), @@ -136,7 +146,7 @@ pub fn specs() -> Vec { "Paste in Place", ["Edit"], Some("Cmd+Shift+V"), - "{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste where the objects were copied (resources and layers as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?, artboard?: index (as edit.pasteInFront)} paste where the objects were copied (resources, layers and drawing modes as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::InPlace) ), @@ -145,7 +155,7 @@ pub fn specs() -> Vec { "Paste on All Artboards", ["Edit"], Some("Cmd+Alt+Shift+V"), - "{swatchConflict?} paste a copy on every artboard at the offset the objects had to the artboard they were copied from (resources and layers as edit.paste) → {ids, added, merged, renamed}", + "{swatchConflict?} paste a copy on every artboard at the offset the objects had to the artboard they were copied from (resources, layers and drawing modes as edit.paste) → {ids, added, merged, renamed}", has_clipboard, |s, p| paste(s, p, PasteMode::AllArtboards) ), @@ -171,7 +181,7 @@ pub fn specs() -> Vec { } fn file_close(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?; + let i = p.get("index").and_then(Value::as_u64).map(index).or(s.active_index()).ok_or(EngineError::NoDocument)?; if !s.close_document(i) { return Err(bad("file.close", "no such document")); } @@ -179,7 +189,7 @@ fn file_close(s: &mut Session, p: &Value) -> Result { } fn doc_activate(s: &mut Session, p: &Value) -> Result { - let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing index"))? as usize; + let i = p.get("index").and_then(Value::as_u64).map(index).ok_or_else(|| bad("document.activate", "missing index"))?; if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) } } @@ -399,7 +409,10 @@ fn undo(s: &mut Session, _: &Value) -> Result { if guides_locked { crate::deselect_guides(&st.doc, &mut st.selection); } + st.sync_new_art(); super::maskedit::follow_history(st, editing); + leave_isolation_if_gone(st); + super::globaledit::follow_history(st); Ok(json!({ "undone": label })) } @@ -423,10 +436,22 @@ fn redo(s: &mut Session, _: &Value) -> Result { if guides_locked { crate::deselect_guides(&st.doc, &mut st.selection); } + st.sync_new_art(); super::maskedit::follow_history(st, editing); + leave_isolation_if_gone(st); + super::globaledit::follow_history(st); Ok(json!({ "redone": label })) } +/// After undo or redo, isolation mode ends when the isolated object is no longer in the document, +/// such as the clip group Draw Inside made around the isolated object (#3705), rather than naming +/// an id a later object may take. +fn leave_isolation_if_gone(st: &mut crate::DocState) { + if st.isolation.is_some_and(|i| st.doc.node(i).is_none()) { + st.isolation = None; + } +} + /// History panel › Clear History: removes the steps after the current state and the steps before /// it, and leaves the document as it is. Typing or a drag still in progress is first kept as a /// step, as Undo keeps it, so it is removed with the others and its change stays in the document. @@ -569,6 +594,40 @@ fn clear(s: &mut Session, p: &Value) -> Result { ok() } +/// Document ids scope node ids: two tabs may contain objects with the same node id. +fn drop_selection(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.dropSelection"; + let key = "sourceDocument"; + let uid = p[key].as_u64().ok_or_else(|| bad(CMD, format!("missing integer `{key}`")))?; + let point = |key: &str| finite_numbers::<2>(&p[key]).ok_or_else(|| bad(CMD, format!("{key} must be [x, y] with finite coordinates"))); + let grab = point("grab")?; + let at = point("at")?; + let source = s.documents().iter().find(|d| d.uid == uid).ok_or_else(|| bad(CMD, "that document is no longer open"))?; + let key = "ids"; + let values = p[key].as_array().ok_or_else(|| bad(CMD, format!("{key} must be an array of non-negative integer object ids")))?; + if values.is_empty() || values.len() > 10000 { + return Err(bad(CMD, "ids must contain 1–10000 object ids")); + } + let ids = values + .iter() + .map(|value| value.as_u64().map(NodeId).ok_or_else(|| bad(CMD, format!("invalid object id {value}: expected a non-negative integer")))) + .collect::>>()?; + // Validate the entire batch in one walk; node ids are local to the source document. + let paths = source.doc.index_paths(ids.iter().copied()); + if let Some(id) = ids.iter().find(|id| !paths.contains_key(id)) { + return Err(EngineError::NoNode(*id)); + } + let clip = Clipboard::copy(source, &roots_of(&source.doc, ids)); + let bounds = clip.bounds().ok_or_else(|| bad(CMD, "the art has no bounds"))?; + let center = bounds.center() + Vec2::new(at[0] - grab[0], at[1] - grab[1]); + if !center.x.is_finite() || !center.y.is_finite() { + return Err(bad(CMD, "result would exceed the canvas (coordinates out of range)")); + } + // A drop preserves the source colours even when the flyer has same-named swatches. + let choices = SwatchChoices::parse(PasteMode::Drag.label(), &json!({"swatchConflict": "add"}))?; + paste_clip(s, &json!({"center": [center.x, center.y]}), PasteMode::Drag, &clip, &choices) +} + #[derive(Clone, Copy, PartialEq, Eq)] enum PasteMode { Offset, @@ -578,9 +637,11 @@ enum PasteMode { AllArtboards, /// A graphic from the Libraries panel: centred where it is placed, as Paste centres. Library, + Drag, } impl PasteMode { + /// Return the user-visible action label for this insertion mode. fn label(self) -> &'static str { match self { PasteMode::Front => "Paste in Front", @@ -589,6 +650,7 @@ impl PasteMode { PasteMode::AllArtboards => "Paste on All Artboards", PasteMode::Offset => "Paste", PasteMode::Library => "Place from Library", + PasteMode::Drag => "Drop Artwork", } } } @@ -621,6 +683,7 @@ fn onto_artboard(doc: &Document, p: &Value, clip: &Clipboard) -> Affine { } } +/// Insert clipboard resources atomically in the active editable context using the requested placement mode. fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, choices: &SwatchChoices) -> Result { let off = s.prefs.paste_offset; let st = s.doc()?; @@ -636,22 +699,34 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho .reduce(|a, b| a.union(b)) .filter(|_| mode != PasteMode::AllArtboards) .map(|all| (&clip.artboards, if mode == PasteMode::Offset { super::panelcmds::beside_artboards(&st.doc, all) } else { Vec2::ZERO })); - // Paste Remembers Layers (not while isolating a group: pastes stay in it); an artboard's art - // always goes back into its layers. - let remember = (st.doc.paste_remembers_layers || boards.is_some()) && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer)); + // Drawing modes (#3705): Draw Inside puts what Paste, Paste in Place and Paste on All + // Artboards bring inside the object it draws into; Draw Behind puts what Paste in Place and + // Paste on All Artboards bring behind the selection, as Paste in Back does. Paste in Front and + // Paste in Back keep their own places, and copied artboards' art goes back into its layers. + let modal = boards.is_none() && matches!(mode, PasteMode::Offset | PasteMode::InPlace | PasteMode::AllArtboards); + // (Only with objects to paste: an artboard copied without art makes no clip group.) + let inside = s.draw_inside.filter(|t| modal && !clip.nodes.is_empty() && s.draw_mode == crate::DrawMode::Inside && st.doc.node(*t).is_some()); + let behind = modal && mode != PasteMode::Offset && s.draw_mode == crate::DrawMode::Behind; + let (front, back) = (mode == PasteMode::Front, mode == PasteMode::Back || behind); + // Paste Remembers Layers (not while isolating a group: pastes stay in it, nor in Draw + // Inside); an artboard's art always goes back into its layers. + // A drop goes into the current editable insertion context, not a same-named source layer. + let remember = mode != PasteMode::Drag + && inside.is_none() + && (st.doc.paste_remembers_layers || boards.is_some()) + && parent.is_some_and(|p| st.doc.node(p).is_some_and(Node::is_layer)); // Front/back: relative to the selection (top-most / bottom-most selected object); with // nothing selected, the top / bottom of the current layer. - let anchor = match mode { - PasteMode::Front | PasteMode::Back => { - let order = st.selection.in_paint_order(&st.doc); - let a = if mode == PasteMode::Front { order.last() } else { order.first() }; - a.and_then(|id| st.doc.position(*id)) - } - _ => None, + let anchor = if front || back { + let order = st.selection.in_paint_order(&st.doc); + let a = if front { order.last() } else { order.first() }; + a.and_then(|id| st.doc.position(*id)) + } else { + None }; let placements: Vec = match (mode, boards) { (_, Some((_, dv))) => vec![Affine::translate(dv)], - (PasteMode::Offset | PasteMode::Library, None) => vec![match point_param(p, "center") { + (PasteMode::Offset | PasteMode::Library | PasteMode::Drag, None) => vec![match point_param(p, "center") { Some(c) => clip.bounds().map_or(Affine::IDENTITY, |b| Affine::translate(c - b.center())), None => Affine::translate((f64_or(p, "dx", off), f64_or(p, "dy", off))), }], @@ -662,8 +737,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho // In place, in front, in back: onto the active artboard when the UI names it. _ => vec![onto_artboard(&st.doc, p, clip)], }; - let (ids, imported, artboards) = s.edit(mode.label(), |d, sel| { + let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| { let imported = clip.import_into(d, choices, same_doc); + // Draw Inside: the clip group of the object drawn into, made on the first paste. + let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?; let artboards: Vec = boards .into_iter() .flat_map(|(all, dv)| all.iter().map(move |a| (a, dv))) @@ -678,21 +755,25 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho let mut c = d.reid(n); imported.apply(&mut c); if xf != Affine::IDENTITY { - c.transform(xf, false); + // A cross-document drag carries the graphic's appearance with it, + // including the pattern phase, independently of transform-tool settings. + c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() }); } let layer = match clip.source_layers.get(k) { Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))), _ => None, }; - let (par, idx) = match (anchor, layer) { + let (par, idx) = match (into, anchor, layer) { + // Inside the object Draw Inside draws into, on top of what is in it already. + (Some(group), ..) => (Some(group), usize::MAX), // Next to the anchor, unless the object goes back to another layer. - (Some((par, i, _)), l) if l.is_none() || l == par => { + (None, Some((par, i, _)), l) if l.is_none() || l == par => { let k = placed.get(&par).copied().unwrap_or(0); - (par, if mode == PasteMode::Front { i + 1 + k } else { i + k }) + (par, if front { i + 1 + k } else { i + k }) } - (_, l) => { + (None, _, l) => { let par = l.or(parent); - (par, if mode == PasteMode::Back { placed.get(&par).copied().unwrap_or(0) } else { usize::MAX }) + (par, if back { placed.get(&par).copied().unwrap_or(0) } else { usize::MAX }) } }; *placed.entry(par).or_default() += 1; @@ -703,8 +784,11 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho // Pasted type arrives without the layout bounds cache: fill it so boxes and alignment are // right away (opening the file later would, but paste must not depend on a reopen). super::typecmd::refresh_bounds_of(d, &new_ids); - Ok((new_ids, imported, artboards)) + Ok((new_ids, imported, artboards, into)) })?; + if let (Some(target), Some(group)) = (inside, into) { + super::create::isolate_draw_inside_group(s, target, group)?; + } // The Artboard tool selects the pasted artboards, the first one active. if !artboards.is_empty() && s.tool_id() == "artboard" { s.set_tool_option("selected", &json!(artboards)); diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index e4660bf67..dc2e18054 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -13,6 +13,7 @@ use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_render::effects; use super::appearance::{ItemTarget, appearance_targets, index_param, item_target, item_target_at}; +use super::expand::has_seeded; use super::rasterfx; use super::*; @@ -54,6 +55,15 @@ pub fn specs() -> Vec { has_doc, set_params ), + cmd!( + "effect.lightingPreset", + "3D Lighting Preset", + [], + None, + "{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}", + has_doc, + lighting_preset + ), cmd!( "effect.expandAppearance", "Expand Appearance", @@ -129,8 +139,11 @@ fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> { Ok(()) } -const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first."; +pub(crate) const ONE_3D: &str = "An object takes one 3D effect. Edit the existing effect's options, or remove it first."; +/// `effect.apply`: adds effect `effect` (or `id`) with `params`, its defaults filled in, to the +/// targets' active item as one undo step. A 3D effect must fit each target, which takes only one; +/// a Pathfinder effect on loose objects groups them first. → `{ids, index, item}`. pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; @@ -144,7 +157,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { if id == effects::CROP_MARKS && params.get("style").is_none() { params["style"] = json!(s.crop_mark_style().id()); } - if id == GLASS { + if texture_load_effect(id) { load_texture(&mut params, C)?; } let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?; @@ -162,13 +175,9 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { if effects::is_three_d(id) { for target in appearance_targets(s, p)? { let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?; - if id == effects::REVOLVE { - effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?; - if effects::has_revolve(n) { - return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); - } - } else { - effects::validate_solid(n, id, &effect.params).map_err(|e| bad(C, e))?; + effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?; + if id == effects::REVOLVE && effects::has_revolve(n) { + return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead.")); } if effects::has_revolve(n) || effects::has_solid(n) { return Err(bad(C, ONE_3D)); @@ -299,6 +308,33 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { ids.iter().filter_map(effect).collect() } +fn lighting_preset(s: &mut Session, p: &Value) -> Result { + const C: &str = "effect.lightingPreset"; + let patch = str_param(p, "preset") + .and_then(effects::lighting_preset_params) + .ok_or_else(|| bad(C, "preset must be standard, diffuse, topLeft or right"))?; + let index = index_param(p, "index", C)?; + let item = item_target(s, p, C)?; + // Validate the whole selection before changing any object. An unrelated effect or + // a missing index must not leave just some objects with a new lighting rig. + for id in appearance_targets(s, p)? { + let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; + let slot = item.effects_item(&n.appearance, C)?; + let effect = n.appearance.effects_at(slot).and_then(|fx| fx.get(index)); + if !effect.is_some_and(|e| effects::is_three_d(&e.id)) { + return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); + } + } + let mut options = p.clone(); + let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?; + map.remove("preset"); + map.insert("params".into(), patch); + set_params(s, &options) +} + +/// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and +/// sets its Warp style (`warpStyle`) or visibility (`visible`), as one undo step. They are checked +/// as `effect.apply` checks them, and a visible 3D effect must still fit. → `{ids}`. fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; @@ -320,7 +356,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { super::params::check_effect_color(C, effect, ¶ms)?; super::params::check_effect_channel(C, effect, ¶ms)?; } - if edited.iter().any(|e| e == GLASS) { + if edited.iter().any(|e| texture_load_effect(e)) { load_texture(&mut params, C)?; } for id in appearance_targets(s, p)? { @@ -338,11 +374,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, ¶ms)) { cur.extend(new); } - if e.id == effects::REVOLVE { - effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?; - } else { - effects::validate_solid(n, &e.id, &prospective).map_err(|e| bad(C, e))?; - } + effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?; } } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| { @@ -373,6 +405,14 @@ fn set_params(s: &mut Session, p: &Value) -> Result { /// Effect › Distort › Glass. const GLASS: &str = "distort.glass"; +/// Effect › Texture › Texturizer. +const TEXTURIZER: &str = "texture.texturizer"; + +/// Does effect `id` have Load Texture: a `textureImage` that `effect.apply` reads from an image +/// file (Glass, Texturizer)? +pub(crate) fn texture_load_effect(id: &str) -> bool { + id == GLASS || id == TEXTURIZER +} /// Glass's `textureImage` in `params` as Glass stores it: an image file (`{path}`, or `{name?, /// dataBase64}`) is read with [`fileio::texture_image`] and picked (`texture: "loaded"`) unless @@ -442,18 +482,70 @@ fn raster_image(d: &mut Document, n: &Node) -> Option<(Node, bool)> { Some((rasterfx::raster_image(d, n, below, true)?, below)) } -/// Give the members of `m`, art evaluated from the object of the same id (its pieces share that -/// id), ids of their own and expand the pieces: those made from its own fills and strokes, or with -/// `all` every member. -fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects::StrokeArt) { +/// Expand the pieces of `m`, art evaluated from the object of the same id (its pieces share that +/// id): those made from its own fills and strokes, or with `all` every member. The other members +/// that repeat an id ([`repeats_an_id`]) and hold a random Transform effect ([`has_seeded`]) are +/// expanded as well ([`expand_in_place`]). Then the members get ids of their own. Random in a +/// Transform effect takes its seed from the id, and the canvas draws the pieces with the ids they +/// have before that. Returns what each of those other members' expansions lists, by the member's +/// new id. +fn expand_pieces( + d: &mut Document, + m: &mut Node, + all: bool, + brushes: &[vectorcraft_brush::Brush], + stroke_art: effects::StrokeArt, +) -> std::collections::HashMap> { let id = m.id; let mut seen = std::collections::HashSet::from([id]); + let mut expanded = std::collections::HashMap::new(); for c in m.children_mut().into_iter().flatten() { - let own = all || c.id == id; let c = Arc::make_mut(c); - effects::fresh_ids(d, c, &mut seen); - if own { + let mut listed = None; + if all || c.id == id { effects::expand_art(d, c, stroke_art); + } else if has_seeded(c) && repeats_an_id(c, &seen) { + listed = Some(expand_in_place(d, c, brushes)); + } + fresh_ids_listed(d, c, &mut seen, listed.as_deref_mut().unwrap_or_default()); + if let Some(listed) = listed { + expanded.insert(c.id, listed); + } + } + expanded +} + +/// Does `n` hold an id of `seen`, or one id twice: does [`effects::fresh_ids`] give it new ids? +fn repeats_an_id(n: &Node, seen: &std::collections::HashSet) -> bool { + let mut ids = std::collections::HashSet::new(); + let mut repeats = false; + n.walk(&mut |c| repeats |= seen.contains(&c.id) || !ids.insert(c.id)); + repeats +} + +/// An object whose appearance expanded: its id, and its place in the walk ([`Node::walk`]) of the +/// art it was listed in. Art evaluated from an object can hold one id more than once, and the place +/// tells which of those nodes it is. +type Listed = (NodeId, usize); + +/// [`expanded_node`] of `c` in place. Returns what the expansion lists, by places in the walk of `c`. +fn expand_in_place(d: &mut Document, c: &mut Node, brushes: &[vectorcraft_brush::Brush]) -> Vec { + let mut listed = vec![]; + if let Some(e) = expanded_node(d, c.clone(), brushes, &mut listed) { + *c = e; + } + listed +} + +/// [`effects::fresh_ids`] on `c`, with each of `listed` (places in the walk of `c`) given the new +/// id of the node at its place. +fn fresh_ids_listed(d: &mut Document, c: &mut Node, seen: &mut std::collections::HashSet, listed: &mut [Listed]) { + effects::fresh_ids(d, c, seen); + let mut ids = vec![]; + c.walk(&mut |n| ids.push(n.id)); + for (id, at) in listed { + if let Some(new) = ids.get(*at) { + *id = *new; } } } @@ -462,46 +554,73 @@ fn expand_pieces(d: &mut Document, m: &mut Node, all: bool, stroke_art: effects: /// stroke becomes an object of its own (strokes outlined, brushes as their art), geometry effects /// are baked, raster effects become an embedded image, and the object keeps its transparency. pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) { - let Some(mut n) = d.node(id).cloned() else { return }; + let Some(n) = d.node(id).cloned() else { return }; + let mut listed = vec![]; + let m = expanded_node(d, n, brushes, &mut listed); + out.extend(listed.into_iter().map(|(id, _)| id)); + if let Some(mut m) = m { + // Art made from type takes its emoji's pixels into the document. + d.adopt_pixels(&mut m); + if let Some(slot) = d.node_mut(id) { + *slot = m; + } + } +} + +/// [`expand_node`] of `n`, which need not be in `d`: `n` with its appearance expanded, `None` when +/// it has nothing to expand. `out` gets the objects whose appearance expands, by places in the walk +/// of the expanded `n`, `n`'s first when its own does. +fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Brush], out: &mut Vec) -> Option { + let id = n.id; if !expandable(&n) { - return; + return None; } // Colour adjustments: the colours inside become the adjusted ones (an embedded image a // recoloured copy), then the rest of the appearance expands. + let mut changed = false; + // Is `n` in `out` yet? + let mut listed_n = false; if effects::has_adjustment(&n) && let Some(m) = effects::adjust_in_document(d, &n) - && let Some(slot) = d.node_mut(id) { - *slot = m.clone(); - out.push(id); + out.push((id, 0)); + listed_n = true; n = m; + changed = true; if !expandable(&n) { - return; + return Some(n); } } // (A symbol instance has become its art, a group.) let container = is_container(&n); - // Crop Marks: a group of the object and its marks, then the object expands on its own. + // Crop Marks: the object and its marks become a group. The object expands on its own first, + // with `id`: the canvas draws it with that id, which Random in a Transform effect takes its + // seed from. Then the object and the marks get ids of their own. if let Some(mut m) = effects::crop_marks_art(&n) { let mut seen = std::collections::HashSet::from([id]); - for c in m.children_mut().into_iter().flatten() { - effects::fresh_ids(d, Arc::make_mut(c), &mut seen); + let mut expanded = vec![]; + for (i, c) in m.children_mut().into_iter().flatten().enumerate() { + let c = Arc::make_mut(c); + if i == 0 { + expanded = expand_in_place(d, c, brushes); + fresh_ids_listed(d, c, &mut seen, &mut expanded); + } else { + effects::fresh_ids(d, c, &mut seen); + } } - let object = m.children().and_then(|c| c.first()).map(|c| c.id); - if let Some(slot) = d.node_mut(id) { - *slot = m; - out.push(id); + if !listed_n { + out.push((id, 0)); } - if let Some(object) = object { - expand_node(d, object, brushes, out); - } - return; + out.extend(expanded.into_iter().map(|(id, at)| (id, at + 1))); + return Some(m); } let image = raster_image(d, &n); let mut v = n.clone(); rasterfx::strip_raster(&mut v, false); let mut stroke_art = |d: &mut Document, path: &PathData, rule: FillRule, st: &StrokeLayer| super::pathops::outlined_stroke(d, brushes, path, rule, st); + // Members of a group or layer that `expand_pieces` expanded, by id, with what their expansion lists. + let mut members = std::collections::HashMap::new(); let expanded = match image { // Blur, feather and inner glow change the object itself: it becomes the image (a layer // keeps it as its only member). @@ -509,21 +628,21 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b image => { let m = if container { // A group's or layer's own fills and strokes become art among its members. - effects::evaluate_container(&v).map(|mut m| { - expand_pieces(d, &mut m, false, &mut stroke_art); + effects::evaluate_container(Some(&*d), &v).map(|mut m| { + members = expand_pieces(d, &mut m, false, brushes, &mut stroke_art); m }) - } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_gradient_copies(&v) { + } else if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) && !effects::has_transform_art(&v) { effects::expand_leaf(d, &v, &mut stroke_art) } else { // Type, images, symbol instances, live objects (and paths whose Transform effect copies - // them with a gradient): through their outlines. + // them with a gradient or changes their fills and strokes): through their outlines. let symbol = match &n.kind { NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, }; - effects::expand_outlined(&v, symbol.as_deref()).map(|mut m| { - expand_pieces(d, &mut m, true, &mut stroke_art); + effects::expand_outlined(Some(&*d), &v, symbol.as_deref()).map(|mut m| { + expand_pieces(d, &mut m, true, brushes, &mut stroke_art); m }) }; @@ -537,20 +656,34 @@ pub(crate) fn expand_node(d: &mut Document, id: NodeId, brushes: &[vectorcraft_b } } }; - if let Some(m) = expanded - && let Some(slot) = d.node_mut(id) - { - *slot = m; - if out.last() != Some(&id) { - out.push(id); + if let Some(m) = expanded { + n = m; + changed = true; + if !listed_n { + out.push((id, 0)); } } if container { - let children: Vec = d.node(id).and_then(|n| n.children()).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(); - for c in children { - expand_node(d, c, brushes, out); + // The place of each member in the walk of `n`: after `n` and the members before it. + let mut at = 1; + for c in n.children_mut().into_iter().flatten() { + let from = out.len(); + match members.remove(&c.id) { + Some(listed) => out.extend(listed), + None => { + if let Some(e) = expanded_node(d, (**c).clone(), brushes, out) { + *c = Arc::new(e); + changed = true; + } + } + } + for (_, place) in &mut out[from..] { + *place += at; + } + at += c.count(); } } + changed.then_some(n) } fn expand_appearance(s: &mut Session, p: &Value) -> Result { @@ -575,6 +708,10 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { Ok(out) } +#[cfg(test)] +#[path = "tests_lighting_presets.rs"] +mod lighting_presets_tests; + #[cfg(test)] mod tests { use serde_json::json; @@ -976,7 +1113,7 @@ mod tests { let glyphs = effects::outline_text(&node(&s, t)).unwrap(); let b = glyphs.geometric_bounds().unwrap(); assert!(b.x0 > 105.0 && b.y0 > 105.0 && b.x1 < 195.0 && b.y1 < 195.0, "the type lies inside the square: {b:?}"); - let area = |n: &vectorcraft_doc::Node| effects::member_shapes(n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); + let area = |n: &vectorcraft_doc::Node| effects::member_shapes(None, n).iter().map(|(p, r)| vectorcraft_pathops::area(p, *r)).sum::(); let glyph_area = area(&glyphs); s.execute("select.set", &json!({"ids": [a.0, t.0]})).unwrap(); let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); @@ -1016,7 +1153,7 @@ mod tests { use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; let (mut s, id) = session_with_rect(); let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); s.edit("gradient", |d, _| { let n = d.node_mut(id).unwrap(); n.appearance.set_fill(ramp); diff --git a/crates/engine/src/cmd/expand.rs b/crates/engine/src/cmd/expand.rs index bd8dfff69..4bafaa7be 100644 --- a/crates/engine/src/cmd/expand.rs +++ b/crates/engine/src/cmd/expand.rs @@ -5,14 +5,15 @@ //! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip //! group shaped like the object. Create Gradient Mesh shares the sampling. +use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::cms::Model; use vectorcraft_color::freeform::spread_scale; -use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; -use vectorcraft_doc::live::{GradientMesh, lerp_color}; +use vectorcraft_doc::live::GradientMesh; use vectorcraft_doc::pattern::{PatternDef, place_art}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Scaling}; use vectorcraft_geom::{Affine, PathData, Point, Rect, SubPath, Vec2, shapes}; @@ -412,6 +413,45 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { }) } +/// Expand the pattern fills under `root` that paint a transparent image into the tiles they paint +/// ([`expand_patterns_where`]), so each tile image becomes an object of its own; the other +/// pattern fills stay. A tile image counts as +/// transparent when its key is in `see_through` (it has see-through pixels), or when it, a group +/// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending +/// mode. Returns how many shapes changed. +pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, see_through: &BTreeSet) -> Result { + let (_, n) = replace_leaves(d, root, |d, l| { + // Most leaves have no pattern fill: skip the ancestry walk for them. + if !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + return None; + } + let shape = d.ancestry(l.id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(blended); + let transparent = |d: &Document, i: &AppearanceItem| { + pattern_fill(d, i).is_some_and(|(f, def, _)| { + let on = shape || f.opacity < 1.0 || f.blend != BlendMode::Normal; + def.art.iter().any(|a| transparent_image(a, see_through, on)) + }) + }; + expand_patterns_where(d, l, transparent) + })?; + Ok(n) +} + +/// Whether `n` has opacity or a blending mode of its own. +fn blended(n: &Node) -> bool { + n.opacity < 1.0 || n.blend != BlendMode::Normal +} + +/// Whether `n` is or holds a transparent image: one with see-through pixels (key in `keys`), or +/// with opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`. +fn transparent_image(n: &Node, keys: &BTreeSet, on: bool) -> bool { + let on = on || blended(n); + match &n.kind { + NodeKind::Image(im) => on || keys.contains(&im.key), + _ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))), + } +} + /// Path `l` with each pattern fill turned into the tiles it paints over the box of the outline it /// fills, in a clip group shaped like that outline with the fill's opacity, blend mode and effects /// other than geometry effects, in appearance order: the other items stay on plain copies of the @@ -420,7 +460,15 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> { /// ([`pattern_tiles`]). A pattern fill that lays no tile there stays. `None` when no pattern fill /// lays a tile. fn expand_patterns(d: &mut Document, l: &Node) -> Option { - if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some()) { + expand_patterns_where(d, l, |_, _| true) +} + +/// [`expand_patterns`] for only the pattern fills `which` picks: the others stay like the other +/// items. +fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &AppearanceItem) -> bool) -> Option { + if !matches!(l.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + || !l.appearance.items.iter().any(|i| pattern_fill(d, i).is_some() && which(d, i)) + { return None; } let reshaped = reshaped(l); @@ -431,7 +479,7 @@ fn expand_patterns(d: &mut Document, l: &Node) -> Option { let mut rest = vec![]; let mut expanded = false; for item in &l.appearance.items { - let fill = pattern_fill(d, item).map(|(f, def, xf)| (f, def.clone(), xf)); + let fill = pattern_fill(d, item).filter(|_| which(d, item)).map(|(f, def, xf)| (f, def.clone(), xf)); let tiles = fill.and_then(|(f, def, xf)| { let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?; let t = pattern_tiles(d, &def, xf, bounds); @@ -607,7 +655,7 @@ fn seeded_here(n: &Node) -> bool { /// Does `n`, something in it or one of their opacity masks have a random Transform effect /// ([`seeded_here`])? -fn has_seeded(n: &Node) -> bool { +pub(crate) fn has_seeded(n: &Node) -> bool { seeded_here(n) || n.extras().mask.as_ref().is_some_and(|m| has_seeded(&m.art)) || (!matches!(n.kind, NodeKind::Compound { .. }) && n.children().is_some_and(|c| c.iter().any(|c| has_seeded(c)))) @@ -739,7 +787,7 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model }); } let geom = g.resolve(bounds); - Box::new(move |p| g.gradient.sample_with(geom.param_at(g.gradient.kind, p) as f32, lerp_color)) + Box::new(move |p| g.gradient.sample_keep_model(geom.param_at(g.gradient.kind, p) as f32)) } /// Where the colour changes along a gradient between parameters `t0` and `t1`: both ends, every @@ -748,8 +796,13 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model /// are over `max_gap` apart. The colour is linear in display RGB between neighbours, as the /// canvas draws it. fn breaks(g: &Gradient, t0: f64, t1: f64, max_gap: f64) -> Vec<(f64, (Color, f32))> { - let at = |t: f64| (t, g.sample_with(t as f32, lerp_color)); + let at = |t: f64| (t, g.sample_keep_model(t as f32)); let mut v = vec![at(t0)]; + // A Perceptual gradient curves between its stops: the mesh takes a column at each of the stops + // its expansion adds, so the linear mix between columns follows that curve. + if !g.method.is_classic() { + v.extend(g.expanded().map(|(o, ..)| f64::from(o)).filter(|o| *o > t0 && *o < t1).map(at)); + } for (i, s) in g.stops.iter().enumerate() { let o = s.offset as f64; if o > t0 && o < t1 { @@ -847,7 +900,7 @@ impl Frame { /// is just its band, so nothing doubles up. fn pieces(&self, g: &Gradient, n: usize) -> Vec<(PathData, (Color, f32))> { let step = (self.t1 - self.t0) / n as f64; - let color = |k: usize| g.sample_with((self.t0 + (k as f64 + 0.5) * step) as f32, lerp_color); + let color = |k: usize| g.sample_keep_model((self.t0 + (k as f64 + 0.5) * step) as f32); let opaque = g.stops.iter().all(|s| s.opacity >= 1.0); if self.radial { let ellipse = |t: f64| { diff --git a/crates/engine/src/cmd/fileio/affinity.rs b/crates/engine/src/cmd/fileio/affinity.rs index 0a5a2f031..ddb6905e9 100644 --- a/crates/engine/src/cmd/fileio/affinity.rs +++ b/crates/engine/src/cmd/fileio/affinity.rs @@ -1,6 +1,6 @@ //! Affinity documents (`.af`, `.afdesign`, `.afphoto`, `.afpub`): the native layers, curves, -//! shapes, artboards, text and images that `vectorcraft-affinity` reads, mapped to editable -//! VectorCraft art. Whatever the reader could not map is listed in the warnings. A file whose +//! shapes, artboards, text, tables, master pages, layer effects and images that +//! `vectorcraft-affinity` reads, mapped to editable VectorCraft art. Whatever the reader could not map is listed in the warnings. A file whose //! native document can't be read opens as its embedded preview instead, with a warning that says //! why, and can't be placed. @@ -8,12 +8,13 @@ use std::io::Cursor; use std::sync::Arc; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; +use serde_json::json; use vectorcraft_affinity::model::{self as af, Align as AfAlign, Blend, Kind, Pixels}; use vectorcraft_affinity::paint::{self as ap, Cap, GradientKind as AfKind, Join}; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; -use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; +use vectorcraft_doc::appearance::{Appearance, AppearanceItem, Dash, Effect, FillLayer, LineCap, LineJoin, StrokeAlign, StrokeLayer}; use vectorcraft_doc::geom::{FillRule, PathData, shapes}; -use vectorcraft_doc::text::{CharStyle, Justify, TextKind, TextObject, TextRun}; +use vectorcraft_doc::text::{CharStyle, FirstBaseline, Justify, TextKind, TextObject, TextRun, VerticalAlign}; use vectorcraft_doc::{Artboard, Document, ImageObject, LayerColor, Node, NodeKind, OpacityMask}; use super::load::{err, raster_doc}; @@ -30,7 +31,7 @@ pub(super) struct Imported { pub(super) fn import(name: &str, bytes: &[u8]) -> Result { match vectorcraft_affinity::read(bytes, vectorcraft_affinity::Limits::default()) { Ok(d) => { - let mut b = Builder { doc: Document::new(0.0, 0.0), warnings: Vec::new(), k: 72.0 / d.dpi, layer: 0 }; + let mut b = Builder::new(72.0 / d.dpi); b.document(&d)?; b.doc.limit_depth_noting(&mut b.warnings); let mut warnings = d.warnings.iter().map(|w| format!("Affinity: not imported or approximated: {w}.")).collect::>(); @@ -73,8 +74,73 @@ struct Builder { k: f64, /// Layers made so far (their colours cycle). layer: u8, + /// How deep inside embedded Affinity documents the node being mapped is: their artboards + /// are only clip groups, never artboards of this document. + embedded: usize, + /// Each master spread's art as shown on pages, in the order the masters are first used. + masters: Vec<(usize, Vec>)>, + /// The document's CMYK profile, until [`Builder::document_cmyk`] makes its table. + cmyk_profile: Option>, + /// That table, once made (`Some(None)`: there is none). + cmyk_lut: Option>>, + /// The working CMYK profile's table, once a CMYK pixel layer needs it. + working_cmyk: Option>, + /// What baking colour and gradient overlays may still take ([`overlay::MAX_WORK`]). + overlay_work: u64, + /// What copying master pages onto the pages that show them may still take. + copy_left: CopyCost, + /// Each master spread's objects as its pages show them, made the first time one does. + master_parts: std::collections::HashMap>, } +/// What copying a subtree takes: its nodes, characters and table cells (`work`), and its pixels. +#[derive(Clone, Copy, Debug, Default)] +struct CopyCost { + work: u64, + pixels: u64, +} + +impl CopyCost { + fn plus(self, o: CopyCost) -> CopyCost { + CopyCost { work: self.work.saturating_add(o.work), pixels: self.pixels.saturating_add(o.pixels) } + } + fn minus(self, o: CopyCost) -> Option { + Some(CopyCost { work: self.work.checked_sub(o.work)?, pixels: self.pixels.checked_sub(o.pixels)? }) + } +} + +/// Master page copies of one import together: as many objects, characters and pixels as a +/// document may hold, a few times over. A master shown on thousands of pages, or a huge one +/// on many, would otherwise multiply them. +const MAX_MASTER_COPIES: CopyCost = CopyCost { work: 1 << 23, pixels: 512 << 20 }; + +/// One object a master spread shows on the pages that show it: a top-level node of the spread +/// (`child` `None`) or one object in one of its layers, with its bounds and what copying it takes. +#[derive(Clone, Copy, Debug)] +struct MasterPart { + node: usize, + child: Option, + bounds: Option, + cost: CopyCost, +} + +fn copy_cost(n: &af::Node, depth: usize) -> CopyCost { + if depth > MAX_DEPTH { + return CopyCost::default(); + } + let len = |runs: &[af::TextRun]| runs.iter().map(|r| r.text.len() as u64).sum::(); + let own = match &n.kind { + Kind::Text(t) => CopyCost { work: 1 + len(&t.runs), pixels: 0 }, + Kind::Table(t) => CopyCost { work: 1 + t.cells.iter().map(|c| 1 + len(&c.runs)).sum::(), pixels: 0 }, + Kind::Image(img) => CopyCost { work: 1, pixels: u64::from(img.width) * u64::from(img.height) }, + _ => CopyCost { work: 1, pixels: 0 }, + }; + n.children.iter().fold(own, |c, ch| c.plus(copy_cost(ch, depth + 1))) +} + +/// Deepest node tree mapped (the reader bounds trees well below this). +const MAX_DEPTH: usize = 160; + fn rect_of(r: af::Rect) -> Rect { Rect::new(r.x0, r.y0, r.x1, r.y1) } @@ -83,7 +149,36 @@ fn affine(a: af::Affine) -> Affine { Affine::new(a.0) } +/// Where text goes: a rigid placement (translation and rotation) and the scale that maps node +/// space into it (applied to the frame's geometry, not the type), plus the type's own scale. +struct Placement { + /// Node space to the frame's space, before `place`. + to_frame: Affine, + place: Affine, + /// Points per node unit of type size. + size_scale: f64, + /// Horizontal scale of the type relative to `size_scale`. + h_scale: f64, +} + impl Builder { + fn new(k: f64) -> Self { + Self { + doc: Document::new(0.0, 0.0), + warnings: Vec::new(), + k, + layer: 0, + embedded: 0, + masters: Vec::new(), + cmyk_profile: None, + cmyk_lut: None, + working_cmyk: None, + overlay_work: vectorcraft_affinity::overlay::MAX_WORK, + copy_left: MAX_MASTER_COPIES, + master_parts: std::collections::HashMap::new(), + } + } + fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); @@ -91,6 +186,7 @@ impl Builder { } fn document(&mut self, d: &af::Document) -> Result<()> { + self.cmyk_profile = d.cmyk_profile.clone(); // Spreads (Publisher pages) sit side by side, 36 pt apart; a single spread keeps its origin. let mut layers = Vec::new(); let mut artboards = Vec::new(); @@ -102,10 +198,12 @@ impl Builder { let mut loose: Vec> = Vec::new(); for n in &spread.nodes { let top = match &n.kind { - Kind::Layer => self.node(n, to_doc, &mut artboards, true), - Kind::Artboard { .. } => self.node(n, to_doc, &mut artboards, true).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)), + Kind::Layer => self.node(n, to_doc, &mut artboards, true, 0), + Kind::Artboard { .. } => { + self.node(n, to_doc, &mut artboards, true, 0).map(|art| self.layer_of(&n.name, art, n.visible, n.locked)) + } _ => { - if let Some(art) = self.node(n, to_doc, &mut artboards, false) { + if let Some(art) = self.node(n, to_doc, &mut artboards, false, 0) { loose.push(art); } continue; @@ -119,6 +217,7 @@ impl Builder { if !loose.is_empty() { layers.push(self.layer_with(None, loose)); } + self.master_pages(d, spread, to_doc); // Without artboards, each page (or the spread) becomes one. if artboards.len() == before { let rects = if spread.pages.is_empty() { vec![spread.bounds] } else { spread.pages.clone() }; @@ -127,7 +226,8 @@ impl Builder { artboards.push((name, to_doc.transform_rect_bbox(rect_of(r)))); } } - x = artboards.iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0; + // (Earlier spreads' artboards are all left of `x` already.) + x = artboards.get(before..).unwrap_or_default().iter().map(|(_, r)| r.x1).fold(x, f64::max) + 36.0; if spread.transparent && i == 0 { self.warn("transparent page backgrounds show as white artboards"); } @@ -136,13 +236,33 @@ impl Builder { .into_iter() .enumerate() .filter(|(_, (_, r))| r.width() > 0.0 && r.height() > 0.0) - .map(|(i, (name, rect))| Artboard { id: i as u32 + 1, name, rect, show_center_mark: false, show_cross_hairs: false }) + .map(|(i, (name, rect))| Artboard { + id: i as u32 + 1, + name, + rect, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }) .collect(); if self.doc.artboards.is_empty() { return Err(err("the Affinity document has no page with a size")); } + // Master pages are behind everything on the pages that show them. + let mut masters = std::mem::take(&mut self.masters); + masters.sort_by_key(|(m, _)| *m); + let mut bottom = Vec::new(); + for (m, art) in masters { + let mut l = (*self.layer_with(Some(&format!("Master {}", master_prefix(m))), art)).clone(); + l.locked = true; + bottom.push(Arc::new(l)); + } // Bottom to top, as Affinity lists them. - self.doc.layers = layers; + bottom.extend(layers); + self.doc.layers = bottom; if self.doc.layers.is_empty() { let empty = self.layer_with(None, Vec::new()); self.doc.layers.push(empty); @@ -150,6 +270,103 @@ impl Builder { Ok(()) } + /// The master pages `spread`'s pages show, placed on those pages (`to_doc` places the spread). + /// Each master page shows only the objects on it, moved from its page to the one showing it. + fn master_pages(&mut self, d: &af::Document, spread: &af::Spread, to_doc: Affine) { + let instances = master_instances(&spread.nodes); + if instances.is_empty() { + return; + } + let owners = page_masters(&instances, spread.pages.len()); + for (pi, page) in spread.pages.iter().enumerate() { + let Some(&Some((master, first, master_page))) = owners.get(pi) else { continue }; + let Some(m) = d.masters.get(master) else { continue }; + let index = master_page.saturating_add(pi.saturating_sub(first)); + let r = m.pages.get(index).or_else(|| m.pages.last()).copied().unwrap_or(m.bounds); + let parts = self.master_parts(master, m); + let on_page = |b: &Option| b.is_some_and(|b| b.x1 > r.x0 && b.x0 < r.x1 && b.y1 > r.y0 && b.y0 < r.y1); + // Looking through the master's objects takes one each, copying them what they hold. + let looked = CopyCost { work: parts.len() as u64, pixels: 0 }; + let cost = parts.iter().filter(|p| on_page(&p.bounds)).fold(looked, |c, p| c.plus(p.cost)); + let Some(left) = self.copy_left.minus(cost) else { + self.warn("master pages shown on more pages than one import copies them to (left out on the rest)"); + return; + }; + self.copy_left = left; + let place = to_doc * Affine::translate((page.x0 - r.x0, page.y0 - r.y0)); + let mut art = Vec::new(); + let mut boards = Vec::new(); + self.embedded += 1; + let mut i = 0; + while let Some(p) = parts.get(i).copied() { + let Some(n) = m.nodes.get(p.node) else { + i += 1; + continue; + }; + if p.child.is_none() { + if on_page(&p.bounds) { + art.extend(self.node(n, place, &mut boards, false, 0)); + } + i += 1; + continue; + } + // A layer of the master spread: just its objects on this page. + let mut kids: Vec> = Vec::new(); + while let Some(q) = parts.get(i).copied().filter(|q| q.node == p.node) { + if on_page(&q.bounds) + && let Some(c) = q.child.and_then(|c| n.children.get(c)) + { + kids.extend(self.node(c, place, &mut boards, false, 1)); + } + i += 1; + } + if !kids.is_empty() { + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.isolate = n.blend != Blend::PassThrough; + art.push(Arc::new(g)); + } + } + self.embedded -= 1; + if art.is_empty() { + continue; + } + let mut g = Node::group(self.doc.alloc_id(), art); + g.name = Some(format!("Master {} (page {})", master_prefix(master), pi + 1)); + match self.masters.iter_mut().find(|(i, _)| *i == master) { + Some((_, v)) => v.push(Arc::new(g)), + None => self.masters.push((master, vec![Arc::new(g)])), + } + } + } + + /// The objects of master spread `m` (number `index`) as its pages show them: its layers' + /// objects one by one and its other top-level objects, with their bounds and costs. + fn master_parts(&mut self, index: usize, m: &af::Spread) -> Arc<[MasterPart]> { + if let Some(parts) = self.master_parts.get(&index) { + return parts.clone(); + } + if !master_instances(&m.nodes).is_empty() { + self.warn("master pages based on other master pages (only their own objects are used)"); + } + let part = |node: usize, child: Option, n: &af::Node| MasterPart { + node, + child, + bounds: af::nodes_bounds(std::slice::from_ref(n)), + cost: copy_cost(n, 0), + }; + let mut parts = Vec::new(); + for (i, n) in m.nodes.iter().enumerate() { + if matches!(n.kind, Kind::Layer) { + parts.extend(n.children.iter().enumerate().map(|(c, child)| part(i, Some(c), child))); + } else { + parts.push(part(i, None, n)); + } + } + let parts: Arc<[MasterPart]> = parts.into(); + self.master_parts.insert(index, parts.clone()); + parts + } + fn layer_with(&mut self, name: Option<&str>, children: Vec>) -> Arc { let id = self.doc.alloc_id(); self.layer = self.layer.wrapping_add(1); @@ -204,18 +421,56 @@ impl Builder { } } + /// `n` with its colour and gradient overlays baked into its paint and pixels (VectorCraft has + /// no overlay effect). + fn overlaid<'n>(&mut self, n: &'n af::Node) -> std::borrow::Cow<'n, af::Node> { + // Each kind of message once, however often it is heard. + let mut warnings: Vec = Vec::new(); + let mut work = self.overlay_work; + let out = vectorcraft_affinity::overlay::apply(n, &mut work, &mut |w| { + if !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } + }); + self.overlay_work = work; + for w in warnings { + self.warn(&w); + } + out + } + /// One Affinity node (and its subtree) as VectorCraft art; `to_doc` maps document pixels to /// points. `layer_level`: the node is directly in a spread or a layer, so a layer stays a layer. - fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool) -> Option> { - // Register before descending so nested artboards keep their document order. - if let Kind::Artboard { rect, .. } = &n.kind { + fn node(&mut self, n: &af::Node, to_doc: Affine, boards: &mut Vec<(String, Rect)>, layer_level: bool, depth: usize) -> Option> { + if depth > MAX_DEPTH { + self.warn("objects nested too deeply (left out)"); + return None; + } + // VectorCraft documents stay within `MAX_COORD` of the origin (renderers and exports rely + // on it): objects reaching beyond it are left out. + if let Some(b) = af::kind_bounds(&n.kind) { + let r = to_doc.transform_rect_bbox(rect_of(b)); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD) { + self.warn("objects beyond the document range VectorCraft supports (left out)"); + return None; + } + } + let n = &*self.overlaid(n); + // Register before descending so nested artboards keep their document order. Inside an + // embedded document an artboard is just its content's frame. + if let Kind::Artboard { rect, .. } = &n.kind + && self.embedded == 0 + { boards.push(( if n.name.is_empty() { format!("Artboard {}", boards.len() + 1) } else { n.name.clone() }, to_doc.transform_rect_bbox(rect_of(*rect)), )); } let child_level = matches!(n.kind, Kind::Layer) && layer_level; - let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level)).collect(); + let embeds = n.class == vectorcraft_affinity::stream::Tag::of(b"EmbN"); + self.embedded += usize::from(embeds); + let children: Vec> = n.children.iter().filter_map(|c| self.node(c, to_doc, boards, child_level, depth + 1)).collect(); + self.embedded -= usize::from(embeds); let mut out = match &n.kind { Kind::Layer if layer_level => { let mut l = (*self.layer_with(Some(if n.name.is_empty() { "Layer" } else { &n.name }), children)).clone(); @@ -234,7 +489,9 @@ impl Builder { Kind::Shape { path, fills, strokes, even_odd } => { let data = self.path(path, to_doc); let rule = if *even_odd { FillRule::EvenOdd } else { FillRule::NonZero }; - let appearance = self.appearance(fills, strokes, to_doc); + let mut appearance = self.appearance(fills, strokes, to_doc); + // An outline effect on a shape is one more stroke. + self.outline_stroke(&n.effects, &mut appearance); if children.is_empty() { let mut p = self.base(n, NodeKind::Path { path: data, rule, live: None, clipping: false, guide: false }); p.appearance = appearance.into(); @@ -268,8 +525,17 @@ impl Builder { g } Kind::Text(t) => { - let text = self.text(t, to_doc); + let Some((mut text, alpha)) = self.text(t, to_doc) else { + // The text is left out (warned); what it holds still comes in. + if children.is_empty() { + return None; + } + let g = self.base(n, NodeKind::Group { children, clip: false }); + return Some(Arc::new(g)); + }; + self.outline_text(&n.effects, &mut text); let mut node = self.base(n, NodeKind::Text(Box::new(text))); + node.opacity *= alpha; if !children.is_empty() { let mut kids = vec![Arc::new(node)]; kids.extend(children); @@ -277,14 +543,29 @@ impl Builder { } node } + Kind::Table(t) => { + let mut kids = self.table(t, to_doc); + if kids.is_empty() && children.is_empty() { + return None; + } + kids.extend(children); + let mut g = self.base(n, NodeKind::Group { children: kids, clip: false }); + g.name.get_or_insert_with(|| "Table".into()); + g + } Kind::Image(img) => match self.image(img, to_doc) { Some(kind) => self.base(n, kind), None if children.is_empty() => return None, None => self.base(n, NodeKind::Group { children, clip: false }), }, + // Shown on the pages through the master's layer (see `master_pages`). + Kind::MasterInstance { .. } => return None, Kind::Unsupported if children.is_empty() => return None, Kind::Unsupported => self.base(n, NodeKind::Group { children, clip: false }), }; + if !matches!(n.kind, Kind::Shape { .. } | Kind::Text(_)) && n.effects.iter().any(|e| matches!(e, af::Effect::Outline { .. })) { + self.warn("outline effects on groups, images and tables (left out)"); + } if let Some(img) = &n.pixel_mask { // A pixel mask: its grey levels show (white) or hide (black) the node, and nothing // shows outside it, as an opacity mask that clips. @@ -306,9 +587,140 @@ impl Builder { (g.opacity, g.blend, g.visible, g.name) = (opacity, blend, visible, name); out = g; } + // Shadows and glows apply to what the node shows, mask and all. + let fx = self.effects(&n.effects); + if !fx.is_empty() { + out.appearance.effects.extend(fx); + } Some(Arc::new(out)) } + /// Drop shadows, inner shadows and outer glows as VectorCraft's Stylize effects. Affinity's + /// blur radius sets the same Gaussian as VectorCraft's blur (σ = half of it), and its angle + /// is the direction the shadow goes in. + fn effects(&mut self, effects: &[af::Effect]) -> Vec { + let k = self.k; + let len = move |v: f64| if v.is_finite() { (v * k).clamp(0.0, 1000.0) } else { 0.0 }; + let num = |v: f64| (v * 1000.0).round() / 1000.0; + let mut out = Vec::new(); + for e in effects { + let (id, params) = match e { + af::Effect::DropShadow { color, opacity, radius, distance, angle } => { + let (color, alpha) = Self::color(*color); + let d = len(*distance); + let a = if angle.is_finite() { *angle } else { 0.0 }; + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "x": num(d * a.cos()), + "y": num(d * a.sin()), + "blur": num(len(*radius)), + "color": color, + }); + ("stylize.dropShadow", p) + } + af::Effect::InnerShadow { color, opacity, radius, distance, .. } => { + self.warn("inner shadows (imported as inner glows at the edge, without their offset)"); + let (color, alpha) = Self::color(*color); + let p = json!({ + "mode": "normal", + "opacity": num(opacity * f64::from(alpha) * 100.0), + "blur": num(len(*radius) + len(*distance)), + "color": color, + "source": "edge", + }); + ("stylize.innerGlow", p) + } + af::Effect::OuterGlow { color, opacity, radius } => { + let (color, alpha) = Self::color(*color); + let p = + json!({ "mode": "normal", "opacity": num(opacity * f64::from(alpha) * 100.0), "blur": num(len(*radius)), "color": color }); + ("stylize.outerGlow", p) + } + af::Effect::Bevel { .. } => { + self.warn("bevel and emboss effects (left out)"); + continue; + } + // Outlines become strokes (`outline_stroke`, `outline_text`), overlays are baked + // into the paint (`overlaid`). + af::Effect::Outline { .. } | af::Effect::ColorOverlay { .. } | af::Effect::GradientOverlay { .. } => continue, + }; + out.push(Effect { id: id.into(), params, visible: true }); + } + out + } + + /// An outline effect on a shape: a stroke layer. Affinity outlines what the shape shows, its own + /// strokes included: an outline outside goes under them (from the path out to as far past them as + /// it is wide), one centred on or inside that edge over them. A centred one reaches as far into + /// the shape as out of it here, which only differs by the width of the shape's own stroke. + fn outline_stroke(&mut self, effects: &[af::Effect], appearance: &mut Appearance) { + // How far the shape's own strokes reach outside its path. + let reach = appearance + .items + .iter() + .filter_map(|i| match i { + AppearanceItem::Stroke(s) => Some(match s.align { + StrokeAlign::Center => s.width / 2.0, + StrokeAlign::Outside => s.width, + StrokeAlign::Inside => 0.0, + }), + _ => None, + }) + .filter(|w| w.is_finite()) + .fold(0.0, f64::max); + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + let (align, w) = match align { + 1 => (StrokeAlign::Inside, w), + 2 => (StrokeAlign::Center, w + 2.0 * reach), + _ => (StrokeAlign::Outside, w + reach), + }; + let mut l = StrokeLayer::new(Paint::solid(c), w.min(1000.0)); + l.opacity = (opacity.clamp(0.0, 1.0) as f32) * alpha; + l.join = LineJoin::Round; + l.align = align; + let item = AppearanceItem::Stroke(l); + if align == StrokeAlign::Outside { + // Under the shape's strokes, over its fills. + let at = appearance.items.iter().position(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap_or(appearance.items.len()); + appearance.items.insert(at, item); + } else { + appearance.items.push(item); + } + } + } + + /// An outline effect on text: the characters' stroke, where they have none of their own. + fn outline_text(&mut self, effects: &[af::Effect], text: &mut TextObject) { + for e in effects { + let af::Effect::Outline { color, opacity, width, align } = e else { continue }; + let w = width * self.k; + if !(w.is_finite() && w > 0.0) { + continue; + } + let (c, alpha) = Self::color(*color); + if opacity * f64::from(alpha) < 0.999 { + self.warn("semi-transparent outline effects on text (imported opaque)"); + } + if *align != 2 { + self.warn("outline effects on text inside or outside the characters (centred on them)"); + } + for r in &mut text.runs { + if r.style.stroke == Paint::None { + r.style.stroke = Paint::solid(c); + // Centred on the outline, so twice as wide to show as far outside. + r.style.stroke_width = if *align == 2 { w } else { 2.0 * w }.min(1000.0); + } + } + } + } + fn path(&self, p: &af::Path, to_doc: Affine) -> PathData { let mut bp = BezPath::new(); for s in &p.subpaths { @@ -329,7 +741,7 @@ impl Builder { } fn color(c: ap::Color) -> (Color, f32) { - let f = |v: f64| v.clamp(0.0, 1.0) as f32; + let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 }; match c { ap::Color::Rgb { r, g, b, a } => (Color::Rgb { r: f(r), g: f(g), b: f(b) }, f(a)), ap::Color::Cmyk { c, m, y, k, a } => (Color::Cmyk { c: f(c), m: f(m), y: f(y), k: f(k) }, f(a)), @@ -374,7 +786,7 @@ impl Builder { } let aspect = if u.hypot() > 0.0 { (v.hypot() / u.hypot()).clamp(0.01, 100.0) } else { 1.0 }; let geom = GradientGeom { start: o, end: x, aspect: if kind == GradientKind::Radial { aspect } else { 1.0 }, focal: None }; - let mut gp = GradientPaint::new(Gradient { kind, stops }); + let mut gp = GradientPaint::new(Gradient::new(kind, stops)); gp.angle = (-(u.y)).atan2(u.x).to_degrees(); gp.geom = Some(geom); Some((Paint::Gradient(Box::new(gp)), 1.0)) @@ -382,6 +794,34 @@ impl Builder { } } + fn stroke_layer(&mut self, s: &ap::Stroke, to_doc: Affine) -> Option { + let (paint, opacity) = self.paint(&s.paint, to_doc)?; + let mut l = StrokeLayer::new(paint, s.width * self.k); + l.opacity = opacity; + l.cap = match s.cap { + Cap::Butt => LineCap::Butt, + Cap::Round => LineCap::Round, + Cap::Square => LineCap::Square, + }; + l.join = match s.join { + Join::Miter => LineJoin::Miter, + Join::Round => LineJoin::Round, + Join::Bevel => LineJoin::Bevel, + }; + l.miter_limit = s.miter_limit; + l.align = match s.align { + ap::Align::Center => StrokeAlign::Center, + ap::Align::Inside => StrokeAlign::Inside, + ap::Align::Outside => StrokeAlign::Outside, + }; + l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { + pattern: pattern.iter().map(|v| v * self.k).collect(), + offset: phase * self.k, + align_corners: false, + }); + Some(l) + } + fn appearance(&mut self, fills: &[ap::Paint], strokes: &[ap::Stroke], to_doc: Affine) -> Appearance { let mut items = Vec::new(); let mut above = Vec::new(); @@ -393,30 +833,7 @@ impl Builder { } } for s in strokes { - let Some((paint, opacity)) = self.paint(&s.paint, to_doc) else { continue }; - let mut l = StrokeLayer::new(paint, s.width * self.k); - l.opacity = opacity; - l.cap = match s.cap { - Cap::Butt => LineCap::Butt, - Cap::Round => LineCap::Round, - Cap::Square => LineCap::Square, - }; - l.join = match s.join { - Join::Miter => LineJoin::Miter, - Join::Round => LineJoin::Round, - Join::Bevel => LineJoin::Bevel, - }; - l.miter_limit = s.miter_limit; - l.align = match s.align { - ap::Align::Center => StrokeAlign::Center, - ap::Align::Inside => StrokeAlign::Inside, - ap::Align::Outside => StrokeAlign::Outside, - }; - l.dash = s.dash.as_ref().map(|(pattern, phase)| Dash { - pattern: pattern.iter().map(|v| v * self.k).collect(), - offset: phase * self.k, - align_corners: false, - }); + let Some(l) = self.stroke_layer(s, to_doc) else { continue }; if s.behind { items.insert(0, AppearanceItem::Stroke(l)); } else { @@ -427,85 +844,423 @@ impl Builder { Appearance { items, ..Appearance::default() } } - fn text(&mut self, t: &af::Text, to_doc: Affine) -> TextObject { - let origin = match t.frame { - Some(f) => Point::new(f.x0, f.y0), - None => Point::new(t.anchor.x, t.anchor.y), + /// Where text placed by `m` (node space to points) goes, its type scaled by `type_m`. + fn placement(&mut self, m: Affine, type_m: Affine) -> Option { + let [a, b, c, d, e, f] = m.as_coeffs(); + let sx = a.hypot(b); + let det = a * d - b * c; + if !(sx > 1e-9 && det.is_finite() && det.abs() > 1e-12 && e.is_finite() && f.is_finite()) { + self.warn("text with an invalid transform (left out)"); + return None; + } + let sy = det / sx; + let shear = (a * c + b * d) / (sx * sx); + if shear.abs() > 1e-3 || sy < 0.0 { + self.warn("skewed or flipped text (imported without the skew or flip)"); + } + // The type's vertical scale gives the font size, its horizontal scale relative to that the + // characters' width. + let [ta, tb, tc, td, ..] = type_m.as_coeffs(); + let (tsx, tdet) = (ta.hypot(tb), ta * td - tb * tc); + let (size_scale, h_scale) = if tsx > 1e-9 && tdet.is_finite() && tdet.abs() > 1e-12 { + let tsy = (tdet / tsx).abs(); + (tsy, tsx / tsy) + } else { + self.warn("text with an invalid text scale (imported unscaled)"); + (det.abs().sqrt(), 1.0) }; - // Text space: origin at the anchor (or frame corner), the transform's uniform scale moved - // into the type size so sizes read in points. - let m = to_doc * affine(t.transform) * Affine::translate(origin.to_vec2()); - let c = m.as_coeffs(); - let k = (c[0] * c[3] - c[1] * c[2]).abs().sqrt().max(1e-9); - let xf = m * Affine::scale(1.0 / k); - let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); - obj.xf = xf; - obj.runs = t - .runs - .iter() - .map(|r| { - let mut style = CharStyle::default(); - if !r.family.is_empty() { - style.font_family = r.family.clone(); - } else if !r.postscript.is_empty() { - style.font_family = r.postscript.clone(); - } else { - self.warn("text without a font name uses the default font"); + Some(Placement { + to_frame: Affine::scale_non_uniform(sx, sy.abs()), + place: Affine::translate((e, f)) * Affine::rotate(b.atan2(a)), + size_scale, + h_scale, + }) + } + + /// Text → its object and the opacity its characters' shared transparency gives the node. + fn text(&mut self, t: &af::Text, to_doc: Affine) -> Option<(TextObject, f32)> { + let p = self.placement(to_doc * affine(t.transform), to_doc * affine(t.text_transform))?; + let alpha = self.text_alpha(&t.runs); + let mut obj = self.type_object(&t.runs, &p)?; + obj.para.justify = justify(t.align); + if let Some(first) = obj.paras.first_mut() { + first.justify = obj.para.justify; + } + match t.frame { + Some(f) => { + let (a, b) = (p.to_frame * Point::new(f.x0, f.y0), p.to_frame * Point::new(f.x1, f.y1)); + let r = Rect::from_points(a, b); + obj.xf = p.place * Affine::translate(r.origin().to_vec2()); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, r.width(), r.height())) }; + // Affinity hangs a frame's first line from its capitals' tops, not the font's ascent. + obj.area.first_baseline = FirstBaseline::CapHeight; + if fit_lines(&mut obj, r.width(), r.height()) { + self.warn("text frames whose last lines hang out of them (made taller so the lines show)"); } - // A family/weight pair cannot identify width variants such as Arial Narrow. - // Resolve a known PostScript identity to the exact installed family and style. - if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { - style.font_family = family; - style.font_style = face_style; - } else { - style.font_style = font_style(r.weight, r.italic); + // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. + if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { + self.warn("text frames too short for their first line (made taller so the line shows)"); } - style.features = r.features.clone(); - if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { - self.warn("unsupported text OpenType features (retained but not rendered)"); - } - style.all_caps = r.all_caps; - style.size = r.size * k; - style.leading = r.leading.map(|l| l * k); - style.tracking = r.tracking * 1000.0; - style.fill = match self.paint(&r.fill, to_doc) { - Some((p, a)) => { - if a < 0.999 { - self.warn("translucent text colours import opaque"); - } - p - } - None => Paint::None, - }; - // Affinity separates paragraphs with U+2029 and lines with U+2028. - TextRun::new(r.text.replace(['\u{2029}', '\u{2028}'], "\n"), style) - }) - .collect(); - obj.para.justify = match t.align { - AfAlign::Left => Justify::Left, - AfAlign::Center => Justify::Center, - AfAlign::Right => Justify::Right, - AfAlign::Justify => Justify::JustifyLeft, - }; - if let Some(f) = t.frame { - obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, (f.x1 - f.x0) * k, (f.y1 - f.y0) * k)) }; - // Affinity shows a line in a frame shorter than it (#1423); here none of it would draw. - if vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj) { - self.warn("text frames too short for their first line (made taller so the line shows)"); + } + None => { + align_lines(&mut obj); + obj.xf = p.place * Affine::translate((p.to_frame * Point::new(t.anchor.x, t.anchor.y)).to_vec2()); } } - obj + Some((obj, alpha)) + } + + /// The opacity text takes from its characters' colour: VectorCraft's characters have none of + /// their own, so text whose characters share one becomes an object with that opacity. + fn text_alpha(&mut self, runs: &[af::TextRun]) -> f32 { + let alphas: Vec = + runs.iter().filter_map(|r| if let ap::Paint::Solid(c) = &r.fill { Some(c.alpha()) } else { None }).filter(|a| a.is_finite()).collect(); + let (lo, hi) = alphas.iter().fold((1.0f64, 0.0f64), |(lo, hi), a| (lo.min(*a), hi.max(*a))); + if alphas.is_empty() || lo >= 1.0 { + return 1.0; + } + if hi - lo > 0.01 { + self.warn("text with characters of different transparency (imported opaque)"); + return 1.0; + } + lo.clamp(0.0, 1.0) as f32 + } + + /// The characters and paragraphs of `runs` as a text object at the origin of text space. + fn type_object(&mut self, runs: &[af::TextRun], p: &Placement) -> Option { + let (v, h) = (p.size_scale, p.size_scale * p.h_scale); + let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); + let mut formats: Vec = Vec::new(); + let mut out: Vec = Vec::new(); + let mut paragraph = 0usize; + // Whether each paragraph here continues the one before after a line break (U+2028): + // VectorCraft has no line break inside a paragraph, so the lines become paragraphs. + let mut continues = vec![false]; + for r in runs { + for c in r.text.chars() { + match c { + '\u{2028}' => continues.push(true), + '\u{2029}' | '\r' | '\n' => continues.push(false), + _ => {} + } + } + // Affinity separates paragraphs with U+2029 and lines with U+2028; other control + // characters don't show. + let text: String = r + .text + .chars() + .filter_map(|c| match c { + '\u{2029}' | '\u{2028}' | '\r' | '\n' => Some('\n'), + '\t' => Some('\t'), + c if c.is_control() => None, + c => Some(c), + }) + .collect(); + if text.is_empty() { + continue; + } + for c in text.chars() { + if formats.len() <= paragraph { + formats.push(r.paragraph); + } + if c == '\n' { + paragraph += 1; + } + } + let mut style = self.char_style(r, (v, p.h_scale)); + if style.leading.is_none() { + style.leading = r.paragraph.leading.map(|l| l * v).filter(|l| l.is_finite() && *l > 0.0).map(|l| l.min(5000.0)); + } + out.push(TextRun::new(text, style)); + } + // Affinity ends a story with a paragraph mark; VectorCraft's last paragraph has none. + if let Some(last) = out.last_mut() + && last.text.ends_with('\n') + { + last.text.pop(); + if last.text.is_empty() { + out.pop(); + } + } + obj.runs = out; + let count = obj.runs.iter().map(|r| r.text.matches('\n').count()).sum::() + 1; + let mut paras = Vec::with_capacity(count); + let mut previous_after = 0.0; + // The run of each paragraph's first character. + let mut first_runs = Vec::with_capacity(count); + { + let mut at_start = true; + for (i, r) in obj.runs.iter().enumerate() { + for c in r.text.chars() { + if at_start { + first_runs.push(i); + at_start = false; + } + if c == '\n' { + at_start = true; + } + } + } + } + // Line gaps by font, each looked up once. + let mut gaps: std::collections::HashMap<(&str, &str), f64> = std::collections::HashMap::new(); + for i in 0..count { + let f = formats.get(i).or(formats.last()).copied().unwrap_or_default(); + let pt = |x: f64, k: f64| if (x * k).is_finite() { (x * k).clamp(-5000.0, 5000.0) } else { 0.0 }; + let mut ps = vectorcraft_doc::text::ParaStyle { justify: justify(f.align), ..Default::default() }; + // Affinity places the first line and the others from the left edge; VectorCraft's + // first-line indent is relative to its left indent. + ps.left_indent = pt(f.left, h); + ps.first_line_indent = pt(f.first - f.left, h); + ps.right_indent = pt(f.right, h); + // Between two paragraphs Affinity keeps the larger of the space after the first and + // before the second; VectorCraft adds them. + let (mut before, mut after) = (pt(f.space_before, v).max(0.0), pt(f.space_after, v).max(0.0)); + // The lines of one Affinity paragraph: no space between them, and the first-line + // indent only on its first. + if continues.get(i).copied().unwrap_or(false) { + before = 0.0; + ps.first_line_indent = 0.0; + } + if continues.get(i + 1).copied().unwrap_or(false) { + after = 0.0; + } + ps.space_before = if i == 0 { before } else { (before - previous_after).max(0.0) }; + ps.space_after = after; + previous_after = after; + // Automatic leading: the font size plus the font's line gap, times the paragraph's + // factor, for artistic and frame text alike. Fitted on Affinity's thumbnails: Bebas + // Neue Pro and Impact lines about 1 em apart, DIN Condensed (a gap of 0.2 em) 1.2 em, + // and the public Arial frame fixture fits this better than the font's line height. + let (family, style) = + first_runs.get(i).and_then(|r| obj.runs.get(*r)).map_or(("", ""), |r| (r.style.font_family.as_str(), r.style.font_style.as_str())); + let gap = *gaps.entry((family, style)).or_insert_with(|| line_gap(family, style)); + let ratio = (1.0 + gap) * f.auto_leading; + // Affinity documents use tiny factors for spacer paragraphs (0.15 of a line); VectorCraft + // takes 0–500 %. + if ratio.is_finite() && (0.01..=5.0).contains(&ratio) { + ps.justification.auto_leading = ratio * 100.0; + } + paras.push(ps); + } + if let Some(first) = paras.first() { + obj.para = first.clone(); + } + obj.paras = paras; + obj.normalize_paras(); + Some(obj) + } + + /// A run's character style; `(size_scale, h_scale)` the type's vertical scale to points and + /// its horizontal scale relative to that. + fn char_style(&mut self, r: &af::TextRun, (size_scale, h_scale): (f64, f64)) -> CharStyle { + let finite = |v: f64, d: f64| if v.is_finite() { v } else { d }; + let mut style = CharStyle::default(); + if !r.family.is_empty() { + style.font_family = r.family.clone(); + } else if !r.postscript.is_empty() { + style.font_family = r.postscript.clone(); + } else { + self.warn("text without a font name uses the default font"); + } + // A family/weight pair cannot identify width variants such as Arial Narrow. + // Resolve a known PostScript identity to the exact installed family and style. + if let Some((family, face_style)) = vectorcraft_text::FontDb::global().by_postscript_name(&r.postscript) { + style.font_family = family; + style.font_style = face_style; + } else { + style.font_style = font_style(r.weight, r.italic); + } + style.features = r.features.clone(); + if r.features.iter().any(|feature| !vectorcraft_text::OtFeatures::known_tag(feature)) { + self.warn("unsupported text OpenType features (retained but not rendered)"); + } + style.all_caps = r.all_caps; + style.underline = r.underline; + style.strikethrough = r.strikethrough; + style.size = finite(r.size * size_scale, 12.0).clamp(0.1, 1296.0); + style.leading = r.leading.map(|l| finite(l * size_scale, 0.0)).filter(|l| *l > 0.0).map(|l| l.min(5000.0)); + style.tracking = finite(r.tracking * 1000.0, 0.0).clamp(-1000.0, 10000.0); + // Text squeezed or stretched by its transform keeps that as horizontal scale, on top of + // its own. + let hs = h_scale * r.h_scale; + if hs.is_finite() && (hs - 1.0).abs() > 1e-3 { + style.h_scale = (hs * 100.0).clamp(1.0, 1000.0); + } + // Transparency is the object's (see `text_alpha`); the paint's transform doesn't matter + // for a solid colour, and gradients on type are laid out in text space. + style.fill = match self.paint(&r.fill, Affine::scale(size_scale)) { + Some((p, _)) => p, + None => Paint::None, + }; + if let Some(st) = &r.stroke { + let w = st.width * size_scale; + if let Some((paint, _)) = self.paint(&st.paint, Affine::scale(size_scale)) + && w.is_finite() + && w > 0.0 + { + style.stroke = paint; + style.stroke_width = w.min(1000.0); + } + } + style + } + + /// A table: its cells as area type and the lines on their edges, as VectorCraft has no + /// tables. + fn table(&mut self, t: &af::Table, to_doc: Affine) -> Vec> { + let mut out = Vec::new(); + let (ncols, nrows) = (t.ncols(), t.nrows()); + if ncols == 0 || nrows == 0 { + return out; + } + self.warn("tables (imported as text frames and lines)"); + let m = to_doc * affine(t.transform); + let Some(p) = self.placement(m, to_doc * affine(t.text_transform)) else { return out }; + let col = |i: usize| t.columns.get(i).copied().unwrap_or(0.0); + let row = |i: usize| t.rows.get(i).copied().unwrap_or(0.0); + for (i, cell) in t.cells.iter().enumerate() { + if cell.merged_left || cell.runs.is_empty() { + continue; + } + let (r, c) = (i / ncols, i % ncols); + // Cells to the right merged into this one widen it. + let span = 1 + t.cells.iter().skip(i + 1).take(ncols - c - 1).take_while(|n| n.merged_left).count(); + let [l, tp, rt, bt] = cell.inset; + let node = af::Rect { x0: col(c) + l, y0: row(r) + tp, x1: col(c + span) - rt, y1: row(r + 1) - bt }; + let (a, b) = (p.to_frame * Point::new(node.x0, node.y0), p.to_frame * Point::new(node.x1, node.y1)); + let rect = Rect::from_points(a, b); + if !(rect.width() > 0.0 && rect.height() > 0.0) { + continue; + } + let Some(mut obj) = self.type_object(&cell.runs, &p) else { continue }; + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, rect.width(), rect.height())) }; + obj.area.vertical_align = match cell.valign { + af::VAlign::Top => VerticalAlign::Top, + af::VAlign::Center => VerticalAlign::Center, + af::VAlign::Bottom => VerticalAlign::Bottom, + }; + obj.area.first_baseline = FirstBaseline::CapHeight; + let dy = place_cell(&mut obj, rect.width(), rect.height()); + obj.xf = p.place * Affine::translate(rect.origin().to_vec2() + Vec2::new(0.0, dy)); + // A cell's text never vanishes for want of a line's height. + vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), &mut obj); + let alpha = self.text_alpha(&cell.runs); + let mut n = Node::new(self.doc.alloc_id(), NodeKind::Text(Box::new(obj))); + n.opacity = alpha; + out.push(Arc::new(n)); + } + // Lines over the cells: the vertical edges row by row (none inside merged cells), then + // the horizontal ones. + let mut line = |b: &mut Self, s: &Option, from: (f64, f64), to: (f64, f64)| { + let Some(s) = s else { return }; + let Some(layer) = b.stroke_layer(s, to_doc) else { return }; + let mut bp = BezPath::new(); + bp.move_to(m * Point::new(from.0, from.1)); + bp.line_to(m * Point::new(to.0, to.1)); + let mut n = Node::new( + b.doc.alloc_id(), + NodeKind::Path { path: PathData::from_bezpath(&bp), rule: FillRule::NonZero, live: None, clipping: false, guide: false }, + ); + n.appearance = Appearance { items: vec![AppearanceItem::Stroke(layer)], ..Appearance::default() }.into(); + out.push(Arc::new(n)); + }; + for r in 0..nrows { + for k in 0..=ncols { + let merged = k > 0 && k < ncols && t.cells.get(r * ncols + k).is_some_and(|c| c.merged_left); + if merged { + continue; + } + if let Some(s) = t.vertical.get(r * (ncols + 1) + k) { + line(self, s, (col(k), row(r)), (col(k), row(r + 1))); + } + } + } + for j in 0..=nrows { + for k in 0..ncols { + if let Some(s) = t.horizontal.get(j * ncols + k) { + line(self, s, (col(k), row(j)), (col(k + 1), row(j))); + } + } + } + out + } + + /// A CMYK pixel layer as an image file. Through the document's own CMYK profile when it + /// embeds one (as Affinity shows it): RGBA. Otherwise, opaque, a CMYK image that keeps its + /// inks (shown and separated through the working CMYK profile, as placed CMYK images are); + /// with transparency, RGBA through the working CMYK profile. + fn cmyk_image(&mut self, width: u32, height: u32, cmyka: &[u8]) -> Option> { + let n = (width as usize).checked_mul(height as usize)?; + let px = cmyka.get(..n.checked_mul(5)?)?.as_chunks::<5>().0; + if let Some(lut) = self.document_cmyk() { + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + return png(width, height, rgba); + } + if px.iter().all(|p| p[4] == 255) { + let inks = px.iter().flat_map(|p| [p[0], p[1], p[2], p[3]]).collect(); + if let Some(blob) = vectorcraft_doc::cmyk::Inks::new(width, height, inks).and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i)) { + return Some(Arc::unwrap_or_clone(blob.bytes)); + } + } + self.warn("CMYK pixel layers with transparency (converted to RGB through the working CMYK profile)"); + let lut = self + .working_cmyk + .get_or_insert_with(|| { + Arc::new(vectorcraft_color::cms::CmykLut::build(|[c, m, y, k]| { + let [r, g, b] = Color::cmyk(c, m, y, k).to_rgb(); + [r, g, b, 0.0] + })) + }) + .clone(); + let rgba = px + .iter() + .flat_map(|p| { + let [r, g, b, _] = lut.apply8([p[0], p[1], p[2], p[3]]); + [r, g, b, p[4]] + }) + .collect(); + png(width, height, rgba) + } + + /// The document's CMYK profile as a table from ink amounts to sRGB (relative colorimetric, + /// with black-point compensation, as Affinity's pixel layers convert), made once; `None` when + /// the document embeds none, or not one that converts. + fn document_cmyk(&mut self) -> Option> { + use vectorcraft_color::cms::{self, CmykLut, IccProfile, Intent}; + if let Some(lut) = &self.cmyk_lut { + return lut.clone(); + } + let embedded = self.cmyk_profile.is_some(); + let lut = self.cmyk_profile.take().and_then(|bytes| { + let profile = IccProfile::from_bytes(None, &bytes).ok()?; + let srgb = cms::icc::builtin_rgb(cms::SRGB)?; + profile.to_rgb_of(&srgb, &[0.0; 4], Intent::RelativeColorimetric, true)?; + Some(Arc::new(CmykLut::build(|inks| { + let [r, g, b] = profile + .to_rgb_of(&srgb, &inks, Intent::RelativeColorimetric, true) + .unwrap_or_else(|| Color::cmyk(inks[0], inks[1], inks[2], inks[3]).to_rgb()); + [r, g, b, 0.0] + }))) + }); + if embedded && lut.is_none() { + self.warn("CMYK pixel layers whose document's colour profile could not be read (shown through the working CMYK profile)"); + } + self.cmyk_lut = Some(lut.clone()); + lut } fn image(&mut self, img: &af::Image, to_doc: Affine) -> Option { let bytes = match &img.pixels { Pixels::Encoded(b) => b.clone(), - Pixels::Rgba8(rgba) => { - let buf = image::RgbaImage::from_raw(img.width, img.height, rgba.clone())?; - let mut png = Vec::new(); - buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - png - } + Pixels::Rgba8(rgba) => png(img.width, img.height, rgba.clone())?, + Pixels::Cmyka8(cmyka) => self.cmyk_image(img.width, img.height, cmyka)?, }; let r = match super::load::raster_image(&bytes) { Ok(r) => r, @@ -519,10 +1274,220 @@ impl Builder { let sy = img.height as f64 / f64::from(r.height.max(1)); let xf = to_doc * affine(img.transform) * Affine::scale_non_uniform(sx, sy); self.doc.images.entry(r.key.clone()).or_insert(r.blob); - Some(NodeKind::Image(Box::new(ImageObject { key: r.key, width: r.width, height: r.height, xf, link: None, placement: Default::default() }))) + Some(NodeKind::Image(Box::new(ImageObject { + key: r.key, + width: r.width, + height: r.height, + xf, + link: None, + placement: Default::default(), + pixels: None, + }))) } } +/// The master page instances among `nodes` (also inside layers and groups): (master, first page, +/// first master page, page count). +fn master_instances(nodes: &[af::Node]) -> Vec<(usize, usize, usize, usize)> { + fn visit(n: &af::Node, depth: usize, out: &mut Vec<(usize, usize, usize, usize)>) { + if depth > MAX_DEPTH || !n.visible { + return; + } + match n.kind { + Kind::MasterInstance { master, page, master_page, count } => out.push((master, page, master_page, count)), + Kind::Layer | Kind::Group => n.children.iter().for_each(|c| visit(c, depth + 1, out)), + _ => {} + } + } + let mut out = Vec::new(); + nodes.iter().for_each(|n| visit(n, 0, &mut out)); + out +} + +/// Which master page instance each of `pages` pages shows: (master, first page, first master +/// page) of the first instance in `instances` that covers it. Each page is settled once (the +/// next page still open is found by a path-halving jump), so this is about linear in pages and +/// instances however much their ranges overlap. +fn page_masters(instances: &[(usize, usize, usize, usize)], pages: usize) -> Vec> { + fn open_from(next: &mut [usize], mut i: usize) -> usize { + while let Some(&n) = next.get(i) { + if n == i { + break; + } + let skip = next.get(n).copied().unwrap_or(n); + if let Some(slot) = next.get_mut(i) { + *slot = skip; + } + i = n; + } + i + } + let mut owner = vec![None; pages]; + // `next[p]` leads towards the first page from `p` on with no master yet (`pages`: none). + let mut next: Vec = (0..=pages).collect(); + for &(master, first, master_page, count) in instances { + let end = first.saturating_add(count).min(pages); + let mut p = open_from(&mut next, first.min(pages)); + while p < end { + if let Some(o) = owner.get_mut(p) { + *o = Some((master, first, master_page)); + } + if let Some(n) = next.get_mut(p) { + *n = p + 1; + } + p = open_from(&mut next, p + 1); + } + } + owner +} + +/// Master names: A, B … Z, then A1, B1 …. +fn master_prefix(index: usize) -> String { + let letter = char::from(b'A' + (index % 26) as u8); + match index / 26 { + 0 => letter.to_string(), + n => format!("{letter}{n}"), + } +} + +/// A table cell's text `w` × `h` at the origin, placed as Affinity places it. Affinity aligns the +/// block from the first line's capitals' tops to the last baseline in the cell, and draws it even +/// where it doesn't fit (centred cells measured on a document's thumbnail: two lines in a cell +/// shorter than them reach out of it at both ends). VectorCraft aligns the block down to the last +/// line's descenders and keeps lines inside the frame: the frame moves by what this returns (down +/// the cell) and grows where the lines need more room. +fn place_cell(obj: &mut TextObject, w: f64, h: f64) -> f64 { + let share = match obj.area.vertical_align { + VerticalAlign::Center => 0.5, + VerticalAlign::Bottom => 1.0, + _ => 0.0, + }; + let mut tall = obj.clone(); + tall.area.vertical_align = VerticalAlign::Top; + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tall).lines; + let Some(last) = lines.iter().rfind(|l| l.region == 0) else { return 0.0 }; + let (baseline, descent) = (last.baseline, last.descent); + if !(baseline.is_finite() && descent.is_finite() && h.is_finite()) { + return 0.0; + } + // (A little more than the lines need, for the rounding of the layout.) + let frame = h.max(baseline + descent + 0.05); + obj.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, frame)) }; + // Where Affinity has the block's top, less where VectorCraft puts it in the frame. + (h - baseline) * share - (frame - baseline - descent) * share +} + +/// Area type `w` × `h` at the origin whose lines don't all fit. Affinity shows each line whose +/// baseline is inside the frame, its descenders hanging out of it; VectorCraft's area type leaves +/// such a line out. The frame is made taller, from its top, by what those lines need. Whether it was. +fn fit_lines(obj: &mut TextObject, w: f64, h: f64) -> bool { + let db = vectorcraft_text::FontDb::global(); + if !(w > 0.0 && h > 0.0 && h.is_finite()) { + return false; + } + let first = vectorcraft_text::layout(db, obj); + if !first.overflow { + return false; + } + // Where the lines go in a frame with room for all of them. + let mut tall = obj.clone(); + tall.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h * 4.0 + 10_000.0)) }; + let need = vectorcraft_text::layout(db, &tall) + .lines + .iter() + .filter(|l| l.region == 0 && l.baseline <= h + 1e-6) + .map(|l| l.baseline + l.descent) + .filter(|b| b.is_finite()) + .fold(0.0, f64::max); + if need <= h { + return false; + } + let before = first.lines.len(); + // (A little more than they need, for the rounding of the layout.) + let short = std::mem::replace(&mut obj.kind, TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, need + 0.05)) }); + // Something else keeps the lines out: as the source has it. + if vectorcraft_text::layout(db, obj).lines.len() <= before { + obj.kind = short; + return false; + } + true +} + +/// RGBA pixels as a PNG file. +fn png(width: u32, height: u32, rgba: Vec) -> Option> { + let buf = image::RgbaImage::from_raw(width, height, rgba)?; + let mut png = Vec::new(); + buf.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + +/// Where a line of `w` starts in a span from 0 to `room` (as a share of `room - w`). +fn share(j: Justify) -> f64 { + match j { + Justify::Center | Justify::JustifyCenter => 0.5, + Justify::Right | Justify::JustifyRight => 1.0, + _ => 0.0, + } +} + +/// Artistic text whose paragraphs are aligned differently. Affinity aligns each line within the +/// width of the longest, and places that block at the anchor by the text's own alignment (its +/// first paragraph's); VectorCraft's point type aligns each line on the anchor. Indents move the +/// lines to where Affinity has them and keep their alignment: a centred line centres between its +/// indents and a right-aligned one ends at its right indent, whatever its width. +fn align_lines(obj: &mut TextObject) { + let block = share(obj.para.justify); + if obj.paras.iter().all(|p| share(p.justify) == block) { + return; + } + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), obj).lines; + let w = lines.iter().map(|l| l.x1 - l.x0).filter(|w| w.is_finite()).fold(0.0, f64::max); + if !(w > 0.0 && w < 1e6) { + return; + } + // The block's left edge, from the anchor. + let left = -w * block; + for p in &mut obj.paras { + let at = share(p.justify); + let shift = left + w * at; + // A left-aligned line starts at its left indent, a right-aligned one ends at minus its right + // indent, and a centred one centres on half their difference. + if at < 0.25 { + p.left_indent += shift; + } else if at > 0.75 { + p.right_indent -= shift; + } else { + p.left_indent += shift; + p.right_indent -= shift; + } + } + if let Some(first) = obj.paras.first() { + obj.para = first.clone(); + } +} + +fn justify(a: AfAlign) -> Justify { + match a { + AfAlign::Left => Justify::Left, + AfAlign::Center => Justify::Center, + AfAlign::Right => Justify::Right, + AfAlign::Justify => Justify::JustifyLeft, + } +} + +/// A font's line gap in em, as VectorCraft will set it (the installed face, or the fallback for +/// a missing one); 0 when no font is loaded. +fn line_gap(family: &str, style: &str) -> f64 { + let defaults = CharStyle::default(); + let family = if family.is_empty() { defaults.font_family.as_str() } else { family }; + let style = if style.is_empty() { defaults.font_style.as_str() } else { style }; + let Some(face) = vectorcraft_text::FontDb::global().face(family, style) else { return 0.0 }; + let upem = face.units_per_em(); + let gap = if upem > 0.0 { face.line_gap() / upem } else { 0.0 }; + if gap.is_finite() { gap.clamp(0.0, 2.0) } else { 0.0 } +} + fn font_style(weight: i64, italic: bool) -> String { let w = match weight { ..=150 => "Thin", diff --git a/crates/engine/src/cmd/fileio/dxf.rs b/crates/engine/src/cmd/fileio/dxf.rs index c13b4fdb8..f0bb1f400 100644 --- a/crates/engine/src/cmd/fileio/dxf.rs +++ b/crates/engine/src/cmd/fileio/dxf.rs @@ -94,6 +94,12 @@ pub const UNSUPPORTED: &[Unsupported] = &[ extensions: &["pict", "pct", "pic"], hint: "PICT is a retired format and is not supported: convert the file to PDF or PNG first", }, + Unsupported { + id: "pmd", + label: "PageMaker", + extensions: &["pmd"], + hint: "PageMaker (.pmd) is not supported: open the layout in PageMaker and export PDF or EPS, or place a PDF export here", + }, ]; /// The unsupported format an id or extension (any case, leading dot allowed) names. diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index ae705ca48..fbc18b422 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -340,7 +340,17 @@ pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Documen /// `d` with one artboard, `rect`. pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document { - d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }]; + d.artboards = vec![Artboard { + id: 1, + name: name.into(), + rect, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }]; d } diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 9dce9a321..dd7307e84 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -19,8 +19,15 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result { let f = writable("document.serialize", Some(str_param(p, "format").unwrap_or("vectorcraft")), None)?; let expanded = expand_presets(s, "document.serialize", p)?; let p = with_black_output(s, &expanded); - let doc = &*source(s, f, &p, "document.serialize")?; - let enc = encode_all(doc, f.id, &p)?; + let (doc, display_doc_title) = source(s, f, &p, "document.serialize")?; + let doc = &*doc; + let mut q = p.clone(); + if f.id == "pdf" + && let Some(o) = q.as_object_mut() + { + o.insert("_displayDocTitle".into(), json!(display_doc_title)); + } + let enc = encode_all(doc, f.id, &q)?; let files = enc.named(doc, &default_name(doc, f.extensions[0])); let (main, linked) = files.split_at(enc.files.len()); // Text formats come back as text; an SVG in UTF-16 or ISO 8859-1 also as its bytes. @@ -53,8 +60,15 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result { let f = writable("document.export", str_param(p, "format"), path)?; let expanded = expand_presets(s, "document.export", p)?; let p = with_black_output(s, &expanded); - let doc = &*source(s, f, &p, "document.export")?; - let enc = encode_all(doc, f.id, &p)?; + let (doc, display_doc_title) = source(s, f, &p, "document.export")?; + let doc = &*doc; + let mut q = p.clone(); + if f.id == "pdf" + && let Some(o) = q.as_object_mut() + { + o.insert("_displayDocTitle".into(), json!(display_doc_title)); + } + let enc = encode_all(doc, f.id, &q)?; write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings })) } @@ -154,13 +168,16 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result> { +fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> { if f.id == "txt" && bool_or(p, "selectionOnly", false) { - return Ok(Cow::Owned(selection(s, cmd)?.0)); + let doc = selection(s, cmd)?.0; + let display_doc_title = super::has_own_title(&doc); + return Ok((Cow::Owned(doc), display_doc_title)); } let st = s.doc()?; let doc = export_source(st, p)?; - Ok(super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned)) + let display_doc_title = super::has_own_title(&doc); + Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title)) } /// The selected objects (not on template layers) alone on one layer, with one artboard: their diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index 9fb7e06db..36b65d29e 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -15,8 +15,13 @@ //! blending mode written opaque is a box hiding the art under it (version 7 images have no alpha //! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art //! under it, rendered from the document as it is, clipped to the area where it changes what shows; -//! the art under it stays as it was. With `omit` they are left out. An image that can't be decoded -//! is missing from the images that flattening and expanding raster effects draw, and the warnings +//! the art under it stays as it was. An image in a group with opacity or a blending mode is +//! flattened the same way, and a pattern fill that paints a transparent image (see-through +//! pixels, or opacity or a blending mode on the image, the fill, the shape or a group it is in) is +//! expanded into its tiles first so its images flatten too. With `omit` the images with +//! see-through pixels, opacity or a blending mode of their own are left out, and pattern fills stay +//! as the writer draws them (their tiles opaque over white). An image that can't be decoded is +//! missing from the images that flattening and expanding raster effects draw, and the warnings //! then carry the raster exports' note once. //! //! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each @@ -117,6 +122,9 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result let (full, placed) = crate::cmd::place::document::full_documents(&one); let mut d = full.into_owned(); expand_appearances(&mut d); + if mode == SeeThrough::Flatten { + expand_image_patterns(&mut d)?; + } let images = see_through_images(&d, mode); if !images.is_empty() { note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED }); @@ -153,17 +161,30 @@ fn expand_appearances(d: &mut Document) { } } +/// Expand every layer's pattern fills that paint a transparent image into their tiles, so those +/// images flatten like any other transparent image. The writer would otherwise draw them opaque +/// over white. +fn expand_image_patterns(d: &mut Document) -> Result<()> { + let keys = super::super::flatten::see_through_images(d); + let layers: Vec = d.layers.iter().map(|l| l.id).collect(); + for id in layers { + super::super::expand::expand_transparent_image_patterns(d, id, &keys)?; + } + Ok(()) +} + /// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as -/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only -/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over -/// white); all are left out with `omit`. +/// [`Document::walk`] counts). These are the images with see-through pixels, and the images +/// that have opacity or a blending mode of their own or, with `flatten`, of a group they are in. +/// With `flatten` only visible images are returned, because a hidden one (which the hidden objects +/// option may write) is written over white. With `omit` all of them are returned. fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { let keys = super::super::flatten::see_through_images(d); let mut out = vec![]; let mut at = 0; d.walk(|n| { if let NodeKind::Image(im) = &n.kind - && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal) + && (keys.contains(&im.key) || n.opacity < 1.0 || n.blend != BlendMode::Normal || (mode == SeeThrough::Flatten && transparent(d, n.id))) { out.push((at, n.id)); } @@ -175,6 +196,12 @@ fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> { out } +/// Whether node `id` or a group it is in has opacity or a blending mode. [`Document::ancestry`] +/// lists `id` itself last, so its own opacity and blending mode count too. +fn transparent(d: &Document, id: NodeId) -> bool { + d.ancestry(id).unwrap_or_default().iter().filter_map(|a| d.node(*a)).any(|n| n.opacity < 1.0 || n.blend != BlendMode::Normal) +} + /// Replace each of `images` (from [`see_through_images`]) with an opaque image of what shows /// where it is, clipped to where it changes that; one that changes nothing is left out. Each is /// rendered from `d` as it was, so images over one another flatten as they look. @@ -188,7 +215,8 @@ fn flatten_images(d: &mut Document, images: &[(usize, NodeId)]) -> Result<()> { let png = flat.png; let key = super::super::menucmds::unique_key(d, "flattened"); d.images.insert(key.clone(), ImageBlob::new("image/png", png)); - let image = ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default() }; + let image = + ImageObject { key, width: flat.width, height: flat.height, xf: flat.xf, link: None, placement: Default::default(), pixels: None }; let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let mut group = super::super::rasterfx::clip_group(d, flat.clip, FillRule::NonZero, image); group.name = src.node(id).and_then(|n| n.name.clone()); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 572c453ab..302240baa 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -197,12 +197,38 @@ fn from_editing_data_alone(bytes: &[u8], opts: &LoadOptions) -> Option bool { + e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() +} + /// Do `opts` read every page of the PDF `bytes`: no `pages`, or a range that names each one? fn every_page(bytes: &[u8], opts: &LoadOptions) -> bool { let Some(range) = &opts.pages else { return true }; vectorcraft_pdf::info(bytes, opts.password.as_deref()).is_ok_and(|i| parse_range(range, i.pages.len()).is_ok_and(|p| p.len() == i.pages.len())) } +/// A page read from a PDF or `.ai` (`read`, as [`super::page_document`] reads it) → (document, +/// notes, false); for a `.ai` saved without PDF compatibility (its page only a placeholder), the +/// file `name` read as `document.open` reads it with `password`, from its editing copy alone → +/// (document, notes, true). +pub(crate) fn page_or_editing_data( + name: &str, + bytes: &[u8], + password: Option<&str>, + read: Result<(Document, Vec)>, +) -> Result<(Document, Vec, bool)> { + match read { + Ok((doc, notes)) => Ok((doc, notes, false)), + Err(e) if is_placeholder(&e) => { + let l = load_with(name, bytes, &LoadOptions { password: password.map(str::to_string), ..Default::default() })?; + Ok((l.doc, l.warnings, true)) + } + Err(e) => Err(e), + } +} + /// [`load`] with `document.open` options (the PDF pages, box and password; the DXF options). pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result { let format = detect(name, bytes).ok_or_else(|| match super::unsupported(&super::extension(name)) { @@ -218,7 +244,8 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result _ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => { let editing = vectorcraft_eps::has_native(bytes).then_some((true, || vectorcraft_eps::native(bytes))); restore_or_import(editing, || { - let r = vectorcraft_eps::import_with(bytes, opts.editing_data).map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; + let r = vectorcraft_eps::import_trusting(bytes, opts.editing_data, opts.trust_editing_data) + .map_err(|e| err(format!("can't open `{}`: {e}", file_name(name))))?; Ok((r.document, r.warnings)) })? } @@ -256,7 +283,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result } // Saved without its PDF part: the editing copy is all the file has (`pages` that // name every page read it as the whole file). - Err(e) if e.to_string() == vectorcraft_pdf::PdfError::PlaceholderOnly.to_string() => { + Err(e) if is_placeholder(&e) => { let whole = LoadOptions { pages: None, ..opts.clone() }; from_editing_data_alone(bytes, if every_page(bytes, opts) { &whole } else { opts }) .unwrap_or(Err(e)) @@ -343,7 +370,7 @@ pub fn open_bytes(s: &mut Session, name: &str, bytes: &[u8], path: Option, p: &Value) -> Result { let update_links = s.prefs.update_links == "automatically"; - open_read(s, read_for_open(name, bytes, path, p, update_links)?) + open_read(s, read_for_open(name, bytes, path, p, update_links, s.prefs.low_res_proxy_eps)?) } /// A file read for `document.open` but not opened yet ([`read_for_open`], [`open_read`]). @@ -369,10 +396,10 @@ impl ReadFile { /// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from, /// and modified ones are read again when `update_links` (Preferences › File Handling › Update /// Links set to Automatically; see [`crate::cmd::links::resolve`]). -pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool) -> Result { +pub fn read_for_open(name: &str, bytes: &[u8], path: Option, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result { let opts = LoadOptions::from_params("document.open", p)?; let mut loaded = load_with(name, bytes, &opts)?; - let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links); + let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps); Session::refresh_text_bounds(&mut loaded.doc); Ok(ReadFile { loaded, opts, path, links }) } @@ -392,7 +419,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st "only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first", )); } - let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically"); + let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true) } @@ -427,6 +454,11 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option Result { let layer = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::scale_non_uniform(sx, sy); - let mut n = - Node::new(id, NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default() }))); + let mut n = Node::new( + id, + NodeKind::Image(Box::new(ImageObject { key: key.clone(), width, height, xf, link: None, placement: Default::default(), pixels: None })), + ); n.name = Some(name.to_string()); d.images.insert(key, blob); d.insert(Some(layer), 0, n).map_err(err)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index e5acf01a1..bea905946 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -59,7 +59,9 @@ pub(crate) use export::{export_crop, isolated}; pub(crate) use load::check_not_lossy_overwrite; use load::err; pub(crate) use load::import_svg; +pub(crate) use load::is_placeholder; pub(crate) use load::native_file; +pub(crate) use load::page_or_editing_data; pub(crate) use load::source; pub use load::{ Loaded, RasterImage, ReadFile, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_read, open_template, raster_image, @@ -76,7 +78,7 @@ pub use save::{ save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; pub use screens::{ - PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize, + PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize, builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_rows as screen_preset_rows, }; pub(crate) use screens::{ @@ -791,6 +793,17 @@ pub fn open_filters() -> impl Iterator impl Iterator { + std::iter::once((library_label, library_exts)).chain(open_filters()) +} + /// File → Place dialog filters: "All placeable files", then one per placeable format, then text, /// RTF and Word. pub fn place_filters() -> impl Iterator { @@ -858,9 +871,12 @@ pub fn file_stem(name: &str) -> String { /// document that only has the Untitled-N name New gave it is titled after its file, as its tab is /// (the title PDF, PNG and SVG metadata carry). A title of its own stays, and native files keep /// the stored title. +pub fn has_own_title(doc: &vectorcraft_doc::Document) -> bool { + !doc.title.trim().is_empty() && !doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) +} + pub fn titled_for_export(doc: &vectorcraft_doc::Document, path: Option<&str>, format: &str) -> Option { - let untitled = doc.title.strip_prefix("Untitled-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); - let path = path.filter(|_| untitled && !matches!(format, "vectorcraft" | "template" | "ai"))?; + let path = path.filter(|_| !has_own_title(doc) && !matches!(format, "vectorcraft" | "template" | "ai"))?; let mut d = doc.clone(); d.title = file_stem(path); Some(d) @@ -963,6 +979,10 @@ fn may_write(path: &str) -> Result<()> { #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_file(path: &str) -> Result> { may_read(path)?; + // A device or pipe may never end: only a regular file is read to its end. + if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() { + return Err(EngineError::Other(format!("{path}: not a file"))); + } std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } @@ -1153,9 +1173,11 @@ pub(crate) fn absolute_path(path: &str) -> String { /// path that is still relative (no document) is refused, as the working directory isn't the /// document's choice. On Windows, a document on a mapped drive is on the share behind the drive /// ([`share_path`]): a path refused as on another computer is checked again against the -/// document's path on that share. Every stored path reaches the file system through this. +/// document's path on that share. A path on a drive mapped to a network share other than the +/// document's ([`drive_share_path`]) is refused as on another computer. Every stored path +/// reaches the file system through this. pub(crate) fn document_path(stored: &str, document: Option<&str>) -> std::result::Result { - document_path_with(stored, document, share_path) + document_path_on(stored, document, share_path, drive_share_path) } /// [`document_path`], with `share` giving a document's path on the network share behind its drive. @@ -1166,7 +1188,7 @@ pub(crate) fn document_path_with( ) -> std::result::Result { use craft_docpath::Refused; let document = document.map(absolute_path); - let resolve = |document: Option<&str>| craft_docpath::resolve(stored, document.map(std::path::Path::new)); + let resolve = |document: Option<&str>| craft_docpath::resolve_absolute(stored, document.map(std::path::Path::new)); let path = match resolve(document.as_deref()) { Err(Refused::Network) => { let share = document.as_deref().and_then(share).ok_or(Refused::Network)?; @@ -1174,12 +1196,71 @@ pub(crate) fn document_path_with( } path => path?, }; - if !path.is_absolute() { - return Err(Refused::Invalid); - } path.into_os_string().into_string().map_err(|_| Refused::Invalid) } +/// [`document_path_with`], also refusing a path on a drive that is mapped to a network share other +/// than the document's (or, for a document on this computer or without a path, to any share) as +/// [`craft_docpath::Refused::Network`]. `drive_share` gives a path's path on the share behind its +/// drive (`Y:\art\x.png` on `\\server\other\art\x.png`), or `None` for a path on this computer. A +/// path on the document's own drive is not looked up. A document written `\\?\Z:\…` is looked up +/// on its share as `Z:\…` is. +pub(crate) fn document_path_on( + stored: &str, + document: Option<&str>, + share: impl Fn(&str) -> Option, + drive_share: impl FnOnce(&str) -> Option, +) -> std::result::Result { + let share = |d: &str| share(d.strip_prefix(r"\\?\").filter(|d| drive_letter(d).is_some()).unwrap_or(d)); + let path = document_path_with(stored, document, share)?; + let document = document.map(absolute_path); + let Some(drive) = drive_letter(&path) else { return Ok(path) }; + if document.as_deref().and_then(drive_letter).is_some_and(|d| d.eq_ignore_ascii_case(&drive)) { + return Ok(path); + } + let Some(on_share) = drive_share(&path) else { return Ok(path) }; + let home = document.map(|d| share(&d).unwrap_or(d)); + craft_docpath::resolve(&on_share, home.as_deref().map(std::path::Path::new))?; + Ok(path) +} + +/// The drive letter of an absolute path on a drive (`C:\…`, `C:/…` or `\\?\C:\…`). +fn drive_letter(path: &str) -> Option { + let mut chars = path.strip_prefix(r"\\?\").unwrap_or(path).chars(); + match (chars.next(), chars.next(), chars.next()) { + (Some(l), Some(':'), Some('\\' | '/')) if l.is_ascii_alphabetic() => Some(l), + _ => None, + } +} + +/// The path of `path` (on a drive, `Y:\art\x.png`) on the network share the drive is mapped to +/// (`\\server\other\art\x.png`), when the drive's root folder is on one and automation may read +/// `path`. The root folder's final path ([`std::fs::canonicalize`]) names the share. +#[cfg(windows)] +pub(crate) fn drive_share_path(path: &str) -> Option { + may_read(path).ok()?; + drive_share_of(path, |root| std::fs::canonicalize(root).ok()) +} + +#[cfg(not(windows))] +pub(crate) fn drive_share_path(_: &str) -> Option { + None +} + +/// The path of `path` on the network share its drive is mapped to, given `final_path`, which +/// returns the final path of the drive's root folder (`Y:\`): as [`share_of`] gives it for a +/// document, also for a path written `\\?\Y:\…`. +#[cfg(any(windows, test))] +pub(crate) fn drive_share_of(path: &str, final_path: impl FnOnce(&str) -> Option) -> Option { + let path = path.strip_prefix(r"\\?\").unwrap_or(path); + share_of(path, |folder| { + let (root, rest) = (folder.get(..3)?, folder.get(3..)?); + let root = final_path(root)?; + let root = root.to_str()?.trim_end_matches('\\'); + Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() }) + }) +} + /// The path of `document` (on a drive, `Z:\jobs\doc.vectorcraft`) on the network share the drive is /// mapped to (`\\server\share\jobs\doc.vectorcraft`), when its folder is on one and automation may /// read the folder. The folder's final path ([`std::fs::canonicalize`]) names the share as @@ -1286,15 +1367,17 @@ pub(crate) fn unique_file_names<'a>(names: impl IntoIterator) -> Vec { let mut taken = std::collections::HashSet::new(); + // The next number to try for each base: a name repeated many times doesn't search the numbers + // taken before it. + let mut next: std::collections::HashMap = std::collections::HashMap::new(); names .into_iter() .map(|base| { let mut name = base.clone(); - for i in 2.. { - if taken.insert(name.to_lowercase()) { - break; - } - name = format!("{base}-{i}"); + while !taken.insert(name.to_lowercase()) { + let n = next.entry(base.to_lowercase()).or_insert(1); + *n += 1; + name = format!("{base}-{n}"); } name }) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index c1ecbb2e6..df512f629 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -243,7 +243,14 @@ pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result { let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; let now = vectorcraft_doc::metadata::output_time(); - Ok(PdfOptions { settings: settings(cmd, p)?, artboards, created: doc.metadata.created.or(now), modified: now, ..Default::default() }) + Ok(PdfOptions { + settings: settings(cmd, p)?, + artboards, + display_doc_title: bool_or(p, "_displayDocTitle", super::has_own_title(doc)), + created: doc.metadata.created.or(now), + modified: now, + ..Default::default() + }) } /// Why a PDF of some artboards carries no editing data. @@ -387,8 +394,15 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result { let expanded = expand_preset(s, C, p)?; let p = &*expanded; let st = s.doc()?; + let display_doc_title = super::has_own_title(&st.doc); let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf"); - let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), p)?; + let mut q = p.clone(); + if let Some(o) = q.as_object_mut() { + o.insert("_displayDocTitle".into(), json!(display_doc_title)); + } else { + q = json!({ "_displayDocTitle": display_doc_title }); + } + let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?; write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) } diff --git a/crates/engine/src/cmd/fileio/progress.rs b/crates/engine/src/cmd/fileio/progress.rs index a69cf96a8..d2e039348 100644 --- a/crates/engine/src/cmd/fileio/progress.rs +++ b/crates/engine/src/cmd/fileio/progress.rs @@ -8,6 +8,9 @@ //! renderer stops drawing and keeps no half-drawn pattern tile or effect raster. Encoders that //! make one file in one go report nothing: the frontend shows them as busy and checks //! [`Progress::cancelled`] itself before writing, so a cancelled export never writes a file. +//! Export for Screens ([`super::ScreenExport::write`]) reports one step per file instead, keeps +//! its encoders' reports out, and stops before the next file, so the files it wrote before the +//! cancel stay. use std::rc::Rc; use std::sync::Arc; @@ -21,6 +24,8 @@ pub struct Progress { token: CancelToken, done: AtomicU64, total: AtomicU64, + /// The steps are files (Export for Screens), not artboards. + files: bool, } impl Progress { @@ -28,6 +33,16 @@ impl Progress { Arc::new(Self::default()) } + /// A [`Progress`] whose steps are files: the export of [`super::ScreenExport::write`]. + pub fn of_files() -> Arc { + Arc::new(Self { files: true, ..Self::default() }) + } + + /// Are the steps files rather than artboards? + pub fn counts_files(&self) -> bool { + self.files + } + /// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go). pub fn counts(&self) -> (u64, u64) { let total = self.total.load(Ordering::Relaxed); diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 166eda8b9..170b55f88 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -385,22 +385,27 @@ fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result Document { let mut d = doc.clone(); let Some(a) = doc.artboards.get(i) else { return d }; - let r = a.rect; d.artboards = vec![a.clone()]; - // It opens fitted to its artboard, not at the master file's view. + keep_art_on(&mut d, a.rect); + d +} + +/// Reduce `d` to the objects touching `r`, in their layers (resources are kept), opening +/// fitted to its artboard rather than at the master file's view. +pub(super) fn keep_art_on(d: &mut Document, r: vectorcraft_geom::Rect) { d.last_view = None; d.layers_open = None; for layer in &mut d.layers { keep_on(layer, r); } - d } -/// Keep in layer `n` (and its sublayers) only the objects touching `r`. +/// Keep in layer `n` (and its sublayers) only the objects touching `r`, and those without bounds. fn keep_on(n: &mut Arc, r: vectorcraft_geom::Rect) { let touches = |b: vectorcraft_geom::Rect| b.x0 <= r.x1 && b.x1 >= r.x0 && b.y0 <= r.y1 && b.y1 >= r.y0; if let Some(children) = Arc::make_mut(n).children_mut() { - children.retain(|c| c.is_layer() || c.visual_bounds().is_some_and(touches)); + // An object whose bounds can't be measured (type in a missing font) stays: dropping it loses art. + children.retain(|c| c.is_layer() || c.visual_bounds().is_none_or(touches)); for c in children.iter_mut().filter(|c| c.is_layer()) { keep_on(c, r); } diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 0da52a9a4..1d9c0303c 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -26,6 +26,8 @@ use super::{ }; const C: &str = "document.exportForScreens"; +/// The Asset Export panel's export, which exports through [`export`]. +const ASSETS: &str = "assets.export"; /// How big a raster row's files are. #[derive(Clone, Copy, Debug, PartialEq)] @@ -364,9 +366,9 @@ impl ScreenParams { } /// How a batch export names its files and sorts them into folders. -struct Naming<'a> { +struct Naming { /// The `name` pattern, when it holds more than spaces. - pattern: Option<&'a str>, + pattern: Option, /// The `prefix`, through [`file_part`]. prefix: String, /// The document's name, safe as a file name (``, the Full Document's item). @@ -379,9 +381,9 @@ struct Naming<'a> { folders: Folders, } -impl<'a> Naming<'a> { +impl Naming { /// The naming of params `o` for a document titled `title`. - fn of(o: &'a ScreenParams, title: &str) -> Result { + fn of(o: &ScreenParams, title: &str) -> Result { let folders = Folders::of(o)?; // A prefix names files, not folders. let prefix = file_part(o.prefix.as_deref().unwrap_or("")); @@ -395,7 +397,7 @@ impl<'a> Naming<'a> { Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))), }; let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into()); - let pattern = o.name.as_deref().filter(|n| !n.trim().is_empty()); + 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 }) } @@ -405,7 +407,7 @@ impl<'a> Naming<'a> { let counter = self.counter_start.saturating_add(if self.per_file { files } else { index as u64 }); let ext = r.format.extensions.first().copied().unwrap_or(r.format.id); let prefix = &self.prefix; - let stem = match self.pattern { + let stem = match self.pattern.as_deref() { Some(pattern) => { let parts = NameParts { filename: &self.filename, @@ -507,8 +509,15 @@ impl Output { None } - /// Write `bytes` as `name` (relative) under `folder`, or keep them. - fn put(&mut self, folder: Option<&str>, keep: bool, name: String, bytes: Vec) -> Result<()> { + /// Write `bytes` as `name` (relative) under `folder` with `writer`, or keep them. + fn put( + &mut self, + folder: Option<&str>, + keep: bool, + name: String, + bytes: Vec, + writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>, + ) -> Result<()> { match folder { Some(dir) if !keep => { if let Some((sub, _)) = name.rsplit_once('/') @@ -517,7 +526,9 @@ impl Output { create_dir(&format!("{dir}/{sub}"))?; } let path = format!("{dir}/{name}"); - write_file(&path, &bytes)?; + // A cancel that came while the file was encoded writes nothing more. + vectorcraft_doc::cancel::check()?; + writer(&path, &bytes)?; self.written.push(path); } _ => self.kept.push((name, bytes)), @@ -659,12 +670,8 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { let rows = rows_of(s, p, o.preset.as_deref())?; o.one_source()?; let naming = Naming::of(&o, &s.doc()?.doc.title)?; - let folder = o.folder.as_deref().filter(|f| !f.is_empty()); let selection = o.selection_mode()?; let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] }; - if let Some(dir) = folder { - create_dir(dir)?; - } let doc = s.doc()?.doc.clone(); // Include Bleed: each artboard grown by the document's bleed. let doc = if o.include_bleed && doc.setup.has_bleed() { @@ -678,12 +685,77 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { }; let crop = rows.iter().any(|r| r.format.id != "pdf"); let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?; + // Appearance of Black › Printing / Exporting for RGB raster rows. + let rows = rows.into_iter().map(|r| Row { options: with_black_output(s, &r.options), ..r }).collect(); + let export = ScreenExport { cmd: C, o, rows, naming, items }; + match &mut s.screens_hand_off { + Some(slot) => { + *slot = Some(export); + Ok(Value::Null) + } + None => export.write(&mut |path, bytes| write_file(path, bytes)), + } +} + +/// An export as `document.exportForScreens` and `assets.export` take it from the session: the +/// params, the format rows, the naming, and each item with the document it writes +/// ([`Session::prepare_screen_export`]). [`ScreenExport::write`] encodes and writes its files +/// without the session, on any thread. +pub struct ScreenExport { + /// The command whose errors it gives: `document.exportForScreens` or `assets.export`. + cmd: &'static str, + o: ScreenParams, + rows: Vec, + naming: Naming, + items: Vec, +} + +impl Session { + /// Run `assets.export` when `id` names it, else `document.exportForScreens`, with `params` as + /// a command (checked and journaled as any other; Export for Screens remembers its settings) + /// that encodes and writes nothing → the export it prepared, which [`ScreenExport::write`] + /// encodes and writes (on the desktop with Background Export on, Export for Screens and the + /// Asset Export panel do this on the background thread). + pub fn prepare_screen_export(&mut self, id: &str, params: &Value) -> Result { + let cmd = if id == ASSETS { ASSETS } else { C }; + self.screens_hand_off = Some(None); + let r = self.execute(cmd, params); + let handed = self.screens_hand_off.take().flatten(); + match (r, handed) { + (Err(e), _) => Err(e), + (Ok(_), Some(export)) => Ok(ScreenExport { cmd, ..export }), + // Every run that succeeds hands one over. + (Ok(_), None) => Err(bad(cmd, "nothing to export")), + } + } +} + +impl ScreenExport { + /// Make the export folder, encode the files and write each with `writer` into the folder and + /// its sub-folders, or keep them to zip (the zip written with `writer` into the folder) or to + /// return → the reply of `document.exportForScreens`. It reports one progress step per file + /// ([`vectorcraft_doc::cancel::progress`]), and a cancel stops it before it encodes or writes + /// another file. + pub fn write(self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + let cmd = self.cmd; + // A refusal names `assets.export` when that command prepared the export, as its reply does. + encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }) + } +} + +/// [`ScreenExport::write`]. +fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + let ScreenExport { o, rows, naming, items, .. } = export; + let folder = o.folder.as_deref().filter(|f| !f.is_empty()); + if let Some(dir) = folder { + create_dir(dir)?; + } let mut out = Output::default(); // The encoders' warnings for every file, each once. let mut warnings: Vec = Vec::new(); - // Appearance of Black › Printing / Exporting for RGB raster rows. let mut write = |doc: &Document, format: &str, options: &Value| -> Result> { - let (bytes, notes) = encode_with_warnings(doc, format, &with_black_output(s, options))?; + // The steps are the files: an encoder's own steps (artboards, pages) are not reported. + let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?; for w in notes { if !warnings.contains(&w) { warnings.push(w); @@ -694,8 +766,14 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { // A whole-document copy of an artboard, for the formats that write documents to edit. let mut board_docs: std::collections::HashMap> = std::collections::HashMap::new(); let mut files = 0u64; + // 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; 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 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 }; @@ -706,12 +784,25 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { write(all, "pdf", &options)? } else if r.whole_document() && item.artboard.is_some() { let b = item.artboard.unwrap_or(0); - let d = board_docs.entry(b).or_insert_with(|| Arc::new(single_artboard(&item.doc, item.region, &item.name))).clone(); - write(&d, r.format.id, &r.options_for(item.region, None)?)? + // That artboard alone, as Save's separateArtboards writes it: its art, not the others' (#3868). + let d = board_docs + .entry(b) + .or_insert_with(|| { + let mut d = single_artboard(&item.doc, item.region, &item.name); + super::save::keep_art_on(&mut d, item.region); + Arc::new(d) + }) + .clone(); + let mut options = r.options_for(item.region, None)?; + // An EPS page is the artboard, not its art's bounds: an artboard without art still writes. + if r.format.id == "eps" { + options["useArtboards"] = json!(true); + } + write(&d, r.format.id, &options)? } else { write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)? }; - out.put(folder, o.zip, name, bytes)?; + out.put(folder, o.zip, name, bytes, writer)?; files += 1; } } @@ -722,7 +813,8 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { return match folder { Some(dir) => { let path = format!("{dir}/{zip_name}"); - write_file(&path, &zip)?; + vectorcraft_doc::cancel::check()?; + writer(&path, &zip)?; Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings })) } None => Ok( @@ -800,10 +892,12 @@ fn items( }; if o.full_document { // The bounds of all visible art, as the one artboard of the files that aren't PDFs (a PDF - // row writes every artboard of the document itself). + // row writes every artboard of the document itself). It is named after the document: an + // SVG of one artboard takes the artboard's name as its title (#3544), and this one stands + // for the whole document. let (whole, region) = if crop { let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; - (Arc::new(single_artboard(doc, bounds, "Document")), bounds) + (Arc::new(single_artboard(doc, bounds, &doc.title)), bounds) } else { (doc.clone(), Rect::ZERO) }; diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index 8f028934a..0651f0039 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -179,9 +179,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result = library_open_filters("Swatch libraries", super::swatchlib::LIBRARY_EXTS).collect(); + assert_eq!(filters[0], ("Swatch libraries", super::swatchlib::LIBRARY_EXTS)); + assert!(filters[0].1.contains(&"ase")); + assert_eq!(filters[1], ("All readable files", OPEN_EXTS)); +} + /// The extensions usage texts list for export: one per writable format, each picking that format /// (`.png` picks PNG; PNG-8 is asked for by `format`). #[test] diff --git a/crates/engine/src/cmd/fileio/tests_aiimport.rs b/crates/engine/src/cmd/fileio/tests_aiimport.rs index 61c74fe58..0593dbf28 100644 --- a/crates/engine/src/cmd/fileio/tests_aiimport.rs +++ b/crates/engine/src/cmd/fileio/tests_aiimport.rs @@ -346,6 +346,77 @@ fn an_ai_file_saved_without_its_pdf_part_opens_with_pages_naming_every_page() { assert!(e.contains("placeholder"), "{e}"); } +/// [`PLACEHOLDER`]'s `.ai` files as `dataBase64`: the sample's editing data, damaged editing data +/// and none. +fn placeholder_files() -> [String; 3] { + [compressed(&ai::sample_data()), b"%AI12_CompressedData not zlib".to_vec(), vec![]] + .map(|private| vectorcraft_format::base64_encode(&ai::ai(&private, PLACEHOLDER))) +} + +#[test] +fn an_ai_file_saved_without_its_pdf_part_pastes_from_its_editing_data() { + let [sample, damaged, plain] = placeholder_files(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // clipboard.importPdf (the PDF flavor's command) takes every object of both layers, as + // document.open reads them. + let r = s.execute("clipboard.importPdf", &json!({"dataBase64": sample})).unwrap(); + assert_eq!(r["count"], json!(7), "{r}"); + s.execute("edit.paste", &json!({})).unwrap(); + let art = s.doc().unwrap().doc.layers[0].children().unwrap().clone(); + assert_eq!(names(&art), ["Pair", "Ring", "Window", "Shaded", "", "Sub", ""]); + assert_eq!(art.iter().map(|n| n.visible).collect::>(), [true, true, true, false, false, false, false], "the hidden layer's objects"); + let e = s.execute("clipboard.importPdf", &json!({"dataBase64": damaged})).unwrap_err().to_string(); + assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}"); + let e = s.execute("clipboard.importPdf", &json!({"dataBase64": plain})).unwrap_err().to_string(); + assert!(e.contains("placeholder"), "{e}"); +} + +#[test] +fn an_ai_file_saved_without_its_pdf_part_places_from_its_editing_data() { + let [sample, damaged, plain] = placeholder_files(); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + // The place takes the art of the layers that show on the artboard and clips it to the artboard. + let r = s.execute("file.place", &json!({"name": "art.ai", "dataBase64": sample, "link": false})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(200.0), Some(100.0)), "{r}"); + let placed = s.doc().unwrap().doc.layers[0].children().unwrap()[0].clone(); + let kids = placed.children().unwrap(); + assert_eq!(names(&kids[1..]), ["Pair", "Ring", "Window"], "after the clipping path"); + // With `crop: "bounding"` the size is the bounds of the art that shows. They run from the red + // square's left edge to the window's right one; the pair's hidden square, stroked 2 pt wide, + // is left out. + let r = s.execute("file.place.info", &json!({"name": "art.ai", "dataBase64": sample, "crop": "bounding"})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(170.0), Some(40.0)), "{r}"); + // The place takes the art of both layers that show. + let square = |x: f64| format!("0 g\n{}f\n", ai::rect(x, 10.0, x + 40.0, 50.0)); + let two = ai::editing_data(200.0, 100.0, &[ai::layer("Back", &square(10.0)), ai::layer("Front", &square(110.0))].concat()); + let two = vectorcraft_format::base64_encode(&ai::ai(&compressed(two.as_bytes()), PLACEHOLDER)); + let r = s.execute("file.place.info", &json!({"name": "two.ai", "dataBase64": two, "crop": "bounding"})).unwrap(); + assert_eq!((r["width"].as_f64(), r["height"].as_f64()), (Some(140.0), Some(40.0)), "{r}"); + // The file has two 100-point artboards and one placeholder page. The second artboard lies 50 + // below the first. A square of 40 lies on the first and an oblong of 60 × 40 on the second. + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_0 -50 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_100 -150 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let art = format!("0 g\n{}f\n{}f\n", ai::rect(10.0, 10.0, 50.0, 50.0), ai::rect(10.0, -140.0, 70.0, -100.0)); + let paged = ai::editing_data(100.0, 100.0, &(boards.to_string() + &ai::layer("Art", &art))); + let paged = vectorcraft_format::base64_encode(&ai::ai(&compressed(paged.as_bytes()), PLACEHOLDER)); + let mut size = |page: u64| { + let r = s.execute("file.place.info", &json!({"name": "boards.ai", "dataBase64": paged, "page": page, "crop": "bounding"})); + r.map(|r| (r["width"].as_f64(), r["height"].as_f64())).map_err(|e| e.to_string()) + }; + assert_eq!(size(1), Ok((Some(40.0), Some(40.0)))); + assert_eq!(size(2), Ok((Some(60.0), Some(40.0)))); + let third = size(3); + assert!(third.as_ref().is_err_and(|e| e.contains("has 2 page(s)")), "{third:?}"); + let e = s.execute("file.place", &json!({"name": "damaged.ai", "dataBase64": damaged})).unwrap_err().to_string(); + assert!(e.contains("without PDF compatibility") && e.contains("zlib"), "{e}"); + let e = s.execute("file.place", &json!({"name": "plain.ai", "dataBase64": plain})).unwrap_err().to_string(); + assert!(e.contains("placeholder"), "{e}"); +} + /// A page of text alone, as text-only art has in its PDF part: no path is painted. const TEXT_ART: &str = "BT /F1 36 Tf 10 40 Td (Text only) Tj ET"; diff --git a/crates/engine/src/cmd/fileio/tests_batch.rs b/crates/engine/src/cmd/fileio/tests_batch.rs index 5535d8ed6..b889eb2d4 100644 --- a/crates/engine/src/cmd/fileio/tests_batch.rs +++ b/crates/engine/src/cmd/fileio/tests_batch.rs @@ -134,17 +134,25 @@ fn layers_and_the_selection_are_exported_by_name() { assert!(s.execute("document.exportForScreens", &json!({"selection": "one"})).is_err(), "nothing selected"); } -/// Document formats write each artboard as a document of its own: one artboard, its art. +/// Document formats write each artboard as a document of its own: one artboard, its art and +/// not the other artboards' (#3868). #[test] fn document_formats_write_one_artboard_each() { let mut s = session(2); + let second = s.doc().unwrap().doc.artboards[1].rect; + s.execute("shape.ellipse", &json!({"x": second.x0 + 5.0, "y": 5, "width": 10, "height": 10})).unwrap(); let r = export(&mut s, json!({"formats": [{"format": "vectorcraft"}, {"format": "ai"}, {"format": "eps"}]})); let n = names(&r); assert_eq!(n, ["Artboard-1.vectorcraft", "Artboard-1.ai", "Artboard-1.eps", "Artboard-2.vectorcraft", "Artboard-2.ai", "Artboard-2.eps"]); - let native = &files(&r)[0].1; - let mut t = Session::new(); - t.execute("document.open", &json!({"name": "a.vectorcraft", "dataBase64": vectorcraft_format::base64_encode(native)})).unwrap(); - assert_eq!(t.doc().unwrap().doc.artboards.len(), 1, "that artboard alone"); + let all = files(&r); + for (name, bytes) in &all { + let mut t = Session::new(); + t.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + let d = &t.doc().unwrap().doc; + assert_eq!(d.artboards.len(), 1, "{name}: that artboard alone"); + let objects: usize = d.layers.iter().filter_map(|l| l.children()).map(|c| c.len()).sum(); + assert_eq!(objects, 1, "{name}: only its own artboard's art"); + } } #[test] diff --git a/crates/engine/src/cmd/fileio/tests_dxf.rs b/crates/engine/src/cmd/fileio/tests_dxf.rs index ab817c6dc..6bea6dad7 100644 --- a/crates/engine/src/cmd/fileio/tests_dxf.rs +++ b/crates/engine/src/cmd/fileio/tests_dxf.rs @@ -125,6 +125,24 @@ fn placed_images_come_back_as_linked_files() { let _ = std::fs::remove_dir_all(dir); } +/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no +/// linked file, and `warnings` holds the note raster exports give, once, without naming either +/// image's key. +#[test] +fn images_that_cant_be_decoded_give_the_raster_note_once() { + const NOTE: &str = "an image couldn't be decoded and is missing from the picture"; + let mut s = session(200.0, 100.0, 1); + crate::tests_menucmds::place_undecodable_png(&mut s); + crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16); + let keys: Vec = s.doc().unwrap().doc.images.keys().cloned().collect(); + assert_eq!(keys.len(), 2); + let r = s.execute("document.export", &json!({"format": "dxf"})).unwrap(); + let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{w:?}"); + assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}"); + assert!(entities(&text(&r)).is_empty() && r.get("linked").is_none(), "{r}"); +} + #[test] fn bad_dxf_options_are_refused() { let mut s = two_boards(); @@ -169,7 +187,7 @@ fn formats_list_dxf_and_what_can_not_be_written() { } assert!(r["writable"].as_array().unwrap().contains(&json!("dxf"))); let unsupported: Vec<&str> = r["unsupported"].as_array().unwrap().iter().map(|u| u["id"].as_str().unwrap()).collect(); - assert_eq!(unsupported, ["dwg", "pict"]); + assert_eq!(unsupported, ["dwg", "pict", "pmd"]); } #[test] @@ -191,4 +209,9 @@ fn dwg_and_pict_say_what_to_use_instead() { .unwrap_err() .to_string(); assert!(e.contains("DWG is not available"), "{e}"); + let e = s + .execute("document.open", &json!({"name": "brochure.PMD", "dataBase64": vectorcraft_format::base64_encode(b"PageMaker")})) + .unwrap_err() + .to_string(); + assert!(e.contains("brochure.PMD") && e.contains("PageMaker"), "{e}"); } diff --git a/crates/engine/src/cmd/fileio/tests_eps.rs b/crates/engine/src/cmd/fileio/tests_eps.rs index 4ec90b60f..681d99852 100644 --- a/crates/engine/src/cmd/fileio/tests_eps.rs +++ b/crates/engine/src/cmd/fileio/tests_eps.rs @@ -156,6 +156,23 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() { assert!(l3.contains("/FlateDecode filter >> image")); } +/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings` +/// holds the note raster exports give, once, without naming either image's key. +#[test] +fn images_that_cant_be_decoded_give_the_raster_note_once() { + const NOTE: &str = "an image couldn't be decoded and is missing from the picture"; + let mut s = session(200.0, 100.0, 1); + crate::tests_menucmds::place_undecodable_png(&mut s); + crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16); + let keys: Vec = s.doc().unwrap().doc.images.keys().cloned().collect(); + assert_eq!(keys.len(), 2); + let r = s.execute("document.exportEps", &json!({"previewFormat": "none"})).unwrap(); + let w = warnings(&r); + assert_eq!(w.iter().filter(|w| *w == NOTE).count(), 1, "{w:?}"); + assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{w:?}"); + assert!(!postscript(&b64(&r)).contains("filter >> image"), "no image is written"); +} + #[test] fn bad_eps_options_are_refused_and_formats_list_eps() { let mut s = session(100.0, 100.0, 1); diff --git a/crates/engine/src/cmd/fileio/tests_metafile.rs b/crates/engine/src/cmd/fileio/tests_metafile.rs index 9d21907aa..80c15ace3 100644 --- a/crates/engine/src/cmd/fileio/tests_metafile.rs +++ b/crates/engine/src/cmd/fileio/tests_metafile.rs @@ -95,6 +95,24 @@ fn raster_effects_are_written_as_images() { assert!(kinds.contains(&114), "the shadow is an image with transparency"); } +/// Two placed images whose pixel data can't be decoded are left out of an EMF or WMF, and +/// `warnings` holds the note raster exports give, once, without naming either image's key. +#[test] +fn images_that_cant_be_decoded_give_the_raster_note_once() { + const NOTE: &str = "an image couldn't be decoded and is missing from the picture"; + let mut s = session(200.0, 100.0, 1); + crate::tests_menucmds::place_undecodable_png(&mut s); + crate::tests_menucmds::place_undecodable_png_sized(&mut s, 32, 16); + let keys: Vec = s.doc().unwrap().doc.images.keys().cloned().collect(); + assert_eq!(keys.len(), 2); + for format in ["emf", "wmf"] { + let r = s.execute("document.export", &json!({"format": format})).unwrap(); + let w: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert_eq!(w.iter().filter(|w| **w == NOTE).count(), 1, "{format}: {w:?}"); + assert!(!w.iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))), "{format}: {w:?}"); + } +} + #[test] fn metafiles_open_and_place() { let mut s = two_boards(); diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index 7438c6fd3..6f4730729 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -131,6 +131,32 @@ fn options_not_applied_yet_and_document_features_warn() { assert!(warnings(&s.execute("document.export", &json!({"format": "png"})).unwrap()).is_empty()); } +/// A placed 16-bit PNG is written at 8 bits a sample in PDF 1.3 and 1.4 files (PDF/X-3 and the +/// pages of a .ai file too), and keeps 16 bits from PDF 1.5. +#[test] +fn a_16_bit_png_is_written_at_8_bits_before_pdf_1_5() { + let mut s = session(1); + let deep = image::ImageBuffer::from_fn(8, 8, |x, y| image::Rgb([x as u16 * 8000, y as u16 * 8000, 40000])); + let mut png = vec![]; + image::DynamicImage::ImageRgb16(deep).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + s.execute("file.place", &json!({"name": "deep.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [50, 40]})).unwrap(); + let readable = json!({"compressText": false}); + for (cmd, p, depth) in [ + ("document.exportPdf", json!({"compatibility": "1.3", "compression": readable}), 8), + ("document.exportPdf", json!({"compatibility": "1.4", "compression": readable}), 8), + ("document.exportPdf", json!({"standard": "pdfX3", "compression": readable}), 8), + ("file.saveCopy", json!({"format": "ai", "compatibility": "1.4", "compress": false}), 8), + ("document.exportPdf", json!({"compatibility": "1.5", "compression": readable}), 16), + ("document.exportPdf", json!({"compression": readable}), 16), + ] { + let v = s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + let text = String::from_utf8_lossy(&b64(&v)).into_owned(); + let (eight, sixteen) = (text.contains("/BitsPerComponent 8"), text.contains("/BitsPerComponent 16")); + assert_eq!((eight, sixteen), (depth == 8, depth == 16), "{cmd} {p}"); + assert!(warnings(&v).iter().all(|w| !w.contains("decoded")), "{cmd} {p}: {}", v["warnings"]); + } +} + /// A placed image whose pixel data can't be decoded is left out of every PDF the engine writes, /// and the rest of the page is written. Each reply that carries the PDF's warnings has the note /// raster exports give, once. diff --git a/crates/engine/src/cmd/fileio/tests_pdfx.rs b/crates/engine/src/cmd/fileio/tests_pdfx.rs index ff43cdd1b..f8c3e0c59 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfx.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfx.rs @@ -89,3 +89,51 @@ fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() { assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}"); } } + +/// The ink amounts (0..1) `doc` shows at `p` in its images (the topmost image there). +fn image_inks_at(doc: &vectorcraft_doc::Document, p: vectorcraft_geom::Point) -> Option<[f32; 4]> { + fn visit(n: &vectorcraft_doc::Node, out: &mut Vec) { + if let vectorcraft_doc::NodeKind::Image(im) = &n.kind { + out.push((**im).clone()); + } + for c in n.children().into_iter().flatten() { + visit(c, out); + } + } + let mut images = vec![]; + for l in &doc.layers { + visit(l, &mut images); + } + images.iter().rev().find_map(|im| { + let q = im.xf.inverse() * p; + if q.x < 0.0 || q.y < 0.0 || q.x >= im.width as f64 || q.y >= im.height as f64 { + return None; + } + let ink = doc.images.get(&im.key)?.sample_cmyk_object(q.x, q.y, (im.width, im.height), 1)?; + Some(ink.map(|v| v as f32 / 255.0)) + }) +} + +#[test] +fn pdfx_1a_flattening_keeps_the_inks_behind_an_image_in_a_cmyk_document() { + // #2160: a CMYK panel under a PNG with see-through pixels. The flattener rasterizes where the + // image reaches; the panel there keeps its ink numbers instead of going through RGB. + let _g = crate::tests_colormgmt::GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "colorMode": "cmyk"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 180, "height": 80})).unwrap(); + let teal = [1.0, 0.06, 0.41, 0.03]; + s.execute("paint.setFill", &json!({"color": {"c": teal[0], "m": teal[1], "y": teal[2], "k": teal[3]}})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let mut png = vec![]; + image::RgbaImage::from_fn(40, 40, |x, _| image::Rgba([0, 120, 255, if x < 20 { 0 } else { 255 }])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + s.execute("file.place", &json!({"name": "logos.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [100, 50]})).unwrap(); + let v = s.execute("document.exportPdf", &json!({"standard": "pdfX1a"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + let back = vectorcraft_pdf::import(&bytes).unwrap(); + // Behind the image's see-through half: the panel's own inks, K included. + let ink = image_inks_at(&back, vectorcraft_geom::Point::new(88.0, 50.0)).expect("a CMYK image behind the see-through pixels"); + assert!(ink.iter().zip(teal).all(|(a, b)| (a - b).abs() <= 1.5 / 255.0), "{ink:?} is not {teal:?}"); +} diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs index 496b16f81..63e93fe57 100644 --- a/crates/engine/src/cmd/fileio/tests_raster.rs +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -248,6 +248,7 @@ fn a_raster_export_says_nothing_of_images_another_thread_could_not_decode() { xf: vectorcraft_geom::Affine::scale(20.0), link: None, placement: Default::default(), + pixels: None, }; let (id, layer) = (broken.alloc_id(), broken.layers[0].id); broken.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 9568a1332..867911681 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -1,6 +1,8 @@ //! Export for Screens: sizes (factor, width, height, resolution), presets and sub-folders, Full //! Document, Include Bleed, per-format settings, ZIP downloads and the settings the document keeps. +use std::collections::BTreeMap; + use serde_json::{Value, json}; use super::screens::ScreenSize; @@ -257,6 +259,169 @@ fn a_zip_is_written_into_the_folder() { let _ = std::fs::remove_dir_all(dir); } +/// The reply of a prepared export's [`super::ScreenExport::write`] and the files it gave the +/// writer, by path. +fn write_prepared(export: super::ScreenExport) -> (Value, BTreeMap>) { + let mut files = BTreeMap::new(); + let mut write = |path: &str, bytes: &[u8]| { + files.insert(path.to_string(), bytes.to_vec()); + Ok(()) + }; + let r = export.write(&mut write).unwrap_or_else(|e| panic!("{e}")); + (r, files) +} + +/// The files reply `r` names (its `files`, else the zip at its `path`) as they are on disk, by path. +fn on_disk(r: &Value) -> BTreeMap> { + let paths: Vec<&str> = match r["path"].as_str() { + Some(zip) => vec![zip], + None => r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(), + }; + paths.into_iter().map(|p| (p.to_string(), std::fs::read(p).unwrap())).collect() +} + +/// [`Session::prepare_screen_export`] runs `document.exportForScreens` and `assets.export` as +/// commands, journaled, and writes nothing; the export it gives writes the files the command +/// writes, with the same names (`-2` included), sub-folders, bytes and reply, into a folder, as a +/// zip in it and without a folder, through the writer it is given. Export for Screens remembers +/// its settings when it is prepared. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_prepared_export_writes_what_the_command_writes() { + let mut s = session(2); + s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "name": "icon"})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-screens-prepared-{}", std::process::id())); + let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"); + let rows = json!([{"format": "png", "suffix": ""}, {"format": "png", "scale": "2x"}, {"format": "svg"}]); + for zip in [false, true] { + let p = json!({"folder": folder, "zip": zip, "folders": "rows", "name": "", "formats": rows}); + let want = export(&mut s, p.clone()); + if !zip { + let names = ["1x/Icon.png", "2x/Icon@2x.png", "SVG/Icon.svg", "1x/icon-2.png", "2x/icon@2x-2.png", "SVG/icon-2.svg"]; + assert_eq!(want["files"], json!(names.map(|n| format!("{folder}/{n}")))); + } + let disk = on_disk(&want); + std::fs::remove_dir_all(&dir).unwrap(); + let journaled = s.journal.len(); + let prepared = s.prepare_screen_export("document.exportForScreens", &p).unwrap(); + assert_eq!((s.journal.len(), s.journal.last().map(|(id, _)| id.as_str())), (journaled + 1, Some("document.exportForScreens"))); + assert!(!dir.exists(), "nothing is written until the export writes"); + let (got, files) = write_prepared(prepared); + assert_eq!(got, want, "zip: {zip}"); + assert_eq!(files, disk, "zip: {zip}"); + let _ = std::fs::remove_dir_all(&dir); + } + // Without a folder, the files come back in the reply and nothing is written. + let p = json!({"name": "", "formats": rows}); + let want = export(&mut s, p.clone()); + let (got, files) = write_prepared(s.prepare_screen_export("document.exportForScreens", &p).unwrap()); + assert_eq!((got, files.len()), (want, 0)); + // The settings are remembered when the export is prepared. + s.prepare_screen_export("document.exportForScreens", &json!({"prefix": "pre-", "formats": rows})).unwrap(); + assert_eq!(s.doc().unwrap().doc.export_settings.get("prefix"), Some(&json!("pre-"))); + // The Asset Export panel's export. + s.execute("select.all", &json!({})).unwrap(); + s.execute("assets.add", &json!({"multiple": false})).unwrap(); + let p = json!({"folder": folder, "formats": rows}); + let want = s.execute("assets.export", &p).unwrap(); + let disk = on_disk(&want); + assert_eq!(disk.len(), 3, "{want}"); + std::fs::remove_dir_all(&dir).unwrap(); + let prepared = s.prepare_screen_export("assets.export", &p).unwrap(); + assert_eq!(s.journal.last().map(|(id, _)| id.as_str()), Some("assets.export")); + assert_eq!(write_prepared(prepared), (want, disk)); + // An encoder's refusal names the command, as the command's own reply does. + let p = json!({"folder": folder, "formats": [{"format": "bmp", "colorModel": "cmyk"}]}); + let want = s.execute("assets.export", &p).unwrap_err(); + let got = s.prepare_screen_export("assets.export", &p).unwrap().write(&mut |_: &str, _: &[u8]| Ok(())).unwrap_err(); + assert!(matches!(&got, crate::EngineError::BadParams { cmd, .. } if cmd == "assets.export"), "{got:?}"); + assert_eq!(got.to_string(), want.to_string()); + // The checks come first, as the command's do. + let journaled = s.journal.len(); + assert!(s.prepare_screen_export("document.exportForScreens", &json!({"range": "9"})).is_err()); + assert_eq!(s.journal.len(), journaled); + assert!(s.screens_hand_off.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_prepared_export_holds_the_document_as_it_was() { + let mut s = session(1); + let p = json!({"formats": [{"format": "svg"}]}); + let want = export(&mut s, p.clone()); + let prepared = s.prepare_screen_export("document.exportForScreens", &p).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_ne!(export(&mut s, p), want, "the edit changes the file"); + assert_eq!(write_prepared(prepared).0, want); +} + +/// Output All Blacks as Rich Black (Appearance of Black › Printing / Exporting) makes the RGB files +/// draw a CMYK black as RGB black, prepared or not. +#[test] +fn rich_black_reaches_the_files() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 30, "colorMode": "cmyk"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); + let p = json!({"formats": [{"format": "png"}]}); + let px = |r: &Value| image::load_from_memory(&files(r)[0].1).unwrap().to_rgba8().get_pixel(10, 10).0; + let accurate = px(&export(&mut s, p.clone())); + assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}"); + s.execute("prefs.set", &json!({"key": "blackOutput", "value": "rich"})).unwrap(); + assert_eq!(px(&export(&mut s, p.clone())), [0, 0, 0, 255]); + let (prepared, _) = write_prepared(s.prepare_screen_export("document.exportForScreens", &p).unwrap()); + assert_eq!(px(&prepared), [0, 0, 0, 255]); +} + +/// A cancel stops a prepared export before its next file; the file written before it stays. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_canceled_export_stops_before_its_next_file() { + let mut s = session(3); + let dir = std::env::temp_dir().join(format!("vc-screens-cancel-{}", std::process::id())); + let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"); + let prepared = s.prepare_screen_export("document.exportForScreens", &json!({"folder": folder, "formats": [{"format": "svg"}]})).unwrap(); + let progress = super::progress::Progress::new(); + let mut written = vec![]; + let r = super::progress::scope(&progress, || { + prepared.write(&mut |path: &str, _: &[u8]| { + written.push(path.to_string()); + progress.cancel(); + Ok(()) + }) + }); + assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{r:?}"); + assert_eq!(written, [format!("{folder}/Artboard-1.svg")]); + let _ = std::fs::remove_dir_all(dir); +} + +/// A prepared export reports one progress step per file, and the raster encoders' artboard steps +/// are not reported. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_prepared_export_counts_its_files() { + let mut s = session(3); + let dir = std::env::temp_dir().join(format!("vc-screens-steps-{}", std::process::id())); + let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"); + let rows = json!([{"format": "png"}, {"format": "png", "scale": "2x"}]); + let prepared = s.prepare_screen_export("document.exportForScreens", &json!({"folder": folder, "formats": rows})).unwrap(); + let progress = super::progress::Progress::of_files(); + let mut seen = vec![]; + let r = super::progress::scope(&progress, || { + prepared.write(&mut |_: &str, _: &[u8]| { + seen.push(progress.counts()); + Ok(()) + }) + }); + assert!(r.is_ok(), "{r:?}"); + assert_eq!(seen, (0..6).map(|n| (n, 6)).collect::>()); + assert!(progress.counts_files()); + let _ = std::fs::remove_dir_all(dir); +} + /// The warnings in `r` that contain `text`. fn warned<'a>(r: &'a Value, text: &str) -> Vec<&'a str> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains(text)).collect() diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index 05cafeb6a..61a96bd5e 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -162,6 +162,48 @@ fn saving_several_artboards_as_plain_svg_warns_they_are_lost() { assert_eq!(artboards(&session(1).execute("document.save", &json!({"format": "svg"})).unwrap()), 0); } +/// #3867: a Preserve Editing SVG opened from disk and saved with plain Save keeps the full +/// document (every artboard), not just the artwork of the first. +#[test] +fn reopened_preserve_editing_svg_saves_as_the_full_document() { + let dir = tmp_dir("reopen"); + let path = dir.join("keep.svg").to_string_lossy().to_string(); + session(3).execute("document.save", &json!({"path": path, "svg": {"preserveEditing": true}})).unwrap(); + let mut s = Session::new(); + s.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.len(), 3); + s.execute("document.save", &json!({})).unwrap(); + let mut t = Session::new(); + let r = t.execute("document.open", &json!({"path": path})).unwrap(); + assert_eq!(r["restored"], json!(true), "{r}"); + assert_eq!(t.doc().unwrap().doc.artboards.len(), 3, "every artboard survives open and Save"); + let _ = std::fs::remove_dir_all(dir); +} + +/// #3869: small capitals, superscript and subscript, and tab leaders, which SVG text can't draw, +/// are written as outlines (as vertical type and ruby are) rather than as plain text at full size; +/// type without them stays text. +#[test] +fn type_svg_text_cant_draw_is_written_as_outlines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 500, "height": 200})).unwrap(); + s.execute("text.create", &json!({"x": 50, "y": 100, "text": "Small caps x2 H2O", "size": 30})).unwrap(); + let plain = svg(&mut s, json!({})); + assert!(plain.contains("`; Save's SVG and Export for Screens' Full +/// Document keep the document's. +#[test] +fn an_svg_of_one_artboard_is_titled_after_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"name": "Icons", "width": 24, "height": 24, "artboards": 2})).unwrap(); + for (index, name) in [(0, "arrow-left"), (1, "arrow-right")] { + s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap(); + } + let square = s.execute("shape.rectangle", &json!({"x": 4, "y": 4, "width": 16, "height": 16})).unwrap()["id"].clone(); + let title = |svg: &str| svg.lines().find_map(|l| l.trim().strip_prefix("")?.strip_suffix("")).map(str::to_string); + let serialize = |s: &mut Session, p: serde_json::Value| s.execute("document.serialize", &p).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 1}))).as_deref(), Some("arrow-right")); + assert_eq!(title(&serialize(&mut s, json!({"format": "svg", "artboard": 0}))).as_deref(), Some("arrow-left")); + assert_eq!(title(&serialize(&mut s, json!({"format": "svg"}))).as_deref(), Some("Icons"), "Save's SVG"); + let all = s.execute("document.serialize", &json!({"format": "svg", "useArtboards": true, "range": "all"})).unwrap(); + let titles: Vec<_> = all["files"].as_array().unwrap().iter().map(|f| title(f["text"].as_str().unwrap())).collect(); + assert_eq!(titles, [Some("arrow-left".into()), Some("arrow-right".into())]); + // Export for Screens: each artboard, an asset, and the Full Document. + let screens = |s: &mut Session, p: serde_json::Value| -> Vec> { + let r = s.execute("document.exportForScreens", &p).unwrap(); + r["files"] + .as_array() + .unwrap() + .iter() + .map(|f| title(&String::from_utf8(vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap()).unwrap())) + .collect() + }; + let svg = json!([{"format": "svg"}]); + assert_eq!(screens(&mut s, json!({"formats": svg})), [Some("arrow-left".into()), Some("arrow-right".into())]); + let asset = s.execute("assets.add", &json!({"ids": [square], "name": "Square"})).unwrap()["assets"][0].clone(); + assert_eq!(screens(&mut s, json!({"assets": [asset], "formats": svg})), [Some("Square".into())]); + assert_eq!(screens(&mut s, json!({"fullDocument": true, "formats": svg})), [Some("Icons".into())]); +} diff --git a/crates/engine/src/cmd/findfiles/walk.rs b/crates/engine/src/cmd/findfiles/walk.rs index 26c453f59..8d3551571 100644 --- a/crates/engine/src/cmd/findfiles/walk.rs +++ b/crates/engine/src/cmd/findfiles/walk.rs @@ -259,6 +259,10 @@ pub(crate) fn start_in( } // The walkers follow no links, so a folder automation may read holds all they read. crate::file_access::check_read(&folder.to_string_lossy())?; + // The check above reads a lossy string: under roots, a path it would alter names another folder. + if folder.to_str().is_none() && crate::file_access::current().is_some() { + return Err("automation path rejected: the folder's name isn't valid text".into()); + } let seconds = if limits.seconds.is_finite() { limits.seconds.clamp(0.0, 3600.0) } else { 60.0 }; let n = pool.reserve(threads.clamp(1, MAX_THREADS)); if n == 0 { @@ -279,6 +283,7 @@ pub(crate) fn start_in( walk: Mutex::new(Walk { root: Some(folder), alive: n, found: vec![0; items], ..Walk::default() }), wake: Condvar::new(), pool, + scope: crate::file_access::ScopeHandle::capture(), }); let mut spawned = 0; for _ in 0..n { @@ -325,6 +330,8 @@ struct Shared { /// Woken when folders are queued, a walker finishes a folder, or the search ends. wake: Condvar, pool: &'static Pool, + /// The automation roots in force when the search started, for the folders the walkers add. + scope: crate::file_access::ScopeHandle, } /// The state walkers change under the lock. @@ -490,7 +497,10 @@ impl Shared { } queue.push((path, depth + 1)); } - let home = rules.home_below(&listed); + // The home folder is added by a rule, not picked: it needs the picked folder's authority. + let home = rules + .home_below(&listed) + .filter(|h| !rules.is_refused(h) && self.scope.clone().run(|| crate::file_access::check_read(&h.to_string_lossy())).is_ok()); { let mut w = self.lock(); w.files += files; diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index e737717d3..d4e8b5ad1 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -11,7 +11,8 @@ //! effects reach there is no single colour: those regions are rendered into one image per group, //! clipped to them with Clip Complex Regions (else the image is a rectangle that takes in the //! regions it overlaps). The raster/vector balance rasterizes whole groups that split into more -//! regions than it allows (all of them at 0). +//! regions than it allows (all of them at 0). For files without transparency (PDF/X-1a, PDF/X-3) +//! a CMYK document's images are opaque CMYK: its colours keep their inks there too. //! //! Edit → Transparency Flattener Presets: the built-in presets plus the user's, saved with the //! preferences ([`crate::Prefs::flattener_presets`]); a `preset` named anywhere options are taken @@ -28,6 +29,7 @@ use serde_json::{Value, json}; use vectorcraft_color::blend::{cmyk_planes, composite, planes_cmyk}; use vectorcraft_color::{BlendMode, Color, Paint}; use vectorcraft_doc::appearance::{AppearanceItem, FillLayer}; +use vectorcraft_doc::cmyk::Inks; use vectorcraft_doc::{Appearance, ColorMode, Document, Effect, ImageBlob, ImageObject, Knockout, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, FillRule, PathData, Rect, shapes}; use vectorcraft_pathops as po; @@ -132,8 +134,8 @@ pub struct FlattenOptions { pub preserve_overprints: bool, /// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with /// see-through pixels (these keys, [`see_through_images`]) count as transparency, and - /// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a - /// preset option. + /// rasterized areas clipped to their regions are opaque (in a CMYK document, images of ink + /// amounts). `None` for ordinary flattening. Not a preset option. #[serde(skip)] pub no_transparency: Option>, } @@ -473,7 +475,8 @@ fn target_roots(s: &Session, p: &Value) -> Result> { let clipping = |id: NodeId| { d.parent_of(id).and_then(|p| d.node(p)).is_some_and(|p| p.clips() && p.children().and_then(|c| c.first()).is_some_and(|c| c.id == id)) }; - Ok(roots.into_iter().filter(|id| d.is_visible(*id) && d.is_editable(*id) && !clipping(*id)).collect()) + let editable = d.editable(); + Ok(roots.into_iter().filter(|id| d.is_visible(*id) && editable(*id) && !clipping(*id)).collect()) } // ---------- preview ---------- @@ -804,7 +807,7 @@ struct RegionFill { /// A rendered image of complex regions, clipped to them or not. struct Raster { - png: Vec, + blob: ImageBlob, width: u32, height: u32, xf: Affine, @@ -944,7 +947,9 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool /// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out /// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and -/// its `clip` reach a pixel further. Fails when the image can't be encoded. +/// its `clip` reach a pixel further. An opaque image in a CMYK document holds the art's ink +/// amounts (a CMYK TIFF), so its CMYK colours keep their numbers (#2160); other images are PNGs. +/// Fails when the image can't be encoded. fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option, o: &FlattenOptions) -> Result { let mut scale = ppi / 72.0; scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt()); @@ -957,11 +962,26 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio rect.x0 + (rect.width() * scale).ceil().max(1.0) / scale, rect.y0 + (rect.height() * scale).ceil().max(1.0) / scale, ); + let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); + let tmp = isolated_doc(doc, art.to_vec()); let mut r = vectorcraft_render::Renderer::new(); - let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha); + let no_alpha = o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha; + if no_alpha && doc.color_mode == ColorMode::Cmyk { + // Drawn as inks over the paper: as screen colours, the file would separate them again, + // and a CMYK colour comes back with other inks (K moves into C, M and Y). + let opts = vectorcraft_render::RenderOptions { background: Some([255; 4]), skip_templates: true, ..Default::default() }; + let inks = r.render_region_inks(&tmp, region, scale, &opts); + vectorcraft_doc::cancel::check()?; + let (width, height) = vectorcraft_render::region_pixels(region, scale); + // Past the size of a CMYK image: drawn in screen colours, below. + if let Some(blob) = Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)) { + return Ok(Raster { blob, width, height, xf, clip }); + } + } + let mut img = r.render_region(&tmp, region, scale, !o.preserve_alpha); // The drawing stops early when cancelled: this image is not whole. vectorcraft_doc::cancel::check()?; - if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha { + if no_alpha { // Without transparency the clip alone shapes the image: it is opaque, over white (its // edge pixels too). for p in img.pixels.as_chunks_mut::<4>().0 { @@ -1001,9 +1021,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio } } } - let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?; - Ok(Raster { png, width: img.width, height: img.height, xf, clip }) + Ok(Raster { blob: ImageBlob::new("image/png", png), width: img.width, height: img.height, xf, clip }) } /// `n` at full opacity with Normal blending throughout (objects, fills and strokes). @@ -1114,10 +1133,18 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o: /// An image of `r` (in a clip group when it is clipped). fn raster_node(d: &mut Document, r: Raster) -> Node { let key = unique_key(d, "flattened"); - d.images.insert(key.clone(), ImageBlob::new("image/png", r.png)); + d.images.insert(key.clone(), r.blob); let image = Node::new( d.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key, width: r.width, height: r.height, xf: r.xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key, + width: r.width, + height: r.height, + xf: r.xf, + link: None, + placement: Default::default(), + pixels: None, + })), ); let Some(path) = r.clip else { return image }; super::rasterfx::clip_group(d, path, FillRule::NonZero, image) @@ -1207,13 +1234,14 @@ impl Scratch { NodeKind::Text(_) => outlined_text(n), NodeKind::SymbolInstance { symbol, .. } => { let art = self.doc.symbols.iter().find(|s| &s.name == symbol).map(|s| vectorcraft_render::instance_art(&s.art, n)); - art.and_then(|a| effects::outline_art(n, Some(&a))).map(|g| carry(n, g)) + art.and_then(|a| effects::outline_art(Some(&self.doc), n, Some(&a))).map(|g| carry(n, g)) } // A placed document stays one: its art's resources aren't the document's. NodeKind::PlacedDocument(_) => None, NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { let hook: &dyn Fn(&Node) -> Option = &effects::outline_text; - Some(carry(n, vectorcraft_doc::live::expanded_group(n, Some(hook)))) + let hooks = vectorcraft_doc::live::Hooks { outline: Some(hook), symbols: Some(&self.doc), ..Default::default() }; + Some(carry(n, vectorcraft_doc::live::expanded_group_hooks(n, hooks))) } NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => outline_strokes(&mut self.doc, &self.brushes, n), _ => None, diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs index 1d4785b55..f1f7c2dfa 100644 --- a/crates/engine/src/cmd/fonts.rs +++ b/crates/engine/src/cmd/fonts.rs @@ -101,6 +101,12 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String { /// The fonts (family, style, version) of the type in the layers and symbols of `d`. pub(super) fn used_fonts(d: &Document) -> BTreeSet { + art_fonts(d, &[]) +} + +/// The fonts (family, style, version) of the type in the layers and symbols of `d` and in `art` +/// (art read from a file, its resources in `d`). +pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet { let mut fonts = BTreeSet::new(); let mut add = |n: &Node| { if let NodeKind::Text(t) = &n.kind { @@ -111,6 +117,9 @@ pub(super) fn used_fonts(d: &Document) -> BTreeSet { for s in &d.symbols { s.art.walk(&mut add); } + for n in art { + n.walk(&mut add); + } fonts } @@ -301,7 +310,8 @@ fn drawn_runs(t: &vectorcraft_doc::TextObject) -> (Vec<&TextRun>, bool) { } /// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font -/// families that aren't available: their type is drawn in the fallback font, not the font it names. +/// families that aren't available (their type is drawn in the fallback font, not the font it +/// names) or styles that its family doesn't have (the closest style is drawn). pub(super) fn substitution_warning(d: &Document) -> Option { substitution_warning_of(used_fonts(d)) } @@ -309,20 +319,34 @@ pub(super) fn substitution_warning(d: &Document) -> Option { /// [`substitution_warning`] for type set in `fonts`. pub(super) fn substitution_warning_of(fonts: BTreeSet) -> Option { let db = vectorcraft_text::FontDb::global(); - let mut missing: Vec = fonts - .into_iter() - .filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing)) - .map(|(family, _, _)| family) - .collect(); + let mut missing: Vec = Vec::new(); + let mut styles: BTreeSet = BTreeSet::new(); + for (family, style, _) in fonts { + 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)); + } + Some(_) => {} + } + } // Sorted by family: its styles and versions are neighbours. missing.dedup(); - (!missing.is_empty()).then(|| { - format!( + 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)", 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)", + styles.into_iter().collect::>().join(", ") + )); + } + (!parts.is_empty()).then(|| parts.join("; ")) } fn text(d: &Document, id: NodeId) -> Option<&vectorcraft_doc::TextObject> { @@ -585,6 +609,16 @@ mod open_tests { assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}"); } + #[test] + fn export_warns_when_a_style_is_drawn_in_another_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi", "font": "Source Sans 3", "style": "Black Wide"})).unwrap(); + let out = s.execute("document.export", &json!({"format": "pdf"})).unwrap(); + let warnings = out["warnings"].to_string(); + assert!(warnings.contains("Source Sans 3 Black Wide was drawn in Source Sans 3"), "{out}"); + } + #[test] fn opened_documents_have_exact_text_bounds() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 67d248072..acefd6102 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -176,7 +176,7 @@ pub(crate) fn parse_mode(m: &str) -> std::result::Result { /// automatic points it shows first, and the stops follow the points' colours. fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freeform, Space) -> std::result::Result) -> Result { let item = item_target(s, p, cmd)?; - let stroke = edits_stroke(s, p, item, !s.fill_active)?; + let stroke = edits_stroke(s, p, item, !s.fill_active, cmd)?; let item = item.of_kind(s, !stroke); let ids = item.targets(s, p)?; if ids.is_empty() { @@ -227,7 +227,7 @@ fn edit(s: &mut Session, p: &Value, cmd: &str, mut f: impl FnMut(&mut Freefor /// The `index` param, else the selected point. fn point_index(s: &Session, p: &Value, cmd: &str) -> Result { match p.get("index") { - Some(v) => v.as_u64().map(|i| i as usize).ok_or_else(|| bad(cmd, "`index` must be a point index")), + Some(v) => v.as_u64().map(index).ok_or_else(|| bad(cmd, "`index` must be a point index")), None => s.selected_freeform_point().ok_or_else(|| bad(cmd, "no point selected (give `index`)")), } } @@ -244,6 +244,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result { Some(Value::Bool(true)) => s.selected_freeform_point(), Some(_) => return Err(bad(C, "`line` must be true or false")), }; + super::paint::check_keep_model(p, C)?; let given = given_color(s, p); let index = edit(s, p, C, |f, sp| { let q = sp.to_paint * at; @@ -266,6 +267,7 @@ fn add_point(s: &mut Session, p: &Value) -> Result { fn set_point(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.freeform.setPoint"; let i = point_index(s, p, C)?; + super::paint::check_keep_model(p, C)?; let given = given_color(s, p); edit(s, p, C, |f, sp| { check_index(f, i)?; @@ -306,7 +308,7 @@ fn split_line(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.freeform.splitLine"; let line = index_param(p, "line", C)?; let segment = index_param(p, "segment", C)?; - let t = f64_or(p, "t", 0.5); + let t = number_or(p, "t", 0.5, C)?; let index = edit(s, p, C, |f, _| f.split_line(line, segment, t).ok_or_else(|| format!("no segment {segment} on line {line}")))?; s.select_freeform_point(Some(index)); Ok(json!({ "index": index })) @@ -318,7 +320,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result { None => return Err(bad(C, "missing `index` (a point index, or null to clear)")), Some(Value::Null) => None, Some(v) => { - let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize; + let i = v.as_u64().map(index).ok_or_else(|| bad(C, "`index` must be a whole number or null"))?; let (f, _) = s.proxy_freeform(!s.fill_active).ok_or_else(|| bad(C, NOT_FREEFORM))?; check_index(&f, i).map_err(|e| bad(C, e))?; Some(i) @@ -330,7 +332,7 @@ fn select_point(s: &mut Session, p: &Value) -> Result { fn get(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.freeform.get"; - let stroke = bool_or(p, "stroke", !s.fill_active); + let stroke = switch_or(p, "stroke", !s.fill_active, C)?; let (f, _) = s.proxy_freeform(stroke).ok_or_else(|| bad(C, NOT_FREEFORM))?; let selected = if stroke != s.fill_active { s.selected_freeform_point().filter(|i| *i < f.points.len()) } else { None }; let mut v = vectorcraft_tools::params::freeform_json(&f); diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs new file mode 100644 index 000000000..c015ffaa2 --- /dev/null +++ b/crates/engine/src/cmd/globaledit.rs @@ -0,0 +1,600 @@ +//! Select › Start Global Edit / Stop Global Edit, and the Global Edit Options. +//! +//! Behaviour from the public help pages "Edit similar objects" +//! () +//! and "Update assets quickly with Global Edit" +//! (), in our words: +//! +//! - With one object selected, Start Global Edit finds the objects that repeat its shape: the +//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance +//! (the same fill and stroke too) and Size (the same width and height too). The search covers +//! every artboard and the canvas around them by default; it can be limited to a range of +//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type, +//! masks, linked files and selections of more than one object are not supported. +//! - While Global Edit is on, an edit made to one of the found objects is made to all of them, +//! each in proportion to its own size: halving the height of a 100 pt object halves a 20 pt +//! match to 10 pt. Stop Global Edit (or Escape) ends it. +//! +//! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the +//! object that was selected, so that the Transform panel and the bounding box measure that object +//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps +//! the first object's outline onto its own. After every edit ([`propagate`], run inside +//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit +//! changed is the model: what changed about it is given to every other member, and nothing else, +//! so members found by shape alone keep their own fills and strokes where the edit didn't touch +//! them: +//! +//! - its outline (and anchors), placed through that transform, so in proportion to each member's +//! size. Only geometry is placed so: no stroke weight is scaled to a member's size; +//! - its opacity, blending, knockout and the object's effects, each when it changed; +//! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending, +//! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the +//! same rank (the second fill from the bottom to the second fill), and a member without one is +//! left without it. A weight set to 4 pt is 4 pt on a smaller member too. The pen pressure a +//! stroke was drawn with stays each member's own; +//! - a fill or stroke added or removed: a member whose fills and strokes lie in the model's +//! former order gains the same item at the same place (or loses the one at that place). A +//! member with another order, and any other change to the list (items moved, or several added +//! or removed at once), takes the model's whole list of fills and strokes. +//! +//! Members that are hidden or locked at the time are left as they are. An edit that would take any +//! member past the canvas (beyond [`vectorcraft_doc::MAX_COORD`]) is refused whole, as one that +//! takes the selection there is. Whenever the document is put back (Undo, Redo, a cancelled drag +//! or scrub, an edit that failed), the members it lacks (or no longer supports) leave the Global +//! Edit, and it ends when fewer than two are left ([`follow_history`]), so that an object made +//! later under a freed id is never edited. Rotated copies, groups (the help page "Edit groups with +//! similar objects") and excluding a member with a Shift-click are not covered. + +use serde::Serialize; +use serde_json::{Value, json}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer}; +use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape}; + +use super::wand::{fill_of, stroke_of}; +use super::*; + +const C: &str = "select.globalEdit"; +const OPTIONS: &str = "select.globalEditOptions"; + +pub fn specs() -> Vec { + vec![ + cmd!( + "select.globalEdit", + "Start Global Edit", + ["Select"], + None, + "{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object, with the objects of the same shape (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard) → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}", + can_toggle, + toggle + ), + cmd!( + "select.globalEditOptions", + "Global Edit Options", + [], + None, + "{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} set the options Start Global Edit matches with (defaults: shape only, all artboards, objects on the canvas included) → {appearance, size, artboards, includeCanvas, count: the objects Start Global Edit would take now (0 unless one supported object is selected)}", + has_doc, + set_options + ), + ] +} + +/// The Global Edit Options: what a similar object must share with the selected one besides its +/// shape, and where it may lie. Kept for the session. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Options { + /// Match the fill and stroke too. + pub appearance: bool, + /// Match the width and height too. + pub size: bool, + /// The artboards to search (1-based range as typed); `None`: all of them. + pub artboards: Option, + /// Include the objects that lie on no artboard. + pub include_canvas: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { appearance: false, size: false, artboards: None, include_canvas: true } + } +} + +impl Options { + /// These options with the ones `p` gives, checked against `d`'s artboards. + fn with(&self, p: &Value, d: &Document, cmd: &str) -> Result { + let mut o = self.clone(); + o.appearance = bool_or(p, "appearance", o.appearance); + o.size = bool_or(p, "size", o.size); + o.include_canvas = bool_or(p, "includeCanvas", o.include_canvas); + match p.get("artboards") { + None | Some(Value::Null) => {} + Some(Value::String(s)) if s.trim().is_empty() || s.trim().eq_ignore_ascii_case("all") => o.artboards = None, + Some(Value::String(s)) => { + vectorcraft_doc::range::parse_range(s, d.artboards.len()).map_err(|e| bad(cmd, e))?; + o.artboards = Some(s.trim().to_string()); + } + Some(v) => return Err(bad(cmd, format!("`artboards`: all or a range such as 1, 3-5, not {v}"))), + } + Ok(o) + } + + fn json(&self, count: usize) -> Value { + json!({ + "appearance": self.appearance, + "size": self.size, + "artboards": self.artboards.as_deref().unwrap_or("all"), + "includeCanvas": self.include_canvas, + "count": count, + }) + } +} + +/// Outlines with their layer colours ([`highlights`]). +pub type Outlines = Vec<(BezPath, [u8; 3])>; + +/// A Global Edit in progress (per document; not saved, not an undo step). +#[derive(Clone, Debug, PartialEq)] +pub struct GlobalEdit { + /// The objects edited together, the one selected at the start first, each with the transform + /// (a move and a uniform scale) that maps that first object's outline onto its own. + pub members: Vec<(NodeId, Affine)>, + /// [`highlights`] as of a document revision: the canvas asks every frame. + outlines: std::cell::RefCell>, +} + +/// An outline reduced to its form: the anchors and handles placed in a unit box, with what tells +/// such outlines apart (kind, fill rule, subpaths and their anchor counts, open or closed). +struct Form { + tag: Vec, + symbol: Option, + points: Vec, + /// The unit box → the object. + frame: Affine, +} + +impl Form { + fn of(n: &Node) -> Option { + let mut tag = vec![]; + let mut points = vec![]; + let mut symbol = None; + let mut push = |path: &PathData, tag: &mut Vec| { + tag.push(path.subpaths.len() as u64); + for sp in &path.subpaths { + tag.extend([u64::from(sp.closed), sp.anchors.len() as u64]); + points.extend(sp.anchors.iter().flat_map(|a| [a.p, a.h_in, a.h_out])); + } + }; + let rule = |r: vectorcraft_geom::FillRule| u64::from(r == vectorcraft_geom::FillRule::EvenOdd); + match &n.kind { + NodeKind::Path { path, rule: r, clipping: false, guide: false, .. } => { + tag.extend([0, rule(*r)]); + push(path, &mut tag); + } + NodeKind::Compound { children, rule: r } => { + tag.extend([1, rule(*r), children.len() as u64]); + for c in children { + push(c.path_data()?, &mut tag); + } + } + NodeKind::SymbolInstance { symbol: s, xf } => { + tag.push(2); + symbol = Some(s.clone()); + points.extend([*xf * Point::ZERO, *xf * Point::new(1.0, 0.0), *xf * Point::new(0.0, 1.0)]); + } + _ => return None, + } + let first = *points.first()?; + let b = points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)); + let side = b.width().max(b.height()); + if !(side.is_finite() && side > 1e-9) { + return None; + } + let frame = Affine::translate(b.origin().to_vec2()) * Affine::scale(side); + let back = frame.inverse(); + points.iter_mut().for_each(|p| *p = back * *p); + Some(Self { tag, symbol, points, frame }) + } + + fn same(&self, o: &Form) -> bool { + self.tag == o.tag + && self.symbol == o.symbol + && self.points.len() == o.points.len() + && self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6) + } +} + +/// Global Edit supports paths, compound paths and symbol instances; not images, type, linked +/// files, objects with an opacity mask, clipping paths or guides (the help page lists images, +/// text, masks and links as unsupported). +fn supported(n: &Node) -> bool { + n.extras().mask.is_none() && Form::of(n).is_some() +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0) +} + +/// The members a Global Edit started on the one selected object would take, with `o`: that +/// object first, then the others in paint order. +fn matches(s: &Session, o: &Options) -> Result> { + let st = s.doc()?; + let d = &st.doc; + let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) }; + let key = d.node(*id).ok_or_else(|| bad(C, "nothing selected"))?; + let form = match Form::of(key) { + Some(f) if supported(key) => f, + _ => return Err(bad(C, "Global Edit works on paths, compound paths and symbol instances")), + }; + let symbols = d.symbol_boxes(); + let size = |n: &Node| n.geometric_bounds_with(&symbols).map(|b| (b.width(), b.height())); + let key_size = size(key); + let (fill, stroke) = (fill_of(key), stroke_of(key)); + let boards: Option> = match &o.artboards { + None => None, + Some(r) => Some( + vectorcraft_doc::range::parse_range(r, d.artboards.len()) + .map_err(|e| bad(C, e))? + .into_iter() + .filter_map(|i| d.artboards.get(i).map(|a| a.rect)) + .collect(), + ), + }; + let on = |r: Rect, b: Rect| b.intersect(r).area() > 0.0 || vectorcraft_doc::artboard_holds(r, b.center()); + let in_scope = |n: &Node| { + let Some(b) = n.geometric_bounds_with(&symbols) else { return false }; + if !d.artboards.iter().any(|a| on(a.rect, b)) { + return o.include_canvas; + } + boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b))) + }; + let mut out = vec![(*id, Affine::IDENTITY)]; + let back = form.frame.inverse(); + super::select::walk_selectable(d, st.isolation, |n| { + if n.id == *id { + return false; + } + match &n.kind { + // Look into layers and plain groups (clip groups too: their art, not their clipping path). + NodeKind::Layer { .. } => return true, + NodeKind::Group { .. } => { + let x = n.extras(); + return x.graph.is_none() && x.shaper.is_none() && x.dimension.is_none() && x.objects_on_path.is_none() && x.trace.is_none(); + } + _ => {} + } + if let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n)) + && (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2))) + && (!o.appearance || { + let (f2, s2) = (fill_of(n), stroke_of(n)); + super::select::same_paint(&fill, &f2, true) && super::select::same_paint(&stroke.0, &s2.0, true) && close(stroke.1, s2.1) + }) + && in_scope(n) + { + out.push((n.id, f.frame * back)); + } + // Compound paths, symbol instances and the other objects are taken whole. + false + }); + Ok(out) +} + +fn can_toggle(s: &Session) -> std::result::Result<(), String> { + let st = s.active().ok_or("no document open")?; + if st.global_edit.is_some() { + return Ok(()); + } + let [id] = st.selection.objects.as_slice() else { + return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into()); + }; + match st.doc.node(*id) { + Some(n) if supported(n) => Ok(()), + _ => Err("Global Edit works on paths, compound paths and symbol instances".into()), + } +} + +fn toggle(s: &mut Session, p: &Value) -> Result { + if s.doc()?.global_edit.is_some() { + let st = s.doc_mut()?; + st.global_edit = None; + st.revision += 1; + return Ok(json!({ "active": false })); + } + let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?; + let members = matches(s, &o)?; + if members.len() < 2 { + return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again")); + } + let ids: Vec = members.iter().map(|(id, _)| id.0).collect(); + let st = s.doc_mut()?; + st.global_edit = Some(GlobalEdit { members, outlines: Default::default() }); + st.revision += 1; + Ok(json!({ "active": true, "count": ids.len(), "ids": ids })) +} + +fn set_options(s: &mut Session, p: &Value) -> Result { + let o = s.global_edit_options.with(p, &s.doc()?.doc, OPTIONS)?; + s.global_edit_options = o.clone(); + Ok(o.json(match_count(s))) +} + +/// How many objects Start Global Edit would take now with the session's options (the Result(s) +/// line of the options): 0 unless one supported object is selected. +pub fn match_count(s: &Session) -> usize { + match s.active() { + Some(st) if st.global_edit.is_none() => matches(s, &s.global_edit_options).map_or(0, |m| m.len()), + Some(st) => st.global_edit.as_ref().map_or(0, |g| g.members.len()), + None => 0, + } +} + +/// Is `id` there, shown and unlocked, with every layer and group it is in? +fn editable(d: &Document, id: NodeId) -> bool { + d.ancestry(id).is_some_and(|a| a.iter().all(|i| d.node(*i).is_some_and(|n| n.visible && !n.locked && !n.is_template()))) +} + +/// After the document of `st` was put back to an earlier one (Undo, Redo, a cancelled interaction +/// or undo group, a failed or panicked command, a preview starting again, Discard Empty Type): the +/// members it lacks (or no longer supports) leave the Global Edit, so that an object made later +/// under a freed id is never edited; with fewer than two left, Global Edit ends. +pub(crate) fn follow_history(st: &mut crate::DocState) { + let Some(ge) = &mut st.global_edit else { return }; + let d = &st.doc; + ge.members.retain(|(id, _)| d.node(*id).is_some_and(supported)); + if ge.members.len() < 2 { + st.global_edit = None; + } +} + +/// What an edit changed about a member: what [`propagate`] gives the others. +#[derive(Default)] +struct Changed { + outline: bool, + /// The member's appearance before and after the edit, when it changed. + appearance: Option<[Shared; 2]>, + opacity: bool, + blend: bool, + isolate: bool, + knockout: bool, + knockout_shape: bool, +} + +impl Changed { + fn between(a: &Node, b: &Node) -> Self { + Self { + outline: a.kind != b.kind || a.bbox_angle != b.bbox_angle, + appearance: (a.appearance != b.appearance).then(|| [a.appearance.clone(), b.appearance.clone()]), + opacity: a.opacity != b.opacity, + blend: a.blend != b.blend, + isolate: a.isolate != b.isolate, + knockout: a.knockout != b.knockout, + knockout_shape: a.knockout_shape != b.knockout_shape, + } + } + + fn any(&self) -> bool { + self.outline || self.appearance.is_some() || self.opacity || self.blend || self.isolate || self.knockout || self.knockout_shape + } + + /// Give `n` what changed, from `model` (the changed member placed on `n`). + fn apply(&self, model: Node, n: &mut Node) { + if self.outline { + n.kind = model.kind; + n.bbox_angle = model.bbox_angle; + } + if let Some([a, b]) = &self.appearance { + give_appearance(a, b, &model.appearance, &mut n.appearance); + } + if self.opacity { + n.opacity = model.opacity; + } + if self.blend { + n.blend = model.blend; + } + if self.isolate { + n.isolate = model.isolate; + } + if self.knockout { + n.knockout = model.knockout; + } + if self.knockout_shape { + n.knockout_shape = model.knockout_shape; + } + } +} + +/// Set each field of `$n` that differs between `$a` and `$b` to the binding of the same name. +macro_rules! give_fields { + ($a:ident, $b:ident, $n:ident: $($f:ident),+) => { + $( + if $a.$f != $b.$f { + $n.$f = Clone::clone($f); + } + )+ + }; +} + +/// Give `n` what changed about the model's appearance from `a` (before the edit) to `b` (after), +/// with the values of `m` (`b` placed on `n`'s object); the module doc gives the rules. +fn give_appearance(a: &Appearance, b: &Appearance, m: &Appearance, n: &mut Shared) { + let Appearance { items, effects, contents_index } = m; + if a.effects != b.effects || a.contents_index != b.contents_index { + let n = &mut **n; + give_fields!(a, b, n: effects, contents_index); + } + if a.items == b.items { + return; + } + let kinds = |v: &[AppearanceItem]| v.iter().map(AppearanceItem::is_fill).collect::>(); + if kinds(&a.items) == kinds(&b.items) { + // Fills and strokes kept in order: each changed one goes to the member's of its kind and + // rank. + for (k, ((p, q), mq)) in a.items.iter().zip(&b.items).zip(items).enumerate() { + if p == q { + continue; + } + let rank = a.items.iter().take(k).filter(|x| x.is_fill() == p.is_fill()).count(); + if let Some(t) = n.items.iter_mut().filter(|x| x.is_fill() == p.is_fill()).nth(rank) { + give_item(p, q, mq, t); + } + } + return; + } + // One item added (`grew`) or removed at `at`, the others kept in order; the member must hold + // the model's former kinds in order. + let one = |long: &[AppearanceItem], short: &[AppearanceItem]| { + if long.len() != short.len() + 1 { + return None; + } + let at = long.iter().zip(short).position(|(x, y)| x != y).unwrap_or(short.len()); + let mut rest = kinds(long); + rest.remove(at); + (rest == kinds(short)).then_some(at) + }; + let step = match (one(&b.items, &a.items), one(&a.items, &b.items)) { + (Some(at), _) => Some((at, true)), + (None, Some(at)) => Some((at, false)), + _ => None, + }; + match step { + Some((at, grew)) if kinds(&n.items) == kinds(&a.items) => { + let n = &mut n.items; + for (i, (p, t)) in a.items.iter().zip(n.iter_mut()).enumerate() { + // The model's item after the edit that was `p` before it. + let j = match (grew, i < at) { + (_, true) => i, + (true, false) => i + 1, + (false, false) if i == at => continue, + (false, false) => i - 1, + }; + if let (Some(q), Some(mq)) = (b.items.get(j), items.get(j)) + && p != q + { + give_item(p, q, mq, t); + } + } + match (grew, items.get(at)) { + (true, Some(new)) => n.insert(at.min(n.len()), new.clone()), + (false, _) if at < n.len() => { + n.remove(at); + } + _ => {} + } + } + _ => n.items = items.clone(), + } +} + +/// Give the member's fill or stroke `n` the settings of the model's that changed from `a` to `b`, +/// with the values of `m` (`b` placed on the member). A stroke's pen pressure stays its own. +fn give_item(a: &AppearanceItem, b: &AppearanceItem, m: &AppearanceItem, n: &mut AppearanceItem) { + use AppearanceItem::{Fill, Stroke}; + match (a, b, m, n) { + (Fill(a), Fill(b), Fill(m), Fill(n)) => { + let FillLayer { paint, opacity, blend, visible, effects, overprint } = m; + give_fields!(a, b, n: paint, opacity, blend, visible, effects, overprint); + } + (Stroke(a), Stroke(b), Stroke(m), Stroke(n)) => { + let StrokeLayer { + paint, + width, + cap, + join, + miter_limit, + align, + dash, + start_arrow, + end_arrow, + arrow_scale, + profile, + brush, + opacity, + blend, + visible, + effects, + arrow_align, + overprint, + gradient_mode, + pressure: _, + } = m; + give_fields!(a, b, n: paint, width, cap, join, miter_limit, align, dash, start_arrow, end_arrow, arrow_scale, profile, brush, opacity, blend, visible, effects, arrow_align, overprint, gradient_mode); + } + _ => {} + } +} + +/// Give the children of a copy placed on another object ids of that object's: its own children's +/// in order, new ids for any more and for everything deeper. +fn renumber(children: &mut [std::sync::Arc], own: &[NodeId], d: &mut Document) { + for (i, c) in children.iter_mut().enumerate() { + let c = std::sync::Arc::make_mut(c); + c.id = own.get(i).copied().unwrap_or_else(|| d.alloc_id()); + if let Some(ch) = c.children_mut() { + renumber(ch, &[], d); + } + } +} + +/// After an edit made while Global Edit is on (`before` → `d`): the first member selected before +/// or after the edit that it changed is the model, and every other member that is shown and +/// unlocked gets what changed about it, placed by the members' transforms. → the members it +/// changed so (which [`Session::edit`] checks stay within the canvas, as it checks the selection). +pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, selected: [&[NodeId]; 2]) -> Vec { + let model = ge.members.iter().filter(|(id, _)| selected.iter().any(|s| s.contains(id))).find_map(|(id, xf)| { + let (a, b) = (before.node(*id)?, d.node(*id)?); + if std::ptr::eq(a, b) { + return None; + } + let changed = Changed::between(a, b); + changed.any().then(|| (*id, *xf, changed, b.clone())) + }); + let Some((model_id, model_xf, changed, model)) = model else { return vec![] }; + let back = model_xf.inverse(); + let mut edited = vec![]; + for (id, xf) in &ge.members { + if *id == model_id || !editable(d, *id) { + continue; + } + let mut placed = model.clone(); + placed.transform(*xf * back, Scaling::default()); + if changed.outline { + let own: Vec = d.node(*id).and_then(Node::children).map(|c| c.iter().map(|c| c.id).collect()).unwrap_or_default(); + if let Some(ch) = placed.children_mut() { + renumber(ch, &own, d); + } + } + if let Some(n) = d.node_mut(*id) { + changed.apply(placed, n); + edited.push(*id); + } + } + edited +} + +/// The outlines of the members of the active document's Global Edit that are not selected, in +/// their layer's colour: the canvas highlights them while Global Edit is on. +pub fn highlights(s: &Session) -> Outlines { + let Some(st) = s.active() else { return vec![] }; + let Some(ge) = &st.global_edit else { return vec![] }; + if let Some((rev, out)) = &*ge.outlines.borrow() + && *rev == st.revision + { + return out.clone(); + } + let symbols = st.doc.symbol_boxes(); + let out: Vec<_> = ge + .members + .iter() + .filter(|(id, _)| !st.selection.contains(*id)) + .filter_map(|(id, _)| { + let n = st.doc.node(*id)?; + let path = match n.stroke_path() { + Some(p) => p, + None => n.geometric_bounds_with(&symbols)?.to_path(0.1), + }; + Some((path, st.doc.layer_color(*id))) + }) + .collect(); + *ge.outlines.borrow_mut() = Some((st.revision, out.clone())); + out +} diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 9d48231d6..603a2d09c 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -1,7 +1,7 @@ //! The Gradient panel and the Gradient tool: in-place gradient edits and the gradient vector. use serde_json::{Value, json}; -use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::appearance::stroke_paint_bounds; use vectorcraft_doc::{Document, Node, NodeKind, StrokeGradientMode}; use vectorcraft_geom::{Affine, Point, Rect}; @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|freeform (freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even, which SVG and PDF exports draw as an image; freeform gradients mix their own way), stops?: [{offset 0..1, color? (needed without swatch), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg, aspect?: %, reverse?: bool, dither?: bool (fills only: a linear or radial gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", has_doc, edit_gradient ), @@ -60,7 +60,7 @@ fn select_stop(s: &mut Session, p: &Value) -> Result { None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")), Some(Value::Null) => None, Some(v) => { - let i = v.as_u64().ok_or_else(|| bad(C, "`index` must be a whole number or null"))? as usize; + let i = v.as_u64().map(index).ok_or_else(|| bad(C, "`index` must be a whole number or null"))?; let n = active_gradient(s).ok_or_else(|| bad(C, "the active paint is not a gradient"))?.gradient.stops.len(); if i >= n { return Err(bad(C, format!("no stop {i} (the gradient has {n})"))); @@ -105,6 +105,14 @@ fn parse_kind(k: &str) -> Parsed { GradientKind::parse(k).ok_or_else(|| format!("unknown gradient kind `{k}` (linear, radial, freeform)")) } +/// The `method` param of `p`, if given: how the colours between the stops mix. +fn method_param(p: &Value) -> Parsed> { + match p.get("method") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(GradientMethod::parse).map(Some).ok_or_else(|| "`method` must be classic or perceptual".into()), + } +} + /// `v` (params holding `stops`) with each stop that names a `swatch` resolved through `d` /// ([`swatch_solid`]): a global or spot colour or a tint swatch gives the stop its colour, link and /// tint, a process colour just its colour. A stop linked to a swatch that no longer exists keeps @@ -117,7 +125,12 @@ pub(crate) fn link_stops(d: &Document, v: &Value) -> Parsed { if d.swatch(&name).is_none() && st.get("color").is_some() { continue; } - let tint = st.get("tint").and_then(Value::as_f64).map(|t| (t / 100.0).clamp(0.0, 1.0) as f32); + let tint = st + .get("tint") + .filter(|v| !v.is_null()) + .map(|v| v.as_f64().ok_or_else(|| format!("stop {i}: `{k}` must be a number", k = "tint"))) + .transpose()?; + let tint = tint.map(|t| (t / 100.0).clamp(0.0, 1.0) as f32); let Paint::Solid { color, swatch, tint } = swatch_solid(d, &name, tint).map_err(|e| format!("stop {i}: {e}"))? else { continue; }; @@ -219,7 +232,12 @@ pub(crate) fn parse_gradient(g: &Value) -> Parsed { } let kind = str_param(g, "kind").map(parse_kind).transpose()?.unwrap_or_default(); let stops = g.get("stops").map(parse_stops).transpose()?.unwrap_or_else(|| Gradient::default().stops); - let mut gp = GradientPaint::new(Gradient { kind, stops }); + let mut gp = GradientPaint::new(Gradient::new(kind, stops)); + gp.gradient.dither = match g.get("dither") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or("`dither` must be true or false")?, + }; + gp.gradient.method = method_param(g)?.unwrap_or_default(); gp.angle = f64_or(g, "angle", 0.0); gp.swatch = str_param(g, "swatch").map(str::to_string); let geom = GeomEdit::parse(g)?; @@ -314,6 +332,12 @@ pub(crate) fn apply_gradient_edit_in( // Midpoints mirrored with their segments. gp.gradient.reverse(); } + if let Some(d) = p.get("dither").and_then(Value::as_bool) { + gp.gradient.dither = d; + } + if let Some(m) = method_param(p)? { + gp.gradient.method = m; + } // Angle and aspect edits keep the focal point in its place in the extent ellipse. let before = gp.geom; if let Some(a) = p.get("angle").and_then(Value::as_f64) { @@ -372,7 +396,9 @@ pub(crate) fn apply_gradient_edit_in( fn edit_gradient(s: &mut Session, p: &Value) -> Result { const C: &str = "paint.editGradient"; let item = item_target(s, p, C)?; - let stroke = edits_stroke(s, p, item, !s.fill_active)?; + let stroke = edits_stroke(s, p, item, !s.fill_active, C)?; + check_switches(p, &["reverse", "dither"], C)?; + check_numbers(p, &["angle"], C)?; let item = item.of_kind(s, !stroke); let ids = item.targets(s, p)?; // How the gradient lies on a stroke (the panel's Stroke buttons). @@ -381,6 +407,10 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result { Some(_) if !stroke => return Err(bad(C, "`strokeMode` applies to strokes (stroke: true)")), Some(m) => Some(StrokeGradientMode::parse(m).ok_or_else(|| bad(C, format!("strokeMode must be within|along|across, got {m}")))?), }; + if p.get("dither").and_then(Value::as_bool).is_some() && stroke { + return Err(bad(C, "`dither` applies to gradient fills (stroke: false)")); + } + super::paint::check_keep_model(p, C)?; // New RGB stop colors take the document's model; those of the swatches the stops name don't. let stored = stop_colors(s, p, p); // Stops naming swatches take their colours from them. @@ -553,7 +583,7 @@ fn set_gradient_geom(s: &mut Session, p: &Value) -> Result { } let p = &p; let item = item_target(s, p, C)?; - let stroke = edits_stroke(s, p, item, !s.fill_active)?; + let stroke = edits_stroke(s, p, item, !s.fill_active, C)?; let item = item.of_kind(s, !stroke); let targets = match item { ItemTarget::Top => leaf_targets(s, &ids_param(p, "ids").map_or_else(|| selected_roots(s), |ids| Ok(outermost(&s.doc()?.doc, ids)))?)?, diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 5e06ca3e1..3681aa261 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -62,7 +62,7 @@ pub fn specs() -> Vec { "Column…", ["Object", "Graph"], None, - "{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}", + "{id?, seriesIndexes?: [index], design?: name|null, columnType?: vertical|uniform|repeating|sliding, each?: value (more than 0), fractions?: chop|scale} draw the columns or bars and the legend swatch of the series selected with Group Selection (or listed, or every series) with a graph design, its backmost object as its frame and its guides left out: vertical stretches it along the value axis to each column's length at the column's width, uniform scales it both ways to that length, centered on the column, repeating stacks copies out from the zero line that each stand for `each` and cuts off the last one where the value ends (chop) or scales it down to fit (scale) (vertical when one copy would be longer than the canvas), and sliding scales the part above its horizontal guide line (in bar graphs, right of a vertical one) in proportion to the column's width and stretches the part below to fill the rest (vertical without a guide, or in a column too short for the part above); type in the design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits, the first digit giving the least number of places before the decimal point (zeros added in front; 0 = as many as the value needs) and the second digit giving the places after it (rounded half away from zero), at the type's own size where the design's copy puts it (a repeating design shows it once, where a whole copy ending at the value end puts it; type inside a group that clips, is hidden or has its own opacity, blend mode, isolation, knockout, appearance or opacity mask, or inside a blend, compound shape, envelope, repeat or intertwine, stays there and is filled in each copy, drawn with the design; type inside a symbol stays as typed), and is left out of the legend swatch (a design of such type alone gets the default swatch); the design keeps its own orientation for negative values, and columns drawn with a design cast no drop shadow; null = plain columns; options left out keep the series' shared ones, else vertical, 10 and chop. Column, stacked column, bar and stacked bar series. No design → {designs: [names], design: the selected series' design or null, columnType, each, fractions}", has_selection, set_column ), @@ -373,6 +373,7 @@ impl Gen<'_> { let style = label_style(self.styles, role); let mut t = TextObject::point(at, s, style); t.para.justify = justify; + t.system_layout = self.d.setup.system_layout; refresh_bounds(&mut t); Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t)))) } @@ -413,10 +414,11 @@ impl Gen<'_> { None => self.path(shapes::rectangle(cell), fill, stroke, w), } } - /// A legend swatch filling `cell`: the series' column design, else its [`Self::marker`]. + /// A legend swatch filling `cell`: the series' column design with its totals left out, else, and when the design + /// draws nothing else, its [`Self::marker`]. fn swatch(&mut self, column: Option<&ColumnArt>, marker: Option<&Arc>, cell: Rect, paint: (Paint, Paint, f64)) -> Arc { - let fitted = match column { - Some(c) if self.columns.take(c.art.count()) => fit_design(self.d, &c.art, cell), + let fitted = match column.and_then(|c| Some((c, c.legend.as_ref()?))) { + Some((c, legend)) if self.columns.take(legend.count()) => c.fit(self.d, legend, cell), _ => None, }; match fitted { @@ -552,12 +554,164 @@ fn fit_affine(frame: Rect, cell: Rect) -> Affine { /// Most objects a graph's column designs may add (each column, repeated copy and legend swatch draws a copy). const COLUMN_BUDGET: usize = 200_000; -/// A series' column design (Object › Graph › Column): its options, its art without guide lines, and the bounds of -/// those guides. +/// A series' column design (Object › Graph › Column): its options; its art without guide lines and without the totals +/// taken out of it ([`without_totals`]); the bounds of those guides; those totals; its frame (the art's without guides, +/// totals included, [`design_frame`]); whether its art still holds totals (inside groups that do more than group, or +/// inside other objects that hold objects), which are filled where they are; and its art without any total for the +/// legend swatch, `None` when that draws nothing. struct ColumnArt { options: ColumnDesign, art: Node, guides: Vec, + totals: Vec, + frame: Option, + in_place: bool, + legend: Option, +} + +impl ColumnArt { + /// A copy of `art` with the design's frame fitted to `cell`, as [`fit_design`] fits it. Fresh ids. + fn fit(&self, d: &mut Document, art: &Node, cell: Rect) -> Option> { + let art = super::place::transformed(art.clone(), fit_affine(self.frame?, cell)); + Some(Arc::new(d.reid(&art))) + } +} + +/// The column design totals in `s` (Add totals to a column design): each `%` followed by two digits, as its byte offset, +/// the places before the decimal point (the first digit) and the places after it (the second), left to right. A `%` not +/// followed by two digits is plain text. +fn total_spans(s: &str) -> Vec<(usize, usize, usize)> { + let b = s.as_bytes(); + let mut out = vec![]; + let mut i = 0; + while let Some(w) = b.get(i..i + 3) { + match *w { + [b'%', x, y] if x.is_ascii_digit() && y.is_ascii_digit() => { + out.push((i, usize::from(x - b'0'), usize::from(y - b'0'))); + i += 3; + } + _ => i += 1, + } + } + out +} + +/// Value `v` as a column design total shows it: rounded half away from zero, from its shortest decimal form, to `after` +/// places after the decimal point, zeros added as needed; and with at least `before` places before the point, zeros +/// added in front (0: as many as the value needs). The places before the point are never cut, and a value that rounds +/// to 0 has no minus sign. +fn total_text(v: f64, before: usize, after: usize) -> String { + if !v.is_finite() { + return fmt_value(v); + } + let s = format!("{}", v.abs()); + let (int, frac) = s.split_once('.').unwrap_or((s.as_str(), "")); + // The digits kept, the point `after` places from the end. + let mut digits: Vec = int.bytes().chain(frac.bytes().chain(std::iter::repeat(b'0')).take(after)).collect(); + if frac.as_bytes().get(after).is_some_and(|d| *d >= b'5') { + let mut carry = true; + for d in digits.iter_mut().rev() { + if *d == b'9' { + *d = b'0'; + } else { + *d += 1; + carry = false; + break; + } + } + if carry { + digits.insert(0, b'1'); + } + } + let (whole, places) = digits.split_at(digits.len().saturating_sub(after)); + let mut out = String::new(); + if v < 0.0 && digits.iter().any(|d| *d != b'0') { + out.push('-'); + } + out.extend(std::iter::repeat_n('0', before.saturating_sub(whole.len()))); + out.extend(whole.iter().map(|d| char::from(*d))); + if after > 0 { + out.push('.'); + out.extend(places.iter().map(|d| char::from(*d))); + } + out +} + +/// Type `t` with each total in it ([`total_spans`]) replaced by value `v` ([`total_text`]), styled as its `%` was; true +/// when it held one. +fn fill_totals(t: &mut TextObject, v: f64) -> bool { + let spans = total_spans(&t.plain_text()); + // Last first, so the offsets of earlier ones stay valid. + for &(at, before, after) in spans.iter().rev() { + let s = total_text(v, before, after); + t.splice_paras(at, at + 3, &s); + vectorcraft_text::edit::replace_range(&mut t.runs, at, at + 3, &s); + } + if !spans.is_empty() { + refresh_bounds(t); + } + !spans.is_empty() +} + +/// Is `n` type that holds a total ([`total_spans`])? +fn holds_total(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Text(t) if !total_spans(&t.plain_text()).is_empty()) +} + +/// Does group `n` only group its children: no clipping, hiding, opacity, blend mode, isolation, knockout, appearance of +/// its own or opacity mask, so that type taken out of it draws as it did inside it? +fn only_groups(n: &Node) -> bool { + matches!(n.kind, NodeKind::Group { clip: false, .. }) + && n.visible + && n.opacity == 1.0 + && n.blend == vectorcraft_color::BlendMode::Normal + && !n.isolate + && n.knockout == vectorcraft_doc::Knockout::default() + && n.appearance.items.is_empty() + && n.appearance.effects.is_empty() + && n.extras().mask.is_none() +} + +/// Is `n` a clipping group whose clipping path is type that holds a total? +fn clipped_by_total(n: &Node) -> bool { + matches!(&n.kind, NodeKind::Group { clip: true, children } if children.first().is_some_and(|c| holds_total(c))) +} + +/// `art` without the type in it that holds a total, inside groups at any depth that only group ([`only_groups`]), and +/// that type, pushed to `totals` in paint order (Add totals to a column design); with `all`, without every total inside +/// any group or other object that holds objects (a blend, compound shape, envelope, repeat or intertwine), and with +/// each group that such type clips left empty. A design that is such type alone leaves an empty group. +fn without_totals(art: &Node, all: bool, totals: &mut Vec) -> Node { + if holds_total(art) || (all && clipped_by_total(art)) { + totals.push(art.clone()); + return Node::group(art.id, vec![]); + } + let mut n = art.clone(); + let take = all || only_groups(&n); + if take && let Some(children) = n.children_mut() { + for c in std::mem::take(children) { + if holds_total(&c) { + totals.push((*c).clone()); + } else if c.children().is_some() { + children.push(Arc::new(without_totals(&c, all, totals))); + } else { + children.push(c); + } + } + } + n +} + +/// `n` with the totals in it at any depth, in groups and the other objects that hold objects, showing value `v` where +/// they are ([`fill_totals`]). +fn fill_in_place(n: &mut Node, v: f64) { + if let NodeKind::Text(t) = &mut n.kind { + fill_totals(t, v); + } else if let Some(children) = n.children_mut() { + for c in children.iter_mut() { + fill_in_place(Arc::make_mut(c), v); + } + } } /// `art` without the paths made into guides, at any depth. @@ -585,7 +739,16 @@ fn column_designs(d: &Document, g: &GraphSpec) -> Vec> { guides.push(b); } }); - Some(ColumnArt { options: options.clone(), art: without_guides(art), guides }) + let art = without_guides(art); + let frame = design_frame(&art); + let legend = without_totals(&art, true, &mut vec![]); + let mut draws = false; + legend.walk(&mut |n| draws |= n.children().is_none()); + let mut totals = vec![]; + let art = without_totals(&art, false, &mut totals); + let mut in_place = false; + art.walk(&mut |n| in_place |= holds_total(n)); + Some(ColumnArt { options: options.clone(), art, guides, totals, frame, in_place, legend: draws.then_some(legend) }) }) .collect() } @@ -621,30 +784,42 @@ impl Allowance { } } -/// Column or bar `rect` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, its copies -/// spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a column above -/// zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot points per -/// unit of value. `None` for art with no area, for a column of no length and when `left` runs out, which are drawn plain. +/// Column or bar `rect` of value `value` drawn with column design `c` (Object › Graph › Column), as a group of fresh art, +/// its copies spent from `left`. `far_low`: the value end of the column is at the rect's lower coordinate (the top of a +/// column above zero, the left end of a bar below zero), so repeating copies stack from the other end. `per_unit`: plot +/// points per unit of value. The design's totals show `value` and come last: each keeps its size and is moved to where +/// the design's copy puts the point it is set from; in a repeating design they are drawn once, where a whole copy ending +/// at the value end puts them, and a sliding design moves each with the part of the design on its side of the guide. +/// Totals left in the art ([`ColumnArt`]) show `value` in each copy, drawn with it. `None` for art with no area, for a +/// column of no length and when `left` runs out, which are drawn plain. fn column_art( d: &mut Document, c: &ColumnArt, - rect: Rect, + (rect, value): (Rect, f64), (horizontal, far_low): (bool, bool), per_unit: f64, left: &mut Allowance, ) -> Option> { - let frame = design_frame(&c.art)?; + let frame = c.frame?; let length = |r: Rect| if horizontal { r.width() } else { r.height() }; let thickness = |r: Rect| if horizontal { r.height() } else { r.width() }; if length(rect) <= 1e-9 || !length(rect).is_finite() { return None; } let cost = c.art.count(); - // A copy of the design fitted to `cell`; and a copy moved by `a` and clipped to what it paints, cut at `at` (an x when - // `along_x`, else a y) with the side below `at` kept when `keep_low` (two objects more). - let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| if left.take(cost) { fit_design(d, &c.art, cell) } else { None }; + // A copy of the design moved by `a`, the totals left in it showing `value`; a copy fitted to `cell`; and a copy moved + // by `a` and clipped to what it paints, cut at `at` (an x when `along_x`, else a y) with the side below `at` kept when + // `keep_low` (two objects more). + let copy = |d: &mut Document, a: Affine| { + let mut art = d.reid(&super::place::transformed(c.art.clone(), a)); + if c.in_place { + fill_in_place(&mut art, value); + } + art + }; + let fit = |d: &mut Document, cell: Rect, left: &mut Allowance| left.take(cost).then(|| Arc::new(copy(d, fit_affine(frame, cell)))); let clipped = |d: &mut Document, a: Affine, (along_x, keep_low, at): (bool, bool, f64), left: &mut Allowance| { - let art = left.take(cost + 2).then(|| d.reid(&super::place::transformed(c.art.clone(), a)))?; + let art = left.take(cost + 2).then(|| copy(d, a))?; // The box of the copy and of each object in it that isn't a group or layer, as the brush art past a brushed // stroke counts only in its own object's box. let mut b = vectorcraft_render::painted_bounds(d, &art); @@ -653,7 +828,8 @@ fn column_art( b = vectorcraft_geom::union_opt(b, vectorcraft_render::painted_bounds(d, n)); } }); - let b = b.or_else(|| art.geometric_bounds())?; + // A copy that draws nothing (a design of totals alone) needs no clip. + let Some(b) = b.or_else(|| art.geometric_bounds()) else { return Some(Arc::new(art)) }; let keep = match (along_x, keep_low) { (true, true) => Rect::new(b.x0, b.y0, at.max(b.x0), b.y1), (true, false) => Rect::new(at.min(b.x1), b.y0, b.x1, b.y1), @@ -669,7 +845,35 @@ fn column_art( (false, true) => Rect::new(rect.x0, rect.y1 - b, rect.x1, rect.y1 - a), (false, false) => Rect::new(rect.x0, rect.y0 + a, rect.x1, rect.y0 + b), }; + // The column's group: `parts`, then the totals, each showing `value` at its own size, moved as `a` moves the point it + // is set from; with `sliding` (the kept part's map, the stretched part's map and the guide, as [`slide`] gives them), + // as the map of the part on its side of the guide moves it. One object each. + let group = |d: &mut Document, mut parts: Vec>, a: Affine, sliding: Option<(Affine, Affine, f64)>, left: &mut Allowance| { + for t in &c.totals { + if !left.take(1) { + return None; + } + let mut n = t.clone(); + let mut by = Affine::IDENTITY; + if let NodeKind::Text(text) = &mut n.kind { + // A move only, so the type keeps its size; pattern paints move with it. + let p = text.xf * Point::ZERO; + let a = match sliding { + Some((kept, stretched, at)) => { + let q = kept * p; + if (horizontal && q.x >= at) || (!horizontal && q.y <= at) { kept } else { stretched } + } + None => a, + }; + fill_totals(text, value); + by = Affine::translate(a * p - p); + } + parts.push(Arc::new(d.reid(&super::place::transformed(n, by)))); + } + Some(Arc::new(Node::group(d.alloc_id(), parts))) + }; let mut parts = vec![]; + let (mut label, mut sliding) = (fit_affine(frame, rect), None); match c.options.column_type { ColumnType::Uniform => { // Scaled both ways to the column's length, centered across it. @@ -680,14 +884,17 @@ fn column_art( Rect::new(rect.center().x - t, rect.y0, rect.center().x + t, rect.y1) }; parts.push(fit(d, cell, left)?); + label = fit_affine(frame, cell); } ColumnType::Repeating => { // When the value per copy is unusable, too small to draw or makes one copy longer than the canvas, the design // is drawn vertically scaled. let Some(unit) = repeat_each(&c.options).map(|e| e * per_unit).filter(|u| u.is_finite() && *u > 1e-9 && *u <= crate::MAX_COORD) else { parts.push(fit(d, rect, left)?); - return Some(Arc::new(Node::group(d.alloc_id(), parts))); + return group(d, parts, label, None, left); }; + // The totals go where a whole copy ending at the value end puts them. + label = fit_affine(frame, piece(length(rect) - unit, length(rect))); // The whole copies, and the points of the column left for the last one (none under a millionth of a point). let whole = ((length(rect) + 1e-6) / unit).floor(); let rest = length(rect) - whole * unit; @@ -717,12 +924,13 @@ fn column_art( // Each copy cut at the guide, on its own side. parts.push(clipped(d, kept, (horizontal, !horizontal, at), left)?); parts.push(clipped(d, stretched, (horizontal, horizontal, at), left)?); + sliding = Some((kept, stretched, at)); } None => parts.push(fit(d, rect, left)?), }, ColumnType::Vertical => parts.push(fit(d, rect, left)?), } - Some(Arc::new(Node::group(d.alloc_id(), parts))) + group(d, parts, label, sliding, left) } /// Sliding design with frame `frame` in column `rect`, at the column's width: the map of the part above the first @@ -1240,7 +1448,8 @@ fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option { // A design can reach past its column (Uniformly Scaled), so the legend keeps clear of it too. if let Some(art) = n.geometric_bounds() { @@ -1281,7 +1490,7 @@ fn generate_with(d: &mut Document, g: &GraphSpec, column_looks: &[Option { if let Some(art) = n.geometric_bounds() { bars_reach = Some(bars_reach.map_or(art, |r| r.union(art))); @@ -4100,6 +4309,315 @@ mod tests { } } + /// A column design with totals: a 10 × 10 rectangle at the back, type "%31" set from 2 points above its top, and a + /// group of type "Votes" inside it and type "%02 and %00" set from 4 points below it, with a horizontal guide line 5 + /// points from the top when `guide`, saved as `name`. Returns the points the two totals are set from. + fn save_totals_design(s: &mut Session, name: &str, guide: bool) -> [vectorcraft_geom::Point; 2] { + let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); + let above = id_of(s.execute("text.create", &json!({"x": 600, "y": 598, "text": "%31", "size": 4})).unwrap()); + let votes = id_of(s.execute("text.create", &json!({"x": 601, "y": 606, "text": "Votes", "size": 3})).unwrap()); + let below = id_of(s.execute("text.create", &json!({"x": 600, "y": 614, "text": "%02 and %00", "size": 4})).unwrap()); + let origin = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.xf * vectorcraft_geom::Point::ZERO, + k => panic!("type: {k:?}"), + }; + let points = [origin(s, above), origin(s, below)]; + s.execute("select.set", &json!({"ids": [votes, below]})).unwrap(); + let inner = id_of(s.execute("object.group", &json!({})).unwrap()); + let mut ids = vec![frame, above, inner]; + if guide { + let line = id_of(s.execute("shape.line", &json!({"x1": 600, "y1": 605, "x2": 610, "y2": 605})).unwrap()); + s.execute("select.set", &json!({"ids": [line]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + ids.push(line); + } + s.execute("select.set", &json!({"ids": ids})).unwrap(); + s.execute("graph.design", &json!({"new": name})).unwrap(); + points + } + + /// The text of each piece of type inside `n`, in paint order. + fn texts_in(n: &vectorcraft_doc::Node) -> Vec { + let mut out = vec![]; + n.walk(&mut |c| { + if let NodeKind::Text(t) = &c.kind { + out.push(t.plain_text()); + } + }); + out + } + + /// The type among `nodes`, each as its text and the point it is set from; each must be type at its own size. + fn totals_of(nodes: &[std::sync::Arc]) -> Vec<(String, vectorcraft_geom::Point)> { + nodes + .iter() + .map(|n| match &n.kind { + NodeKind::Text(t) => { + let [a, b, c, d, x, y] = t.xf.as_coeffs(); + assert_eq!([a, b, c, d], [1.0, 0.0, 0.0, 1.0], "a total keeps its size: {:?}", t.xf); + (t.plain_text(), vectorcraft_geom::Point::new(x, y)) + } + k => panic!("a total: {k:?}"), + }) + .collect() + } + + fn close(a: vectorcraft_geom::Point, b: vectorcraft_geom::Point) -> bool { + (a - b).hypot() < 1e-6 + } + + #[test] + fn column_design_totals_show_each_columns_value_at_their_own_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let [above, below] = save_totals_design(&mut s, "Ballot", false); + let id = one_series(&mut s, "column", &[35.0, 7.256]); + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot"})).unwrap(); + let check = |s: &Session, want: [[&str; 2]; 2]| { + let parts = series_parts(s, id, 0); + for (column, want) in parts.iter().zip(want) { + let children = column.children().unwrap(); + // The design's copy keeps the type without a total, stretched with the design, and leaves the totals out. + assert_eq!(texts_in(&children[0]), ["Votes"]); + let frame = frames(column)[0]; + let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); + let totals = totals_of(&children[1..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), want); + // Each set from where the design's copy puts the point it was set from. + assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); + } + // The legend swatch keeps the type without a total and leaves the totals out. + assert_eq!(texts_in(parts.last().unwrap()), ["Votes"]); + }; + check(&s, [["035.0", "35.00 and 35"], ["007.3", "7.26 and 7"]]); + // New data draws the new values. + s.execute("graph.setData", &json!({"id": id.0, "rows": [[20], [0.004]]})).unwrap(); + check(&s, [["020.0", "20.00 and 20"], ["000.0", "0.00 and 0"]]); + // The design in the Graph Design list is left as it was. + assert_eq!(texts_in(&s.doc().unwrap().doc.graph_designs[0].art), ["%31", "Votes", "%02 and %00"]); + // A bar below zero shows its minus sign, its totals moved with the design along the bar. + let bars = one_series(&mut s, "bar", &[-15.0]); + s.execute("graph.setType", &json!({"id": bars.0, "axisMin": -50, "axisMax": 50})).unwrap(); + s.execute("graph.column", &json!({"id": bars.0, "design": "Ballot"})).unwrap(); + let bar = series_parts(&s, bars, 0)[0].clone(); + let frame = frames(&bar)[0]; + let (sx, sy) = (frame.width() / 10.0, frame.height() / 10.0); + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(frame.x0 + (p.x - 600.0) * sx, frame.y0 + (p.y - 600.0) * sy); + let totals = totals_of(&bar.children().unwrap()[1..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["-015.0", "-15.00 and -15"]); + assert!((frame.x1 - 250.0).abs() < 1e-6 && close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?} {frame:?}"); + // A design that is a single piece of type with a total shows the value alone, and its legend swatch is the + // default square. + let one = s.execute("text.create", &json!({"x": 600, "y": 700, "text": "%00", "size": 4})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [one]})).unwrap(); + s.execute("graph.design", &json!({"new": "Count"})).unwrap(); + let count = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": count.0, "design": "Count"})).unwrap(); + let parts = series_parts(&s, count, 0); + assert_eq!(texts_in(&parts[0]), ["35"]); + assert_eq!(totals_of(&parts[0].children().unwrap()[1..]).len(), 1); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); + // Repeating, with a last copy to chop, it still shows the value. + s.execute("graph.column", &json!({"id": count.0, "design": "Count", "columnType": "repeating", "each": 10})).unwrap(); + let column = series_parts(&s, count, 0)[0].clone(); + assert!(matches!(column.kind, NodeKind::Group { .. }) && texts_in(&column) == ["35"], "{:?}", column.kind); + } + + #[test] + fn a_repeating_design_shows_its_totals_once_at_the_value_end_and_a_sliding_one_beside_its_part() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let [above, below] = save_totals_design(&mut s, "Ballot", true); + let id = one_series(&mut s, "column", &[35.0]); + let plain = series_parts(&s, id, 0)[0].geometric_bounds().unwrap(); + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "repeating", "each": 10})).unwrap(); + let column = series_parts(&s, id, 0)[0].clone(); + let children = column.children().unwrap(); + // Three whole copies and the one cut off at the value, none with a total; then the two totals, not cut. + assert_eq!(children.len(), 6, "{children:?}"); + assert!(matches!(children[3].kind, NodeKind::Group { clip: true, .. }), "the last copy is clipped"); + assert!(children[..4].iter().all(|c| texts_in(c) == ["Votes"])); + // Set as in a whole copy that ends at the value: 40 points tall from y 160, as wide as the column. + let sx = plain.width() / 10.0; + let at = |p: vectorcraft_geom::Point| vectorcraft_geom::Point::new(plain.x0 + (p.x - 600.0) * sx, 160.0 + (p.y - 600.0) * 4.0); + let totals = totals_of(&children[4..]); + assert_eq!(totals.iter().map(|(t, _)| t.as_str()).collect::>(), ["035.0", "35.00 and 35"]); + assert!(close(totals[0].1, at(above)) && close(totals[1].1, at(below)), "{totals:?}"); + // Sliding: the total above the guide moves with the part kept in proportion at the value end, the one below the + // design with the part stretched down to the zero line. + s.execute("graph.column", &json!({"id": id.0, "design": "Ballot", "columnType": "sliding"})).unwrap(); + let column = series_parts(&s, id, 0)[0].clone(); + let children = column.children().unwrap(); + assert_eq!(children.len(), 4, "two halves and two totals: {children:?}"); + let k = (140.0 - 5.0 * sx) / 5.0; + let totals = totals_of(&children[2..]); + let kept = vectorcraft_geom::Point::new(plain.x0 + (above.x - 600.0) * sx, 160.0 + (above.y - 600.0) * sx); + let stretched = vectorcraft_geom::Point::new(plain.x0 + (below.x - 600.0) * sx, 300.0 + (below.y - 610.0) * k); + assert!(close(totals[0].1, kept) && close(totals[1].1, stretched), "{totals:?} {kept:?} {stretched:?}"); + // A total's pattern fill moves with it. + let mut d = vectorcraft_doc::Document::new(800.0, 800.0); + let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + let frame = vectorcraft_doc::Node::path(d.alloc_id(), square, vectorcraft_doc::Appearance::basic(Paint::None, Paint::None, 0.0)); + let origin = vectorcraft_geom::Point::new(0.0, -2.0); + let t = vectorcraft_doc::TextObject::point(origin, "%00", CharStyle::default()); + let mut total = vectorcraft_doc::Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let dots = Paint::Pattern { pattern: "Dots".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + total.appearance = vectorcraft_doc::Appearance::basic(dots, Paint::None, 0.0).into(); + let art = vectorcraft_doc::Node::group(d.alloc_id(), vec![std::sync::Arc::new(frame), std::sync::Arc::new(total)]); + d.graph_designs.push(vectorcraft_doc::GraphDesign { name: "Dotted".into(), art: std::sync::Arc::new(art) }); + let options = ColumnDesign { design: "Dotted".into(), ..ColumnDesign::default() }; + let g = GraphSpec { + rows: vec![vec![35.0]], + axis_min: Some(0.0), + axis_max: Some(50.0), + series_columns: vec![Some(options)], + ..GraphSpec::default() + }; + let out = super::generate(&mut d, &g); + let column = out.iter().find(|n| n.extras().series_index == Some(0)).unwrap().children().unwrap()[0].clone(); + let (text, at) = totals_of(&column.children().unwrap()[1..]).remove(0); + assert_eq!(text, "35"); + let Paint::Pattern { xf, .. } = column.children().unwrap()[1].appearance.fill_paint() else { panic!("a pattern fill") }; + assert!((xf * origin - at).hypot() < 1e-6 && (at - origin).hypot() > 1.0, "{xf:?} {at:?}"); + } + + #[test] + fn totals_inside_a_group_with_a_look_of_its_own_show_the_value_where_they_are() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap(); + let id_of = |v: serde_json::Value| v["id"].as_u64().unwrap(); + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()); + let total = id_of(s.execute("text.create", &json!({"x": 600, "y": 605, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [total]})).unwrap(); + let faded = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [faded]})).unwrap(); + s.execute("transparency.set", &json!({"opacity": 50})).unwrap(); + s.execute("select.set", &json!({"ids": [frame, faded]})).unwrap(); + s.execute("graph.design", &json!({"new": "Faded"})).unwrap(); + let id = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": id.0, "design": "Faded", "columnType": "repeating", "each": 10})).unwrap(); + // The total stays in its half-opaque group: each of the four copies shows the value there. + let parts = series_parts(&s, id, 0); + let copies = parts[0].children().unwrap(); + assert_eq!(copies.len(), 4, "{copies:?}"); + let mut faded_groups = 0; + for c in copies { + assert_eq!(texts_in(c), ["35"]); + c.walk(&mut |n| faded_groups += usize::from(matches!(n.kind, NodeKind::Group { .. }) && (n.opacity - 0.5).abs() < 1e-6)); + } + assert_eq!(faded_groups, 4); + // The legend swatch shows the design without it. + let swatch = parts.last().unwrap(); + assert!(matches!(swatch.kind, NodeKind::Group { .. }) && texts_in(swatch).is_empty(), "{:?}", swatch.kind); + // Type with a total that clips other art clips it with the value in each copy, and the legend swatch leaves out + // what it clips. + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); + let inside = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 650, "width": 10, "height": 10})).unwrap()); + let mask = id_of(s.execute("text.create", &json!({"x": 600, "y": 658, "text": "%00", "size": 8})).unwrap()); + s.execute("select.set", &json!({"ids": [inside, mask]})).unwrap(); + let clipped = id_of(s.execute("object.clippingMask.make", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [frame, clipped]})).unwrap(); + s.execute("graph.design", &json!({"new": "Masked"})).unwrap(); + let masked = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": masked.0, "design": "Masked"})).unwrap(); + let parts = series_parts(&s, masked, 0); + let clip_groups = |n: &vectorcraft_doc::Node| { + let mut out = vec![]; + n.walk(&mut |c| { + if let NodeKind::Group { clip: true, children } = &c.kind { + out.push(children.first().map(|m| texts_in(m)).unwrap_or_default()); + } + }); + out + }; + assert_eq!(clip_groups(&parts[0]), [["35"]], "the copy's clipping type shows the value"); + let swatch = parts.last().unwrap(); + let mut drawn = 0; + swatch.walk(&mut |n| drawn += usize::from(!matches!(n.kind, NodeKind::Group { .. }))); + assert!(clip_groups(swatch).is_empty() && drawn == 1, "only the frame: {swatch:?}"); + // Totals that are a blend's key objects stay in the blend and show the value there, and the legend swatch leaves + // them out. + let frame = id_of(s.execute("shape.rectangle", &json!({"x": 600, "y": 680, "width": 10, "height": 10})).unwrap()); + let first = id_of(s.execute("text.create", &json!({"x": 600, "y": 685, "text": "%00", "size": 4})).unwrap()); + let second = id_of(s.execute("text.create", &json!({"x": 606, "y": 685, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [first, second]})).unwrap(); + let blend = id_of(s.execute("object.blend.make", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [frame, blend]})).unwrap(); + s.execute("graph.design", &json!({"new": "Blended"})).unwrap(); + let blended = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": blended.0, "design": "Blended"})).unwrap(); + let parts = series_parts(&s, blended, 0); + let mut blends = vec![]; + parts[0].walk(&mut |n| { + if matches!(n.kind, NodeKind::Blend { .. }) { + blends.push(texts_in(n)); + } + }); + assert_eq!(blends, [["35", "35"]], "{:?}", parts[0]); + assert!(texts_in(parts.last().unwrap()).is_empty(), "{:?}", parts.last().unwrap()); + // A design that is that blend alone gets the default legend swatch. + s.execute("select.set", &json!({"ids": [blend]})).unwrap(); + s.execute("graph.design", &json!({"new": "Keys"})).unwrap(); + let keys = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": keys.0, "design": "Keys"})).unwrap(); + let parts = series_parts(&s, keys, 0); + assert_eq!(texts_in(&parts[0]), ["35", "35"]); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); + // A design of a total inside two plain groups shows the value, and its legend swatch is the default square. + let total = id_of(s.execute("text.create", &json!({"x": 700, "y": 700, "text": "%00", "size": 4})).unwrap()); + s.execute("select.set", &json!({"ids": [total]})).unwrap(); + let inner = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [inner]})).unwrap(); + let outer = id_of(s.execute("object.group", &json!({})).unwrap()); + s.execute("select.set", &json!({"ids": [outer]})).unwrap(); + s.execute("graph.design", &json!({"new": "Nested"})).unwrap(); + let nested = one_series(&mut s, "column", &[35.0]); + s.execute("graph.column", &json!({"id": nested.0, "design": "Nested"})).unwrap(); + let parts = series_parts(&s, nested, 0); + assert_eq!(texts_in(&parts[0]), ["35"]); + assert!(matches!(parts.last().unwrap().kind, NodeKind::Path { .. }), "the default swatch: {:?}", parts.last().unwrap().kind); + } + + #[test] + fn totals_take_their_places_from_the_two_digits_after_the_percent_sign() { + for (v, before, after, want) in [ + (122.0, 3, 0, "122"), + (122.0, 0, 0, "122"), + (5.0, 3, 1, "005.0"), + (1234.0, 2, 0, "1234"), + (2.5, 0, 0, "3"), + (-2.5, 0, 0, "-3"), + (0.25, 0, 0, "0"), + (0.25, 0, 2, "0.25"), + (1.005, 0, 2, "1.01"), + (0.1 + 0.2, 0, 2, "0.30"), + (9.995, 0, 2, "10.00"), + (99.96, 0, 1, "100.0"), + (-0.004, 0, 2, "0.00"), + (-7.25, 2, 1, "-07.3"), + (7.0, 0, 9, "7.000000000"), + (1e21, 0, 0, "1000000000000000000000"), + ] { + assert_eq!(super::total_text(v, before, after), want, "{v} %{before}{after}"); + } + // Each `%` and two digits, left to right; any other `%` stays. + let style = CharStyle::default; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "%31 and %%12, %1x, 50%", style()); + assert!(super::fill_totals(&mut t, 7.25)); + assert_eq!(t.plain_text(), "007.3 and %7.25, %1x, 50%"); + // A total split between two runs takes the style of its `%`. + let (small, big) = (CharStyle { size: 10.0, ..style() }, CharStyle { size: 20.0, ..style() }); + t.runs = vec![vectorcraft_doc::TextRun::new("a %3", small), vectorcraft_doc::TextRun::new("1 b", big)]; + assert!(super::fill_totals(&mut t, 7.25)); + assert_eq!(t.runs.iter().map(|r| (r.text.as_str(), r.style.size)).collect::>(), [("a 007.3", 10.0), (" b", 20.0)]); + // Type without a total is left as it is. + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Votes: 50%", style()); + assert!(!super::fill_totals(&mut t, 7.25)); + assert_eq!(t.plain_text(), "Votes: 50%"); + } + fn make(s: &mut Session, ty: &str, csv: &str) -> NodeId { NodeId( s.execute("graph.create", &json!({"type": ty, "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"] diff --git a/crates/engine/src/cmd/inline.rs b/crates/engine/src/cmd/inline.rs index 7c6fa20ed..68538a5cd 100644 --- a/crates/engine/src/cmd/inline.rs +++ b/crates/engine/src/cmd/inline.rs @@ -89,6 +89,8 @@ fn insert_inline(s: &mut Session, p: &Value) -> Result { let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) }; let style = edit::insertion_style(&t.runs, a, b); let caret = edit::replace_range_styled(&mut t.runs, a, b, &[TextRun::inline(art, style)]); + // The inline character is one plain-text byte range: ruby over it follows the text. + t.ruby_after_replace(a, b, vectorcraft_doc::text::INLINE_CHAR.len_utf8()); refresh_bounds(t); Ok(caret) })?; diff --git a/crates/engine/src/cmd/layer.rs b/crates/engine/src/cmd/layer.rs index 2a71cc86a..5c31ca009 100644 --- a/crates/engine/src/cmd/layer.rs +++ b/crates/engine/src/cmd/layer.rs @@ -125,7 +125,7 @@ pub fn specs() -> Vec { "New Artboard", ["Window", "Artboards"], None, - "{x?, y?, width?, height?, name?} (default: right of the last)", + "{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1)} (default place: right of the last)", has_doc, artboard_new ), @@ -134,7 +134,7 @@ pub fn specs() -> Vec { "Delete Artboard", ["Window", "Artboards"], None, - "{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left", + "{index | indices: [index…]} delete artboards (with their guides) in one step; one is always left, and a locked one is an error", has_doc, artboard_delete ), @@ -143,7 +143,7 @@ pub fn specs() -> Vec { "Artboard Options…", ["Window", "Artboards"], None, - "{index | artboards: [{index, name?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}", + "{index | artboards: [{index, name?, color?, locked?, showCenterMark?, showCrossHairs?, showVideoSafeAreas?, videoRulerPixelAspectRatio?, x?, y?, width?, height?}…] (several artboards in one undo step, each with its own fields; the art inside each before any of them changes goes with it, and an object inside several goes with the first listed that moves or scales its art), name?, color?: color (\"#rrggbb\", {c,m,y,k}, … as the paint commands take: the background color the canvas draws the artboard's paper in, multiplied with the Simulate Colored Paper color while that is on; exports and print leave it out) | null (no background color), locked?: bool (the Artboards panel's lock column: a locked artboard keeps its rectangle (a new x, y, width or height is an error unless locked is false), can't be moved, deleted or cut, and Rearrange and Fit to Artwork Bounds leave it as it is; the art on it (each object of a layer or sublayer whose bounds with strokes overlap it, a group as one object) acts as locked art until it is unlocked: clicks, marquees, select.set, select.all and the tools pass over it (art drawn or pasted onto it stays selected and editable until a selection leaves it out or it is moved off the locked artboards), it is deselected, and when another artboard moves or scales it stays unless lockedAndHidden), showCenterMark?: bool (Artboard Options' Show Center Mark: the canvas draws a mark at the artboard's center), showCrossHairs?: bool (Show Cross Hairs: the canvas draws a line from the middle of each side through the center), showVideoSafeAreas?: bool (Show Video Safe Areas: the canvas draws the action-safe area 3.5% and the title-safe area 5% in from each edge, as EBU R 95 sets them; the three are screen-only, as the artboard's edges are: exports and print leave them out), videoRulerPixelAspectRatio?: number 0.1–10 (Video Ruler Pixel Aspect Ratio: the width of one video pixel over its height, which View › Rulers › Show Video Rulers counts the artboard in; default 1, square pixels), x?, y?, width?, height?, moveArt?: bool (default false; a position-only change moves the contained art with its artboard), scaleArt?: bool (Scale Artwork with Artboard: resized, the artboard takes the art fully inside it (locked and hidden art only with lockedAndHidden?: bool, default prefs moveLockedWithArtboard) and its guides from its old rectangle onto the new one, each side by its own ratio; strokes/corners/patterns as object.scale), strokes?: bool, corners?: bool, patterns?: bool (the three as object.scale, for art that moves or scales with the artboard), lockedAndHidden?: bool (locked and hidden art moves along; default: the preference; recorded in the journal so a replay matches)} → null, or with scaleArt {scaled: the art scaled}", has_doc, artboard_set ), @@ -152,7 +152,7 @@ pub fn specs() -> Vec { "Fit to Artwork Bounds", ["Object", "Artboards"], None, - "{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art stays as it is, and the command fails when no artboard it names has art", + "{index?: n | indices?: [n…] (0-based; the app passes the selected artboards: the Artboard tool's, else the active one; default: the first)} each artboard fits the art on it that shows (with its strokes and effects; hidden objects and layers, guides and template layers don't count), in one undo step: an object counts for every artboard its bounds overlap, and a group or clipping layer counts as one object; an artboard with no art, or a locked one, stays as it is, and the command fails when every artboard it names is locked or none has art", has_doc, artboard_fit_art ), @@ -161,7 +161,7 @@ pub fn specs() -> Vec { "Fit to Selected Art", ["Object", "Artboards"], None, - "{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes)", + "{index?: n (0-based; the app passes the active artboard; default: the first)} the artboard fits the selected art (with its strokes); a locked artboard stays as it is (an error when the fit would change it)", has_selection, artboard_fit_sel ), @@ -289,7 +289,7 @@ pub(crate) fn make_layer(d: &mut Document, name: Option<&str>) -> Node { pub(super) fn layer_color_param(v: &Value) -> std::result::Result { match v { Value::Number(n) => match n.as_u64() { - Some(i) if (i as usize) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)), + Some(i) if index(i) < LAYER_COLORS.len() => Ok(LayerColor::Preset(i as u8)), _ => Err(format!("a colour index is 0 to {}, not {n}", LAYER_COLORS.len() - 1)), }, Value::String(s) => { @@ -781,7 +781,7 @@ fn move_node(s: &mut Session, p: &Value) -> Result { const C: &str = "node.move"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?; let parent = id_param(p, "parent"); - let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX).min(usize::MAX as u64) as usize; + let index = p.get("index").and_then(Value::as_u64).map_or(usize::MAX, index); let d = &s.doc()?.doc; let n = d.node(id).ok_or(EngineError::NoNode(id))?; if !accepts(d, parent, n) { @@ -803,16 +803,54 @@ fn rect_from(p: &Value, default: Rect) -> Rect { Rect::new(x, y, x + w, y + h) } +/// An artboard command's `color` param, its background color: None when absent, `Some(None)` for +/// null (no color), else `Some(Some(color))`. +fn artboard_color(p: &Value, cmd: &str) -> Result>> { + match p.get("color") { + None => Ok(None), + Some(Value::Null) => Ok(Some(None)), + Some(v) => color_value(v).map(|c| Some(Some(c))).ok_or_else(|| bad(cmd, format!("bad color {v}"))), + } +} + +/// An artboard command's `videoRulerPixelAspectRatio` param: None when absent, else a number within +/// [`vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE`]. +fn video_pixel_aspect(p: &Value, cmd: &str) -> Result> { + match p.get("videoRulerPixelAspectRatio") { + None => Ok(None), + Some(v) => v + .as_f64() + .filter(|r| vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE.contains(r)) + .map(Some) + .ok_or_else(|| bad(cmd, "videoRulerPixelAspectRatio must be a number from 0.1 to 10")), + } +} + fn artboard_new(s: &mut Session, p: &Value) -> Result { let d0 = &s.doc()?.doc; let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1); let r = rect_from(p, def); + let color = artboard_color(p, "artboard.new")?.flatten(); + check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?; + let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false)); + let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false); + let video_ruler_pixel_aspect_ratio = video_pixel_aspect(p, "artboard.new")?.unwrap_or(1.0); let idx = s.edit("New Artboard", |d, _| { let id = d.next_artboard_id(); let given = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()); let name = given.map_or_else(|| format!("Artboard {}", d.artboards.len() + 1), str::to_string); - d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false }); + d.artboards.push(Artboard { + id, + name, + rect: r, + locked: false, + show_center_mark, + show_cross_hairs, + show_video_safe_areas, + video_ruler_pixel_aspect_ratio, + color, + }); Ok(d.artboards.len() - 1) })?; Ok(json!({ "index": idx })) @@ -820,6 +858,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result { fn artboard_delete(s: &mut Session, p: &Value) -> Result { let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.delete")?; + super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?; s.edit("Delete Artboard", |d, sel| { if indices.len() >= d.artboards.len() { return Err(EngineError::Other("a document needs at least one artboard".into())); @@ -834,13 +873,20 @@ fn artboard_delete(s: &mut Session, p: &Value) -> Result { ok() } -/// One artboard's part of `artboard.setProps`: its index, new rectangle and name, how far it moved, -/// whether it was resized, the map its art and guides take when they go with it (`fit` when it -/// scales them), and that art. +/// One artboard's part of `artboard.setProps`: its index, new rectangle, name, color, lock, +/// display marks (Show Center Mark, Show Cross Hairs, Show Video Safe Areas) and Video Ruler Pixel +/// Aspect Ratio, how far it moved, whether it was resized, the map its art and guides take when +/// they go with it (`fit` when it scales them), and that art. struct ArtboardEdit<'a> { index: usize, now: Rect, name: Option<&'a str>, + color: Option>, + lock: Option, + center_mark: Option, + cross_hairs: Option, + safe_areas: Option, + pixel_aspect: Option, delta: vectorcraft_geom::Vec2, resized: bool, fit: Option, @@ -856,6 +902,11 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { } None => vec![p], }; + check_switches(p, &["moveArt", "scaleArt", "strokes", "corners", "patterns", "lockedAndHidden"], "artboard.setProps")?; + for q in &entries { + check_switches(q, &["locked", "showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.setProps")?; + check_numbers(q, &["x", "y", "width", "height"], "artboard.setProps")?; + } // Scale Artwork with Artboard: resized, an artboard takes the art fully inside it (as Move // Artwork with Artboard gathers it) and its guides from the old rectangle onto the new one. let scale_art = bool_or(p, "scaleArt", false); @@ -872,7 +923,21 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { return Err(bad("artboard.setProps", "an artboard is listed twice")); } let was = d.artboards.get(index).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; + // The background color: a color sets it, null clears it, absent leaves it as it is. + let color = artboard_color(q, "artboard.setProps")?; let now = rect_from(q, was); + let lock = q.get("locked").and_then(Value::as_bool); + let (center_mark, cross_hairs) = (q.get("showCenterMark").and_then(Value::as_bool), q.get("showCrossHairs").and_then(Value::as_bool)); + let (safe_areas, pixel_aspect) = (q.get("showVideoSafeAreas").and_then(Value::as_bool), video_pixel_aspect(q, "artboard.setProps")?); + // A locked artboard keeps its rectangle unless this unlocks it; its name can change. + let reshaped = [now.x0 - was.x0, now.y0 - was.y0, now.x1 - was.x1, now.y1 - was.y1].iter().any(|d| d.abs() > 1e-6); + if reshaped && lock != Some(false) { + super::panelcmds::refuse_locked(d, &[index])?; + } + // Values within 1e-6 pt of a locked artboard's (as Artboard Options passes them) leave its + // rectangle exactly as it is. + let held = lock != Some(false) && d.artboards.get(index).is_some_and(|a| a.locked); + let now = if held { was } else { now }; let resized = (now.width() - was.width()).abs() > 1e-6 || (now.height() - was.height()).abs() > 1e-6; let fit = if scale_art && resized { rect_map(was, now) } else { None }; // Position fields honor Move Artwork independently of Scale Artwork. A resize still @@ -880,8 +945,23 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { let delta = now.origin() - was.origin(); let moving = !resized && bool_or(p, "moveArt", false) && delta != vectorcraft_geom::Vec2::ZERO; let transform = fit.or_else(|| moving.then(|| Affine::translate(delta))); - let art = if transform.is_some() { d.art_on_artboard(was, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] }; - edits.push(ArtboardEdit { index, now, name: str_param(q, "name").map(str::trim), delta, resized, fit, transform, art }); + let art = if transform.is_some() { d.art_moving_with(index, all).into_iter().filter(|id| taken.insert(*id)).collect() } else { vec![] }; + edits.push(ArtboardEdit { + index, + now, + name: str_param(q, "name").map(str::trim), + color, + lock, + center_mark, + cross_hairs, + safe_areas, + pixel_aspect, + delta, + resized, + fit, + transform, + art, + }); } // Noted in the journal when art goes along, so a replay transforms the same objects whatever // the preference is then. @@ -900,6 +980,24 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result { // A blank name gives the artboard its default name, Artboard N for the Nth artboard. a.name = if n.is_empty() { format!("Artboard {}", e.index + 1) } else { n.to_string() }; } + if let Some(c) = e.color { + a.color = c; + } + if let Some(l) = e.lock { + a.locked = l; + } + if let Some(m) = e.center_mark { + a.show_center_mark = m; + } + if let Some(c) = e.cross_hairs { + a.show_cross_hairs = c; + } + if let Some(v) = e.safe_areas { + a.show_video_safe_areas = v; + } + if let Some(r) = e.pixel_aspect { + a.video_ruler_pixel_aspect_ratio = r; + } let id = a.id; if let Some(xf) = e.transform { let mut sc = if e.fit.is_some_and(|xf| Scaling::factor(xf).is_some()) { scaling } else { Scaling::default() }; @@ -937,14 +1035,19 @@ fn rect_map(from: Rect, to: Rect) -> Option { /// Each artboard of `index` or `indices` (default: the first) fits the art on it that exports /// draw ([`vectorcraft_render::encode::art_bounds_on`]): hidden objects and layers, guides and -/// template layers don't count. An artboard with no art on it stays as it is, and the command -/// fails when none has art. The fits are one undo step named Artboard Options. +/// template layers don't count. An artboard with no art on it, or a locked one, stays as it is, +/// and the command fails when every one it names is locked or none has art. The fits are one undo +/// step named Artboard Options. fn artboard_fit_art(s: &mut Session, p: &Value) -> Result { let d = &s.doc()?.doc; let indices = if p.get("index").is_none() && p.get("indices").is_none() { vec![0] } else { super::panelcmds::artboard_indices(d, p, "artboard.fitToArt")? }; + let unlocked: Vec = indices.iter().copied().filter(|i| d.artboards.get(*i).is_some_and(|a| !a.locked)).collect(); + if unlocked.is_empty() { + super::panelcmds::refuse_locked(d, &indices)?; + } let mut fits = vec![]; - for i in indices { + for i in unlocked { let was = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; if let Some(b) = vectorcraft_render::encode::art_bounds_on(d, was) { // At least 1 pt a side, as `rect_from` makes Artboard Options' sizes. diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 74a70fd28..0752c215e 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -932,16 +932,13 @@ fn use_item(s: &mut Session, p: &Value) -> Result { } } -/// Run `f`'s commands as one undo step (undone together when it fails), unless an undo group is -/// already open (its own step then holds them). +/// Run `f`'s commands as one undo step, or as part of the step of an undo group open in the active +/// document, and undo them together when it fails ([`Session::begin_undo_scope`]). fn in_one_step(s: &mut Session, f: impl FnOnce(&mut Session) -> Result) -> Result { - let open = s.active().is_some_and(|st| st.undo_group.is_some()); - if !open { - s.begin_undo_group(); - } + let scope = s.begin_undo_scope(); let r = f(s); - if !open { - s.end_undo_group(r.is_err()); + if let Some(scope) = scope { + s.end_undo_scope(scope, r.is_err()); } r } @@ -1091,7 +1088,7 @@ fn move_group(s: &mut Session, p: &Value) -> Result { let (name, at) = s.libraries.change(&id, |lib| { let i = group_at(lib, group, C)?; let g = lib.groups.remove(i); - let at = usize::try_from(to).unwrap_or(usize::MAX).min(lib.groups.len()); + let at = index(to).min(lib.groups.len()); let name = g.name.clone(); lib.groups.insert(at, g); Ok((name, at)) diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 99eb2af88..e8f122e96 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -17,16 +17,17 @@ //! (`links.embed`) and Unembed writes an embedded image to a file it then links to //! (`links.unembed`). //! -//! Placed documents ([`PlacedDocument`], VectorCraft documents placed with Link) link to their -//! files the same way: checking, updating, relinking and finding them on open work alike, and they -//! are listed. Embed (Break Link) turns one into an editable copy of its art. +//! Placed documents ([`PlacedDocument`], VectorCraft documents and PDF or `.ai` pages placed with +//! Link) link to their files the same way: checking, updating, relinking and finding them on open +//! work alike, and they are listed. Embed (Break Link) turns one into an editable copy of its art. -use std::collections::BTreeMap; +use std::borrow::Cow; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Component, Path}; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; -use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; use vectorcraft_geom::{Affine, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; @@ -66,7 +67,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, copy?: false (with path, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", has_doc, relink ), @@ -111,7 +112,7 @@ pub fn specs() -> Vec { "Link Info", [], None, - "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's 100% size", + "{id?} an image's or placed document's Link Info (default: the one selected) → image.info's fields (id, name, linked, link, colorMode, pixelWidth, pixelHeight, width, height) and status: ok|modified|missing|embedded, format, ppi: [x, y] (the file's), effectivePpi: [x, y] (at its placed size), scale: [x%, y%] (of its 100% size), rotation (degrees, counter-clockwise), placement: {preserve, align, clip}; linked images also fileName, location (its folder), page?, fileSize? (bytes), modified?, created? (ms since the Unix epoch), refused? (why the path is not followed); a placed document has document: true, pageWidth and pageHeight (pt) instead of pixels, ppi and colorMode, and its scale is of its artboard's or page's 100% size", has_doc, info ), @@ -148,21 +149,25 @@ impl Status { } /// The image objects that show one linked file as one content (same path, same image), or the -/// placed documents that show one artboard of it. +/// placed documents that show one artboard or page of it. struct Group { link: LinkInfo, key: String, /// The objects in the layers (images in symbols and patterns share the content, not an id). ids: Vec, - /// Placed documents: whether the art's bounds (not the artboard) are shown. - document: Option, + /// Placed documents: what they show. + document: Option, } -/// The linked object `n` as (link, content key, placed document's bounding). -fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { +/// What a placed document shows: whether the art's bounds (not the artboard or page) are shown, +/// and a PDF page's box. +type Shown = (bool, PageBox); + +/// The linked object `n` as (link, content key, what a placed document shows). +fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { match &n.kind { NodeKind::Image(im) => Some((im.link.as_ref()?, &im.key, None)), - NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some(p.bounding))), + NodeKind::PlacedDocument(p) => Some((&p.link, &p.key, Some((p.bounding, p.page_box)))), _ => None, } } @@ -177,7 +182,9 @@ pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let mut out: Vec = vec![]; let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); - let mut add = |link: &LinkInfo, key: &str, document: Option, id: Option| { + // Looked up for every linked node: a set, not the list (which may repeat an id many times). + let wanted: Option> = ids.map(|ids| ids.iter().copied().collect()); + let mut add = |link: &LinkInfo, key: &str, document: Option, id: Option| { let i = *index.entry((link.path.clone(), key.to_string(), document.is_some())).or_insert_with(|| { out.push(Group { link: link.clone(), key: key.to_string(), ids: vec![], document }); out.len() - 1 @@ -188,7 +195,7 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { }; d.walk(|n| { if let Some((l, key, document)) = linked(n) - && ids.is_none_or(|ids| ids.contains(&n.id)) + && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) { add(l, key, document, Some(n.id)); } @@ -199,7 +206,7 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { add(l, &im.key, None, None); } }); - d.visit_placed(|_, p| add(&p.link, &p.key, Some(p.bounding), None)); + d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None)); } out } @@ -282,6 +289,18 @@ fn previewing(d: &Document, key: &str) -> bool { d.images.get(key).is_none_or(ImageBlob::is_proxy) } +/// A linked raster image whose file is an EPS (Preferences › File Handling › Use Low Resolution +/// Proxy for Linked EPS). +fn linked_eps(link: &LinkInfo) -> bool { + fileio::format_for_name(link.name()).is_some_and(|f| f.id == "eps") +} + +/// With `low_res_proxy_eps`, a linked EPS image that already shows its saved preview keeps it on +/// open instead of reading the file again (Update Links still reads modified files). +fn keep_eps_preview(low_res_proxy_eps: bool, g: &Group, d: &Document) -> bool { + low_res_proxy_eps && g.document.is_none() && linked_eps(&g.link) && previewing(d, &g.key) +} + /// Does group `g` of `d` show something other than its file: an image its preview, a placed /// document nothing (its file's bytes are gone)? fn stale(d: &Document, g: &Group) -> bool { @@ -320,6 +339,47 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec) { + let mut keys = BTreeSet::new(); + d.visit_images(|_, im| { + if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) { + keys.insert(im.key.clone()); + } + }); + if keys.is_empty() { + return (Cow::Borrowed(d), vec![]); + } + let mut out = d.clone(); + let mut previews = BTreeSet::new(); + let all = groups(d, None); + for key in keys { + let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue }; + let Some(mut f) = locate(&g.link, doc_path) else { + previews.insert(key); + continue; + }; + let Some(bytes) = f.read().ok() else { + previews.insert(key); + continue; + }; + let Ok(img) = fileio::raster_image(&bytes) else { + previews.insert(key); + continue; + }; + let preview = d.images.get(&key).map(|b| b.bytes.clone()); + let mime = fileio::format_for_name(g.link.name()).map(|f| f.mime.to_string()).unwrap_or_else(|| img.blob.mime.clone()); + out.images.insert(key, ImageBlob { mime, bytes: img.blob.bytes, proxy: preview }); + } + let mut warnings = vec![]; + if !previews.is_empty() { + warnings + .push(format!("{} linked image(s) output as their previews: their files can't be found or read (relink or update them)", previews.len())); + } + (Cow::Owned(out), warnings) +} + /// The linked files of the open document `st` that `links.findFiles` looks for, each with the /// objects in the layers that show it: the files of objects `ids` whatever their status, else the /// files that can't be found. @@ -334,11 +394,12 @@ pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec // ---------- reading files into the document ---------- /// Keep `full` (a file's pixels) as image `key` of `d`, unless pixels are there already; a -/// preview there is kept, and a linked image gets one. +/// preview there is kept, and a linked image gets one (the one `full` carries, when it has one). pub(crate) fn store_image(d: &mut Document, key: &str, full: ImageBlob, linked: bool) { + let made = full.proxy.clone().filter(|_| linked); let blob = match d.images.get(key) { Some(b) if !b.is_proxy() => b.clone(), - old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()), ..full }, + old => ImageBlob { proxy: old.and_then(|b| b.proxy.clone()).or(made), ..full }, }; d.images.insert(key.to_string(), if linked { blob.with_proxy() } else { blob }); } @@ -355,16 +416,17 @@ fn read_image(f: &mut Found, base: &LinkInfo) -> Result { /// A linked file read for the objects that show it. enum Read { Image(FileRead), - /// A VectorCraft document read for placed documents: the link to it and what they show. + /// A VectorCraft document or a PDF or `.ai` file read for placed documents: the link to it and + /// what they show. Document(LinkInfo, super::place::document::Source), } -/// The file at `f` read for group `g`: its image, or the artboard `g` shows. +/// The file at `f` read for group `g`: its image, or the artboard or page `g` shows. fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result { - let Some(bounding) = g.document else { return read_image(f, &g.link).map(Read::Image) }; + let Some((bounding, page_box)) = g.document else { return read_image(f, &g.link).map(Read::Image) }; let bytes = f.read()?; let page = g.link.page.unwrap_or(1); - let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, cmd)?; + let src = super::place::document::source(&f.path, &bytes, Some(&f.path), page, bounding, page_box, cmd)?; Ok(Read::Document(f.link(&bytes, &g.link), src)) } @@ -554,11 +616,12 @@ impl Resolved { /// away from their path take the new path. A link nothing is known of whose image shows a preview /// (a file another app wrote kept only that) was never read: its file is read now, into the /// image's bounds, which isn't a change to report. Nothing here is an undo step. -pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolved { +pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved { let mut out = Resolved::default(); for g in groups(d, None) { let row = rows(std::iter::once(&g), doc_path); let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); + let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d); match probe(&g, doc_path) { (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { @@ -593,8 +656,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool) -> Resolv (Status::Ok, Some(mut f)) => { let preview = previewing(d, &g.key); // Read when the images show the preview, or when the file's details changed. - let bytes = if preview || f.bytes.is_some() { f.read().ok() } else { None }; - if preview { + let bytes = if (preview && !eps_preview) || f.bytes.is_some() { f.read().ok() } else { None }; + if preview && !eps_preview { let Some(img) = bytes.as_deref().and_then(|b| fileio::raster_image(b).ok()) else { out.missing.extend(row); continue; @@ -834,13 +897,13 @@ fn relink(s: &mut Session, p: &Value) -> Result { let (mut copied, mut kept) = (vec![], vec![]); for (ids, path) in files { let bytes = read_file(&path)?; - // Images show the file; placed documents each show their artboard of it. + // Images show the file; placed documents each show their artboard or page of it. let mut images = vec![]; - let mut documents: Vec<((u32, bool), Vec)> = vec![]; + let mut documents: Vec<((u32, bool, PageBox), Vec)> = vec![]; for id in ids { match st.doc.node(id).map(|n| &n.kind) { Some(NodeKind::PlacedDocument(pl)) => { - let k = (pl.page(), pl.bounding); + let k = (pl.page(), pl.bounding, pl.page_box); match documents.iter_mut().find(|(e, _)| *e == k) { Some((_, ids)) => ids.push(id), None => documents.push((k, vec![id])), @@ -852,8 +915,8 @@ fn relink(s: &mut Session, p: &Value) -> Result { // Read before it is copied: a file that can't be shown isn't copied. let img = if images.is_empty() { None } else { Some(fileio::raster_image(&bytes).map_err(|e| bad(C, format!("{path}: {e}")))?) }; let mut sources = vec![]; - for ((page, bounding), ids) in documents { - sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, C)?)); + for ((page, bounding, page_box), ids) in documents { + sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?)); } let to = match ©_to { Some(dir) => { @@ -997,10 +1060,12 @@ fn format_label(d: &Document, im: &ImageObject) -> &'static str { .map_or("Image", |f| f.label) } -/// A placed document's `links.list` fields: `format`, `document: true` and its artboard's size -/// (`pageWidth`, `pageHeight`, pt) instead of pixels. +/// A placed document's `links.list` fields: `format` (`VectorCraft`, or the PDF or `.ai` file's +/// format), `document: true` and its artboard's or page's size (`pageWidth`, `pageHeight`, pt) +/// instead of pixels. fn document_row(p: &PlacedDocument) -> Value { - json!({ "format": "VectorCraft", "document": true, "pageWidth": p.width, "pageHeight": p.height }) + let pdf = fileio::format_for_name(p.link.name()).filter(|f| matches!(f.id, "pdf" | "ai" | "ait")); + json!({ "format": pdf.map_or("VectorCraft", |f| f.label), "document": true, "pageWidth": p.width, "pageHeight": p.height }) } /// Sort rank of a `links.list` status: missing, modified, ok, embedded. diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index 46ff6574e..d228c7d81 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -10,8 +10,8 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::live::{ - self, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, GradientMesh, MeshAppearance, - PreserveShape, Spine, + self, BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, EnvelopeMap, EnvelopeOptions, + GradientMesh, MeshAppearance, PreserveShape, Spine, }; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Selection}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2}; @@ -29,7 +29,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Blend"], Some("Cmd+Alt+B"), - "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", + "{ids?, steps?: n | distance?: pt | smooth?: bool, orientation?: page|path (default: the Blend Options set with nothing selected, else smooth colour and page), starts?: [anchor index | null, …] (per object of `ids`, else of the selection in paint order: the anchor of its first subpath the blend starts from; an open path's last anchor runs it the other way), spacing?: smooth|steps|distance (with value), value?: n, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100; 0: even)} blend the selected objects (paint order) into a live blend; a blend among them takes the others in as more key objects (keeping its options, name and transparency) instead of nesting → {id}", has_doc, blend_make ), @@ -47,7 +47,7 @@ pub fn specs() -> Vec { "Blend Options…", ["Object", "Blend"], None, - "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool} set the options of the selected blends; with no blend selected, the options new blends start with (a tool setting, not an undo step) → {defaults?: true}", + "{spacing?: smooth|steps|distance, value?: n, steps?: n, distance?: pt, orientation?: page|path, smooth?: bool, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct} set the options of the selected blends; easingType chooses the distribution, ramp sets its strength (0..100; 0 = even), colorShift (-100..100; 0 = even) shifts colours independently of shape/position easing (omitted ones keep their value; out of range is an error); with no blend selected, the options new blends start with (spacing and orientation only; a tool setting, not an undo step) → {defaults?: true}", always, blend_options ), @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Blend Info", [], None, - "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", + "{} the options of the first selected blend, else those new blends start with → {target: blend|defaults, id?, spacing: smooth|steps|distance, steps, distance, orientation: page|path, easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct, colorShift?: pct, keys?: [ids], starts?: [anchor|null], spine?: {anchors: [{x, y, in: [x, y], out: [x, y]}], closed, keyAnchors: [anchor each key sits on] | null (keys spread evenly: a spine from Replace Spine), explicit: false for the straight lines between the key centres}}", always, blend_info ), @@ -411,6 +411,7 @@ const DEFAULT_DISTANCE: f64 = 10.0; fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result { check_numbers(p, &["value", "steps", "distance"], cmd)?; + let smooth = switch_or(p, "smooth", false, cmd)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let steps = |k: &str, n: f64| whole(n, k, cmd).map(|n| BlendSpacing::Steps(n as u32)); let mode = str_param(p, "spacing"); @@ -428,7 +429,7 @@ fn spacing_param(p: &Value, cmd: &str, current: BlendSpacing) -> Result BlendOrientation { } } +/// Blend panel Type and Ramp, validated before any document mutation. +fn ease_param(p: &Value, cmd: &str, current: BlendEase) -> Result { + let kind = match p.get("easingType").filter(|v| !v.is_null()) { + None => current.kind, + Some(v) => match v.as_str() { + Some("easeIn") => BlendEaseType::In, + Some("easeOut") => BlendEaseType::Out, + Some("easeInOut") => BlendEaseType::InOut, + Some("easeOutIn") => BlendEaseType::OutIn, + _ => return Err(bad(cmd, "easingType must be easeIn, easeOut, easeInOut or easeOutIn")), + }, + }; + Ok(BlendEase { kind, ramp: percent_param(p, cmd, "ramp", current.ramp * 100.0, 0.0)? / 100.0 }) +} + +/// Bounded percentage input; missing or null preserves the current value. +fn percent_param(p: &Value, cmd: &str, key: &str, current: f64, min: f64) -> Result { + match p.get(key).filter(|v| !v.is_null()) { + None => Ok(current), + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (min..=100.0).contains(n)) + .ok_or_else(|| bad(cmd, format!("{key} must be a percentage {min}..100"))), + } +} + /// `starts`: per object of `ids` (else of `roots`), the anchor its blend starts from. fn starts_param(p: &Value, roots: &[NodeId]) -> Result> { const C: &str = "object.blend.make"; @@ -532,6 +559,12 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); + let (ease0, color0) = match base.as_ref().map(|n| &n.kind) { + Some(NodeKind::Blend { spec, .. }) => (spec.ease, spec.color_shift), + _ => Default::default(), + }; + let ease = ease_param(p, C, ease0)?; + let color_shift = percent_param(p, C, "colorShift", color0, -100.0)?; let id = s.edit("Make Blend", |d, sel| { wrap(d, sel, &roots, |id, nodes| { let (children, starts) = merge_keys(nodes, &starts); @@ -542,7 +575,7 @@ fn blend_make(s: &mut Session, p: &Value) -> Result { n.knockout = vectorcraft_doc::Knockout::On; n }); - let mut spec = BlendSpec { spacing, orientation, starts, ..Default::default() }; + let mut spec = BlendSpec { spacing, orientation, ease, color_shift, starts, ..Default::default() }; if let NodeKind::Blend { children: old, spec: old_spec } = &n.kind { extend_spine(&mut spec, old, old_spec, &children); } @@ -643,7 +676,7 @@ fn spine_move_anchor(s: &mut Session, p: &Value) -> Result { Some("in") => Some(false), Some(_) => return Err(bad(C, "handle must be in or out")), }; - let independent = bool_or(p, "independent", false); + let independent = switch_or(p, "independent", false, C)?; edit_spine(s, p, C, "Reshape Spine", |sp, anchors, keys| { let a = sp.anchors.get_mut(index).ok_or_else(|| bad(C, "no such spine point"))?; if let Some(out) = handle { @@ -697,12 +730,12 @@ fn spine_convert_anchor(s: &mut Session, p: &Value) -> Result { fn spine_reshape_segment(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.spine.reshapeSegment"; let seg = index_param(p, "segment", C)?; - let t = f64_or(p, "t", 0.5).clamp(0.05, 0.95); - let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + let t = number_or(p, "t", 0.5, C)?.clamp(0.05, 0.95); + let dv = Vec2::new(number_or(p, "dx", 0.0, C)?, number_or(p, "dy", 0.0, C)?); if !(t.is_finite() && dv.x.is_finite() && dv.y.is_finite() && dv.hypot() < 1e9) { return Err(bad(C, "t, dx and dy must be finite")); } - let perpendicular = bool_or(p, "perpendicular", false); + let perpendicular = switch_or(p, "perpendicular", false, C)?; edit_spine(s, p, C, "Reshape Spine", |sp, _, _| { let bent = if perpendicular { sp.bend_segment_perpendicular(seg, t, dv) } else { sp.bend_segment(seg, t, dv) }; if bent { Ok(()) } else { Err(bad(C, "no such spine segment")) } @@ -771,7 +804,10 @@ fn selected_spec(s: &Session) -> Option<(NodeId, BlendSpec, Vec)> { fn blend_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.blend.options"; let Some((_, cur, _)) = selected_spec(s) else { - // Nothing to set them on: the options new blends start with. + if ["easingType", "ramp", "colorShift"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())) { + return Err(bad(C, "select a blend to set easing or color shift")); + } + // Nothing to set them on: the options new blends start with (easing is per blend). let d = s.prefs.blend_options.unwrap_or_default(); let spacing = spacing_param(p, C, d.spacing)?; s.prefs.blend_options = Some(BlendDefaults { spacing, orientation: orientation_param(p, d.orientation) }); @@ -779,9 +815,13 @@ fn blend_options(s: &mut Session, p: &Value) -> Result { }; let spacing = spacing_param(p, C, cur.spacing)?; let orientation = orientation_param(p, cur.orientation); + let ease = ease_param(p, C, cur.ease)?; + let color_shift = percent_param(p, C, "colorShift", cur.color_shift, -100.0)?; edit_blends(s, "Blend Options", |_, spec| { spec.spacing = spacing; spec.orientation = orientation; + spec.ease = ease; + spec.color_shift = color_shift; }) } @@ -803,6 +843,9 @@ fn blend_info(s: &mut Session, _: &Value) -> Result { let mut v = blend_options_json(spec.spacing, spec.orientation); v["target"] = json!("blend"); v["id"] = json!(id.0); + v["easingType"] = json!(spec.ease.kind.id()); + v["ramp"] = json!(spec.ease.ramp * 100.0); + v["colorShift"] = json!(spec.color_shift); v["starts"] = json!((0..keys.len()).map(|i| spec.start(i)).collect::>()); v["keys"] = json!(keys.iter().map(|k| k.0).collect::>()); if let Some(NodeKind::Blend { children, .. }) = s.doc()?.doc.node(id).map(|n| &n.kind) @@ -991,10 +1034,11 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result false, Some("horizontal") => true, - _ => bool_or(p, "horizontal", horizontal), + _ => given, }; Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) } @@ -1119,7 +1163,7 @@ fn env_reset_warp(s: &mut Session, p: &Value) -> Result { fn env_reset_mesh(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.resetWithMesh"; - let maintain = bool_or(p, "maintainShape", true); + let maintain = switch_or(p, "maintainShape", true, C)?; let kinds = reshaped(s, |env, kind| { let own = match kind { EnvelopeKind::Mesh { rows, cols, .. } => (*rows, *cols), @@ -1150,11 +1194,11 @@ fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result cur.preserve_shape, }; Ok(EnvelopeOptions { - anti_alias: bool_or(p, "antiAlias", cur.anti_alias), + anti_alias: switch_or(p, "antiAlias", cur.anti_alias, cmd)?, preserve_shape, - distort_appearance: bool_or(p, "distortAppearance", cur.distort_appearance), - distort_linear_gradients: bool_or(p, "distortLinearGradients", cur.distort_linear_gradients), - distort_pattern_fills: bool_or(p, "distortPatternFills", cur.distort_pattern_fills), + distort_appearance: switch_or(p, "distortAppearance", cur.distort_appearance, cmd)?, + distort_linear_gradients: switch_or(p, "distortLinearGradients", cur.distort_linear_gradients, cmd)?, + distort_pattern_fills: switch_or(p, "distortPatternFills", cur.distort_pattern_fills, cmd)?, }) } @@ -1249,6 +1293,7 @@ fn env_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.options"; check_numbers(p, &["fidelity"], C)?; check_numbers(p, WARP_NUMBERS, C)?; + check_switches(p, &["horizontal"], C)?; let fid = p.get("fidelity").and_then(Value::as_f64); if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { return Err(bad(C, "fidelity must be 0..100")); @@ -1289,6 +1334,8 @@ fn expand_envelope(d: &mut Document, id: NodeId) -> Result<()> { let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; let mut g = vectorcraft_render::effects::expanded_live_group(Some(d), &n); fix_ids(d, &mut g); + // Outlined type's emoji take their pixels into the document. + d.adopt_pixels(&mut g); // Outlined type repeats its object's id on its pieces. vectorcraft_render::effects::fresh_ids(d, &mut g, &mut Default::default()); *d.node_mut(id).ok_or(EngineError::NoNode(id))? = g; @@ -1330,9 +1377,10 @@ fn env_expand(s: &mut Session, _: &Value) -> Result { } fn env_edit_contents(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.envelope.editContents"; let envs = selected_of(s, is_envelope); let cur = matches!(envs.first().and_then(|e| s.doc().ok()?.doc.node(*e)).map(|n| &n.kind), Some(NodeKind::Envelope { editing: true, .. })); - let want = bool_or(p, "editing", !cur); + let want = switch_or(p, "editing", !cur, C)?; s.edit(if want { "Edit Contents" } else { "Edit Envelope" }, |d, sel| { let mut content_ids = vec![]; for e in &envs { @@ -1535,6 +1583,7 @@ fn whole(x: f64, key: &str, cmd: &str) -> Result { fn mesh_set_color(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.setPointColor"; check_numbers(p, &["opacity"], C)?; + super::paint::check_keep_model(p, C)?; let index = index_param(p, "index", C)?; let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c)); let opacity = p.get("opacity").and_then(Value::as_f64); @@ -1573,6 +1622,7 @@ fn mesh_move_point(s: &mut Session, p: &Value) -> Result { fn mesh_add_line(s: &mut Session, p: &Value) -> Result { const C: &str = "object.mesh.addLine"; let q = Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?); + super::paint::check_keep_model(p, C)?; let color = p.get("color").and_then(color_value).map(|c| super::paint::new_color(s, p, c)); let index = with_mesh(s, p, C, "Add Mesh Line", true, |m| { let i = m.add_lines_at(q).ok_or_else(|| bad(C, "point is outside the mesh"))?; diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index de8ada434..fe0cb1c52 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -157,7 +157,7 @@ pub fn specs() -> Vec { "Rearrange All Artboards…", ["Object", "Artboards"], None, - "{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (locked and hidden art only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's → {artboards, rows, columns}", + "{layout?: gridByRow|gridByColumn|row|column (gridByRow; byColumn?: true is gridByColumn), order?: leftToRight|rightToLeft (leftToRight: right-to-left mirrors the order along the rows), columns?: n (2; the rows for gridByColumn; row and column ignore it), spacing?: pt (20), moveArtwork?: true (each object of a layer or sublayer moves with the artboard its center is on; locked and hidden art, and art on a locked artboard, only with prefs moveLockedWithArtboard), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), byColumn?: bool (an older way to ask for layout gridByColumn)} lay the artboards out in their order, the grid's top-left at the first artboard's; a locked artboard stays where it is → {artboards, rows, columns}", has_doc, rearrange_artboards ), @@ -348,11 +348,12 @@ fn transform_each_as(s: &mut Session, p: &Value, label: &str) -> Result { let mh = f64_or(p, "moveH", 0.0).clamp(-1.0e7, 1.0e7); let mv = f64_or(p, "moveV", 0.0).clamp(-1.0e7, 1.0e7); let rot = f64_or(p, "rotate", 0.0); - let rx = bool_or(p, "reflectX", false); - let ry = bool_or(p, "reflectY", false); - let random = bool_or(p, "random", false); + let rx = switch_or(p, "reflectX", false, "object.transformEach")?; + let ry = switch_or(p, "reflectY", false, "object.transformEach")?; + let random = switch_or(p, "random", false, "object.transformEach")?; let refi = whole_or(p, "reference", 4, "object.transformEach")?.min(8) as usize; - let copy = bool_or(p, "copy", false); + let copy = switch_or(p, "copy", false, "object.transformEach")?; + check_switches(p, &["strokes", "corners", "objects", "patterns"], "object.transformEach")?; let objects = super::object::transforms_objects(p); if sh == 0.0 || sv == 0.0 { return Err(bad("object.transformEach", "scale must be non-zero")); @@ -468,7 +469,7 @@ impl Rasterized { 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() }; + 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))); match self.clip { Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node), @@ -598,7 +599,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result { let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?; let cols = whole_or(p, "columns", 10, C)?.clamp(1, 1000) as u32; let rows = whole_or(p, "rows", 10, C)?.clamp(1, 1000) as u32; - let gray = bool_or(p, "gray", false); + let gray = switch_or(p, "gray", false, C)?; let colors = vectorcraft_trace::mosaic(&raster, cols, rows); // Tiles in image space, placed by the image transform (scaled to a new size if asked). let b = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?; @@ -613,7 +614,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result { let ppx = (xf * Point::new(1.0, 0.0) - xf * Point::ZERO).hypot().max(1e-9); let ppy = (xf * Point::new(0.0, 1.0) - xf * Point::ZERO).hypot().max(1e-9); let (gx, gy) = ((f64_or(p, "spacingX", 0.0).max(0.0) / ppx).min(tw * 0.9), (f64_or(p, "spacingY", 0.0).max(0.0) / ppy).min(th * 0.9)); - let delete = bool_or(p, "deleteRaster", false); + let delete = switch_or(p, "deleteRaster", false, C)?; let (gid, n) = s.edit("Create Object Mosaic", |d, sel| { let mut tiles = vec![]; for r in 0..rows { @@ -670,7 +671,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { .ok_or_else(|| bad(C, "select an image"))?; let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) }; - if p.get("trim").and_then(Value::as_bool) == Some(true) { + if switch_or(p, "trim", false, C)? { return trim_image(s, id, (**im).clone()); } let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?; @@ -720,6 +721,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { xf: Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale), link: None, placement: Default::default(), + pixels: None, })); } Ok(()) @@ -787,6 +789,7 @@ fn trim_image(s: &mut Session, id: NodeId, im: ImageObject) -> Result { xf: im.xf * Affine::translate((f64::from(x0), f64::from(y0))), link: None, placement: Default::default(), + pixels: None, })); } Ok(()) @@ -836,9 +839,9 @@ impl Session { fn trim_marks(s: &mut Session, p: &Value) -> Result { const C: &str = "object.createTrimMarks"; check_numbers(p, &["offset", "length", "bleed", "weight"], C)?; - let style = match str_param(p, "style") { - Some(v) => MarkStyle::parse(v).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?, - None => s.crop_mark_style(), + let style = match p.get("style") { + None | Some(Value::Null) => s.crop_mark_style(), + Some(v) => v.as_str().and_then(MarkStyle::parse).ok_or_else(|| bad(C, "`style` must be roman or japanese"))?, }; let d = TrimMarks::default(); let marks = TrimMarks { @@ -849,7 +852,7 @@ fn trim_marks(s: &mut Session, p: &Value) -> Result { weight: f64_or(p, "weight", d.weight).max(0.01), }; let st = s.doc()?; - let rects: Vec = if bool_or(p, "allArtboards", st.selection.is_empty()) { + let rects: Vec = if switch_or(p, "allArtboards", st.selection.is_empty(), C)? { st.doc.artboards.iter().map(|a| a.rect).collect() } else { st.doc.bounds_of(&selected_roots(s)?, true).into_iter().collect() @@ -899,7 +902,14 @@ pub(crate) fn detect_shape(path: &PathData) -> Option { } let rot = Affine::rotate(e0.y.atan2(e0.x)); let flip = if e0.cross(e1) < 0.0 { Affine::scale_non_uniform(1.0, -1.0) } else { Affine::IDENTITY }; - return Some(LiveShape::Rectangle { w, h, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate(a[0].p.to_vec2()) * rot * flip }); + return Some(LiveShape::Rectangle { + w, + h, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate(a[0].p.to_vec2()) * rot * flip, + }); } let b = path.bounds()?; let c = b.center(); @@ -964,8 +974,12 @@ fn convert_to_artboards(s: &mut Session, _: &Value) -> Result { id: aid, name: format!("Artboard {aid}"), rect: b, + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); converted.push(id); } @@ -984,9 +998,10 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { use super::newdoc::ArtboardLayout as L; // A value given (not null) must be one of the names. let named = |key: &str| p.get(key).filter(|v| !v.is_null()).map(|v| v.as_str().unwrap_or_default()); + let by_column = switch_or(p, "byColumn", false, CMD)?; let layout = match named("layout") { Some(l) => L::parse(l).ok_or_else(|| bad(CMD, "layout must be gridByRow, gridByColumn, row or column"))?, - None if bool_or(p, "byColumn", false) => L::GridByColumn, + None if by_column => L::GridByColumn, None => L::GridByRow, }; let rtl = match named("order") { @@ -1007,29 +1022,40 @@ fn rearrange_artboards(s: &mut Session, p: &Value) -> Result { }; check_numbers(p, &["spacing"], CMD)?; let spacing = f64_or(p, "spacing", 20.0).clamp(-1.0e5, 1.0e5); - let move_art = bool_or(p, "moveArtwork", true); + let move_art = switch_or(p, "moveArtwork", true, CMD)?; + check_switches(p, &["patterns"], CMD)?; let locked_and_hidden = s.prefs.move_locked_with_artboard; let d = &s.doc()?.doc; let rects: Vec = d.artboards.iter().map(|a| a.rect).collect(); let sizes: Vec<(f64, f64)> = rects.iter().map(|r| (r.width(), r.height())).collect(); let first = rects.first().map_or(Point::ZERO, |r| r.origin()); let origins = super::newdoc::grid_origins_rc(&sizes, first, (rows, cols), spacing, layout == L::GridByColumn, rtl); - let deltas: Vec = rects.iter().zip(origins).map(|(r, o)| o - r.origin()).collect(); + // A locked artboard stays where it is, and keeps its place in the grid. + let deltas: Vec = + rects.iter().zip(origins).zip(&d.artboards).map(|((r, o), a)| if a.locked { Vec2::ZERO } else { o - r.origin() }).collect(); // When no artboard moves more than 1e-9 pt on either axis, or there is none, the document and // its undo history stay as they are. if deltas.iter().all(|dl| dl.x.abs() <= 1e-9 && dl.y.abs() <= 1e-9) { return Ok(json!({ "artboards": count, "rows": rows, "columns": cols })); } - // With moveArtwork, each top-level object moves with the artboard its center is on, with symbol - // instances measured by their art as `Document::art_on_artboard` measures them; locked and - // hidden art moves only when prefs moveLockedWithArtboard is on. + // With moveArtwork, each top-level object (a child of a layer or sublayer) moves with the + // artboard its center is on, with symbol instances measured by their art as + // `Document::art_on_artboard` measures them; locked and hidden art, and art on a locked + // artboard, moves only when prefs moveLockedWithArtboard is on. + fn objects<'a>(n: &'a Node, all: bool, out: &mut Vec<&'a Node>) { + for c in n.children().into_iter().flatten().filter(|c| c.rides_with_artboard(all)) { + if c.is_layer() { objects(c, all, out) } else { out.push(c) } + } + } + let locks = d.locked_artboards(); let symbols = d.symbol_boxes(); let art: Vec<(NodeId, Vec2)> = if move_art { - d.layers - .iter() - .filter(|l| l.rides_with_artboard(locked_and_hidden)) - .flat_map(|l| l.children().into_iter().flatten()) - .filter(|n| n.rides_with_artboard(locked_and_hidden)) + let mut tops = vec![]; + for l in d.layers.iter().filter(|l| l.rides_with_artboard(locked_and_hidden)) { + objects(l, locked_and_hidden, &mut tops); + } + tops.into_iter() + .filter(|n| locked_and_hidden || !locks.holds(n)) .filter_map(|n| { let c = n.geometric_bounds_with(&symbols)?.center(); let dl = *d.artboard_holding(c, false).and_then(|i| deltas.get(i))?; diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5ec103e89..c7414ed90 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -9,7 +9,7 @@ pub mod clipboard; mod colorcmds; pub mod colormgmt; pub mod colortheme; -mod create; +pub(crate) mod create; mod css; mod cut; pub(crate) mod dimension; @@ -28,6 +28,7 @@ pub mod flatten; pub mod fontfiles; mod fonts; pub(crate) mod freeform; +pub mod globaledit; pub(crate) mod gradient; pub(crate) mod graph; mod halftone; @@ -50,16 +51,16 @@ mod object; pub(crate) mod objects_on_path; mod opacitymask; mod overprint; -mod package; +pub mod package; mod paint; mod panelcmds; pub mod params; mod path; -mod pathops; +pub(crate) mod pathops; mod patterncmds; pub mod pdfcmds; pub mod perspgrid; -pub(crate) mod place; +pub mod place; pub mod plugin; pub mod prefscmds; pub mod print; @@ -79,6 +80,7 @@ mod style; pub mod stylelib; mod swatch; pub mod swatchlib; +pub mod symbollib; pub(crate) mod tabs; mod textedit; pub mod textstyles; @@ -214,6 +216,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(object::specs()); v.extend(path::specs()); v.extend(select::specs()); + v.extend(globaledit::specs()); v.extend(wand::specs()); v.extend(paint::specs()); // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command @@ -267,6 +270,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(freeform::specs()); v.extend(flatten::specs()); v.extend(stylelib::specs()); + v.extend(symbollib::specs()); v.extend(library::specs()); v.extend(expand::specs()); v.extend(attributes::specs()); @@ -318,20 +322,42 @@ pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 { pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result { p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) } -/// Error unless each of `keys` that `p` gives is a number (or null): an optional number read with -/// [`f64_or`] would otherwise fall back to its default for a string such as `"60 mm"` (#1952). +/// The number `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any other +/// value, such as `"40"`, is an error: "`{key}` must be a number, not {v}". +pub(crate) fn number_or(p: &Value, key: &str, default: f64, cmd: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_f64().ok_or_else(|| bad(cmd, format!("`{key}` must be a number, not {v}"))), + } +} +/// Error unless each of `keys` that `p` gives is a number (or null), as [`number_or`] reads them: an +/// optional number read with [`f64_or`] would otherwise fall back to its default for a string such as +/// `"60 mm"` (#1952). pub(crate) fn check_numbers(p: &Value, keys: &[&str], cmd: &str) -> Result<()> { for key in keys { - match p.get(*key) { - None | Some(Value::Null) | Some(Value::Number(_)) => {} - Some(v) => return Err(bad(cmd, format!("`{key}` must be a number, not {v}"))), - } + number_or(p, key, 0.0, cmd)?; } Ok(()) } pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool { p.get(key).and_then(Value::as_bool).unwrap_or(default) } +/// The true or false `p` gives for `key`, or `default` when `p` leaves it out or gives null. Any +/// other value, such as `"false"`, is an error: "`{key}` must be true or false". +pub(crate) fn switch_or(p: &Value, key: &str, default: bool, cmd: &str) -> Result { + match p.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v) => v.as_bool().ok_or_else(|| bad(cmd, format!("`{key}` must be true or false"))), + } +} +/// Error unless each of `keys` that `p` gives is true or false (or null), as [`switch_or`] reads +/// them. +pub(crate) fn check_switches(p: &Value, keys: &[&str], cmd: &str) -> Result<()> { + for key in keys { + switch_or(p, key, false, cmd)?; + } + Ok(()) +} pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { p.get(key).and_then(Value::as_str) } @@ -341,6 +367,10 @@ pub(crate) fn id_param(p: &Value, key: &str) -> Option { pub(crate) fn ids_param(p: &Value, key: &str) -> Option> { p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()) } +/// `v` as an index, a count or a byte offset: past `usize::MAX` reads as `usize::MAX`, past the end of any list. +pub(crate) fn index(v: u64) -> usize { + usize::try_from(v).unwrap_or(usize::MAX) +} /// Validate all explicit targets before a command changes the document or selection. pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result> { let values = @@ -451,10 +481,12 @@ pub(crate) fn ok() -> Result { } /// Leaves whose appearance a paint command changes: groups and layers expand to their contents, -/// and compound paths own their children's appearance. +/// and compound paths own their children's appearance, so a member stands for its compound path +/// (#3692), which is listed once. pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { use vectorcraft_doc::NodeKind; let d = &s.doc()?.doc; + let compound = |id: NodeId| d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. }))); let mut out = vec![]; for id in ids { let Some(n) = d.node(*id) else { continue }; @@ -464,11 +496,13 @@ pub(crate) fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result> { out.push(c.id) } }), - _ => out.push(*id), + _ => out.push(compound(*id).unwrap_or(*id)), } } let comp: Vec = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect(); out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c))); + let mut seen = std::collections::HashSet::new(); + out.retain(|id| seen.insert(*id)); Ok(out) } @@ -486,6 +520,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { } pub(crate) use edit::{settle_interaction, typing_in_progress}; +pub(crate) use object::bounds_rewrite; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see @@ -525,6 +560,9 @@ pub use flatten::FlattenerPreset; /// The shapes a group selected alone gives the Pathfinder panel. pub use pathops::shape_count; +/// The gaps View › Show Live Paint Gaps shows on a Live Paint group. +pub use buildcmds::live_paint_gaps; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/newdoc.rs b/crates/engine/src/cmd/newdoc.rs index a662f6cfd..588307f20 100644 --- a/crates/engine/src/cmd/newdoc.rs +++ b/crates/engine/src/cmd/newdoc.rs @@ -338,8 +338,12 @@ impl DocSettings { id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect, + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }) .collect(); d @@ -513,6 +517,7 @@ pub const CATEGORIES: &[Category] = &[ ("Logo 1000×1000", 1000.0, 1000.0, PX), ("Business Card 3.5 × 2 in", 3.5, 2.0, IN), ("Business Card 85 × 55 mm", 85.0, 55.0, MM), + ("Business Card 91 × 55 mm", 91.0, 55.0, MM), ("Letterhead Letter", 8.5, 11.0, IN), ("Letterhead A4", 210.0, 297.0, MM), ("Envelope DL", 220.0, 110.0, MM), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 186630a88..c559b4bff 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -6,10 +6,11 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{BlendMode, Paint}; -use vectorcraft_doc::corners::set_corners; +use vectorcraft_doc::corners::{convert_radii_relative, set_corners}; use vectorcraft_doc::{Appearance, Document, Knockout, LiveCorners, LiveShape, Node, NodeId, NodeKind, OrientedBox, Scaling}; use vectorcraft_geom::shapes::CornerKind; use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2}; +use vectorcraft_render::effects; use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_transform}; use super::opacitymask::percent; @@ -86,7 +87,7 @@ pub fn specs() -> Vec { "Nudge", [], None, - "{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, else ruler guides)", + "{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the objects that move whole; default: prefs transformPatternTiles), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} arrow-key nudge by the keyboard increment (the selected anchors, else objects, along the x and y axes turned by the constrainAngle preference; else ruler guides, along the page axes)", has_selection_or_guides, nudge ), @@ -227,7 +228,15 @@ pub fn specs() -> Vec { has_selection, |s, _| clip_edit(s, true) ), - cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id?} (default: the selection's first object)", has_doc, isolate), + cmd!( + "object.isolate", + "Enter Isolation Mode", + [], + None, + "{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused", + has_doc, + isolate + ), cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation), cmd!( "object.setProps", @@ -243,8 +252,8 @@ pub fn specs() -> Vec { "Align", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", - has_selection, + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points align instead: to their own bounds, or to the artboard (#1180)", + has_selection_or_guides, align ), cmd!( @@ -252,7 +261,7 @@ pub fn specs() -> Vec { "Distribute", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key object, select.key: the key object stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; not used without a key object or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line: without spacing and a key object they are spaced evenly, the first and last objects stay put, and fewer than three objects don't move; with spacing and a key object, see spacing (two objects are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", can_distribute, distribute ), @@ -261,7 +270,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected; default: the selection; \"key\" spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the objects equal, in the order of their left (top) edges: the first object's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", + "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", can_distribute, distribute_spacing ), @@ -270,7 +279,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid; a number past 8 is read as 8), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), @@ -280,7 +289,7 @@ pub fn specs() -> Vec { "Live Shape Properties", [], None, - "{id?, ids?, radius?: pt, kind?: \"round\"|\"invertedRound\"|\"chamfer\", corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", + "{id?, ids?, radius?: pt (or % of each corner's maximum when `relativeRadii` is true), kind?: \"round\"|\"invertedRound\"|\"chamfer\", relativeRadii?: bool (Relative rounding, as in the reference app: store radii as fractions of each corner's maximum so they scale with the shape), corners?: [i…], items?: [{id, corners?: [i…]}] (several objects, each with its own corners, in place of ids and corners: the canvas's corner widgets across a selection), sides?: n, polygonAngle?: degrees (counterclockwise from its first vertex straight up), polygonRadius?: pt (centre to vertex), sideLength?: pt (sets the radius), makeSidesEqual?: true (a polygon scaled unevenly: drops the uneven scale and shear, keeping its centre, angle and mean radius), pieStart?, pieEnd?: degrees (an ellipse's pie, counterclockwise from 3 o'clock; 0 to 360 is the whole ellipse), invertPie?: true (swaps them: the other part of the ellipse)} (Live Corners on any path: radius and kind set its corners (anchors that aren't smooth, where two sides, straight or curved (#1144), meet at an angle): `corners` (anchor indices of the path with its corners uncut, counting every subpath's anchors in order: a rectangle's 0 top-left, 1 top-right, 2 bottom-right, 3 bottom-left; a polygon's from the first vertex clockwise), else the corners with a Direct-Selected anchor, else every corner; each radius is drawn no larger than half the corner's shorter side allows; a path that isn't a live shape keeps its uncut outline so its corners stay editable, and is plain again once none is cut; sides: a polygon's, which keep the radius they shared; the polygon params apply in that order: sides, makeSidesEqual, polygonAngle, then sideLength or else polygonRadius)", has_selection, set_live_shape ), @@ -294,7 +303,7 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { // Direct-selected anchors transform alone (see [`apply_transform`]), about their own centre. let st = s.doc()?; if !bool_or(p, "copy", false) - && let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds) + && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) { return Ok(b.center()); } @@ -305,10 +314,16 @@ fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result { /// its `strokes` and `corners` params, else the preferences. The values join the command's journal /// entry, so replaying it scales the same way whatever the preferences are then. pub(crate) fn scaling(s: &mut Session, p: &Value) -> Scaling { + let sc = scaling_options(s, p); + s.note_journal("strokes", json!(sc.strokes)); + s.note_journal("corners", json!(!sc.keep_corners)); + sc +} + +/// [`scaling`] without noting the values in the journal. +fn scaling_options(s: &Session, p: &Value) -> Scaling { let strokes = bool_or(p, "strokes", s.prefs.scale_strokes); let corners = bool_or(p, "corners", s.prefs.scale_corners); - s.note_journal("strokes", json!(strokes)); - s.note_journal("corners", json!(corners)); Scaling { strokes, effects: strokes.then_some(vectorcraft_render::effects::scale_effect), @@ -341,6 +356,11 @@ pub(crate) fn transforms_objects(p: &Value) -> bool { bool_or(p, "objects", true) } +/// The on/off params of the transform commands that [`apply_transform`], [`scaling`], +/// [`transform_patterns`] and [`transforms_objects`] read. The transform commands in this file +/// check them with [`check_switches`] before they change anything. +const TRANSFORM_SWITCHES: [&str; 6] = ["copy", "strokes", "corners", "patterns", "objects", "typeAreas"]; + /// `xf` on `n` with Transform Objects (`objects`, see [`transforms_objects`]) on or off. pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: bool) { if objects { @@ -360,6 +380,21 @@ pub(crate) fn transform_node(n: &mut Node, xf: Affine, sc: Scaling, objects: boo /// `anchors` (`ids` being the selection): with anchors selected with Direct Selection and no /// copy, only those anchors transform, their handles with them, and the objects selected whole. pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf: Affine, p: &Value, anchors: bool) -> Result { + apply_transform_moving_guides(s, label, ids, xf, p, anchors, None) +} + +/// [`apply_transform`], with the selected ruler guides moved by `guides` instead of by the +/// translation `xf` when given (a nudge along a turned Constrain Angle moves the art along it and +/// the guides along the page's axes). +fn apply_transform_moving_guides( + s: &mut Session, + label: &str, + ids: Vec, + xf: Affine, + p: &Value, + anchors: bool, + guides: Option, +) -> Result { let copy = bool_or(p, "copy", false); let areas = bool_or(p, "typeAreas", false); let objects = transforms_objects(p); @@ -367,7 +402,7 @@ pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec, xf sc.patterns = transform_patterns(s, p, &ids)?; let c = xf.as_coeffs(); let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9; - let delta = Vec2::new(c[4], c[5]); + let delta = guides.unwrap_or(Vec2::new(c[4], c[5])); let with_guides = objects && ids_param(p, "ids").is_none() && is_translation; let has_guides = with_guides && !s.doc()?.selection.guides.is_empty(); // A transform of ids that name no object, or an identity transform without a copy, moves @@ -490,6 +525,8 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool { } fn transform(s: &mut Session, p: &Value) -> Result { + check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?; + check_switches(p, &["anchors"], "object.transform")?; let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?; let (ids, anchors) = match ids_param(p, "ids") { Some(v) => (outermost(&s.doc()?.doc, v), false), @@ -500,15 +537,32 @@ fn transform(s: &mut Session, p: &Value) -> Result { fn move_cmd(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["dx", "dy"], "object.move")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.move")?; let ids = selected_roots_to_transform(s)?; apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), p, true) } +/// The arrow keys' step `(dx, dy)` (page axes, y down) along the x and y axes turned by General › +/// Constrain Angle (`deg`, counter-clockwise on the page): with 30°, → moves 30° up from the +/// horizontal, as the reference app's arrow keys follow the constrain angle (users set it to −90° +/// to nudge along a view rotated by 90°: +/// ). +pub(crate) fn along_constrain_angle(dx: f64, dy: f64, deg: f64) -> (f64, f64) { + // Whole turns are the page's axes, exactly; quarter turns are exact too. + let Some(axes) = vectorcraft_geom::constrain_axes(deg) else { return (dx, dy) }; + let v = axes * Point::new(dx, dy); + (v.x, v.y) +} + fn nudge(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["dx", "dy"], "object.nudge")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.nudge")?; + check_switches(p, &["big"], "object.nudge")?; let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 }; - let dx = f64_or(p, "dx", 0.0) * k; - let dy = f64_or(p, "dy", 0.0) * k; + let (dx, dy) = (f64_or(p, "dx", 0.0) * k, f64_or(p, "dy", 0.0) * k); + // Guides keep to the page's axes; art and anchors move along the constrain angle's. + let (gx, gy) = (dx, dy); + let (dx, dy) = along_constrain_angle(dx, dy, s.prefs.constrain_angle); if !s.doc()?.selection.anchors.is_empty() { // When the anchors move by 0, the document and its undo history stay as they are. if dx == 0.0 && dy == 0.0 { @@ -519,13 +573,13 @@ fn nudge(s: &mut Session, p: &Value) -> Result { if s.doc()?.selection.is_empty() { let copy = bool_or(p, "copy", false); // When guides move by 0 without a copy, the document and its undo history stay as they are. - if dx == 0.0 && dy == 0.0 && !copy { + if gx == 0.0 && gy == 0.0 && !copy { return ok(); } - return super::docmenu::guide_move(s, &json!({ "dx": dx, "dy": dy, "copy": copy })); + return super::docmenu::guide_move(s, &json!({ "dx": gx, "dy": gy, "copy": copy })); } let ids = selected_roots_to_transform(s)?; - apply_transform(s, "Move", ids, Affine::translate((dx, dy)), p, true) + apply_transform_moving_guides(s, "Move", ids, Affine::translate((dx, dy)), p, true, Some(Vec2::new(gx, gy))) } fn about(o: Point, a: Affine) -> Affine { @@ -534,6 +588,8 @@ fn about(o: Point, a: Affine) -> Affine { fn rotate(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], "object.rotate")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.rotate")?; + check_switches(p, &["absolute"], "object.rotate")?; let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let mut angle = f64_or(p, "angle", 0.0); @@ -547,6 +603,7 @@ fn rotate(s: &mut Session, p: &Value) -> Result { fn scale(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["sy"], "object.scale")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.scale")?; let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let sx = f64_req(p, "sx", "object.scale")? / 100.0; @@ -558,6 +615,7 @@ fn scale(s: &mut Session, p: &Value) -> Result { } fn reflect(s: &mut Session, p: &Value) -> Result { + check_switches(p, &TRANSFORM_SWITCHES, "object.reflect")?; let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let m = match p.get("axis") { @@ -578,6 +636,7 @@ fn reflect(s: &mut Session, p: &Value) -> Result { fn shear(s: &mut Session, p: &Value) -> Result { check_numbers(p, &["angle"], "object.shear")?; + check_switches(p, &TRANSFORM_SWITCHES, "object.shear")?; let ids = selected_roots_to_transform(s)?; let o = origin_of(s, p, &ids)?; let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan(); @@ -590,6 +649,7 @@ fn shear(s: &mut Session, p: &Value) -> Result { /// that `p` sets to true or false (a recorded Transform Again's) takes precedence. A Transform /// Each is repeated by running it again (its transform is per object, about each one's bounds). fn transform_again(s: &mut Session, p: &Value) -> Result { + check_switches(p, &TRANSFORM_SWITCHES, "object.transformAgain")?; if let Some(again) = s.doc()?.last_perspective.clone() { return super::distortcmds::transform_again(s, &again); } @@ -639,18 +699,24 @@ fn arrange(s: &mut Session, how: Arrange) -> Result { None => by_parent.push((par, vec![*id])), } } - for (par, group) in by_parent { + for (par, mut group) in by_parent { + // In a clip group or a clipping layer the bottom child is the clipping path: it stays + // there, and the art above it never goes below it (#3188). + let floor = usize::from(par.and_then(|p| d.node(p)).is_some_and(vectorcraft_doc::Node::clips)); + if floor > 0 { + group.retain(|id| d.position(*id).is_some_and(|(_, idx, _)| idx >= floor)); + } for (k, id) in group.iter().enumerate() { let Some((_, idx, len)) = d.position(*id) else { continue }; - if len == 0 { + if len <= floor { continue; } let last = len - 1; let to = match how { - Arrange::Front => last.saturating_sub(k), - Arrange::Back => k.min(last), + Arrange::Front => last.saturating_sub(k).max(floor), + Arrange::Back => (floor + k).min(last), Arrange::Forward => (idx + 1).min(last.saturating_sub(k)).max(idx), - Arrange::Backward => idx.saturating_sub(1).max(k.min(idx)), + Arrange::Backward => idx.saturating_sub(1).max(floor + k.min(idx.saturating_sub(floor))), }; if to != idx { d.move_node(*id, par, to)?; @@ -1047,6 +1113,7 @@ fn isolate(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id") .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("object.isolate", "missing id"))?; + let guides_locked = s.menu.guides_locked; let st = s.doc_mut()?; // Groups, layers and single objects (a path, a compound path, an image…); type isn't // isolated: a double-click on it edits its text. @@ -1054,8 +1121,19 @@ fn isolate(s: &mut Session, p: &Value) -> Result { return Err(bad("object.isolate", "type objects can't be isolated")); } let isolation = st.doc.node(id).filter(|n| n.extras().shaper.is_some()).and_then(|n| n.children()).and_then(|c| c.first()).map_or(id, |n| n.id); + // A hidden or locked object, one in a hidden or locked group or layer, and a guide path while + // Lock Guides is on aren't isolated either: a click with the Selection tool doesn't select them. + // A Shaper Group isolates its source group, so that group is the one checked. + if !st.doc.is_editable(isolation) { + return Err(bad("object.isolate", "the object is hidden or locked")); + } + if guides_locked && crate::is_guide_path(&st.doc, isolation) { + return Err(EngineError::Other("guides are locked".into())); + } st.isolation = Some(isolation); st.selection.clear(); + // New art on a locked artboard, now deselected, is protected. + st.sync_new_art(); st.revision += 1; ok() } @@ -1071,6 +1149,8 @@ fn exit_isolation(s: &mut Session, _: &Value) -> Result { if guides_locked { crate::deselect_guides(&st.doc, &mut st.selection); } + // The art on a locked artboard can't be selected. + st.protect_locked_artboards(&[], &Default::default(), None); } st.revision += 1; ok() @@ -1103,6 +1183,8 @@ fn data_param(m: &serde_json::Map) -> Result Result { + check_switches(p, &["visible", "locked", "isolate", "knockoutShape"], "object.setProps")?; + check_numbers(p, &["opacity"], "object.setProps")?; let ids = targets(s, p)?; let opacity = p.get("opacity").and_then(Value::as_f64).map(percent); let blend = match str_param(p, "blend") { @@ -1203,14 +1285,15 @@ fn set_props(s: &mut Session, p: &Value) -> Result { impl Session { /// The bounds of `ids` the Transform panel, the bounding box, Align and Distribute measure: - /// visual bounds (strokes included) with Use Preview Bounds on, else geometric bounds. + /// with Use Preview Bounds on, visual bounds as drawn (strokes and geometry effects included, + /// see [`effects::preview`]), else geometric bounds. pub fn transform_bounds(&self, ids: &[NodeId]) -> Option { - self.doc().ok()?.doc.bounds_of(ids, self.prefs.use_preview_bounds) + effects::preview::bounds_of(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) } /// [`Session::transform_bounds`] square to the objects' own angle (their rotated bounding /// box, see [`Document::oriented_bounds`]). pub fn transform_box(&self, ids: &[NodeId]) -> Option { - self.doc().ok()?.doc.oriented_bounds(ids, self.prefs.use_preview_bounds) + effects::preview::oriented_bounds(&self.doc().ok()?.doc, ids, self.prefs.use_preview_bounds) } } @@ -1222,9 +1305,9 @@ fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result { } } -/// Param `key` of the Align commands and Shear: one of `values`, or `None` when it is missing or -/// null. Any other value is an error that lists `values`. -fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { +/// Param `key` of the Align commands, Shear and `text.setFormat`'s lists: one of `values`, or +/// `None` when it is missing or null. Any other value is an error that lists `values`. +pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; match v.as_str() { Some(s) if values.contains(&s) => Ok(Some(s)), @@ -1246,9 +1329,9 @@ fn spacing(p: &Value, cmd: &str) -> Result> { .transpose() } -/// `ids` with their bounds (`preview`: visual bounds). +/// `ids` with their bounds (`preview`: visual bounds as drawn, see [`effects::preview::bounds_of`]). fn items_bounds(d: &Document, ids: &[NodeId], preview: bool) -> Vec<(NodeId, Rect)> { - ids.iter().filter_map(|id| Some((*id, d.bounds_of(&[*id], preview)?))).collect() + ids.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], preview)?))).collect() } /// Move each object of `moves` by its offset and each ruler guide of `guides` (index, position) @@ -1308,11 +1391,22 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: O Ok((r, true, true)) } +/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to +/// Selection when no objects are selected. +fn selected_guides_bounds(st: &crate::DocState) -> Option { + st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, g| { + let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) }; + Some(acc.map(|a| a.union(r)).unwrap_or(r)) + }) +} + fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result { let st = s.doc()?; match align_to(s, p) { Some("artboard") => { - let b = st.doc.bounds_of(ids, preview).unwrap_or_default(); + let b = effects::preview::bounds_of(&st.doc, ids, preview) + .or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten()) + .unwrap_or_default(); // Align to Artboard uses artboard `artboard` (the app passes the active one). Without // it, or with an index the document doesn't have, it uses the artboard under the center // of the selection, or the first one when no artboard is there. @@ -1322,9 +1416,15 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu } Some("key") => { let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?; - st.doc.bounds_of(&[k], preview).ok_or(EngineError::NoNode(k)) + effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k)) + } + _ => { + if ids.is_empty() { + selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into())) + } else { + effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())) + } } - _ => st.doc.bounds_of(ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())), } } @@ -1353,6 +1453,8 @@ fn align(s: &mut Session, p: &Value) -> Result { let preview = preview_bounds(s, p, "object.align")?; let (h, v) = direction(p, "object.align")?; choice(p, "object.align", "to", &["selection", "artboard", "key"])?; + check_switches(p, &["patterns"], "object.align")?; + check_numbers(p, &["artboard"], "object.align")?; if h.is_none() && v.is_none() { return Err(bad("object.align", "give horizontal or vertical")); } @@ -1389,15 +1491,22 @@ fn align(s: &mut Session, p: &Value) -> Result { apply_moves(s, "Align", p, &ids, &moves, &guides) } -/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a -/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal -/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides -/// when aligning to the selection or the artboard. +/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge, +/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that +/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put. +/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are +/// selected; with objects in the selection they stay put. fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result> { - if align_to(s, p) != Some("key") { + let st = s.doc()?; + let to = align_to(s, p); + let move_guides = match to { + Some("key" | "artboard") => true, + Some("selection") => st.selection.is_empty(), + _ => false, + }; + if !move_guides { return Ok(vec![]); } - let st = s.doc()?; let at = |vertical: bool| match (vertical, h, v) { (true, Some("left"), _) => Some(r.x0), (true, Some("center"), _) => Some(r.center().x), @@ -1407,11 +1516,12 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str> (false, _, Some("bottom")) => Some(r.y1), _ => None, }; + let skip_key = to == Some("key"); Ok(st .selection .guides .iter() - .filter(|&&i| Some(i) != st.selection.key_guide) + .filter(|&&i| !skip_key || Some(i) != st.selection.key_guide) .filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?))) .collect()) } @@ -1467,10 +1577,68 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st ok() } -/// Distribute needs two or more objects, or anchor points selected with Direct Selection (they -/// may all be on one path). +/// Distribute needs two or more objects and ruler guides, or anchor points selected with Direct +/// Selection (they may all be on one path). fn can_distribute(s: &Session) -> std::result::Result<(), String> { - if s.active().is_some_and(|st| !st.selection.anchors.is_empty()) { Ok(()) } else { has_multi(s) } + if s.active().is_some_and(|st| !st.selection.anchors.is_empty() || st.selection.len() + st.selection.guides.len() >= 2) { + Ok(()) + } else { + has_multi(s) + } +} + +/// What Distribute and Distribute Spacing space: a selected object, or a selected ruler guide +/// across the axis (#1597), which has no size. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Spaced { + Object(NodeId), + Guide(usize), +} + +/// The selected objects `ids` with their bounds (`preview`: visual bounds), and the selected +/// ruler guides across the axis (vertical ones when `horiz`) with their line as their bounds. +/// Guides along the axis are left out, so they stay where they are. +fn spaced_items(s: &Session, ids: &[NodeId], preview: bool, horiz: bool) -> Result> { + let st = s.doc()?; + let objects = items_bounds(&st.doc, ids, preview).into_iter().map(|(id, r)| (Spaced::Object(id), r)); + let guides = st.selection.guides.iter().filter_map(|&i| { + let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?; + Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos))) + }); + Ok(objects.chain(guides).collect()) +} + +/// The index in `items` of what Distribute and Distribute Spacing keep in place with a spacing: +/// the selected root that is the key object or contains it ([`key_root`]), or the key guide when +/// aligning to the key (#2143). +fn spaced_key(s: &Session, p: &Value, ids: &[NodeId], items: &[(Spaced, Rect)]) -> Option { + let key = match key_root(s, ids) { + Some(id) => Spaced::Object(id), + None => key_guide(s, p).and(s.doc().ok()?.selection.key_guide).map(Spaced::Guide)?, + }; + items.iter().position(|(it, _)| *it == key) +} + +/// Whether the key is a guide along the axis (a horizontal guide for a horizontal distribution), +/// aligning to the key. It has no line there, so a spacing from it leaves everything where it +/// is, as Align does. +fn key_guide_along(s: &Session, p: &Value, horiz: bool) -> bool { + key_guide(s, p).is_some_and(|(_, vertical)| vertical != horiz) +} + +/// Moves each of `deltas` that far along the axis (x when `horiz`) as undo step `label`, as +/// [`apply_moves`] does: an object by that offset, a ruler guide to its new position. +fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: bool, deltas: &[(Spaced, f64)]) -> Result { + let along = |d: f64| if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) }; + let (mut moves, mut guides) = (vec![], vec![]); + let d = &s.doc()?.doc; + for &(it, delta) in deltas { + match it { + Spaced::Object(id) => moves.push((id, along(delta))), + Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))), + } + } + apply_moves(s, label, p, ids, &moves, &guides) } /// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them @@ -1513,6 +1681,8 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option Result { + check_switches(p, &["patterns"], "object.distribute")?; + check_numbers(p, &["artboard"], "object.distribute")?; let ids = selected_roots(s)?; let preview = preview_bounds(s, p, "object.distribute")?; let board = distribute_to(s, p, &ids, preview, "object.distribute")?; @@ -1524,7 +1694,6 @@ fn distribute(s: &mut Session, p: &Value) -> Result { (h, _) => distribute_anchors(s, "Distribute", h.is_some(), None), }; } - let mut items = items_bounds(&s.doc()?.doc, &ids, preview); let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? { (Some(h), _) => ( true, @@ -1544,44 +1713,51 @@ fn distribute(s: &mut Session, p: &Value) -> Result { ), _ => return Err(bad("object.distribute", "give horizontal or vertical")), }; + // The selected guides across the axis are spaced with the objects (#1597). A guide has no + // size, so its left edge, center and right edge are all its line. + let mut items = spaced_items(s, &ids, preview, horiz)?; items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz))); let n = items.len(); - // With a spacing, the key object (or the compound path or selected group it is in) stays where - // it is, and the lines are placed `spacing` apart, outward from its line, in their order. - // Without a key object the spacing is not used. `from_key` is the key's index in `items` and - // the spacing. - let from_key = spacing.zip(key_root(s, &ids)).and_then(|(g, k)| Some((items.iter().position(|(id, _)| *id == k)?, g))); - // Two objects are enough on an artboard, where they go to its edges, and when they are spaced - // from a key object. + if spacing.is_some() && key_guide_along(s, p, horiz) { + return ok(); + } + // With a spacing, the key object (or the compound path or selected group it is in) or the key + // guide stays where it is, and the lines are placed `spacing` apart, outward from its line, in + // their order. Without a key the spacing is not used. `from_key` is the key's index in + // `items` and the spacing. + let from_key = spacing.zip(spaced_key(s, p, &ids, &items)).map(|(g, k)| (k, g)); + // Two items are enough on an artboard, where they go to its edges, and when they are spaced + // from a key. if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) { return ok(); } let (first_box, last_box) = (&items[0].1, &items[n - 1].1); - // `first` and `last` are the lines that the first and last objects end on. Without an artboard - // the first and last objects stay where they are. On an artboard the first object moves until - // its left (top) edge is on the artboard's left (top) edge, and the last object moves until its + // `first` and `last` are the lines that the first and last items end on. Without an artboard + // the first and last items stay where they are. On an artboard the first item moves until + // its left (top) edge is on the artboard's left (top) edge, and the last item moves until its // right (bottom) edge is on the artboard's right (bottom) edge. let (first, last) = match board { Some(r) if horiz => (key(first_box, horiz) + r.x0 - first_box.x0, key(last_box, horiz) + r.x1 - last_box.x1), Some(r) => (key(first_box, horiz) + r.y0 - first_box.y0, key(last_box, horiz) + r.y1 - last_box.y1), None => (key(first_box, horiz), key(last_box, horiz)), }; - let moves: Vec<(NodeId, Vec2)> = items + let deltas: Vec<(Spaced, f64)> = items .iter() .enumerate() - .map(|(i, (id, r))| { + .map(|(i, (it, r))| { let target = match from_key { Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64), None => first + (last - first) * i as f64 / (n - 1) as f64, }; - let delta = target - key(r, horiz); - (*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }) + (*it, target - key(r, horiz)) }) .collect(); - apply_moves(s, "Distribute", p, &ids, &moves, &[]) + apply_spaced(s, "Distribute", p, &ids, horiz, &deltas) } fn distribute_spacing(s: &mut Session, p: &Value) -> Result { + check_switches(p, &["patterns"], "object.distributeSpacing")?; + check_numbers(p, &["artboard"], "object.distributeSpacing")?; let ids = selected_roots(s)?; let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical"); let preview = preview_bounds(s, p, "object.distributeSpacing")?; @@ -1590,16 +1766,21 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { if !s.doc()?.selection.anchors.is_empty() { return distribute_anchors(s, "Distribute Spacing", horiz, spacing); } - let mut items = items_bounds(&s.doc()?.doc, &ids, preview); + // The selected guides across the axis are spaced with the objects, as items with no size (#1597). + let mut items = spaced_items(s, &ids, preview, horiz)?; items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); + if spacing.is_some() && key_guide_along(s, p, horiz) { + return ok(); + } let n = items.len(); let &[(_, first), .., (_, last)] = items.as_slice() else { - return Err(EngineError::Other("select two or more objects to distribute".into())); + // Selected guides along the axis have nothing to space. + return if s.doc()?.selection.guides.is_empty() { Err(EngineError::Other("select two or more objects to distribute".into())) } else { ok() }; }; let size = |r: &Rect| if horiz { r.width() } else { r.height() }; let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; - // The objects start at `lo`, and without a spacing the last one ends at `hi`. These are the - // first object's left (top) edge and the last one's right (bottom) edge, or the artboard's + // The items start at `lo`, and without a spacing the last one ends at `hi`. These are the + // first item's left (top) edge and the last one's right (bottom) edge, or the artboard's // edges with Align to Artboard and no spacing. let (lo, hi) = match board.filter(|_| spacing.is_none()) { Some(r) if horiz => (r.x0, r.x1), @@ -1613,47 +1794,84 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { }; let mut pos = lo; let mut deltas = vec![]; - for (id, r) in &items { - deltas.push((*id, pos - start(r))); + for (it, r) in &items { + deltas.push((*it, pos - start(r))); pos += size(r) + gap; } - // With a spacing (aligning to a key object) the key object stays where it is, with the - // compound path or selected group it is in: the others are spaced from it. - let key = key_root(s, &ids); - let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1); - let moves: Vec<(NodeId, Vec2)> = - deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect(); - apply_moves(s, "Distribute Spacing", p, &ids, &moves, &[]) + // With a spacing the key stays where it is: the key object, with the compound path or + // selected group it is in, or the key guide (#2143). The others are spaced from it. + let fixed = spacing.and_then(|_| spaced_key(s, p, &ids, &items)).and_then(|k| deltas.get(k)).map_or(0.0, |k| k.1); + let deltas: Vec<(Spaced, f64)> = deltas.into_iter().map(|(it, d)| (it, d - fixed)).collect(); + apply_spaced(s, "Distribute Spacing", p, &ids, horiz, &deltas) } fn set_bounds(s: &mut Session, p: &Value) -> Result { - check_numbers(p, &["x", "y", "width", "height"], "object.setBounds")?; - let ids = selected_roots(s)?; + match bounds_transform(s, p, selected_roots(s)?, 0.0)? { + Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b), + Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false), + } +} + +/// What `object.setBounds` does with its values ([`bounds_transform`]). +enum Bounds { + /// With anchors selected with Direct Selection: the objects and the box of the anchors that + /// [`set_anchor_bounds`] moves or scales. + Anchors(Vec, Rect), + /// The objects and the transform that gives them the values. + Objects(Vec, Affine), +} + +/// A previewed `object.setBounds` that transforms objects runs as `object.transform` with the +/// transform [`bounds_transform`] finds for it, so the interaction commits that transform (Transform +/// Again repeats it, and the journal holds it). A bounding-box handle drag with Use Preview Bounds +/// previews `object.setBounds`. The objects are those `object.transform` transforms (a path of a +/// compound path selected on its own is one), and the art keeps at least 1e-4 of its width and +/// height, as with [`vectorcraft_tools::bbox::scale_for_drag`]. `None` with anchors selected with +/// Direct Selection and when the values are not valid. +pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(String, Value)> { + 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 mut p = params.as_object().cloned().unwrap_or_default(); + for k in ["x", "y", "width", "height", "reference", "proportional"] { + p.remove(k); + } + p.insert("matrix".into(), json!(xf.as_coeffs())); + Some(("object.transform".to_string(), Value::Object(p))) +} + +/// 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, least: f64) -> Result { + 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) && !bool_or(p, "copy", false) - && let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds) + && let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds) { - return set_anchor_bounds(s, p, ids, b); + return Ok(Bounds::Anchors(ids, b)); } let d = &s.doc()?.doc; // Sizes are measured square to the bounding box, which a rotation turns with the objects: // the math below runs in its frame. let frame = d.oriented_bounds(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?; let g = frame.rect; - // With Use Preview Bounds the values measure the visual box, whose margins around the + // With Use Preview Bounds the values measure the visual box as drawn, whose margins around the // geometric box (left, top, right, bottom) scale with the strokes or stay. The margins of // `vs`, the visual box without the strokes in symbols' art, scale; those strokes keep their // weight when an instance scales, so the rest of the margins stay. let (v, vs) = if s.prefs.use_preview_bounds { - (d.oriented_bounds(&ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g)) + (effects::preview::oriented_bounds(d, &ids, true).map_or(g, |b| b.rect), scaling_box(d, &ids, &frame).unwrap_or(g)) } else { (g, g) }; let margins = |b: Rect| [g.x0 - b.x0, g.y0 - b.y0, b.x1 - g.x1, b.y1 - g.y1]; let (m, all) = (margins(vs), margins(v)); let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]]; - let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize; let rp = vectorcraft_geom::reference_point(v, refi); let (w, h) = wanted_size(p, v); // The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is @@ -1689,20 +1907,112 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { // leave a rounding error in it on a rotated object or with Use Preview Bounds. let current = |k: &str, now: f64| p.get(k).and_then(Value::as_f64).is_none_or(|x| x == now); if current("x", page.x) && current("y", page.y) && current("width", v.width()) && current("height", v.height()) { - return apply_transform(s, "Transform", ids, Affine::IDENTITY, p, false); + return Ok(Bounds::Objects(ids, Affine::IDENTITY)); } let to = frame.to_local(Point::new(f64_or(p, "x", page.x), f64_or(p, "y", page.y))); // The scales and the point above can miss the size or the reference point when the margins of // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one - // by one ([`Spans::measure`]), and the scales solved for. - let solved = if s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0 { - Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp)) - } else { + // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are + // measured as drawn after each scale tried instead ([`refit_drawn`]). + let symbols = d.symbol_boxes(); + let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n)); + let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { None + } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { + let options = scaling_options(s, p); + let to_local = frame.to_doc().inverse(); + let measure = |sx: f64, sy: f64| { + let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy))); + let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() }; + ids.iter().filter_map(|id| d.node(*id)).fold(None, |acc, n| { + let mut n = n.clone(); + n.transform(xf, sc); + vectorcraft_geom::union_opt(acc, effects::preview::bounds_in(&symbols, &n, to_local)) + }) + }; + // The scales above keep the effects' reach as a margin that stays, and give 0 for a size + // smaller than that reach. Strokes and effects that scale shrink the reach with the art, so + // the search then starts from the size wanted over the size as drawn. + let seed = |s: f64, size: f64, now: f64| if s <= 0.0 && size > 0.0 && now > 1e-9 { size / now } else { s }; + refit_drawn(measure, refi, [w, h], (seed(sx, w, v.width()), seed(sy, h, v.height())), [g.width() > 1e-9, g.height() > 1e-9]) + } else { + Spans::measure(d, &ids, &frame, strokes).refit(rp, refi, [w, h], (sx, sy, new_rp)) }; - let (scale, new_rp) = solved.map_or((scale, new_rp), |(sx, sy, at)| (about(rp, Affine::scale_non_uniform(sx, sy)), at)); - apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false) + let (sx, sy, new_rp) = solved.unwrap_or((sx, sy, new_rp)); + // A scale below `least` of art with a width (height) keeps `least`, and the reference point + // moves with the scales as above. + let keep = |s: f64, geo: f64| if geo > 1e-9 && (0.0..least).contains(&s) { least } else { s }; + let (kx, ky) = (keep(sx, g.width()), keep(sy, g.height())); + let scale = about(rp, Affine::scale_non_uniform(kx, ky)); + let new_rp = if (kx, ky) == (sx, sy) { new_rp } else { scale * gp + (sp - gp) * f(kx, ky) + (rp - sp) }; + let xf = frame.conjugate(Affine::translate(to - new_rp) * scale); + Ok(Bounds::Objects(ids, xf)) +} + +/// The scales along x and y that give the box `measure` returns for them the size `size`, and point +/// `refi` of that box: the point that goes to the reference point's place. The search starts at +/// `start`. Each step measures how the width and the height change with each scale that `free` +/// marks true, by measuring again with that scale moved by 1e-4 of it (at least 1e-6), and moves +/// those scales by the least-squares solution of that linear change, never below 0. The width can +/// change with the y scale and the height with the x scale (a geometry effect can turn the art). A +/// scale that `free` marks false stays as it is. The search stops when both sizes are within 1e-9 +/// of the sizes wanted (of the largest of 1 and those sizes), when no scale moves, when `measure` +/// finds no box, or after 16 steps, and returns the scales that came nearest. `None` when `measure` +/// finds no box for `start`. +fn refit_drawn( + measure: impl Fn(f64, f64) -> Option, + refi: usize, + size: [f64; 2], + start: (f64, f64), + free: [bool; 2], +) -> Option<(f64, f64, Point)> { + let [w, h] = size; + let tolerance = 1e-9 * w.max(h).max(1.0); + let miss = |b: Rect| (b.width() - w).abs() + (b.height() - h).abs(); + let (mut sx, mut sy) = start; + let mut b = measure(sx, sy)?; + let mut best = (sx, sy, b); + for _ in 0..16 { + let f = [b.width() - w, b.height() - h]; + if f[0].abs() <= tolerance && f[1].abs() <= tolerance { + break; + } + // How the width and the height change with the x scale (`a`) and the y scale (`c`). + let change = |x: bool| -> Option<[f64; 2]> { + let (on, s) = if x { (free[0], sx) } else { (free[1], sy) }; + if !on { + return Some([0.0, 0.0]); + } + let ds = (s.abs() * 1e-4).max(1e-6); + let m = if x { measure(sx + ds, sy) } else { measure(sx, sy + ds) }?; + Some([(m.width() - b.width()) / ds, (m.height() - b.height()) / ds]) + }; + let (Some(a), Some(c)) = (change(true), change(false)) else { break }; + let dot = |u: [f64; 2], v: [f64; 2]| u[0] * v[0] + u[1] * v[1]; + let (aa, ac, cc, af, cf) = (dot(a, a), dot(a, c), dot(c, c), dot(a, f), dot(c, f)); + let det = aa * cc - ac * ac; + let (dx, dy) = if det > 1e-12 * aa * cc && det > 0.0 { + ((ac * cf - cc * af) / det, (ac * af - aa * cf) / det) + } else if aa >= cc && aa > 0.0 { + (-af / aa, 0.0) + } else if cc > 0.0 { + (0.0, -cf / cc) + } else { + break; + }; + let (nx, ny) = ((sx + dx).max(0.0), (sy + dy).max(0.0)); + if !(nx.is_finite() && ny.is_finite()) || (nx == sx && ny == sy) { + break; + } + let Some(next) = measure(nx, ny) else { break }; + (sx, sy, b) = (nx, ny, next); + if miss(b) < miss(best.2) { + best = (sx, sy, b); + } + } + let (sx, sy, b) = best; + Some((sx, sy, vectorcraft_geom::reference_point(b, refi))) } /// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or @@ -1724,7 +2034,7 @@ fn wanted_size(p: &Value, b: Rect) -> (f64, f64) { /// `b`, the box around the anchors (and the objects selected whole) square to the page, and only /// the anchors, their handles with them, and those objects move or scale. fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { - let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4).min(8) as usize; let rp = vectorcraft_geom::reference_point(b, refi); let (w, h) = wanted_size(p, b); // A single anchor, or anchors in a line, have no size to scale on that side. @@ -2069,6 +2379,8 @@ fn corners_param(p: &Value) -> Result>> { } fn set_live_shape(s: &mut Session, p: &Value) -> Result { + check_switches(p, &["invertPie", "makeSidesEqual", "relativeRadii"], "object.setLiveShape")?; + check_numbers(p, &["radius", "sides"], "object.setLiveShape")?; // `items`: objects each with its own `corners`; else `ids` (or the selection) sharing them. let items = match p.get("items").filter(|v| !v.is_null()) { None => None, @@ -2104,6 +2416,7 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { }) .transpose()?; let radius = p.get("radius").and_then(Value::as_f64); + let relative_radii = p.get("relativeRadii").and_then(Value::as_bool); let sides = p.get("sides").and_then(Value::as_u64); // A number that must be `what` (None when absent). let number = |k: &str, ok: fn(f64) -> bool, what: &str| -> Result> { @@ -2148,6 +2461,9 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { Some(LiveShape::Ellipse { pie, .. }) if pie_of(*pie) != *pie => return false, _ => {} } + if relative_radii.is_some_and(|on| live.as_deref().map(|l| l.radii_relative()) != Some(on)) { + return false; + } if radius.is_none() && kind.is_none() { return true; } @@ -2156,9 +2472,11 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { // Setting corners folds a scale left in a rectangle's transform into its size, which // changes it. live.as_deref().is_none_or(|l| l.folded() == *l) - && picked - .iter() - .all(|k| *k < c.base.anchor_count() && radius.is_none_or(|r| c.radius(*k) == r.max(0.0)) && kind.is_none_or(|kd| c.kind(*k) == kd)) + && picked.iter().all(|k| { + *k < c.base.anchor_count() + && radius.is_none_or(|r| (c.drawn_radius(*k) - r.max(0.0)).abs() < 1e-9) + && kind.is_none_or(|kd| c.kind(*k) == kd) + }) }; if ids.iter().all(has_values) { return ok(); @@ -2192,6 +2510,12 @@ fn set_live_shape(s: &mut Session, p: &Value) -> Result { } *path = l.to_path(); } + if let Some(on) = relative_radii + && let Some(l) = live.as_deref_mut() + { + convert_radii_relative(l, on); + *path = l.to_path(); + } if radius.is_none() && kind.is_none() { continue; } diff --git a/crates/engine/src/cmd/objects_on_path.rs b/crates/engine/src/cmd/objects_on_path.rs index c8d07cb69..52f63ffe0 100644 --- a/crates/engine/src/cmd/objects_on_path.rs +++ b/crates/engine/src/cmd/objects_on_path.rs @@ -21,7 +21,7 @@ use std::collections::HashMap; use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::objects_on_path::{CENTER, ObjectsOnPath, attachable_path, is_objects_on_path, place}; +use vectorcraft_doc::objects_on_path::{CENTER, Held, ObjectsOnPath, attachable_path, held, is_objects_on_path, place}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; @@ -168,7 +168,10 @@ fn path_refused(d: &Document, path: NodeId) -> Option<&'static str> { if group_around(d, path).is_some() { return Some("the path is already in an Objects on Path group"); } - None + match held(d, path)? { + Held::Live => Some("a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects"), + Held::Clipping => Some("a clipping path can't take objects"), + } } /// Why `n` can't be attached to a path, if it can't (the help page names graphs). @@ -179,6 +182,11 @@ fn object_refused(d: &Document, n: &Node) -> Option<&'static str> { if group_around(d, n.id).is_some() { return Some("an object already on a path can't be attached again"); } + match held(d, n.id) { + Some(Held::Live) => return Some("objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path"), + Some(Held::Clipping) => return Some("a clipping path can't be attached to a path"), + None => {} + } let mut graph = false; n.walk(&mut |c| graph |= c.extras().graph.is_some()); graph.then_some("graphs can't be attached to a path") diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs index 20efbf361..a874bc88f 100644 --- a/crates/engine/src/cmd/package.rs +++ b/crates/engine/src/cmd/package.rs @@ -4,7 +4,7 @@ //! report). The packaged document links to the copies (Relink); the open document doesn't change. //! Without a folder (the web, agents) the same files come back as a zip archive. -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::io::Write; use std::path::{Path, PathBuf}; @@ -101,6 +101,34 @@ fn safe_asset_name(name: &str) -> String { if name.is_empty() { "asset".into() } else { name.into() } } +/// A file placed: (page, bounding, page box) of each placed object showing it. +type PlacedPages = Vec<(u32, bool, vectorcraft_doc::PageBox)>; + +#[cfg(test)] +thread_local! { + /// Tests: how many placed PDF or `.ai` pages [`page_fonts`] read on this thread. + pub(crate) static PAGES_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Tests: how many linked `.vectorcraft` files had their preview and PDF read on this thread, + /// to keep them in the rewritten copy. + pub(crate) static PARTS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The fonts of the type on `pages` of the PDF or `.ai` file `name` (`bytes`, found at `path`); +/// none for other files. +fn page_fonts(name: &str, bytes: &[u8], path: &str, pages: &PlacedPages) -> BTreeSet { + let mut fonts = BTreeSet::new(); + if super::place::document::is_pdf(name, bytes) { + for &(page, bounding, page_box) in pages { + #[cfg(test)] + PAGES_READ.with(|c| c.set(c.get() + 1)); + if let Ok(a) = super::place::document::document_art(name, bytes, Some(path), page, bounding, page_box, C) { + fonts.extend(super::fonts::art_fonts(&a.doc, &a.nodes)); + } + } + } + fonts +} + /// Collect dependencies depth-first so nested VectorCraft files are rewritten before /// their parents: each parent can record the final hash of the rewritten child. /// Cycles, excessive nesting, and unreadable children are reported, not fatal. @@ -148,7 +176,20 @@ impl<'a> Collector<'a> { self.warnings.push(message); } - fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize) -> Result<()> { + /// Warns when a file that placed `.ai` file `source` links (`name`) was copied as `copy`: the + /// copy of `source` finds it only by its name, beside it. + fn renamed(&mut self, lines: &mut Vec, source: &str, path: &str, name: &str, copy: &str) { + if Path::new(copy).file_name().and_then(|n| n.to_str()) != Some(name) { + let message = format!( + "{path}: copied as {copy}; the copy of {source} looks for it as {name} and shows the file of that name, if any, else its preview" + ); + self.warn(lines, message); + } + } + + /// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data, + /// whose links are not rewritten, so its copy finds them by name beside it. + fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> { // The caller checks cycles and depth before descending; active documents have // a destination reserved so back-links can point at their single packaged copy. self.visiting.insert(source.to_string(), destination.to_string()); @@ -167,11 +208,24 @@ impl<'a> Collector<'a> { // Different linked documents can reuse the same stale absolute path yet // resolve it to distinct local assets. Keep a mapping per document. let mut local = BTreeMap::::new(); + // The fonts of the type on the PDF and `.ai` pages placed linked. + let mut placed_fonts = BTreeSet::new(); + let mut placed = BTreeMap::::new(); + doc.visit_placed(|_, p| { + let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box); + let pages = placed.entry(p.link.path.clone()).or_default(); + if !pages.contains(&page) { + pages.push(page); + } + }); + if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() { + for file in super::links::linked_files(doc, Some(source)) { + if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) { + placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages)); + } + } + } if self.opts.copy_links { - let mut placed = HashSet::new(); - doc.visit_placed(|_, p| { - placed.insert(p.link.path.clone()); - }); heading(&mut lines, "LINKED FILES"); for file in super::links::linked_files(doc, Some(source)) { let found_path = file.found_path.as_deref().unwrap_or(&file.path); @@ -183,8 +237,17 @@ impl<'a> Collector<'a> { local.insert(file.path.clone(), Copied { name: ancestor, size: None, hash: None }); continue; } - if let Some(existing) = self.copies.get(found_path) { - local.insert(file.path.clone(), existing.clone()); + if let Some(existing) = self.copies.get(found_path).cloned() { + if by_name { + self.renamed(&mut lines, source, &file.path, &file.name, &existing.name); + } + // Another document may place other pages of a PDF copied already. + if self.opts.copy_fonts + && let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) + { + placed_fonts.extend(page_fonts(&file.name, bytes, found_path, pages)); + } + local.insert(file.path.clone(), existing); continue; } let Some(mut bytes) = file.bytes else { @@ -199,24 +262,42 @@ impl<'a> Collector<'a> { let name = safe_asset_name(&file.name); let unique = free_name(&name, if self.opts.links_folder { &mut self.link_names } else { &mut self.taken }); let destination_file = format!("{dir}{unique}"); - if placed.contains(&file.path) { + if by_name { + self.renamed(&mut lines, source, &file.path, &file.name, &destination_file); + } + // A placed PDF or .ai file is copied as it is, as an image is; the files a .ai file + // read from its editing data links are copied beside it. + let pages = placed.get(&file.path); + let placed_file = pages.is_some(); + if placed_file && let Some(mut art) = super::place::document::editing_data(&file.name, &bytes) { + if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { + self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); + } else { + self.collect(&mut art, found_path, &destination_file, depth + 1, true)?; + } + } else if self.opts.copy_fonts + && let Some(pages) = pages + { + placed_fonts.extend(page_fonts(&file.name, &bytes, found_path, pages)); + } + if placed_file && super::place::document::is_native(&file.name, &bytes) { if depth >= vectorcraft_doc::placed_document::MAX_DEPTH { self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path)); } else { match vectorcraft_format::load_file(&bytes) { Ok(saved) => { - let preview = vectorcraft_format::preview(&bytes); - let pdf = vectorcraft_format::pdf_content(&bytes); let compressed = bytes.starts_with(&[0x1f, 0x8b]); let include_linked = saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy())); let mut nested = saved.doc; - self.collect(&mut nested, found_path, &destination_file, depth + 1)?; + self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?; if self.opts.relink { + #[cfg(test)] + PARTS_READ.with(|c| c.set(c.get() + 1)); // Preserve auxiliary native-file content when rewriting. let mut options = vectorcraft_format::SaveOptions::for_doc(&nested); - options.preview = preview; - options.pdf = pdf; + options.preview = vectorcraft_format::preview(&bytes); + options.pdf = vectorcraft_format::pdf_content(&bytes); options.profiles = saved.profiles; options.compress = compressed; options.include_linked = include_linked; @@ -250,7 +331,9 @@ impl<'a> Collector<'a> { if self.opts.copy_fonts { let db = vectorcraft_text::FontDb::global(); heading(&mut lines, "FONTS"); - for used in super::fonts::used_fonts(doc) { + let mut used_fonts = super::fonts::used_fonts(doc); + used_fonts.append(&mut placed_fonts); + for used in used_fonts { let font = super::fonts::font_label(&used); let (family, style, version) = used; // Found by any of its names, as the canvas draws it (in the version the type names). @@ -306,6 +389,51 @@ impl<'a> Collector<'a> { } fn package(s: &mut Session, p: &Value) -> Result { + let package = prepare(s, p)?; + match &mut s.package_hand_off { + Some(slot) => { + *slot = Some(package); + Ok(Value::Null) + } + None => package.write(&mut |path, bytes| write_file(path, bytes)), + } +} + +/// A package of the active document as `file.package` takes it from the session: the options, the +/// names, the report's Document Info text and the document as it is now +/// ([`Session::prepare_package`]). [`Package::write`] collects and writes it without the session, +/// on any thread. +pub struct Package { + o: Options, + doc_path: String, + stem: String, + name: String, + root: Option, + info: Option, + doc: std::sync::Arc, +} + +impl Session { + /// Run `file.package` with `params` as a command (checked and journaled as any other) that + /// collects and writes nothing → the package it prepared, which [`Package::write`] collects + /// and writes (on the desktop with Background Export on, File › Package… does this on the + /// background thread). + pub fn prepare_package(&mut self, params: &Value) -> Result { + self.package_hand_off = Some(None); + let r = self.execute(C, params); + let handed = self.package_hand_off.take().flatten(); + match (r, handed) { + (Err(e), _) => Err(e), + (Ok(_), Some(package)) => Ok(package), + // Every run that succeeds hands one over; otherwise the package is prepared here. + (Ok(_), None) => prepare(self, params), + } + } +} + +/// What `file.package` reads from the session: the options, the checks of the document and the +/// name, the report's Document Info text and the document. +fn prepare(s: &mut Session, p: &Value) -> Result { let o = Options { copy_links: bool_or(p, "copyLinks", true), links_folder: bool_or(p, "linksFolder", true), @@ -322,8 +450,23 @@ fn package(s: &mut Session, p: &Value) -> Result { let folder = str_param(p, "folder"); let root = folder.map(|f| Path::new(f).join(&name)); let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None }; - let st = s.doc()?; - let mut doc = (*st.doc).clone(); + let doc = s.doc()?.doc.clone(); + Ok(Package { o, doc_path, stem, name, root, info, doc }) +} + +impl Package { + /// Collect the linked files and fonts and write the package's files with `write` into + /// `folder/name`, making its folders first, or into the zip without a folder → the reply of + /// `file.package`. + pub fn write(self, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + collect_and_write(self, write) + } +} + +/// [`Package::write`]. +fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + let Package { o, doc_path, stem, name, root, info, doc } = package; + let mut doc = (*doc).clone(); let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION); // Prepare original links for the destination *before* rebasing copied links. // Recalculating afterward would overwrite the portable relative paths. @@ -333,7 +476,7 @@ fn package(s: &mut Session, p: &Value) -> Result { doc = d; } let mut collector = Collector::new(&o, root.clone(), &doc_name); - collector.collect(&mut doc, &doc_path, &doc_name, 0)?; + collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?; let mut entries = std::mem::take(&mut collector.entries); entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc))); let mut taken = std::mem::take(&mut collector.taken); @@ -366,7 +509,7 @@ fn package(s: &mut Session, p: &Value) -> Result { if let Some(dir) = path.parent() { create_dir(&dir.to_string_lossy())?; } - write_file(&path.to_string_lossy(), bytes)?; + write(&path.to_string_lossy(), bytes)?; } out["folder"] = json!(root.to_string_lossy()); } diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 0cbab116e..8636ffff1 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -16,7 +16,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|freeform, method?: classic|perceptual (as paint.editGradient; default classic), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg, start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits), dither?: bool (linear and radial fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear or radial `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only", has_doc, |s, p| set_paint(s, p, true) ), @@ -55,7 +55,7 @@ pub fn specs() -> Vec { "{fill?: bool (true: bring the Fill proxy forward, false: the Stroke proxy; omitted: swap which is in front)} → {fillActive}", always, |s, p| { - s.fill_active = p.get("fill").and_then(Value::as_bool).unwrap_or(!s.fill_active); + s.fill_active = switch_or(p, "fill", !s.fill_active, "paint.toggleActive")?; Ok(json!({ "fillActive": s.fill_active })) } ), @@ -105,7 +105,7 @@ pub fn proxy_specs() -> Vec { has_doc, |s, p| { let paint = Paint::solid(s.last_solid); - apply_to_proxy(s, p, paint) + apply_to_proxy(s, p, paint, "paint.lastColor") } ), cmd!( @@ -117,7 +117,7 @@ pub fn proxy_specs() -> Vec { has_doc, |s, p| { let paint = super::gradient::unplaced(&Paint::Gradient(Box::new(s.last_gradient.clone()))); - apply_to_proxy(s, p, paint) + apply_to_proxy(s, p, paint, "paint.lastGradient") } ), cmd!( @@ -287,8 +287,15 @@ fn proxies(s: &mut Session, _: &Value) -> Result { } /// `stroke` param, defaulting to the proxy that is in front. -fn stroke_param(s: &Session, p: &Value) -> bool { - p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active) +fn stroke_param(s: &Session, p: &Value, cmd: &str) -> Result { + switch_or(p, "stroke", !s.fill_active, cmd) +} + +/// Error unless `p` gives `keepModel` as true, false or null, or leaves it out ("`keepModel` must +/// be true or false"). [`paint_from`] calls it first, and each command whose params text lists +/// `keepModel` calls it before it changes anything. +pub(crate) fn check_keep_model(p: &Value, cmd: &str) -> Result<()> { + check_switches(p, &["keepModel"], cmd) } /// The model RGB colours given in `p` are stored in: the document's in a CMYK document (`None` in @@ -319,11 +326,13 @@ pub(crate) fn new_color(s: &Session, p: &Value, c: Color) -> Color { /// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given. A /// color or gradient (its stops and freeform points) given in RGB takes the document's color -/// model ([`new_color_model`]), converted through the color settings. +/// model ([`new_color_model`]), converted through the color settings. A `none` or `keepModel` +/// other than true, false or null is an error. pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { + check_keep_model(p, "paint")?; let model = new_color_model(s, p); let new_color = |c: Color| stored_color(model, c); - if bool_or(p, "none", false) { + if switch_or(p, "none", false, "paint")? { return Ok(Some(Paint::None)); } if let Some(name) = str_param(p, "swatch") { @@ -342,6 +351,7 @@ pub(crate) fn paint_from(s: &Session, p: &Value) -> Result> { }; } if let Some(g) = p.get("gradient") { + check_numbers(g, &["angle"], "paint")?; // A stop naming a process swatch keeps the swatch's model. let stored = super::gradient::stop_colors(s, p, g); let g = super::gradient::link_stops(&s.doc()?.doc, g).map_err(|e| bad("paint", e))?; @@ -391,13 +401,19 @@ fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result { if p.get("tint").is_some() && p.get("swatch").is_none() { return Err(bad(cmd, "`tint` goes with a global or spot `swatch`")); } + check_keep_model(p, cmd)?; let paint = paint_from(s, p)?.ok_or_else(|| bad(cmd, "give color, none, swatch or gradient"))?; + // As `paint.editGradient`: a `dither` given applies to gradient fills only. A gradient swatch + // (named) keeps its own dither; `"swatch": null` names none. + if !fill && matches!(&paint, Paint::Gradient(g) if g.gradient.dither) && p.get("swatch").and_then(Value::as_str).is_none() { + return Err(bad(cmd, "`dither` applies to gradient fills (paint.setFill)")); + } apply_paint(s, p, paint, fill) } /// [`apply_paint`] on the proxy named by `stroke` (default: the active one), focusing it. -fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result { - let fill = !stroke_param(s, p); +fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint, cmd: &str) -> Result { + let fill = !stroke_param(s, p, cmd)?; apply_paint(s, p, paint, fill) } @@ -406,6 +422,7 @@ fn apply_to_proxy(s: &mut Session, p: &Value, paint: Paint) -> Result { /// remember the paint. fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result { let cmd = if fill { "paint.setFill" } else { "paint.setStroke" }; + let focus = switch_or(p, "focus", true, cmd)?; let span = spans_targets(p, &paint).map_err(|e| bad(cmd, e))?; // Characters selected with the Type tool take the paint, not the whole type object (#1063). if p.get("ids").is_none() @@ -431,7 +448,7 @@ fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) -> Result Result Result { /// default when there are none) and remember the first target's new paint. The Appearance panel's /// active item of the proxy's kind is recoloured in each selected object's own stack instead. fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result { - let stroke = stroke_param(s, p); + let stroke = stroke_param(s, p, cmd)?; let ids = match ids_param(p, "ids") { Some(ids) => ids, None => super::edit::selected_roots(s)?, diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index c46b5478d..b99e6d9b0 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { "Cut Artboard", [], None, - "{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", + "{index? | indices?, art?} artboard.copy, then delete the artboards and that art in one undo step (one artboard is always left: the cut fails when it names every artboard, or a locked one; art also on another locked artboard stays, and is left off the clipboard, unless prefs moveLockedWithArtboard); edit.cut runs this while the Artboard tool is chosen → {copied: objects}", has_doc, |s, p| artboard_copy(s, p, true) ), @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), 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, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\" (Paragraph panel › Bullets: each paragraph of a bulleted list starts with its bullet, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the bullet, the left indent counting as a stop; list \"bullet\" keeps a list's bullet, else a dot; \"none\" in either key turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), 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, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ), @@ -55,7 +55,7 @@ pub fn specs() -> Vec { fn artboard_reorder(s: &mut Session, p: &Value) -> Result { const C: &str = "artboard.reorder"; let i = artboard_index(&s.doc()?.doc, p, C)?; - let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(C, "missing to"))? as usize; + let to = p.get("to").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing to"))?; s.edit("Reorder Artboards", |d, _| { if i >= d.artboards.len() { return Err(EngineError::Other("no such artboard".into())); @@ -72,6 +72,7 @@ fn artboard_reorder(s: &mut Session, p: &Value) -> Result { /// copy commands: by default the artboards the Artboard tool has selected (its active one when none /// of them is in the document) and its Move/Copy Artwork with Artboard option. fn boards_and_art(s: &Session, p: &Value, cmd: &str) -> Result<(Vec, bool)> { + check_switches(p, &["art"], cmd)?; let t = s.tool_options_of("artboard"); let d = &s.doc()?.doc; let indices = if p.get("index").is_some() || p.get("indices").is_some() { @@ -114,21 +115,37 @@ pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &s Ok(indices) } +/// An error when one of artboards `indices` of `d` is locked: a locked artboard isn't moved, +/// resized or deleted. +pub(crate) fn refuse_locked(d: &vectorcraft_doc::Document, indices: &[usize]) -> Result<()> { + match indices.iter().any(|i| d.artboards.get(*i).is_some_and(|a| a.locked)) { + true => Err(EngineError::Other("the artboard is locked".into())), + false => Ok(()), + } +} + /// The art that goes with artboard `i` when `art`: the objects fully inside it (locked and hidden -/// ones only with prefs moveLockedWithArtboard), in paint order. -pub(crate) fn artboard_art(s: &Session, i: usize, art: bool) -> Result> { +/// ones only with prefs moveLockedWithArtboard), in paint order. With `moving` (a move or a cut), +/// the art also on another locked artboard stays unless prefs moveLockedWithArtboard +/// ([`vectorcraft_doc::Document::art_moving_with`]); a copy takes it. +pub(crate) fn artboard_art(s: &Session, i: usize, art: bool, moving: bool) -> Result> { let d = &s.doc()?.doc; let rect = d.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?; - Ok(if art { d.art_on_artboard(rect, s.prefs.move_locked_with_artboard) } else { vec![] }) + let all = s.prefs.move_locked_with_artboard; + Ok(match (art, moving) { + (false, _) => vec![], + (true, false) => d.art_on_artboard(rect, all), + (true, true) => d.art_moving_with(i, all), + }) } /// Each of `indices` with the art that goes with it when `art` ([`artboard_art`]), an object /// counted once, with the first of them it is on (artboards can overlap). -pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool) -> Result)>> { +pub(crate) fn artboards_art(s: &Session, indices: &[usize], art: bool, moving: bool) -> Result)>> { let mut seen = std::collections::HashSet::new(); let mut arts = vec![]; for &i in indices { - let art = artboard_art(s, i, art)?.into_iter().filter(|id| seen.insert(*id)).collect(); + let art = artboard_art(s, i, art, moving)?.into_iter().filter(|id| seen.insert(*id)).collect(); arts.push((i, art)); } Ok(arts) @@ -143,8 +160,9 @@ pub(crate) fn beside_artboards(d: &vectorcraft_doc::Document, rect: Rect) -> Vec /// Duplicate Artboards: a copy of each artboard with its art, in one undo step. The copies keep /// their places relative to each other, together right of the last artboard. fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { + check_switches(p, &["patterns"], "artboard.duplicate")?; let (indices, art) = boards_and_art(s, p, "artboard.duplicate")?; - let arts = artboards_art(s, &indices, art)?; + let arts = artboards_art(s, &indices, art, false)?; let all: Vec = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect(); let sc = Scaling { patterns: super::object::transform_patterns(s, p, &all)?, ..Scaling::default() }; let (index, ids) = s.edit("Duplicate Artboard", |d, _| { @@ -170,11 +188,14 @@ fn artboard_duplicate(s: &mut Session, p: &Value) -> Result { /// a cut then deletes them. pub(crate) fn artboard_copy(s: &mut Session, p: &Value, cut: bool) -> Result { let (indices, art) = boards_and_art(s, p, if cut { "artboard.cut" } else { "artboard.copy" })?; - let arts = artboards_art(s, &indices, art)?; + let arts = artboards_art(s, &indices, art, cut)?; let st = s.doc()?; if cut && st.doc.artboards.len() <= indices.len() { return Err(EngineError::Other("a document needs at least one artboard".into())); } + if cut { + refuse_locked(&st.doc, &indices)?; + } let art = st.doc.paint_order(arts.into_iter().flat_map(|(_, a)| a)); let boards: Vec<_> = indices.iter().filter_map(|i| st.doc.artboards.get(*i).cloned()).collect(); let clip = Clipboard { source_artboard: boards.first().map(|a| a.rect), artboards: boards, ..Clipboard::copy(st, &art) }; @@ -215,11 +236,12 @@ pub(crate) fn copy_artboard(d: &mut vectorcraft_doc::Document, i: usize, dv: Vec Ok((index, copies)) } -/// Add a copy of artboard `src` at `rect`, with an id of its own and its name, or ` copy` -/// (` copy 2`…) when that is taken → its index. +/// Add a copy of artboard `src` at `rect`, unlocked, with an id of its own and its name, or +/// ` copy` (` copy 2`…) when that is taken → its index. pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vectorcraft_doc::Artboard, rect: Rect) -> usize { let mut a = src.clone(); a.id = d.next_artboard_id(); + a.locked = false; let taken = |name: &str| d.artboards.iter().any(|a| a.name == name); a.name = [src.name.clone(), format!("{} copy", src.name)] .into_iter() @@ -236,6 +258,14 @@ pub(crate) fn push_artboard_copy(d: &mut vectorcraft_doc::Document, src: &vector /// Largest indent or paragraph spacing (points; Illustrator's limit). const MAX_PARA: f64 = 1296.0; +/// `text.setFormat`'s `bullet` values and the bullets they pick. +const BULLETS: [(&str, vectorcraft_doc::text::Bullet); 4] = [ + ("dot", vectorcraft_doc::text::Bullet::Dot), + ("dash", vectorcraft_doc::text::Bullet::Dash), + ("circle", vectorcraft_doc::text::Bullet::Circle), + ("square", vectorcraft_doc::text::Bullet::Square), +]; + /// A justification spacing of `text.setFormat` (`wordSpacing`, `letterSpacing`, `glyphScaling`): /// `[min, desired, max]` or `{min?, desired?, max?}`, in percent. The values not given are kept. fn spacing_param(p: &Value, key: &str, c: &str) -> Result; 3]>> { @@ -290,6 +320,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "firstLineIndent", "spaceBefore", "spaceAfter", + "list", + "bullet", "hyphenate", "wordsLongerThan", "afterFirst", @@ -307,11 +339,15 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "direction", "leadingModel", "charAlign", + "charDirection", "proportionalMetrics", + "tateChuYoko", + "noBreak", "digits", "fontAxes", "burasagari", "romanHangingPunctuation", + "kashida", "composer", ]; if !keys.iter().any(|k| p.get(*k).is_some()) { @@ -327,6 +363,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }; let (position, small_caps) = super::docsetup::script_params(p, &s.doc()?.doc.setup, C)?; let char_align = super::textedit::char_align_param(p, C)?; + let char_direction = super::textedit::char_direction_param(p, C)?; let digits = super::textedit::digits_param(p, C)?; let font_axes = super::textedit::font_axes_param(p, C)?; let mojikumi = match p.get("mojikumi") { @@ -372,6 +409,23 @@ fn set_format(s: &mut Session, p: &Value) -> Result { _ => return Err(bad(C, "`burasagari` must be \"none\", \"standard\" or \"forced\"")), }), }; + let kashida = match p.get("kashida") { + None => None, + Some(v) => Some(match v.as_str() { + Some("none") => vectorcraft_doc::text::Kashida::None, + Some("short") => vectorcraft_doc::text::Kashida::Short, + Some("medium") => vectorcraft_doc::text::Kashida::Medium, + Some("long") => vectorcraft_doc::text::Kashida::Long, + Some("stylistic") => vectorcraft_doc::text::Kashida::Stylistic, + _ => return Err(bad(C, "`kashida` must be \"none\", \"short\", \"medium\", \"long\" or \"stylistic\"")), + }), + }; + // Bullets: `list` turns a bulleted list on or off and `bullet` picks its bullet; "none" in + // either turns the list off. + let list = super::object::choice(p, C, "list", &["none", "bullet"])?; + let bullet = super::object::choice(p, C, "bullet", &["none", "dot", "dash", "circle", "square"])? + .map(|b| BULLETS.iter().find(|(k, _)| *k == b).map(|&(_, bullet)| bullet)); + let indents_given = num("leftIndent").is_some() || num("firstLineIndent").is_some(); let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); let single_word = match p.get("singleWordJustification") { @@ -426,12 +480,21 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = char_align { st.char_align = v; } + if let Some(v) = char_direction { + st.char_direction = v; + } if let Some(v) = digits { st.digits = v; } if let Some(v) = flag("proportionalMetrics") { st.proportional_metrics = v; } + if let Some(v) = flag("tateChuYoko") { + st.tate_chu_yoko = v; + } + if let Some(v) = flag("noBreak") { + st.no_break = v; + } if let Some(v) = &font_axes { super::textedit::change_font_axes(st, v); } @@ -459,6 +522,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = num("spaceAfter") { para.space_after = v.clamp(-MAX_PARA, MAX_PARA); } + if list.is_some() || bullet.is_some() { + let on = list != Some("none") && bullet != Some(None); + let had = para.list; + para.list = on.then(|| vectorcraft_doc::text::ParaList::bulleted(bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default())); + // A paragraph that joins a list with no left or first-line indent gets a + // hanging indent, unless the call sets an indent. + if on && had.is_none() && !indents_given && para.left_indent == 0.0 && para.first_line_indent == 0.0 { + para.left_indent = vectorcraft_doc::text::LIST_HANGING_INDENT; + para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; + } + } if let Some(v) = flag("hyphenate") { para.hyphenate = v; } @@ -521,6 +595,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = flag("romanHangingPunctuation") { para.roman_hanging = v; } + if let Some(v) = kashida { + para.kashida = v; + } if let Some(v) = composer { para.composer = v; } diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index 7a671478a..f55378a34 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -158,8 +158,11 @@ fn append_anchor(s: &mut Session, p: &Value) -> Result { fn close(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?; - let h_in = point_param(p, "in"); - let independent = bool_or(p, "independent", false); + let h_in = match p.get("in").filter(|h| !h.is_null()) { + None => None, + Some(h) => Some(finite_numbers(h).map(|[x, y]| Point::new(x, y)).ok_or_else(|| bad("path.close", "`in` must be [x, y]"))?), + }; + let independent = switch_or(p, "independent", false, "path.close")?; s.edit("Close Path", |d, sel| { let path = path_mut(d, id)?; let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?; @@ -178,7 +181,8 @@ fn close(s: &mut Session, p: &Value) -> Result { } pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result { - let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); + let d = Vec2::new(number_or(p, "dx", 0.0, "path.moveAnchors")?, number_or(p, "dy", 0.0, "path.moveAnchors")?); + check_switches(p, &["patterns"], "path.moveAnchors")?; // Objects selected whole move as objects, and their pattern tiles move with them when // `patterns` (default: General › Transform Pattern Tiles) is on. let whole = s.doc()?.selection.whole(); @@ -226,10 +230,10 @@ pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_ge fn set_handle(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?; let si = usize_opt(p, "subpath", "path.setHandle")?.unwrap_or(0); - let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize; + let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.setHandle", "missing anchor"))?; let out = str_param(p, "which") != Some("in"); let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?); - let independent = bool_or(p, "independent", false); + let independent = switch_or(p, "independent", false, "path.setHandle")?; s.edit("Reshape", |d, _| { let path = path_mut(d, id)?; let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?; @@ -244,7 +248,7 @@ fn set_handle(s: &mut Session, p: &Value) -> Result { fn move_anchor(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.moveAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.moveAnchor")?.unwrap_or(0); - let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))? as usize; + let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.moveAnchor", "missing anchor"))?; let pos = Point::new(f64_req(p, "x", "path.moveAnchor")?, f64_req(p, "y", "path.moveAnchor")?); s.edit("Move", |d, _| { let path = path_mut(d, id)?; @@ -725,6 +729,7 @@ fn open_run(mut anchors: Vec) -> Option { fn reshape(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.reshape", "missing id"))?; let at = Point::new(f64_req(p, "x", "path.reshape")?, f64_req(p, "y", "path.reshape")?); + check_numbers(p, &["dx", "dy", "tol", "radius"], "path.reshape")?; let delta = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let tol = f64_or(p, "tol", 3.0).max(0.0); let radius = p.get("radius").and_then(Value::as_f64); @@ -772,8 +777,8 @@ fn reshape(s: &mut Session, p: &Value) -> Result { fn insert_anchor(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?; let si = usize_opt(p, "subpath", "path.insertAnchor")?.unwrap_or(0); - let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize; - let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0); + let seg = p.get("segment").and_then(Value::as_u64).map(index).ok_or_else(|| bad("path.insertAnchor", "missing segment"))?; + let t = number_or(p, "t", 0.5, "path.insertAnchor")?.clamp(0.0, 1.0); let idx = s.edit("Add Anchor Point", |d, _| { let path = path_mut(d, id)?; let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?; diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 658b368f6..e399d5390 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -2,12 +2,13 @@ //! `vectorcraft-pathops`: Offset Path, Outline Stroke, Simplify, Add Anchor Points, Split Into Grid, //! Clean Up and Divide Objects Below. +use std::collections::BTreeSet; use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::BlendMode; use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; -use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_doc::{AnchorRef, Appearance, Document, Node, NodeId, NodeKind, SegmentRef, TextObject}; use vectorcraft_geom::{FillRule, PathData}; use vectorcraft_pathops as po; use vectorcraft_pathops::{BoolOp, PathfinderOp}; @@ -108,6 +109,15 @@ pub fn specs() -> Vec { has_selection, |s, _| run_pf(s, PathfinderOp::MinusBack) ), + cmd!( + "object.pathfinder.trap", + "Trap…", + ["Window", "Pathfinder"], + None, + "{ids?: [id…] (selected first; default: the selection), thickness?: pt 0.01..5000 (0.25; how far the lighter colour reaches into the darker one across vertical edges), heightWidth?: % 1..1000 (100; the reach across horizontal edges as a percentage of the vertical one), tintReduction?: % 0..100 (40; the trap paints the lighter colour at this percentage of its tint), trapsWithProcessColor?: bool (false; a swatch (spot) colour's trap is painted with its CMYK process equivalent), reverseTraps?: bool (false; the darker colour spreads into the lighter one, except a rich black)} adds overprinting traps where the selected objects' visible areas of different solid colours meet, grouped directly above the topmost selected object and selected; the objects stay as they are → {ids}", + has_selection, + trap + ), cmd!( "object.path.outlineStroke", "Outline Stroke", @@ -131,7 +141,7 @@ pub fn specs() -> Vec { "Simplify…", ["Object", "Path"], None, - "{tolerance?: pt (1), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool} → {ids, before, after} anchor counts, and the level used with level or auto", + "{tolerance?: pt (1), level?: 0..100 (the Simplify slider, in place of tolerance: 0 fewest anchor points, 50 is 1 pt, every 10 levels halves or doubles it), auto?: bool (Auto-Simplify: the lowest level whose tolerance is at most 1/400 of the selection's longer side, or 100 when that side is under 12.5 pt), cornerAngle?: deg (30), straightLines?: bool, wholePaths?: bool (false: a path with segments direct-selected, or between direct-selected anchors, simplifies only each run of them between its end anchors, which stay as they are, and keeps the runs selected; true simplifies those paths whole)} → {ids, before, after} anchor counts, and the level used with level or auto", has_selection, simplify ), @@ -382,6 +392,8 @@ fn pf_label(op: PathfinderOp) -> &'static str { PathfinderOp::Crop => "Crop", PathfinderOp::Outline => "Outline", PathfinderOp::MinusBack => "Minus Back", + PathfinderOp::HardMix => "Hard Mix", + PathfinderOp::SoftMix => "Soft Mix", } } @@ -498,6 +510,68 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { Ok(ids_json(&ids)) } +// ---------- Trap ---------- + +/// Pathfinder panel › Trap ([`vectorcraft_render::effects::trap`]): the traps between the selected +/// objects' leaf shapes, as one group of overprinting paths above the topmost selected object. +fn trap(s: &mut Session, p: &Value) -> Result { + use vectorcraft_render::effects::trap::{TRAP_LABEL, TrapOptions, TrapSource, trap_appearance, traps}; + const ID: &str = "object.pathfinder.trap"; + let mut q = p.clone(); + if let Some(t) = len_param(p, "thickness") { + if !(po::TRAP_MIN_WIDTH..=po::TRAP_MAX_WIDTH).contains(&t) { + return Err(bad(ID, "thickness must be from 0.01 to 5000 pt")); + } + q["thickness"] = json!(t); + } + if num_param(p, "heightWidth").is_some_and(|v| !(1.0..=1000.0).contains(&v)) { + return Err(bad(ID, "heightWidth must be from 1 to 1000 %")); + } + if num_param(p, "tintReduction").is_some_and(|v| !(0.0..=100.0).contains(&v)) { + return Err(bad(ID, "tintReduction must be from 0 to 100 %")); + } + // The effect-only settings stay at their defaults. + if let Some(o) = q.as_object_mut() { + o.remove("precision"); + o.remove("removeRedundantPoints"); + } + let options = TrapOptions::from_params(&q); + let roots = selected_roots(s)?; + if roots.is_empty() { + return Err(EngineError::Other("nothing selected".into())); + } + let ids = s.edit(TRAP_LABEL, |d, sel| { + let mut sources = vec![]; + for id in &roots { + let n = d.node(*id).ok_or(EngineError::NoNode(*id))?; + each_leaf(n, &mut |l| { + if let Some((path, rule)) = node_path(l) { + sources.push(TrapSource { path, rule, fill: style_of(l).appearance.fill_paint() }); + } + }); + } + if sources.iter().filter(|s| s.fill.color().is_some()).count() < 2 { + return Err(EngineError::Other("Trap: select two or more objects filled with solid colours".into())); + } + let made = traps(&sources, &options); + if made.is_empty() { + return Err(EngineError::Other("Trap: no edges of different colours meet among the selected objects".into())); + } + let mut children = Vec::with_capacity(made.len()); + for piece in made { + let mut n = shape_node(d, piece.path, None); + n.appearance = trap_appearance(piece.paint).into(); + children.push(Arc::new(n)); + } + let gid = d.alloc_id(); + let top = *roots.last().ok_or_else(|| EngineError::Other("nothing selected".into()))?; + d.insert_beside(vec![(top, vec![Node::group(gid, children)])], false)?; + sel.set([gid]); + Ok(vec![gid]) + })?; + Ok(ids_json(&ids)) +} + // ---------- Offset Path ---------- fn join_param(p: &Value, key: &str) -> po::Join { @@ -634,7 +708,7 @@ pub(crate) fn outlined_stroke( blend: st.blend, effects: st.effects.clone(), overprint: st.overprint, - ..vectorcraft_doc::appearance::FillLayer::new(st.paint.clone()) + ..vectorcraft_doc::appearance::FillLayer::new(st.paint.stroke_as_fill()) }; n.appearance = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }.into(); Some(n) @@ -806,12 +880,23 @@ fn path_ids(d: &Document, roots: &[NodeId]) -> Vec { out } -/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it: → (their ids, how +/// The direct selection a path keeps after an edit of part of it: (its selected anchors, and its +/// selected segments when they were picked as segments). `None` selects the path whole. +type PartSelection = Option<(BTreeSet, Option>)>; + +/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it, given its +/// direct-selected anchors and picked segments (none when it is selected whole): → (their ids, how /// many anchors they have before and after). A path that `f` would empty or returns unchanged is -/// left as it is, with its live shape and its direct-selected anchors; a replaced path loses both. -fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) -> Result<(Vec, usize, usize)> { +/// left as it is, with its live shape and its direct selection; a replaced path loses its live +/// shape and has the direct selection `f` gives it. +fn edit_paths( + s: &mut Session, + label: &str, + f: impl Fn(&PathData, Option<&BTreeSet>, Option<&BTreeSet>) -> (PathData, PartSelection), +) -> Result<(Vec, usize, usize)> { let roots = selected_roots(s)?; - let d = &s.doc()?.doc; + let st = s.doc()?; + let (d, sel) = (&st.doc, &st.selection); let ids = path_ids(d, &roots); if ids.is_empty() { return Err(EngineError::Other(format!("{label}: select paths"))); @@ -820,22 +905,29 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) - for id in &ids { let Some(NodeKind::Path { path, .. }) = d.node(*id).map(|n| &n.kind) else { continue }; before += path.anchor_count(); - let np = f(path); + let (np, part) = f(path, sel.anchors.get(id), sel.segments.get(id)); if (np.is_empty() && !path.is_empty()) || np == *path { after += path.anchor_count(); continue; } after += np.anchor_count(); - changed.push((*id, np)); + changed.push((*id, np, part)); } // When no path changes, the document and its undo history stay as they are. if !changed.is_empty() { s.edit(label, |d, sel| { - for (id, np) in changed { + for (id, np, part) in changed { if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) { - // Refit and subdivision change what the old component indexes refer to. - // Keep the object selected whole so its new points remain editable. + // Refit and subdivision change what the old component indexes refer to: the + // path is selected whole, or with the part `f` says. sel.anchors.remove(&id); + sel.segments.remove(&id); + if let Some((anchors, segments)) = part.filter(|(a, _)| a.len() < np.anchor_count()) { + sel.anchors.insert(id, anchors); + if let Some(segments) = segments { + sel.segments.insert(id, segments); + } + } *path = np; *live = None; } @@ -846,12 +938,44 @@ fn edit_paths(s: &mut Session, label: &str, f: impl Fn(&PathData) -> PathData) - Ok((ids, before, after)) } +/// The segments of `path` a direct selection picks: the `segments` picked as such, or else every +/// segment with both its anchors among `anchors`. `None` when the path is selected whole, or when +/// no segment of it is selected. +fn selected_segments( + path: &PathData, + anchors: Option<&BTreeSet>, + segments: Option<&BTreeSet>, +) -> Option> { + let anchors = anchors?; + let picked: BTreeSet = match segments { + Some(segs) => segs.iter().copied().filter(|&(si, i)| path.subpaths.get(si).is_some_and(|sp| i < sp.segment_count())).collect(), + None => path + .subpaths + .iter() + .enumerate() + .flat_map(|(si, sp)| { + (0..sp.segment_count()) + .filter(move |&i| anchors.contains(&(si, i)) && anchors.contains(&(si, (i + 1) % sp.anchors.len()))) + .map(move |i| (si, i)) + }) + .collect(), + }; + (!picked.is_empty()).then_some(picked) +} + +/// Auto-Simplify's level for the selection: [`po::auto_simplify_level`] for the longer side of the +/// selected objects' bounds. Object › Path › Simplify's slider opens at it, and +/// `object.path.simplify {auto: true}` simplifies at it. +pub fn auto_simplify_level(s: &Session) -> Result { + let roots = selected_roots(s)?; + let size = s.doc()?.doc.bounds_of(&roots, false).map_or(0.0, |b| b.width().max(b.height())); + Ok(po::auto_simplify_level(size)) +} + fn simplify(s: &mut Session, p: &Value) -> Result { // With auto, the slider's level is Auto-Simplify's for the selection's size; otherwise it is the level given. let level = if bool_or(p, "auto", false) { - let roots = selected_roots(s)?; - let size = s.doc()?.doc.bounds_of(&roots, false).map_or(0.0, |b| b.width().max(b.height())); - Some(po::auto_simplify_level(size)) + Some(auto_simplify_level(s)?) } else { num_param(p, "level").map(|l| if l.is_finite() { l.round().clamp(0.0, 100.0) } else { po::SIMPLIFY_LEVEL }) }; @@ -864,7 +988,22 @@ fn simplify(s: &mut Session, p: &Value) -> Result { corner_angle_deg: num_param(p, "cornerAngle").unwrap_or(30.0).clamp(0.0, 180.0), straight_lines: bool_or(p, "straightLines", false), }; - let (ids, before, after) = edit_paths(s, "Simplify", |path| po::simplify_with(path, &opts))?; + let whole = bool_or(p, "wholePaths", false); + // A path with segments direct-selected simplifies only their runs, and keeps those runs (and the + // other anchors it had selected) direct-selected. + let (ids, before, after) = edit_paths(s, "Simplify", |path, anchors, segments| { + let Some(picked) = selected_segments(path, anchors, segments).filter(|_| !whole) else { + return (po::simplify_with(path, &opts), None); + }; + let part = po::simplify_part(path, &picked, &opts); + let mut kept: BTreeSet = anchors.into_iter().flatten().filter_map(|a| part.anchors.get(a).copied()).collect(); + for &(si, i) in &part.segments { + let m = part.path.subpaths.get(si).map_or(1, |sp| sp.anchors.len().max(1)); + kept.extend([(si, i), (si, (i + 1) % m)]); + } + let segs = segments.map(|_| part.segments.clone()); + (part.path, Some((kept, segs))) + })?; let mut out = json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after }); if let Some(l) = level { out["level"] = json!(l); @@ -873,7 +1012,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { } fn add_anchor_points(s: &mut Session, _: &Value) -> Result { - let (ids, before, after) = edit_paths(s, "Add Anchor Points", po::add_anchor_points)?; + let (ids, before, after) = edit_paths(s, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?; Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "before": before, "after": after })) } @@ -993,7 +1132,8 @@ fn clean_up(s: &mut Session, p: &Value) -> Result { for l in &d.layers { visit(l, false, false, false, &mut v, (stray, unpainted, empty_text)); } - v.retain(|id| d.is_editable(*id)); + let editable = d.editable(); + v.retain(|id| editable(*id)); v }; if found.is_empty() { @@ -1038,13 +1178,15 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result { } // Compound children are handled via their compound. targets.retain(|id| !matches!(d.parent_of(*id).and_then(|p| d.node(p)).map(|p| &p.kind), Some(NodeKind::Compound { .. }))); + let editable = d.editable(); targets.retain(|id| { *id != cutter_id - && d.is_editable(*id) + && editable(*id) && d.is_visible(*id) && d.index_path(*id).is_some_and(|ip| ip < cutter_pos) && d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| b.intersect(cb).area() > 0.0) }); + drop(editable); let mut out = vec![]; let mut cut = Beside::replacing(d, &[targets.as_slice(), &[cutter_id]].concat()); for t in targets { diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 8ed9820b7..b25b427d3 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -491,7 +491,7 @@ fn swatch_name_clash(d: &Document, old: &str, new: &str) -> bool { let own = |w: &vectorcraft_color::Swatch| pattern_name(&w.paint).is_some_and(|p| p == old); let mut holders = d.swatches_iter().filter(|w| w.name == new).peekable(); let only_own = holders.peek().is_some() && holders.all(own); - d.swatch_name_taken(new) && !(only_own && !d.swatches_iter().any(|w| w.name == old && own(w))) + d.swatch_name_taken(new) && (!only_own || d.swatches_iter().any(|w| w.name == old && own(w))) } /// Rename a pattern in swatches, paints and the edit record. Of the swatches named `old`, only the @@ -737,7 +737,8 @@ fn repeat_expand(s: &mut Session, _: &Value) -> Result { /// instance node gets a fresh id. fn expand_repeat(d: &mut Document, r: NodeId) -> Result<()> { let Some(n) = d.node(r).cloned() else { return Ok(()) }; - let mut g = vectorcraft_doc::live::expanded_group(&n, None); + let hooks = vectorcraft_doc::live::Hooks { symbols: Some(d), ..Default::default() }; + let mut g = vectorcraft_doc::live::expanded_group_hooks(&n, hooks); if let Some(ch) = g.children_mut() { for c in ch.iter_mut() { fix_ids(d, Arc::make_mut(c)); diff --git a/crates/engine/src/cmd/perspgrid.rs b/crates/engine/src/cmd/perspgrid.rs index 54ed1d480..77f5d158f 100644 --- a/crates/engine/src/cmd/perspgrid.rs +++ b/crates/engine/src/cmd/perspgrid.rs @@ -366,7 +366,7 @@ fn presets_import(s: &mut Session, p: &Value) -> Result { let base = GridDefinition::default(); let check = |v: &Value| base.merged(v).map_err(|e| bad(C, format!("`{}`: {e}", v["name"].as_str().unwrap_or("?")))); let presets = file.presets.iter().map(check).collect::>>()?; - let replace = bool_or(p, "replace", false); + let replace = switch_or(p, "replace", false, C)?; let mut imported = vec![]; for mut preset in presets { // A built-in view comes in as a saved copy ("2P-Normal View"…): the built-in one stays. @@ -390,9 +390,13 @@ fn presets_import(s: &mut Session, p: &Value) -> Result { Ok(json!({ "imported": imported })) } -/// Toggle (or set, with `on`) a view option of the grid. -fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result { - let want = p.get("on").and_then(Value::as_bool); +/// Toggle (or set, with `on`) a view option of the grid for the command `cmd`. +fn view_option(s: &mut Session, p: &Value, cmd: &str, field: fn(&mut PerspectiveGrid) -> &mut bool) -> Result { + // A missing or null `on` toggles the option. + let want = match p.get("on") { + None | Some(Value::Null) => None, + Some(_) => Some(switch_or(p, "on", false, cmd)?), + }; let mut g = silent(s, |g| { let f = field(g); *f = want.unwrap_or(!*f); @@ -401,19 +405,19 @@ fn view_option(s: &mut Session, p: &Value, field: fn(&mut PerspectiveGrid) -> &m } fn lock(s: &mut Session, p: &Value) -> Result { - view_option(s, p, |g| &mut g.locked) + view_option(s, p, "perspective.grid.lock", |g| &mut g.locked) } fn lock_station(s: &mut Session, p: &Value) -> Result { - view_option(s, p, |g| &mut g.lock_station) + view_option(s, p, "perspective.grid.lockStation", |g| &mut g.lock_station) } fn snap(s: &mut Session, p: &Value) -> Result { - view_option(s, p, |g| &mut g.snap) + view_option(s, p, "perspective.grid.snap", |g| &mut g.snap) } fn rulers(s: &mut Session, p: &Value) -> Result { - view_option(s, p, |g| &mut g.rulers) + view_option(s, p, "perspective.grid.rulers", |g| &mut g.rulers) } /// Snap to Grid for a move of `ids` from page point `from` to `to` (unless `p` says `snap: false`): diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index b4baf4625..49924c5c8 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -1,10 +1,11 @@ -//! Placed documents (`file.place` of a VectorCraft document from a file, with `link`): one -//! locked object showing an artboard of the document, a [`PlacedDocument`]. +//! Placed documents (`file.place` of a VectorCraft document, a PDF or a `.ai` file from a file, +//! with `link`): one locked object showing an artboard of the document or a page of the PDF, a +//! [`PlacedDocument`]. //! //! The object keeps the file's bytes (in the document's images, like image data) and the link; the //! art it shows is read from them the way placing the file without Link reads it -//! ([`document_art`]: the artboard's art, with the file's own linked images found), so Break Link -//! and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands +//! ([`document_art`]: the artboard's or page's art, with the file's own linked images found), so +//! Break Link and Object › Expand ([`expand`]) give exactly that editable copy. The Links commands //! ([`super::super::links`]) tell when the file changed and read it again. //! //! Each placed file also gets a preview ([`PREVIEW_PX_PER_PT`]; JPEG when opaque, else PNG), kept @@ -19,8 +20,9 @@ use std::sync::Arc; use vectorcraft_doc::links::hash_bytes; use vectorcraft_doc::placed_document::{PREVIEW_MAX, PREVIEW_PX_PER_PT, key_for}; -use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PlacedDocument, PlacementOptions}; +use vectorcraft_doc::{Document, ImageBlob, LinkInfo, Node, NodeKind, PageBox, PlacedDocument, PlacementOptions}; use vectorcraft_geom::{Affine, Rect}; +use vectorcraft_pdf::CropTo; use super::super::fileio; use super::super::*; @@ -29,7 +31,7 @@ use super::super::*; pub(crate) const MAX_FILE: usize = 256 << 20; /// The format ids of VectorCraft documents. const NATIVE: [&str; 2] = ["vectorcraft", "template"]; -/// The type a placed file's bytes are stored with. +/// The type a placed VectorCraft document's bytes are stored with (a PDF's: its format's). const MIME: &str = "application/json"; /// Is `name` with `bytes` a VectorCraft document? @@ -37,7 +39,55 @@ pub(crate) fn is_native(name: &str, bytes: &[u8]) -> bool { fileio::detect(name, bytes).is_some_and(|f| NATIVE.contains(&f.id)) } -/// An artboard of a VectorCraft document, read as art to place. +/// Is `name` with `bytes` a PDF, or a `.ai` or `.ait` file read as PDF pages (not PostScript)? +pub(crate) fn is_pdf(name: &str, bytes: &[u8]) -> bool { + fileio::detect(name, bytes).is_some_and(|f| matches!(f.id, "pdf" | "ai" | "ait")) && !vectorcraft_pdf::is_postscript(bytes) +} + +/// The editing data of the `.ai` file `name` (`bytes`) when its PDF part is only a placeholder: +/// the document its pages are read from ([`super::pdf_art`]), with the files it links. `None` for +/// other files. +pub(crate) fn editing_data(name: &str, bytes: &[u8]) -> Option { + if !is_pdf(name, bytes) { + return None; + } + let read = fileio::page_document(bytes, 0, &fileio::LoadOptions::default()); + match fileio::page_or_editing_data(name, bytes, None, read) { + Ok((doc, _, true)) => Some(doc), + _ => None, + } +} + +/// The type the bytes of placed file `name` (`bytes`) are stored with: a PDF's format's, else +/// [`MIME`]. +fn mime(name: &str, bytes: &[u8]) -> &'static str { + fileio::detect(name, bytes).filter(|_| is_pdf(name, bytes)).map_or(MIME, |f| f.mime) +} + +/// The box of a PDF page placed linked for File › Place's `crop` (Bounding Box: the crop box, +/// the art's bounds being [`PlacedDocument::bounding`]). +pub(crate) fn page_box(crop: CropTo) -> PageBox { + match crop { + CropTo::Art => PageBox::Art, + CropTo::Trim => PageBox::Trim, + CropTo::Bleed => PageBox::Bleed, + CropTo::Media => PageBox::Media, + CropTo::Crop | CropTo::Bounding => PageBox::Crop, + } +} + +/// The `crop` a PDF page placed linked showing `b` is read with. +fn crop_to(b: PageBox) -> CropTo { + match b { + PageBox::Crop => CropTo::Crop, + PageBox::Art => CropTo::Art, + PageBox::Trim => CropTo::Trim, + PageBox::Bleed => CropTo::Bleed, + PageBox::Media => CropTo::Media, + } +} + +/// An artboard of a VectorCraft document, or a page of a PDF or `.ai` file, read as art to place. pub(crate) struct DocumentArt { /// The document, its layers taken: the art's resources. pub doc: Document, @@ -51,18 +101,33 @@ pub(crate) struct DocumentArt { } /// Artboard `page` (1-based) of the VectorCraft document `name` (`bytes`, read from `path` when -/// there is one, so its linked images are found), clipped to the artboard unless `bounding`. -pub(crate) fn document_art(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result { +/// there is one, so its linked images are found), clipped to the artboard unless `bounding`; or +/// page `page` of the PDF or `.ai` file `name`, clipped to its box `page_box` unless `bounding` +/// ([`super::pdf_art`]). +pub(crate) fn document_art( + name: &str, + bytes: &[u8], + path: Option<&str>, + page: u32, + bounding: bool, + page_box: PageBox, + cmd: &str, +) -> Result { let file = fileio::file_name(name); - if !is_native(name, bytes) { - return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document"))); + let pdf = is_pdf(name, bytes); + if !pdf && !is_native(name, bytes) { + return Err(bad(cmd, format!("`{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file"))); } if bytes.len() > MAX_FILE { return Err(bad(cmd, format!("`{file}` is over {} MB", MAX_FILE >> 20))); } + if pdf { + let o = fileio::LoadOptions { crop: crop_to(page_box), ..Default::default() }; + return super::pdf_art(name, bytes, path, page as usize, o, bounding, cmd); + } let mut l = fileio::load(name, bytes)?; // Its linked images show their files; its placed documents stay as they are. - super::super::links::resolve(&mut l.doc, path, false); + super::super::links::resolve(&mut l.doc, path, false, false); l.doc.drop_edit_modes(); let n = l.doc.artboards.len(); let board = (page as usize).checked_sub(1).and_then(|i| l.doc.artboards.get(i)).map(|a| a.rect); @@ -87,46 +152,57 @@ fn group(nodes: Vec, clip: Option) -> Node { /// `p`'s file (`bytes`) read as the art it shows: how the document model reads placed documents /// ([`vectorcraft_doc::placed_document::set_loader`]). pub(crate) fn read_document(p: &PlacedDocument, bytes: &[u8]) -> Option<(Document, Node, Rect)> { - let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, "draw").ok()?; + let a = document_art(p.link.name(), bytes, Some(&p.link.path), p.page(), p.bounding, p.page_box, "draw").ok()?; Some((a.doc, group(a.nodes, a.clip), a.frame)) } /// `p`'s file read again, while it is the file it was read from (how the document model gets it /// when only the preview is stored, [`vectorcraft_doc::placed_document::set_file_reader`]). pub(crate) fn read_file_again(p: &PlacedDocument) -> Option> { - let bytes = fileio::read_file(&p.link.path).ok()?; + // One byte over the limit tells a file that is too big, without reading it all. + let bytes = fileio::read_file_head(&p.link.path, MAX_FILE + 1).ok()?; + if bytes.len() > MAX_FILE { + return None; + } if p.link.hash.as_ref().is_some_and(|h| *h != hash_bytes(&bytes)) { return None; } Some(bytes) } -/// A VectorCraft document read for placing linked. +/// A VectorCraft document, or a PDF or `.ai` file, read for placing linked. #[derive(Clone)] pub(crate) struct Source { /// The file's bytes, with their preview. pub blob: ImageBlob, /// The blob's key ([`key_for`]). pub key: String, - /// What is shown, in the file's coordinates: the artboard or the art's bounds. + /// The box of a PDF page shown (unless the art's bounds are). + pub page_box: PageBox, + /// What is shown, in the file's coordinates: the artboard, the page's box or the art's bounds. pub frame: Rect, pub warnings: Vec, } /// The VectorCraft document `name` (`bytes`, from `path` when there is one) read for showing -/// artboard `page` (its art's bounds when `bounding`). -pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, cmd: &str) -> Result { +/// artboard `page`, or the PDF or `.ai` file for showing page `page` clipped to its box +/// `page_box` (its art's bounds when `bounding`). +pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bounding: bool, page_box: PageBox, cmd: &str) -> Result { // The same document placed the same way is read once (placing it again is free). - let key = key_for(&hash_bytes(bytes), page, bounding); - if let Some(s) = lock_memo().iter().find(|s| s.key == key).cloned() { + let key = key_for(&hash_bytes(bytes), page, bounding, page_box); + // Resolving its links depends on where the file is and on what the caller may read, so a hit + // needs the same path, and nothing is shared with or from a confined (automation) caller. + let confined = crate::file_access::current().is_some(); + if !confined && let Some((_, s)) = lock_memo().iter().find(|(at, s)| s.key == key && at.as_deref() == path).cloned() { return Ok(s); } - let a = document_art(name, bytes, path, page, bounding, cmd)?; - let mut blob = ImageBlob::new(MIME, bytes.to_vec()); + let a = document_art(name, bytes, path, page, bounding, page_box, cmd)?; + let mut blob = ImageBlob::new(mime(name, bytes), bytes.to_vec()); let shown = PlacedDocument { link: LinkInfo { page: Some(page), ..LinkInfo::new(path.unwrap_or(name)) }, key: key.clone(), bounding, + page_box, width: a.frame.width(), height: a.frame.height(), xf: Affine::IDENTITY, @@ -136,12 +212,14 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo blob.proxy = preview(&a.doc, &art, a.frame).map(Arc::new); // Drawing it needn't read it again. vectorcraft_doc::placed_document::prime(&shown, blob.bytes.clone(), (a.doc, art, a.frame)); - let s = Source { blob, key, frame: a.frame, warnings: a.warnings }; - let mut m = lock_memo(); - if m.len() >= MEMO_SIZE { - m.remove(0); + let s = Source { blob, key, page_box, frame: a.frame, warnings: a.warnings }; + if !confined { + let mut m = lock_memo(); + if m.len() >= MEMO_SIZE { + m.remove(0); + } + m.push((path.map(str::to_string), s.clone())); } - m.push(s.clone()); Ok(s) } @@ -149,9 +227,9 @@ pub(crate) fn source(name: &str, bytes: &[u8], path: Option<&str>, page: u32, bo const MEMO_SIZE: usize = 32; /// Files read lately for placing, by key. -static MEMO: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); +static MEMO: std::sync::Mutex, Source)>> = std::sync::Mutex::new(Vec::new()); -fn lock_memo() -> std::sync::MutexGuard<'static, Vec> { +fn lock_memo() -> std::sync::MutexGuard<'static, Vec<(Option, Source)>> { // Entries are only pushed and removed: a panic while held leaves it whole. MEMO.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } @@ -203,6 +281,7 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) link, key: s.key.clone(), bounding, + page_box: s.page_box, width: s.frame.width(), height: s.frame.height(), xf: Affine::translate((s.frame.x0, s.frame.y0)), @@ -211,32 +290,39 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool) Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))) } -/// `d` for output: the placed documents that keep only their previews get their files back, -/// else (their files gone or changed) output as their previews, with a warning. +/// `d` for output: linked images and placed documents that keep only their previews get their +/// files back, else output as their previews, with a warning. pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { + let (d, mut warnings) = super::super::links::full_linked_images(d, None); + let doc = match &d { + Cow::Borrowed(doc) => doc, + Cow::Owned(doc) => doc, + }; let mut need: Vec = vec![]; - d.visit_placed(|_, p| { - if d.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) { + doc.visit_placed(|_, p| { + if doc.images.get(&p.key).is_some_and(ImageBlob::is_proxy) && !need.iter().any(|n| n.key == p.key) { need.push(p.clone()); } }); if need.is_empty() { - return (Cow::Borrowed(d), vec![]); + return (d, warnings); } - let mut out = d.clone(); + let mut out = match d { + Cow::Borrowed(doc) => doc.clone(), + Cow::Owned(doc) => doc, + }; let mut previews = BTreeSet::new(); for p in need { - let Some(blob) = d.images.get(&p.key) else { continue }; + let Some(blob) = out.images.get(&p.key) else { continue }; match vectorcraft_doc::placed_document::full_bytes(&p, blob) { Some(bytes) => { - out.images.insert(p.key.clone(), ImageBlob { mime: MIME.into(), bytes, proxy: blob.proxy.clone() }); + out.images.insert(p.key.clone(), ImageBlob { mime: mime(p.link.name(), &bytes).into(), bytes, proxy: blob.proxy.clone() }); } None => { previews.insert(p.key); } } } - let mut warnings = vec![]; if !previews.is_empty() { warnings.push(format!( "{} placed document(s) output as their previews: their files can't be found, or changed since they were read (relink or update them)", @@ -256,7 +342,7 @@ pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?; let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob) .ok_or_else(|| bad(cmd, format!("{} can't be read, or changed since it was read: relink or update it first", p.link.path)))?; - let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, cmd)?; + let a = document_art(p.link.name(), &bytes, Some(&p.link.path), p.page(), p.bounding, p.page_box, cmd)?; let m = p.art_xf(a.frame).ok_or_else(|| bad(cmd, format!("object {} has no size", id.0)))?; let mut nodes = a.nodes; super::adopt::adopt(d, &a.doc, &mut nodes); diff --git a/crates/engine/src/cmd/place/docx.rs b/crates/engine/src/cmd/place/docx.rs index 400f4d168..bf394f63c 100644 --- a/crates/engine/src/cmd/place/docx.rs +++ b/crates/engine/src/cmd/place/docx.rs @@ -19,7 +19,9 @@ //! the color. //! - Paragraph formatting: alignment (`w:jc`), start and end indents (`w:start` or `w:left`, //! `w:end` or `w:right`: the right and left indents of a right-to-left paragraph), first-line and -//! hanging indents, space before and after, and direction (`w:bidi`). +//! hanging indents, space before and after, and direction (`w:bidi`). A paragraph justified with +//! a low, medium or high kashida length (`lowKashida`, `mediumKashida`, `highKashida`) is +//! justified with Short, Medium or Long kashidas; `both` is justified without kashidas. //! - Styles (§17.7.2): the document defaults, then the paragraph style (else the default paragraph //! style) and the styles it is based on, then the run's character style (else the default //! character style) and the styles it is based on, then direct formatting. Bold, italic, caps, @@ -37,6 +39,7 @@ use std::collections::HashMap; use roxmltree::Node; +use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; use super::text::{self, Story}; @@ -162,7 +165,7 @@ fn parse<'a>(xml: &'a str, name: &str, cmd: &str) -> Result usize { +pub(crate) fn nesting(xml: &str) -> usize { let b = xml.as_bytes(); let (mut depth, mut most, mut i) = (0usize, 0usize, 0usize); // The index just past the first `end` at or after `from` (the end of the text if none). @@ -361,8 +364,9 @@ enum Align { Left, Center, Right, - /// Justified, the last line at the start (the right of a right-to-left paragraph). - Both, + /// Justified, the last line at the start (the right of a right-to-left paragraph), with the + /// kashida length of `lowKashida`, `mediumKashida` and `highKashida` (§17.18.44). + Both(Kashida), /// Justified, the last line too. Distribute, Start, @@ -860,8 +864,8 @@ impl Reader { Align::Left => Justify::Left, Align::Center => Justify::Center, Align::Right => Justify::Right, - Align::Both if rtl => Justify::JustifyRight, - Align::Both => Justify::JustifyLeft, + Align::Both(_) if rtl => Justify::JustifyRight, + Align::Both(_) => Justify::JustifyLeft, Align::Distribute => Justify::JustifyAll, Align::Start => Justify::Auto, Align::End if rtl => Justify::Left, @@ -873,6 +877,7 @@ impl Reader { space_before: points(p.before), space_after: points(p.after), direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }), + kashida: if let Align::Both(k) = p.align { k } else { Kashida::None }, ..ParaStyle::default() } }) @@ -898,7 +903,10 @@ fn para_props(ppr: Node) -> ParaProps { Some("left") => Some(Align::Left), Some("center") => Some(Align::Center), Some("right") => Some(Align::Right), - Some("both" | "lowKashida" | "mediumKashida" | "highKashida") => Some(Align::Both), + Some("both") => Some(Align::Both(Kashida::None)), + Some("lowKashida") => Some(Align::Both(Kashida::Short)), + Some("mediumKashida") => Some(Align::Both(Kashida::Medium)), + Some("highKashida") => Some(Align::Both(Kashida::Long)), Some("distribute" | "thaiDistribute") => Some(Align::Distribute), Some("start") => Some(Align::Start), Some("end") => Some(Align::End), diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 6e787a0d7..64e6c19e1 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -13,7 +13,8 @@ //! preview ([`fileio::load`]). //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page //! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art -//! (`crop: "bounding"`). +//! (`crop: "bounding"`). With Link, a PDF/.ai/.ait page placed from a file becomes a placed +//! document instead, as a VectorCraft document does (below). //! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`, //! [`text`]). //! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]). @@ -30,7 +31,7 @@ pub(crate) mod adopt; pub(crate) mod document; -mod docx; +pub(crate) mod docx; mod rtf; mod text; @@ -38,7 +39,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::Paint; -use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, Scaling, SymbolBoxes}; +use vectorcraft_doc::{Appearance, Document, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, Scaling, SymbolBoxes}; use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_pdf::CropTo; @@ -62,7 +63,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", has_doc, place ), @@ -104,8 +105,8 @@ enum Art { /// A text file's, an RTF file's or a Word document's text, placed as area type filling the /// natural box. Text(text::Story), - /// A VectorCraft document placed linked: the file read, the link, and whether the art's - /// bounds (not the artboard) are shown. + /// A VectorCraft document or a PDF/.ai page placed linked: the file read, the link, and + /// whether the art's bounds (not the artboard or page) are shown. Document(document::Source, LinkInfo, bool), Vector { /// The loaded document: the source of the art's resources. @@ -124,7 +125,7 @@ struct Loaded { /// The art's natural box (what `at` centres and `rect` fits). natural: Rect, warnings: Vec, - /// The link to a raster image's file (for Link), when read from a path. + /// The link to a raster image's file or a placed document's (for Link), when read from a path. link: Option, /// The artboard a DXF drawing fitted to an artboard, or not centred, was laid out on: it lands /// on the target artboard the same way. @@ -138,8 +139,9 @@ pub(crate) fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) { } /// The file `p` names, read into placeable art; a DXF drawing's Fit to Artboard fills `board` -/// (unless the params name their own box). -fn load(p: &Value, cmd: &str, board: Option) -> Result { +/// (unless the params name their own box). A PDF/.ai page read from a path is placed linked with +/// Link when `link_pdf` (false for the queries, which read it embedded). +fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result { let src = fileio::source(p, cmd)?; let name = fileio::file_name(src.name); // An Affinity file is recognised by its signature, whatever its name says. @@ -192,12 +194,12 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { if matches!(format.id, "vectorcraft" | "template") { let page = page as u32; if let Some(path) = src.path.filter(|_| bool_or(p, "link", true)) { - let s = document::source(src.name, &src.bytes, Some(path), page, !crop, cmd)?; + let s = document::source(src.name, &src.bytes, Some(path), page, !crop, PageBox::Crop, cmd)?; let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) }; let (natural, warnings) = (s.frame, s.warnings.clone()); return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None }); } - let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, cmd)?; + let a = document::document_art(src.name, &src.bytes, src.path, page, !crop, PageBox::Crop, cmd)?; let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip }; return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None }); } @@ -209,32 +211,39 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { let warnings = fileio::too_large_note(&img.blob.bytes).into_iter().collect(); return Ok(Loaded { name, format, art: Art::Image(img), natural, warnings, link, board: None }); } + if pdf { + // Linked: placed like a VectorCraft document, read again with the page's box only. A page + // asked for with a password or with its text as outlines is embedded. + let linked = link_pdf && bool_or(p, "link", true) && opts.password.is_none() && opts.text_as == vectorcraft_pdf::TextAs::Text; + if let Some(path) = src.path.filter(|_| linked) { + let page = page as u32; + let s = document::source(src.name, &src.bytes, Some(path), page, !crop, document::page_box(opts.crop), cmd)?; + let link = LinkInfo { page: Some(page), ..super::links::link_info(path, &src.bytes) }; + let (natural, warnings) = (s.frame, s.warnings.clone()); + return Ok(Loaded { name, format, art: Art::Document(s, link.clone(), !crop), natural, warnings, link: Some(link), board: None }); + } + let a = pdf_art(src.name, &src.bytes, src.path, page, opts, !crop, cmd)?; + let art = Art::Vector { src: Box::new(a.doc), nodes: a.nodes, clip: a.clip }; + return Ok(Loaded { name, format, art, natural: a.frame, warnings: a.warnings, link: None, board: None }); + } let anchored = format.id == "dxf" && (opts.dxf.fit || !opts.dxf.center); - let (mut doc, warnings) = match format.id { - // Only the page placed is read, its artboard the box asked for (Bounding Box: the art's - // bounds, below). - "pdf" | "ai" | "ait" if pdf => { - let o = fileio::LoadOptions { crop: if crop { opts.crop } else { CropTo::Crop }, ..opts }; - fileio::page_document(&src.bytes, page - 1, &o).map_err(|e| bad(cmd, format!("{name}: {e}")))? - } - _ => { - // Of the open options, only a DXF drawing's apply (the colour mode stays the file's). - let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() }; - let mut l = fileio::load_with(src.name, &src.bytes, &o)?; - // An Affinity file whose native document couldn't be read is only its preview: it - // must never silently stand in for the art. - if l.preview_only { - return Err(bad( - cmd, - format!( - "{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing" - ), - )); - } - // Linked images show their files, found from its folder. - super::links::resolve(&mut l.doc, src.path, false); - (l.doc, l.warnings) + let (mut doc, warnings) = { + // Of the open options, only a DXF drawing's apply (the colour mode stays the file's). + let o = fileio::LoadOptions { dxf: opts.dxf, ..Default::default() }; + let mut l = fileio::load_with(src.name, &src.bytes, &o)?; + // An Affinity file whose native document couldn't be read is only its preview: it + // must never silently stand in for the art. + if l.preview_only { + return Err(bad( + cmd, + format!( + "{name}: only this Affinity file's embedded preview could be read; use File › Open to see it with its warning, or export SVG or PDF from Affinity before placing" + ), + )); } + // Linked images show their files, found from its folder. + super::links::resolve(&mut l.doc, src.path, false, false); + (l.doc, l.warnings) }; doc.drop_edit_modes(); let board = doc.artboards.first().map(|a| a.rect).filter(|_| anchored); @@ -248,19 +257,64 @@ fn load(p: &Value, cmd: &str, board: Option) -> Result { let frame = doc.artboards.first().map(|a| a.rect).filter(|_| framed); (art_of(layer_art(&layers)), frame) } else { - // The page placed is a PDF import's only one. - let index = if pdf { 0 } else { page - 1 }; let n = doc.artboards.len(); - let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?; - // A PDF import has one layer per page; an EPS file's art is whatever lies on the artboard. - let pages = if pdf { layers.get(index..=index).unwrap_or_default() } else { &layers[..] }; - (on_artboard(&symbols, pages, board), crop.then_some(board)) + let board = doc.artboards.get(page - 1).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?; + // An EPS file's art is whatever lies on the artboard. + (on_artboard(&symbols, &layers, board), crop.then_some(board)) }; let natural = clip.or_else(|| shown_bounds(&symbols, &nodes)); let natural = natural.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; Ok(Loaded { name, format, art: Art::Vector { src: Box::new(doc), nodes, clip }, natural, warnings, link: None, board }) } +/// Page `page` (1-based) of the PDF/.ai/.ait file `src` (`bytes`, read from `path` when there is +/// one) as art to place: clipped to the box `opts.crop` names, or (`bounding`) the bounds of its +/// art; read with `opts.password`. Only the page placed is read. A .ai saved without PDF +/// compatibility is read from its editing data, as it opens, and placed as an EPS is: the art on +/// artboard `page`, its linked images found from its folder. +pub(crate) fn pdf_art( + src: &str, + bytes: &[u8], + path: Option<&str>, + page: usize, + opts: fileio::LoadOptions, + bounding: bool, + cmd: &str, +) -> Result { + let name = fileio::file_name(src); + let index = page.checked_sub(1).ok_or_else(|| bad(cmd, "page must be a whole number from 1"))?; + let o = fileio::LoadOptions { crop: if bounding { CropTo::Crop } else { opts.crop }, ..opts }; + let mut read = fileio::page_document(bytes, index, &o); + // Such a file can have more artboards than placeholder pages. + if page > 1 + && read.is_err() + && let Err(e) = fileio::page_document(bytes, 0, &o) + && fileio::is_placeholder(&e) + { + read = Err(e); + } + let (mut doc, warnings, alone) = + fileio::page_or_editing_data(src, bytes, o.password.as_deref(), read).map_err(|e| bad(cmd, format!("{name}: {e}")))?; + if alone { + // Linked images show their files, found from its folder. + super::links::resolve(&mut doc, path, false, false); + } + doc.drop_edit_modes(); + let layers = std::mem::take(&mut doc.layers); + let symbols = doc.symbol_boxes(); + // The page placed is a PDF import's only one, with one layer; the editing data's art is + // whatever lies on the artboard. + let index = if alone { index } else { 0 }; + let n = doc.artboards.len(); + let board = doc.artboards.get(index).map(|a| a.rect).ok_or_else(|| bad(cmd, format!("page {page}: `{name}` has {n} page(s)")))?; + let pages = if alone { &layers[..] } else { layers.get(index..=index).unwrap_or_default() }; + let nodes = on_artboard(&symbols, pages, board); + let clip = (!bounding).then_some(board); + let frame = clip.or_else(|| shown_bounds(&symbols, &nodes)); + let frame = frame.filter(|r| r.width() > 0.0 || r.height() > 0.0).ok_or_else(|| bad(cmd, format!("`{name}` has no art to place")))?; + Ok(document::DocumentArt { doc, nodes, clip, frame, warnings }) +} + /// The bounds of what `nodes` draw, as an export measures art /// ([`vectorcraft_render::encode::drawn_bounds`]): hidden objects, guides and template layers /// left out, symbol instances measured by their art in `symbols`. @@ -331,6 +385,7 @@ fn build(d: &mut Document, l: Loaded, link: Option) -> Node { xf: Affine::scale_non_uniform(sx, sy), link, placement: Default::default(), + pixels: None, }; Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))) } @@ -400,7 +455,89 @@ fn fit(natural: Rect, r: Rect) -> Affine { Affine::translate(r.center().to_vec2()) * Affine::scale(k) * Affine::translate(-natural.center().to_vec2()) } +/// What `file.place` takes from the session before it reads the file: the params, the object +/// Replace swaps and the artboard under `at`, which a DXF drawing is laid out on +/// ([`Session::prepare_place`]). [`PlaceJob::read`] reads and decodes the file without the session, +/// on any thread. +pub struct PlaceJob { + params: Value, + old: Option, + /// The object Replace swaps as it was when Place started. + replaced: Option, + target: Option, +} + +/// A file [`PlaceJob::read`] read, which [`Session::place_read`] places. +pub struct PlaceRead { + job: PlaceJob, + loaded: Loaded, +} + +impl PlaceJob { + /// The name of the file it places ("photo.tif"). + pub fn name(&self) -> String { + str_param(&self.params, "path").or_else(|| str_param(&self.params, "name")).map(fileio::file_name).unwrap_or_else(|| "Untitled".into()) + } + + /// Read and decode the file, as `file.place` does, without the session. A linked image's + /// preview is made here too. + pub fn read(self) -> Result { + let mut loaded = load(&self.params, PLACE, self.target, true)?; + if loaded.link.is_some() + && bool_or(&self.params, "link", true) + && let Art::Image(img) = &mut loaded.art + { + img.blob = img.blob.clone().with_proxy(); + } + Ok(PlaceRead { job: self, loaded }) + } +} + +impl Session { + /// `file.place`'s checks (with no document open, the error `file.place` gives) of `params` + /// against the active document as it is now → what reads the file ([`PlaceJob::read`]; on the + /// desktop File › Place does this off the UI thread). Replace swaps the object selected now; + /// the art is placed against the artboard under `at` as it is when [`Session::place_read`] + /// places it. + pub fn prepare_place(&self, params: &Value) -> Result { + has_doc(self).map_err(|why| EngineError::Disabled(PLACE.into(), why))?; + prepare(self, params) + } + + /// Run `file.place` with the params of `read` as a command (checked and journaled as any + /// other) that places the file `read` holds instead of reading it again → `file.place`'s + /// reply. The object Replace swaps must still be in the document as it was when Place started; + /// when the selection changed meanwhile, `select.set` selects that object again first, so a + /// replay of the journal replaces the same object. + pub fn place_read(&mut self, read: PlaceRead) -> Result { + let params = read.job.params.clone(); + if let Some(old) = read.job.old { + let st = self.doc()?; + // Deleted, changed, or another object with its id: an undo that removes the object frees + // its id for the next object made. + if st.doc.node(old) != read.job.replaced.as_ref() { + return Err(bad(PLACE, "replace: the object to replace was changed or deleted")); + } + if st.selection.objects != [old] { + self.execute("select.set", &json!({ "ids": [old.0] }))?; + } + } + self.place_hand_off = Some(read); + let r = self.execute(PLACE, ¶ms); + self.place_hand_off = None; + r + } +} + fn place(s: &mut Session, p: &Value) -> Result { + let read = match s.place_hand_off.take() { + Some(read) => read, + None => prepare(s, p)?.read()?, + }; + apply(s, read) +} + +fn prepare(s: &Session, p: &Value) -> Result { let replace = bool_or(p, "replace", false); let template = bool_or(p, "template", false); let at = at_param(p)?; @@ -418,16 +555,35 @@ fn place(s: &mut Session, p: &Value) -> Result { (true, [id]) => Some(*id), (true, _) => return Err(bad(PLACE, "replace: select the one object to replace")), }; + let replaced = old.and_then(|id| st.doc.node(id)).cloned(); + Ok(PlaceJob { params: p.clone(), old, replaced, target: board_at(&st.doc, at) }) +} + +/// Place the file `read` holds in the active document. +fn apply(s: &mut Session, read: PlaceRead) -> Result { + let PlaceRead { job: PlaceJob { params, old, target: read_for, .. }, mut loaded } = read; + let p = ¶ms; + let template = bool_or(p, "template", false); + let at = at_param(p)?; + let rect = rect_param(p)?; + let st = s.doc()?; + let old_node = match old { + Some(id) => Some(st.doc.node(id).ok_or_else(|| bad(PLACE, "replace: the object to replace was changed or deleted"))?), + None => None, + }; + // The artboard under `at` as it is now, as a replay of the journal finds it: a DXF drawing laid + // out on the artboard it was read for is read again when that artboard changed meanwhile. let target = board_at(&st.doc, at); - let mut loaded = load(p, PLACE, target)?; - let old_node = old.and_then(|id| st.doc.node(id)); + if loaded.board.is_some() && target != read_for { + loaded = load(p, PLACE, target, true)?; + } // Type reflows rather than scales: its frame takes the size it is placed at. let is_text = matches!(loaded.art, Art::Text(_)); if is_text { let size = match (old_node.and_then(|o| o.geometric_bounds_with(&st.doc.symbol_boxes())), rect) { (Some(b), _) => b.size(), (None, Some(r)) => r.size(), - (None, None) => text_frame(&st.doc, at).size(), + (None, None) => text_frame(target).size(), }; loaded.natural = Rect::from_origin_size(Point::ORIGIN, size); } @@ -478,10 +634,10 @@ fn board_at(d: &Document, at: Option) -> Option { at.and_then(|c| d.artboard_holding(c, false)).and_then(|i| d.artboards.get(i)).or(d.artboards.first()).map(|a| a.rect) } -/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a -/// 36 pt margin. -fn text_frame(d: &Document, at: Option) -> Rect { - let board = board_at(d, at).unwrap_or(TEXT_FRAME); +/// Placed type's frame without `rect` or Replace: the artboard `board` (the one under `at`, else the +/// first, [`board_at`]) less a 36 pt margin. +fn text_frame(board: Option) -> Rect { + let board = board.unwrap_or(TEXT_FRAME); Rect::from_center_size(board.center(), ((board.width() - 72.0).max(72.0), (board.height() - 72.0).max(72.0))) } @@ -523,16 +679,48 @@ fn queued_files(p: &Value) -> Result> { } } -fn queue(s: &mut Session, p: &Value) -> Result { +/// The files `file.place.queue` loads the place cursor with, before they are read +/// ([`Session::prepare_place_queue`]). [`QueueJob::read`] reads them without the session, on any +/// thread. +pub struct QueueJob { + params: Value, + files: Vec, +} + +/// The files [`QueueJob::read`] read, which [`Session::place_queue_read`] loads the place cursor +/// with. +pub struct QueueRead { + params: Value, + entries: Vec, + files: Vec, + skipped: Vec, +} + +impl QueueJob { + /// The names of the files it loads ("a.tif", "b.psd"). + pub fn names(&self) -> Vec { + self.files.iter().filter_map(|q| str_param(q, "path").or(str_param(q, "name"))).map(fileio::file_name).collect() + } + + /// Read every file for its size and thumbnail, as `file.place.queue` does, without the session. + pub fn read(self) -> Result { + read_queue(self.params, self.files) + } +} + +/// Read each of `queued`, the files of `file.place.queue` with params `p`, for its size and +/// thumbnail. +fn read_queue(p: Value, queued: Vec) -> Result { let thumbnail = p.get("thumbnail").and_then(Value::as_f64); let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]); - for mut q in queued_files(p)? { + for mut q in queued { for k in ["link", "template", "page", "crop", "text", "dxf"] { if let Some(v) = p.get(k) { q[k] = v.clone(); } } - match load(&q, QUEUE, None) { + // Read for its size and thumbnail: each click places it with file.place. + match load(&q, QUEUE, None, false) { Ok(l) => { let (w, h) = (l.natural.width(), l.natural.height()); let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h }); @@ -548,6 +736,35 @@ fn queue(s: &mut Session, p: &Value) -> Result { } } } + Ok(QueueRead { params: p, entries, files, skipped }) +} + +impl Session { + /// `file.place.queue`'s checks (with no document open, the error `file.place.queue` gives) of + /// `params` → what reads the files ([`QueueJob::read`]; on the + /// desktop the Place dialog's OK with several files does this off the UI thread). + pub fn prepare_place_queue(&self, params: &Value) -> Result { + has_doc(self).map_err(|why| EngineError::Disabled(QUEUE.into(), why))?; + Ok(QueueJob { params: params.clone(), files: queued_files(params)? }) + } + + /// Run `file.place.queue` with the params of `read` as a command that loads the place cursor + /// with the files `read` holds instead of reading them again → `file.place.queue`'s reply. + pub fn place_queue_read(&mut self, read: QueueRead) -> Result { + let params = read.params.clone(); + self.queue_hand_off = Some(read); + let r = self.execute(QUEUE, ¶ms); + self.queue_hand_off = None; + r + } +} + +fn queue(s: &mut Session, p: &Value) -> Result { + let read = match s.queue_hand_off.take() { + Some(read) => read, + None => s.prepare_place_queue(p)?.read()?, + }; + let QueueRead { entries, files, skipped, .. } = read; if entries.is_empty() { let why = skipped.first().and_then(|e| e["error"].as_str()).unwrap_or("nothing to place"); return Err(bad(QUEUE, why)); @@ -590,7 +807,14 @@ fn thumbnail_png(l: Loaded, px: f64) -> Result { } fn info(_: &mut Session, p: &Value) -> Result { - let mut l = load(p, "file.place.info", None)?; + read_info(p) +} + +/// `file.place.info` with `p`, without the session (on the desktop the Place dialog reads its +/// files' lines this way off the UI thread). +pub fn read_info(p: &Value) -> Result { + // A PDF/.ai page linked or not has the same size and art. + let mut l = load(p, "file.place.info", None, false)?; let natural = l.natural; let mut out = json!({ "name": l.name, diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index 0bedc0ed1..78302890c 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -228,6 +228,7 @@ pub(super) fn area_text(story: Story, frame: Rect, setup: &vectorcraft_doc::DocS t.set_paragraph_styles(story.paras); } t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) }; + t.system_layout = setup.system_layout; crate::cmd::typecmd::refresh_bounds(&mut t); t } diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index f7b62f950..ecddf7cae 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -4,20 +4,30 @@ //! [`Session::attach_plugin_store`]) or from every `*.wasm` in the Additional Plug-ins Folder //! preference, and listed with `plugin.list` / `plugin.info`. Object filters run //! with `plugin.run` on the selected paths and compound paths (one undo step, transactional: a -//! plug-in that fails leaves the document untouched); live-effect plug-ins are applied like any -//! effect (`effect.apply {effect: "plugin."}`) and drawn by `vectorcraft-effects`; exporters -//! add a file format that `document.export` writes (`fileio::plugin_formats`). +//! plug-in that fails leaves the document untouched), and read and write the data they keep with +//! the document ([`vectorcraft_doc::plugin_data`], theirs alone); live-effect plug-ins are applied +//! like any effect (`effect.apply {effect: "plugin."}`) and drawn by `vectorcraft-effects`; +//! exporters add a file format that `document.export` writes (`fileio::plugin_formats`). +//! +//! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing, +//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`), +//! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with +//! Cancel ([`FilterRun`]). use std::sync::{Arc, Mutex}; use serde_json::{Map, Value, json}; -use vectorcraft_plugins::{Kind, Plugin, effect, export, objects, registry}; +use vectorcraft_doc::{Effect, Node}; +use vectorcraft_plugins::{Budget, Kind, Plugin, effect, export, objects, registry}; use super::*; -/// What a plug-in error looks like to the user. +/// What a plug-in error looks like to the user. A run the caller cancelled changed nothing. fn err(e: vectorcraft_plugins::Error) -> EngineError { - EngineError::Other(e.to_string()) + match e { + vectorcraft_plugins::Error::Cancelled => EngineError::Cancelled, + e => EngineError::Other(e.to_string()), + } } /// JSON description of an installed plug-in. @@ -36,6 +46,9 @@ pub fn describe(p: &Plugin) -> Value { } Kind::Filter => {} } + if m.budget == Budget::Heavy { + v["budget"] = json!("heavy"); + } v } @@ -58,38 +71,189 @@ fn plugin_by_id(cmd: &str, p: &Value) -> Result> { registry::get(id).ok_or_else(|| err(vectorcraft_plugins::Error::NotFound(id.into()))) } -fn run(s: &mut Session, p: &Value) -> Result { - const C: &str = "plugin.run"; - let plugin = plugin_by_id(C, p)?; - let m = plugin.manifest(); - match m.kind { - Kind::Filter => {} - Kind::Effect => { - return Err(bad(C, format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id)))); - } - Kind::Exporter => { - return Err(bad(C, format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id)))); +/// One run of an object filter, split so the plug-in can compute away from the session: made from +/// the active document ([`FilterRun::prepare`]), run on any thread ([`FilterRun::run`] touches no +/// document), then handed in ([`FilterRun::hand_in`]) for `plugin.run` to apply instead of running +/// the plug-in again. The app runs a heavy filter ([`Budget::Heavy`]) this way, in the background +/// with Cancel, and the edit itself stays a `plugin.run`: journaled, guarded and undone as one step +/// like any other command. +pub struct FilterRun { + plugin: Arc, + input: Vec, + params: Vec, + /// The objects the plug-in is given, and the container new objects go in. + inputs: Vec, + parent: Option, + /// The document it was made from, and that document's revision then. + document: (u64, u64), +} + +/// A filter's output handed in by a [`FilterRun`] made away from `plugin.run`, with what the run +/// was made of: only a run of the same installation of the plug-in on the same input with the same +/// parameters takes it (a plug-in's output depends on nothing else). +pub(crate) struct HandedIn { + serial: u64, + input: Vec, + params: Vec, + output: Vec, +} + +impl FilterRun { + /// The run `plugin.run`'s params `p` ask for, on the active document: the plug-in (a filter), + /// its checked parameters and `_context`, and the selection's objects. + pub fn prepare(s: &Session, p: &Value) -> Result { + const C: &str = "plugin.run"; + let plugin = plugin_by_id(C, p)?; + let m = plugin.manifest(); + match m.kind { + Kind::Filter => {} + Kind::Effect => { + return Err(bad( + C, + format!("`{}` is a live effect: apply it with effect.apply {{\"effect\": \"{}\"}}", m.id, effect::effect_id(&m.id)), + )); + } + Kind::Exporter => { + return Err(bad( + C, + format!("`{}` is an exporter: export with document.export {{\"format\": \"{}\"}}", m.id, export::format_id(&m.id)), + )); + } } + // Bad parameters fail before anything runs. + let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); + let st = s.doc()?; + let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); + let inputs = objects::filter_targets(&st.doc, &selected); + let parent = st.insertion_parent(); + let bounds = st.doc.bounds_of(&inputs, false); + let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); + let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + // What the plug-in keeps with the document, as it last wrote it. + let data = st.doc.plugin_data(&m.id).cloned().unwrap_or(Value::Null); + params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect)), "documentData": data}); + let input = objects::encode_input(&st.doc, &inputs); + let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; + let document = (st.uid, st.revision); + Ok(FilterRun { plugin, input, params, inputs, parent, document }) } - // Bad parameters fail before anything runs. - let mut params = Value::Object(m.resolve_params(&plugin_params(p)).map_err(|e| bad(C, e.to_string()))?); - let st = s.doc()?; - let selected = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.clone()); - let inputs = objects::filter_targets(&st.doc, &selected); - let parent = st.insertion_parent(); - let bounds = st.doc.bounds_of(&inputs, false); - let board = bounds.and_then(|b| st.doc.artboard_at(b.center())).and_then(|i| st.doc.artboards.get(i)).or(st.doc.artboards.first()); - let rect = |r: vectorcraft_geom::Rect| json!([r.x0, r.y0, r.x1, r.y1]); - params["_context"] = json!({"mode": "filter", "bounds": bounds.map(rect), "artboard": board.map(|a| rect(a.rect))}); - let input = objects::encode_input(&st.doc, &inputs); - let params = serde_json::to_vec(¶ms).map_err(|e| bad(C, e.to_string()))?; - let out = objects::decode(&plugin.run(&input, ¶ms).map_err(err)?).map_err(err)?; - let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { - let ids = objects::apply_output(d, &inputs, out, parent).map_err(err)?; - sel.set(ids.iter().copied()); - Ok(ids) - })?; - Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::>()})) + + /// The plug-in, for its name and budget. + pub fn plugin(&self) -> &Arc { + &self.plugin + } + + /// The objects it gives the plug-in: `plugin.run`'s `ids` that make this same run again. + pub fn inputs(&self) -> &[NodeId] { + &self.inputs + } + + /// The document it was made from (`DocState::uid`). + pub fn document(&self) -> u64 { + self.document.0 + } + + /// Does it still apply: is the active document the one it was made from, unchanged since, and + /// the plug-in still installed as it was? + pub fn current(&self, s: &Session) -> bool { + let installed = registry::get(self.plugin.id()).is_some_and(|p| p.serial() == self.plugin.serial()); + installed && s.active().is_some_and(|st| (st.uid, st.revision) == self.document) + } + + /// Runs the plug-in → its output, or why it failed or stopped. It touches no document, so it + /// can run on any thread; inside a [`vectorcraft_doc::cancel::scope`] Cancel stops it between + /// two slices of its budget. + pub fn run(&self) -> Result> { + self.plugin.run(&self.input, &self.params).map_err(err) + } + + /// Hands in `output`, which [`FilterRun::run`] returned: the next `plugin.run` that makes this + /// same run (same plug-in, `ids` and parameters, the document unchanged) applies it instead of + /// running the plug-in. Any other run drops it. + pub fn hand_in(self, s: &mut Session, output: Vec) { + s.plugin_output = Some(HandedIn { serial: self.plugin.serial(), input: self.input, params: self.params, output }); + } + + /// Applies the plug-in's `output` to the active document as one undo step: its objects, and what + /// it keeps with the document from now on. + fn finish(self, s: &mut Session, output: &[u8]) -> Result { + let m = self.plugin.manifest(); + let out = objects::decode_output(output).map_err(err)?; + let (inputs, parent) = (self.inputs, self.parent); + let ids = s.edit(m.name.trim_end_matches('…'), |d, sel| { + let ids = objects::apply_output(d, &inputs, out.objects, parent, &admit_effects).map_err(err)?; + if let Some(data) = out.document_data { + d.set_plugin_data(&m.id, data); + } + sel.set(ids.iter().copied()); + Ok(ids) + })?; + Ok(json!({"plugin": m.id, "ids": ids.iter().map(|i| i.0).collect::>()})) + } +} + +/// `plugin.run`: runs filter `id` on `ids` (else the selected paths and compound paths) with its +/// parameters and `_context` (what it keeps with the document included), and applies its output as +/// one undo step ([`objects::apply_output`]; effect records as [`admit_effects`] admits them), with +/// what it keeps with the document from now on: the output handed in for this very run when there +/// is one ([`FilterRun::hand_in`]), else the plug-in's, run now. → `{plugin, ids}`: what it made or +/// changed, which is then selected. +fn run(s: &mut Session, p: &Value) -> Result { + // An output handed in is for this run only (the app hands it in just before), whatever it does. + let handed = s.plugin_output.take(); + let r = FilterRun::prepare(s, p)?; + // Handed in for this very run (the app ran it in the background), it is what running the + // plug-in again would return. + let handed = handed.filter(|h| (h.serial, &h.input, &h.params) == (r.plugin.serial(), &r.input, &r.params)); + let output = match handed { + Some(h) => h.output, + None => r.run()?, + }; + r.finish(s, &output) +} + +/// Admits the effect records a filter returned for object `n` (written, its effects still its own) +/// as `effect.apply` would apply them: each new record a known effect (one of ours, or an installed +/// live-effect plug-in) with parameters it takes, filled in to their defaults. A record the object +/// already had stays as it was. An object takes one 3D effect (its fills' and strokes' own +/// counted), which must fit it, and the `textureImage` of an effect with Load Texture (Glass, +/// Texturizer) comes as the effect stores it, never as a file to read: a plug-in can't have the +/// host read one. +fn admit_effects(fx: &[Effect], n: &Node) -> std::result::Result, String> { + use vectorcraft_render::effects; + const C: &str = "plugin.run"; + let mut out = Vec::with_capacity(fx.len()); + for (j, e) in fx.iter().enumerate() { + if n.appearance.effects.contains(e) { + out.push(e.clone()); + continue; + } + let refuse = |why: String| format!("[{j}] `{}`: {why}", e.id); + let params = if e.params.is_null() { json!({}) } else { e.params.clone() }; + match effect::installed(&e.id) { + Some(plugin) => { + plugin.manifest().resolve_params(¶ms).map_err(|why| refuse(why.to_string()))?; + } + None => super::params::check_effect_params(C, &e.id, ¶ms).map_err(|why| refuse(why.to_string()))?, + } + if super::effectcmd::texture_load_effect(&e.id) + && let Some(t) = params.get(effects::pixel::TEXTURE_IMAGE) + && t.as_str().is_none_or(|t| effects::pixel::texture(t).is_none()) + { + return Err(refuse("`textureImage` must be a texture as effect.list gives it".into())); + } + let mut admitted = effects::new_effect(&e.id, ¶ms).ok_or_else(|| refuse("no such effect".into()))?; + if effects::is_three_d(&e.id) { + effects::validate_three_d(n, &e.id, &admitted.params).map_err(refuse)?; + } + admitted.visible = e.visible; + out.push(admitted); + } + let items = n.appearance.items.iter().flat_map(|i| i.effects()); + if out.iter().chain(items).filter(|e| effects::is_three_d(&e.id)).count() > 1 { + return Err(super::effectcmd::ONE_3D.into()); + } + Ok(out) } /// Largest base64 payload `plugin.install` accepts (the module limit, encoded). @@ -98,32 +262,57 @@ const MAX_DATA_CHARS: usize = (32 << 20) / 3 * 4 + 4; /// The `.wasm` extension plug-in files have (File › Open installs them). pub const EXTS: &[&str] = &["wasm"]; -fn install(s: &mut Session, p: &Value) -> Result { - const C: &str = "plugin.install"; - let (plugin, bytes) = match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) { +/// The plug-in module command `c`'s params give, loaded and checked, and its bytes: `path` (a +/// `.wasm` file) or `dataBase64` (the module, with `name` shown as its source). +fn load_given(c: &str, p: &Value) -> Result<(Plugin, Vec)> { + match (str_param(p, "path").filter(|s| !s.is_empty()), str_param(p, "dataBase64")) { (_, Some(data)) => { if data.len() > MAX_DATA_CHARS { - return Err(bad(C, "`dataBase64` is larger than the module limit")); + return Err(bad(c, "`dataBase64` is larger than the module limit")); } - let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(C, "`dataBase64` is not valid base64"))?; + let bytes = vectorcraft_format::base64_decode(data).ok_or_else(|| bad(c, "`dataBase64` is not valid base64"))?; let plugin = Plugin::load(&bytes, vectorcraft_plugins::Limits::default()).map_err(err)?; let plugin = match str_param(p, "name") { Some(name) => plugin.with_source(name), None => plugin, }; - (plugin, bytes) + Ok((plugin, bytes)) } - (Some(path), None) => load_path(path)?, - (None, None) => return Err(bad(C, "give `path` (a .wasm file) or `dataBase64` (the module)")), - }; + (Some(path), None) => load_path(path), + (None, None) => Err(bad(c, "give `path` (a .wasm file) or `dataBase64` (the module)")), + } +} + +/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless +/// `allowHeavy` says the user agreed, keeps it for the next start and installs it → its +/// `plugin.list` entry. +fn install(s: &mut Session, p: &Value) -> Result { + const C: &str = "plugin.install"; + let (plugin, bytes) = load_given(C, p)?; if !p.get("replace").and_then(Value::as_bool).unwrap_or(true) && registry::get(plugin.id()).is_some() { return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id()))); } + // A heavy plug-in is installed once the user agrees: whoever installs it asks them first. + if plugin.manifest().budget == Budget::Heavy && !p.get("allowHeavy").and_then(Value::as_bool).unwrap_or(false) { + let (minutes, times) = vectorcraft_plugins::heavy_terms(); + return Err(bad( + C, + format!( + "“{}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees", + plugin.manifest().name + ), + )); + } // Installed plug-ins stay installed after a restart (#2541). s.store_plugin(plugin.id(), &bytes); Ok(describe(®istry::install(plugin))) } +/// `plugin.inspect`: what installing the module would install, without installing it. +fn inspect(_: &mut Session, p: &Value) -> Result { + load_given("plugin.inspect", p).map(|(plugin, _)| describe(&plugin)) +} + /// The plug-in in the `.wasm` file `path`, and its module. #[cfg(not(target_arch = "wasm32"))] fn load_path(path: &str) -> Result<(Plugin, Vec)> { @@ -161,7 +350,12 @@ fn load_folder(folder: &str) -> Value { return json!({"folder": folder, "error": e}); } // A file in it may be a link to one elsewhere. - match registry::load_folder_where(std::path::Path::new(folder), |f| crate::file_access::check_read(&f.to_string_lossy())) { + match registry::load_folder_where(std::path::Path::new(folder), |f| match f.to_str() { + Some(f) => crate::file_access::check_read(f), + // A lossy name would check another file than the one loaded. + None if crate::file_access::current().is_none() => Ok(()), + None => Err(format!("automation path rejected: {} isn't valid text", f.display())), + }) { Ok(r) => { json!({"folder": folder, "loaded": r.loaded, "failed": r.failed.iter().map(|(f, e)| json!({"file": f, "error": e})).collect::>()}) } @@ -181,7 +375,7 @@ fn reload(s: &mut Session, p: &Value) -> Result { None => s.prefs.plugins_folder.trim().to_string(), }; if folder.is_empty() { - return Err(bad(C, "no folder: pass `path` or set Preferences › Performance & Storage › Additional Plug-ins Folder")); + return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder")); } let report = load_folder(&folder); if let Some(e) = report.get("error").and_then(Value::as_str) { @@ -211,6 +405,8 @@ fn remove(s: &mut Session, p: &Value) -> Result { if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) } } +/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove` and +/// `.reload`. pub fn specs() -> Vec { vec![ cmd!( @@ -218,7 +414,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's format id, plugin., for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), @@ -245,10 +441,19 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), + cmd!( + query "plugin.inspect", + "Inspect Plug-in", + [], + None, + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have", + always, + inspect + ), cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove), cmd!( query "plugin.reload", diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 2135a63ef..907e8fcd6 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -49,7 +49,7 @@ pub struct PrefSpec { pub kind: PrefKind, } -/// Categories in Illustrator's order (Plug-ins & Scratch Disks is "Performance & Storage" here). +/// Categories in Illustrator's order. pub const PREF_CATEGORIES: &[&str] = &[ "General", "Selection & Anchor Display", @@ -59,7 +59,7 @@ pub const PREF_CATEGORIES: &[&str] = &[ "Smart Guides", "Slices", "Hyphenation", - "Performance & Storage", + "Plug-ins & Scratch Disks", "User Interface", "Performance", "File Handling", @@ -234,7 +234,10 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("anchorPathLabels", "Smart Guides", "Display Options", "Anchor/Path Labels", bool), p!("measurementLabels", "Smart Guides", "Display Options", "Measurement Labels", bool), p!("spacingGuides", "Smart Guides", "Display Options", "Spacing Guides", bool), + p!("distanceGuides", "Smart Guides", "Display Options", "Invoke Distance Guides", bool), p!("snappingTolerance", "Smart Guides", "", "Snapping Tolerance", num(0.0, 40.0, "pt")), + p!("snapTangent", "Smart Guides", "Geometric Guides", "Snap to Tangent", bool), + p!("snapPerpendicular", "Smart Guides", "Geometric Guides", "Snap to Perpendicular", bool), // Slices p!("showSliceNumbers", "Slices", "", "Show Slice Numbers", bool), p!("sliceLineColor", "Slices", "", "Line Color", color), @@ -256,10 +259,10 @@ pub const PREF_SPECS: &[PrefSpec] = &[ ]) ), p!("hyphenationExceptions", "Hyphenation", "", "Exceptions (comma separated)", text), - // Performance & Storage - p!("pluginsFolder", "Performance & Storage", "Plug-ins", "Additional Plug-ins Folder", text), - p!("scratchPrimary", "Performance & Storage", "Scratch Disks", "Primary", text), - p!("scratchSecondary", "Performance & Storage", "Scratch Disks", "Secondary", text), + // Plug-ins & Scratch Disks + p!("pluginsFolder", "Plug-ins & Scratch Disks", "Plug-ins", "Additional Plug-ins Folder", text), + p!("scratchPrimary", "Plug-ins & Scratch Disks", "Scratch Disks", "Primary", text), + p!("scratchSecondary", "Plug-ins & Scratch Disks", "Scratch Disks", "Secondary", text), // User Interface p!( "uiBrightness", @@ -469,13 +472,14 @@ impl Prefs { /// The folders the app reads (fonts, plug-ins) or writes (Data Recovery copies, the Templates /// folder it makes for its dialogs) on its own, with the access each needs. - fn folders_mut(&mut self) -> [(&mut String, crate::file_access::Access); 4] { + fn folders_mut(&mut self) -> [(&mut String, &'static [crate::file_access::Access]); 4] { use crate::file_access::Access; [ - (&mut self.fonts_folder, Access::Read), - (&mut self.plugins_folder, Access::Read), - (&mut self.recovery_folder, Access::Write), - (&mut self.templates_folder, Access::Write), + (&mut self.fonts_folder, &[Access::Read][..]), + (&mut self.plugins_folder, &[Access::Read][..]), + // Data Recovery lists and restores what is in its folder too. + (&mut self.recovery_folder, &[Access::Write, Access::Read][..]), + (&mut self.templates_folder, &[Access::Write][..]), ] } @@ -491,7 +495,7 @@ impl Prefs { if folder.is_empty() || folder == old.trim() { continue; } - if let Err(e) = roots.check(folder, access) { + if let Err(e) = access.iter().try_for_each(|a| roots.check(folder, *a)) { refused.push(e); *new = old.clone(); } diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs index 68fd5cbda..76f52f4b8 100644 --- a/crates/engine/src/cmd/print.rs +++ b/crates/engine/src/cmd/print.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Print Setup", [], None, - "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|b4|b5|custom, width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", + "{preset?: name ([Default] or a saved print preset, any case: print.presets.list; its settings replace the document's, as choosing it in the Print dialog does), settings?: {copies: 1 (1–999), collate: true, reverse, artboards: all|range|ignore (all the art as one page), range: \"1-3, 5\" (1-based, with artboards: range), skipBlank (leave out artboards with no art that prints), media: letter|legal|tabloid|a3|a4|a5|a6|b4|b5|b4 (jis)|b5 (jis)|b6 (jis)|japanese postcard|japanese double postcard|japanese envelope #3|japanese envelope #4|kaku #2|kaku #3|yonroku-ban|custom (a paper is named by its id or its dialog label, any case and spacing, so `B5 (JIS)` works; b4 and b5 stay the ISO 216 sizes, 250 × 353 and 176 × 250 mm, and the JIS sizes are b4 (jis) 257 × 364 mm, b5 (jis) 182 × 257 mm and b6 (jis) 128 × 182 mm), width, height (custom paper, 72–14400 pt), orientation: portrait|landscape|portraitFlipped|landscapeFlipped, autoRotate: true (turn the paper to each artboard; orientation ignored), transverse (the page a quarter turn on the paper), printLayers: visiblePrintable|visible|all (template layers never print), placement: {origin: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight (the point of the printed area, the artboard with its bleed and marks, on the same point of the imageable area), x, y (pt, right and down)}, scaling: none|fit|custom|tileFull|tileImageable, scale: {width: 100, height: 100} (% with custom and tiling), overlap: 0 (pt between tiles), tileRange: \"\" (1-based tiles across then down; empty: all), margin: 0 (pt the device can't print around the paper: the imageable area is inside it), marks: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6} (as document.exportPdf, at the paper's scale; page information adds the tile and the ink), bleed: {useDocument: true (the document's bleed, document.setup), top, bottom, left, right} (pt), output: {mode: composite|separations (one grey page per ink that prints), emulsion: up|down (down mirrors), image: positive|negative, spotsToProcess, inks: [{name: \"Cyan\"|…|a spot swatch, print: true, frequency (lpi, default 60), angle (default C 15, M 75, Y 0, K 45, spots 45)}]}, graphics: {autoFlatness: true, flatness: 1 (0.2–100), fonts: none|subset|complete}, color: {intent: perceptual|relativeColorimetric|saturation|absoluteColorimetric, preserveNumbers: true (CMYK colours keep their values), profile: \"\" (the printer profile, an RGB or CMYK one: composite PDF colours are converted to it with the intent, separations separate with a CMYK one; empty: as they are)}, advanced: {printAsBitmap (composite pages print as one image each, at the document's raster effects resolution), overprints: preserve|discard|simulate (composite output; preserve writes them to overprint (/OP), simulate prints them as Overprint Preview shows them; separations always keep them), flattenerPreset: \"\" (a flattener preset, built-in or saved: transparency is flattened before printing; empty: it prints live; PostScript defaults to medium)}, tileOrigin: {placed: false, x, y} (where the Print Tiling tool put the pages: print.tiling.set)}} store the print settings with the document, over the ones it has or the preset's (null keeps a value), as one undo step; no settings or preset → {settings} (the current ones, defaults if never set up)", has_doc, setup ), diff --git a/crates/engine/src/cmd/printadvanced.rs b/crates/engine/src/cmd/printadvanced.rs index bd9fce789..301f696df 100644 --- a/crates/engine/src/cmd/printadvanced.rs +++ b/crates/engine/src/cmd/printadvanced.rs @@ -96,7 +96,7 @@ fn bitmap_document(cmd: &str, doc: &Document, set: &PrintSettings, rich_black: b let key = unique_key(&out, "print-bitmap"); out.images.insert(key.clone(), ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?)); let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); - let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; + let image = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; images.push(Arc::new(Node::new(out.alloc_id(), NodeKind::Image(Box::new(image))))); } let mut layer = Node::layer(out.alloc_id(), "Bitmap", LayerColor::Preset(0)); diff --git a/crates/engine/src/cmd/printtiling.rs b/crates/engine/src/cmd/printtiling.rs index 0cb37207c..690ef6196 100644 --- a/crates/engine/src/cmd/printtiling.rs +++ b/crates/engine/src/cmd/printtiling.rs @@ -96,7 +96,7 @@ fn set_origin(s: &mut Session, p: &Value) -> Result { let regions = vectorcraft_pdf::print_regions(doc, &set).map_err(|e| pdf_error(SET, e))?; let picked = match p.get("artboard").filter(|v| !v.is_null()) { Some(v) => { - let i = v.as_u64().ok_or_else(|| bad(SET, "artboard must be a 0-based index"))? as usize; + let i = v.as_u64().map(index).ok_or_else(|| bad(SET, "artboard must be a 0-based index"))?; regions.iter().find(|(a, _)| a.is_none() || *a == Some(i)).ok_or_else(|| bad(SET, format!("artboard {i} doesn't print")))? } None => region_holding(®ions, at).or(regions.first()).ok_or_else(|| bad(SET, "nothing prints"))?, diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index d55bf1b0a..ae88da1fa 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -192,8 +192,10 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N if !area.is_finite() { return None; } - // Whole pixels, and no larger than 64 Mpx. - let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()); + // Whole pixels, no larger than 64 Mpx, and no side longer than the renderer can address + // (a long thin object stays within the area but not within a side). + let side = f64::from(vectorcraft_render::MAX_RASTER_SIDE) - 2.0; + let scale = scale.min((64.0e6 / area.max(1.0)).sqrt()).min(side / b.width().max(b.height()).max(1.0)); if !(scale.is_finite() && scale > 0.0) { return None; } @@ -223,7 +225,15 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale); let mut image = Node::new( id, - NodeKind::Image(Box::new(ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key, + width: img.width, + height: img.height, + xf, + link: None, + placement: Default::default(), + pixels: None, + })), ); image.name = Some("Raster effect".into()); Some(image) diff --git a/crates/engine/src/cmd/rasterfx/report.rs b/crates/engine/src/cmd/rasterfx/report.rs index e263b03fc..f26db6db6 100644 --- a/crates/engine/src/cmd/rasterfx/report.rs +++ b/crates/engine/src/cmd/rasterfx/report.rs @@ -92,6 +92,7 @@ mod tests { xf: Affine::scale(72.0 / ppi), link: None, placement: Default::default(), + pixels: None, })), ) } diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index cbab99a88..d9a8b487a 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces, except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches]}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null, exclude?: false (the row's colours are kept)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], note?: set when a colour kept a model other than its new colour's (see model)}", has_doc, apply ), @@ -156,11 +156,11 @@ fn reduce(s: &mut Session, p: &Value) -> Result { } } let unit_colors = |g: &[&Used]| g.iter().map(|u| u.color).collect::>(); - let rows: Vec> = match p.get("colors").and_then(Value::as_u64) { - Some(n) if (n as usize) < units.len() => { + let rows: Vec> = match p.get("colors").and_then(Value::as_u64).map(index) { + Some(n) if n < units.len() => { let keys: Vec = units.iter().map(|g| method.key_color(&unit_colors(g)).unwrap_or_default()).collect(); let weights: Vec = units.iter().map(|g| g.iter().map(|u| u.count as f32).sum()).collect(); - cluster(&keys, &weights, n as usize).into_iter().map(|ix| ix.into_iter().flat_map(|i| units[i].iter().copied()).collect()).collect() + cluster(&keys, &weights, n).into_iter().map(|ix| ix.into_iter().flat_map(|i| units[i].iter().copied()).collect()).collect() } _ => units, }; @@ -268,17 +268,19 @@ struct ColorMap { links: Vec>, keys: HashMap, hexes: HashMap, + /// Set when a colour took a new colour of another model in its own model. + converted: std::cell::Cell, } impl ColorMap { /// `links`: the swatch of each of `rows`' new colours (as many as `rows`, or none). - fn new(rows: &[Row], method: Method, links: &[Option]) -> Self { - let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new() }; + fn new(rows: &[Row], method: Method, own_model: bool, links: &[Option]) -> Self { + let mut out = Self { rows: vec![], links: vec![], keys: HashMap::new(), hexes: HashMap::new(), converted: Default::default() }; for (n, r) in rows.iter().enumerate().filter(|(_, r)| !r.from.is_empty()) { let Some(to) = r.new_color() else { continue }; let i = out.rows.len(); let colors: Vec = r.from.iter().map(Match::color).collect(); - out.rows.push(RowMap::new(method, &colors, to)); + out.rows.push(RowMap::new(method, &colors, to).with_own_model(own_model)); out.links.push(links.get(n).cloned().flatten()); for m in &r.from { match m { @@ -296,7 +298,11 @@ impl ColorMap { } fn map(&self, c: Color) -> Color { - self.row_of(c).and_then(|i| self.rows.get(i)).map_or(c, |r| r.map(c)) + let Some(r) = self.row_of(c).and_then(|i| self.rows.get(i)) else { return c }; + if r.converts(c) { + self.converted.set(true); + } + r.map(c) } /// Recolour a colour that can link to a swatch: a row whose new colour is a global or spot @@ -315,7 +321,12 @@ impl ColorMap { return changed; } (Some(l), Some(t)) => l.color.tinted(t), - _ => row.map(*c), + _ => { + if row.converts(*c) { + self.converted.set(true); + } + row.map(*c) + } }; if new == *c { return false; @@ -351,17 +362,42 @@ fn apply(s: &mut Session, p: &Value) -> Result { } let scope = Scope { fill: true, stroke: true, ..Scope::of(p) }; let label = if ids.is_empty() { "Edit Color Group" } else { "Recolor Artwork" }; + // `model`: keep each colour's model (default), take the new colour's own, or the document's. + // A given `model` must be a string; only a missing one means "keep". + let model = match p.get("model") { + None => "keep", + Some(Value::String(m)) => m.as_str(), + Some(other) => return Err(bad(C, format!("bad model {other} (keep, new or document)"))), + }; + let mut doc_model = None; + let own_model = match model { + "keep" => false, + "new" => true, + "document" => { + let m = s.doc()?.doc.color_mode.model(); + doc_model = Some(m); + for r in &mut rows { + r.to = r.to.map(|c| c.in_model(m)); + } + true + } + other => return Err(bad(C, format!("unknown model `{other}` (keep, new or document)"))), + }; + // The colours a colour group takes follow the model too (`document` converts them). + let in_doc_model = |c: Color| doc_model.map_or(c, |m| c.in_model(m)); let new: Vec = match p.get("groupColors").and_then(Value::as_array) { Some(cs) => cs .iter() .map(|v| { - color_value(v).map(|c| palette.as_ref().map_or(c, |pl| pl.nearest(c))).ok_or_else(|| bad(C, format!("bad colour {v} in groupColors"))) + color_value(v) + .map(|c| in_doc_model(palette.as_ref().map_or(c, |pl| pl.nearest(c)))) + .ok_or_else(|| bad(C, format!("bad colour {v} in groupColors"))) }) .collect::>()?, None => rows.iter().filter_map(Row::new_color).collect(), }; let rename = str_param(p, "rename"); - let (changed, edit, added) = s.edit(label, |d, _| { + let (changed, edit, added, converted) = s.edit(label, |d, _| { // The rows' library swatches join the document (one it has with that name and colour is // used as it is). let mut added = vec![]; @@ -382,17 +418,24 @@ fn apply(s: &mut Session, p: &Value) -> Result { Some(Link { name, global: w.global, color }) }) .collect(); - let map = ColorMap::new(&rows, method, &links); + let map = ColorMap::new(&rows, method, own_model, &links); let f = |c: Color| map.map(c); let lf = |c: &mut Color, link: &mut Option, tint: &mut f32| map.map_link(c, link, tint); let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; - Ok((changed, edit, added)) + Ok((changed, edit, added, map.converted.get())) })?; if let Some(e) = edit { e.defaults(s); } - Ok(json!({ "changed": changed, "swatches": added })) + let mut out = json!({ "changed": changed, "swatches": added }); + if converted { + out["note"] = json!( + "some colours kept their own colour model, so the new colour was converted into it and may look different; \ + pass model \"new\" (the new colour's model) or \"document\" (the document's colour mode) to change the model" + ); + } + Ok(out) } fn randomize(_: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index 9f046b39b..7d54c9a44 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -252,6 +252,18 @@ impl FolderStore { } } + /// Create area folder `dir`, and the store's folder when it is missing. On Unix every folder + /// made here is owner-only (`0o700`): the copies are unsaved documents, and with the default + /// mode other accounts on the machine could read them. A store folder that already exists + /// keeps its mode, since it can be one the user picked in Preferences. + fn create_area(dir: &std::path::Path) -> std::result::Result<(), String> { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700); + builder.create(dir).map_err(|e| format!("{}: {e}", dir.display())) + } + /// The plain names of the files (or, `dirs`, folders) in `dir`; none when it doesn't exist. fn names(dir: &std::path::Path, dirs: bool) -> std::result::Result, String> { let entries = match std::fs::read_dir(dir) { @@ -285,7 +297,7 @@ impl RecoveryStore for FolderStore { fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> { let f = self.file(name)?; if let Some(dir) = f.parent() { - std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + Self::create_area(dir)?; } vectorcraft_format::write_atomic(&f, bytes).map_err(|e| format!("{}: {e}", f.display())) } @@ -304,7 +316,7 @@ impl RecoveryStore for FolderStore { } fn lock(&self, area: &str) -> std::result::Result { let dir = self.area(area)?; - std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + Self::create_area(&dir)?; let path = dir.join(LOCK_FILE); let file = std::fs::OpenOptions::new() .read(true) @@ -987,7 +999,7 @@ fn restore_one(s: &mut Session, store: &Arc, file: &str) -> R doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() }; } // Linked files are looked for from the document's own folder. - let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically"); + let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps); let index = s.add_document(doc, meta.path.clone()); let st = s.doc_mut()?; st.recovered = true; diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 2197877e3..8a7d220e3 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -76,7 +76,7 @@ pub fn specs() -> Vec { "Select Segments", [], None, - "{id, segments: [[subpath, segment]…]} select path segments; their endpoints move with them, Delete removes only their connections", + "{id, segments: [[subpath, segment]…], mode?: \"set\"|\"add\"|\"toggle\"} select path segments; their endpoints move with them, Delete removes only their connections", has_doc, segments ), @@ -266,7 +266,7 @@ fn all(s: &mut Session, _: &Value) -> Result { fn all_on_artboard(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; - let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; + let i = p.get("artboard").and_then(Value::as_u64).map_or(0, index); let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?; // Symbol instances are measured by their art. let symbols = st.doc.symbol_boxes(); @@ -436,22 +436,85 @@ pub(crate) fn checked_refs(v: Option<&Value>, cmd: &str) -> Result, cmd: &str) -> Result> { + let path = doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "object is not a path"))?; + let mut endpoints = BTreeSet::new(); + for &(si, seg) in refs { + let sp = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()).ok_or_else(|| bad(cmd, "segment does not exist"))?; + let n = sp.anchors.len().max(1); + endpoints.extend([(si, seg), (si, (seg + 1) % n)]); + } + Ok(endpoints) +} + fn segments(s: &mut Session, p: &Value) -> Result { const C: &str = "select.segments"; let id = checked_id(s, p.get("id").ok_or_else(|| bad(C, "missing id"))?, C)?; let refs: BTreeSet<_> = checked_refs(p.get("segments"), C)?.into_iter().collect(); - let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "object is not a path"))?; - let mut endpoints = BTreeSet::new(); - for &(si, seg) in &refs { - let sp = path.subpaths.get(si).filter(|sp| seg < sp.segment_count()).ok_or_else(|| bad(C, "segment does not exist"))?; - endpoints.extend([(si, seg), (si, (seg + 1) % sp.anchors.len())]); - } - s.select(|_, sel| { - sel.clear(); - if !refs.is_empty() { - sel.add(id); - sel.anchors.insert(id, endpoints); - sel.segments.insert(id, refs); + let mode = match str_param(p, "mode") { + None | Some("set") => "set", + Some("add") => "add", + Some("toggle") => "toggle", + Some(m) => return Err(bad(C, format!("unknown mode {m:?}"))), + }; + let endpoints = segment_endpoints(&s.doc()?.doc, id, &refs, C)?; + s.select(|d, sel| { + if mode == "set" { + sel.clear(); + if !refs.is_empty() { + sel.add(id); + sel.anchors.insert(id, endpoints); + sel.segments.insert(id, refs); + } + } else if mode == "add" { + if !refs.is_empty() { + sel.add(id); + sel.anchors.entry(id).or_default().extend(endpoints); + sel.segments.entry(id).or_default().extend(refs); + } + } else if mode == "toggle" { + let seg_entry = sel.segments.entry(id).or_default(); + let mut removed_endpoints = BTreeSet::new(); + let mut added_endpoints = BTreeSet::new(); + let path = d.node(id).and_then(|n| n.path_data()); + for &(si, seg) in &refs { + let Some(sp) = path.as_ref().and_then(|p| p.subpaths.get(si)) else { continue }; + let n = sp.anchors.len().max(1); + let end1 = (si, seg); + let end2 = (si, (seg + 1) % n); + if seg_entry.contains(&(si, seg)) { + seg_entry.remove(&(si, seg)); + removed_endpoints.insert(end1); + removed_endpoints.insert(end2); + } else { + seg_entry.insert((si, seg)); + added_endpoints.insert(end1); + added_endpoints.insert(end2); + } + } + let mut surviving_seg_endpoints = BTreeSet::new(); + for &(si, seg) in seg_entry.iter() { + if let Some(sp) = path.as_ref().and_then(|p| p.subpaths.get(si)) { + let n = sp.anchors.len().max(1); + surviving_seg_endpoints.insert((si, seg)); + surviving_seg_endpoints.insert((si, (seg + 1) % n)); + } + } + let anchor_entry = sel.anchors.entry(id).or_default(); + for pt in removed_endpoints { + if !surviving_seg_endpoints.contains(&pt) { + anchor_entry.remove(&pt); + } + } + anchor_entry.extend(added_endpoints); + if seg_entry.is_empty() { + sel.segments.remove(&id); + } + if anchor_entry.is_empty() { + sel.remove(id); + } else { + sel.add(id); + } } })?; ok() @@ -471,8 +534,9 @@ fn anchors(s: &mut Session, p: &Value) -> Result { if mode == "set" { sel.clear(); } - // A path selected as a whole has every anchor selected: toggle and add start from them all. - let whole = sel.contains(id) && sel.partial(id).is_none(); + // A path selected as a whole has every anchor selected: toggle and add start from them all, + // but only when no partial anchor selection is active and only on a single isolated object. + let whole = sel.contains(id) && sel.partial(id).is_none() && sel.anchors.is_empty() && sel.objects.len() <= 1; sel.add(id); // A segment pick's endpoints aren't selected anchors: start from none of them. if sel.segments.remove(&id).is_some() { @@ -489,6 +553,9 @@ fn anchors(s: &mut Session, p: &Value) -> Result { e.insert(r); } } + if e.is_empty() { + sel.remove(id); + } })?; ok() } @@ -559,7 +626,7 @@ fn anchors_many(s: &mut Session, p: &Value) -> Result { /// Select › Same › Fill/Stroke Color: is `a` the same colour as `b`? Tints of one global or spot /// swatch are, unless `tint` (General › Select Same Tint %) asks for the same tint too. -fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool { +pub(crate) fn same_paint(a: &Paint, b: &Paint, tint: bool) -> bool { match (a, b) { (Paint::Solid { swatch: Some(x), tint: ta, .. }, Paint::Solid { swatch: Some(y), tint: tb, .. }) if x == y => !tint || (ta - tb).abs() < 1e-4, _ => a == b, @@ -644,7 +711,7 @@ fn same_attribute(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).ok_or_else(|| bad(cmd, "nothing selected"))?; let ap = &refn.appearance; - let item = p.get("item").and_then(Value::as_u64).map(|i| i as usize).or(s.appearance_item()); + let item = p.get("item").and_then(Value::as_u64).map(index).or(s.appearance_item()); let attr = match (item.and_then(|i| ap.items.get(i)), ap.effects.first()) { (Some(it), _) => Attribute::Item(it.clone()), (None, Some(e)) => Attribute::Effect(e.clone()), diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 9aeaa4a30..503229f94 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -511,10 +511,11 @@ fn candidates(s: &Session, p: &Value, points: &[Point], tolerance: f64) -> Resul let supplied = p.get("ids").map(|_| checked_ids_param(s, p, "ids", "shaper.scribble")).transpose()?; let expand = supplied.is_none() && st.selection.objects.len() < 2; let ids = supplied.unwrap_or_else(|| if st.selection.objects.len() > 1 { st.selection.objects.clone() } else { st.doc.selectable_art() }); + let editable = st.doc.editable(); let eligible: Vec = ids .into_iter() .filter(|id| { - st.doc.is_editable(*id) + editable(*id) && st.doc.node(*id).is_some_and(|n| { !matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. }) && (n.extras().shaper.is_some() || b::node_outline(n).is_some()) @@ -691,10 +692,11 @@ fn scribble(s: &mut Session, p: &Value) -> Result { /// When `within` is given, only that group is considered. fn group_at(d: &Document, point: Point, construction: bool, within: Option) -> Option { let mut groups = vec![]; + let editable = d.editable(); d.walk(|n| { if n.extras().shaper.is_some() && within.is_none_or(|g| g == n.id) - && d.is_editable(n.id) + && editable(n.id) && (if construction { source_group(n).and_then(Node::geometric_bounds) } else { n.geometric_bounds() }) .is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) { diff --git a/crates/engine/src/cmd/slices.rs b/crates/engine/src/cmd/slices.rs index 2a814a55e..b75680e1a 100644 --- a/crates/engine/src/cmd/slices.rs +++ b/crates/engine/src/cmd/slices.rs @@ -409,7 +409,7 @@ fn splits(lo: f64, hi: f64, count: Option, size: Option) -> std::resul (None, Some(_)) => return Err("a slice size must be a positive number of points".into()), (None, None) => 1, }; - if n == 0 || n as usize > MAX_PIECES { + if n == 0 || index(n) > MAX_PIECES { return Err(format!("divide into 1 to {MAX_PIECES} slices")); } let step = match (count, size) { diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index 9c1c49a68..c346631c8 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -4,14 +4,14 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ - ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, StrokeAlign, StrokeLayer, Unit, WidthProfile, + ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile, }; use super::appearance::{ItemTarget, edit_items, item_target}; use super::paint::painted; use super::*; use crate::DocState; -use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke}; +use crate::inspect::{ALIGNS, CAPS, JOINS, StrokeMixed, node_stroke, scale_layer}; pub fn specs() -> Vec { vec![ @@ -171,6 +171,16 @@ impl StrokeChange { self.apply(&mut st); style.set_stroke_layer(&st); } + + /// [`Self::apply_char`] to type drawn at mean scale `k` (its `xf`'s): the weight and dash + /// lengths are document-space, the character stroke is drawn in text space. + pub(crate) fn apply_char_scaled(&self, style: &mut CharStyle, k: f64) { + let mut st = style.stroke_layer(); + scale_layer(&mut st, k); + self.apply(&mut st); + scale_layer(&mut st, 1.0 / k); + style.set_stroke_layer(&st); + } } /// Does a Stroke panel edit find nothing selected (and no `ids`)? It then sets up new art. @@ -227,7 +237,9 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result { if index.is_none() && let NodeKind::Text(t) = &mut n.kind { - t.runs.iter_mut().for_each(|r| change.apply_char(&mut r.style)); + // Character strokes are drawn in text space, scaled by the type's transform. + let k = Scaling::factor(t.xf).filter(|k| k.is_finite() && *k > 1e-12).unwrap_or(1.0); + t.runs.iter_mut().for_each(|r| change.apply_char_scaled(&mut r.style, k)); return Ok(()); } if index.is_none() && n.appearance.stroke().is_none() { diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 5f796bcf2..486aa0d4e 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -547,7 +547,7 @@ fn style_move(s: &mut Session, p: &Value) -> Result { let to = p.get("to").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing `to` (an index)"))?; let d = &s.doc()?.doc; let from = style_index(d, name)?; - let to = (to as usize).min(d.graphic_styles.len() - 1); + let to = index(to).min(d.graphic_styles.len() - 1); if from == to { return ok(); } diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index e78dea380..2df742a97 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -137,6 +137,8 @@ pub fn specs() -> Vec { fn swatch_new(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.new"; let mode = str_param(p, "mode"); + super::paint::check_keep_model(p, C)?; + check_switches(p, &["spot", "global"], C)?; let paints = match (p.get("colors").and_then(Value::as_array), str_param(p, "pattern")) { (Some(cs), _) => { let stored = |c: Color| if mode.is_some() { c } else { super::paint::new_color(s, p, c) }; @@ -228,6 +230,7 @@ pub(super) fn names_param(p: &Value, cmd: &str) -> Result> { fn swatch_delete(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.delete"; let names = names_param(p, C)?; + check_switches(p, &["unlink"], C)?; let d = &s.doc()?.doc; // Global swatches going away (named ones and those in named groups): their links are dropped. let mut global: Vec = vec![]; @@ -302,6 +305,8 @@ fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> u fn swatch_new_group(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.newGroup"; let mut names = str_list(p, "swatches"); + super::paint::check_keep_model(p, C)?; + check_switches(p, &["fromArtwork", "toGlobal", "includeTints"], C)?; let colors: Vec = p .get("colors") .and_then(Value::as_array) @@ -351,7 +356,7 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { /// The name of the item that items moving to index `to` of `list` go before: the first one at or /// after `to` that isn't moving (`None`: the end). -fn anchor(list: &[T], to: Option, moving: &[String], name: impl Fn(&T) -> &str) -> Option { +pub(super) fn anchor(list: &[T], to: Option, moving: &[String], name: impl Fn(&T) -> &str) -> Option { list.iter().skip(to?).map(&name).find(|n| !moving.iter().any(|m| m == n)).map(str::to_string) } @@ -363,8 +368,9 @@ fn insert_before(list: &mut Vec, items: Vec, anchor: Option<&str>, name fn swatch_move(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.move"; + check_numbers(p, &["to"], C)?; let names = names_param(p, C)?; - let to = p.get("to").and_then(Value::as_u64).map(|v| v as usize); + let to = p.get("to").and_then(Value::as_u64).map(index); let group = str_param(p, "group").map(str::to_string); let moved = names.len(); s.edit("Move Swatch", |d, _| { @@ -528,6 +534,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { if sw.is_reserved() { return Err(bad(C, format!("`{name}` can't be edited"))); } + check_switches(p, &["spot", "global"], C)?; let given = |k: &str| p.get(k).is_some_and(|v| !v.is_null()); let solid = sw.paint.color(); if solid.is_none() && (given("color") || given("mode") || bool_or(p, "spot", false)) { @@ -538,6 +545,10 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result { { return Err(bad(C, format!("`{name}` is a tint of `{base}`: edit `{base}` (a tint swatch only takes a new name)"))); } + super::paint::check_keep_model(p, C)?; + if let Some(v) = p.get("paint") { + super::paint::check_keep_model(v, C)?; + } // A replacement paint of the swatch's kind (a dropped colour or edited gradient). let paint = match p.get("paint").filter(|v| !v.is_null()) { Some(v) => match (if given("mode") { paint_from(s, &keeping_model(v))? } else { paint_from(s, v)? }, &sw.paint) { @@ -643,8 +654,8 @@ fn swatch_sort(s: &mut Session, _: &Value) -> Result { fn swatch_add_used(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.addUsedColors"; - let global = bool_or(p, "global", false); - let only_selection = bool_or(p, "selection", false); + let global = switch_or(p, "global", false, C)?; + let only_selection = switch_or(p, "selection", false, C)?; let (added, linked) = s.edit("Add Used Colors", |d, sel| { let ids: Vec = if only_selection { sel.objects.clone() } else { d.layers.iter().map(|l| l.id).collect() }; if only_selection && ids.is_empty() { @@ -875,6 +886,7 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result { .iter() .map(|v| color_value(v).ok_or_else(|| bad(C, format!("bad colour {v}")))) .collect::>()?; + super::paint::check_keep_model(p, C)?; // A color its swatch already has keeps its model. The others take the document's. let old: Vec> = s.doc()?.doc.swatch_groups.iter().find(|g| g.name == group).map(|g| g.swatches.iter().map(|w| w.paint.color()).collect()).unwrap_or_default(); diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index f5d4b74a1..36c67f696 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -152,14 +152,17 @@ impl Libraries { } /// Re-read the library files of the user library folder (unreadable ones are skipped). The - /// folder is the app's own: automation roots don't apply to it ([`crate::file_access`]). + /// folder is the app's own: automation roots don't apply to it ([`crate::file_access`]). Each + /// file is read inside [`crate::guard::catch_panic`], and a file whose reading panics is + /// skipped too. pub fn rescan(&mut self) { self.extra.retain(|e| e.info.category != "user"); let Some(dir) = self.user_dir.clone() else { return }; for path in library_files(&dir, L::EXTS) { let file = file_name(&path); - let Some(lib) = crate::file_access::unconfined(|| read_file(&path)).ok().and_then(|b| L::read(&b, stem(&file)).ok()) else { continue }; - let info = LibraryInfo { id: format!("user/{file}"), name: lib.name().to_string(), category: "user" }; + let read = || crate::file_access::unconfined(|| read_file(&path)).ok().and_then(|b| L::read(&b, stem(&file)).ok()); + let Some(lib) = crate::guard::catch_panic(read).ok().flatten() else { continue }; + let info = LibraryInfo { id: format!("user/{file}"), name: library_tab_name(&file, lib.name()), category: "user" }; self.extra.push(Extra { info, path: Some(path), lib: Arc::new(lib) }); } } @@ -242,7 +245,7 @@ impl Libraries { if let Some(e) = path.and_then(user) { return Ok((e.info.clone(), e.lib.clone())); } - let info = LibraryInfo { id: format!("loaded/{}", path.unwrap_or(&file)), name: lib.name().to_string(), category: "loaded" }; + let info = LibraryInfo { id: format!("loaded/{}", path.unwrap_or(&file)), name: library_tab_name(&file, lib.name()), category: "loaded" }; let lib = Arc::new(lib); self.extra.retain(|x| x.info.id != info.id); self.extra.push(Extra { info: info.clone(), path: path.map(str::to_string), lib: lib.clone() }); @@ -275,9 +278,10 @@ impl Libraries { Some(p) => { let ext = extension(&p); let base = file_name(&p); - // Color books (.acb) name the tab from the book title inside the file; a sandbox - // copy of an import often lands as tmp….tmp.acb (#2996). - let file = if ext == "acb" || temporary_library_file_name(&base) { library_file_name(lib.name(), &ext) } else { base }; + // A sandbox copy of an import often lands as tmp….tmp.acb (#2996): store it under the + // book title. A normal .acb path keeps its file name so the tab matches Illustrator + // (#3395). + let file = if temporary_library_file_name(&base) { library_file_name(lib.name(), &ext) } else { base }; (file, read_file(&p)?) } None => (library_file_name(lib.name(), ext), encode(&lib)?), @@ -315,6 +319,13 @@ fn temporary_library_file_name(name: &str) -> bool { lower.contains(".tmp.") } +/// The name library panels and menus show: color books (.acb) use the file name without its +/// extension (Illustrator), except when the path is a host temporary (#2996), then the book title. +fn library_tab_name(file: &str, lib_name: &str) -> String { + let base = file_name(file); + if extension(&base) == "acb" && !temporary_library_file_name(&base) { stem(&base).to_string() } else { lib_name.to_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`. pub fn library_file_name(name: &str, ext: &str) -> String { diff --git a/crates/engine/src/cmd/symbollib.rs b/crates/engine/src/cmd/symbollib.rs new file mode 100644 index 000000000..5b361df6a --- /dev/null +++ b/crates/engine/src/cmd/symbollib.rs @@ -0,0 +1,346 @@ +//! Symbol libraries (Window → Symbol Libraries, the Symbols panel's library button): read-only sets +//! of symbols the library panel shows. A library is a document holding the symbols with the +//! resources they use (nested symbols, patterns, swatches, brushes, graphic styles, images) and +//! their Symbol Options; saved, it is a native `.vectorcraft` file, which File › Open also opens. +//! The built-in library is generated here ([`BUILTIN`]); User Defined ones are the `.vectorcraft` +//! files in the user library folder ([`Libraries`]), and Other Library… loads the symbols of any +//! document VectorCraft opens. `symbol.addFromLibrary` copies symbols into the document; +//! `symbol.saveLibrary` writes the document's. + +use std::collections::HashSet; +use std::sync::{Arc, LazyLock}; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, Symbol}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; + +use super::brushsym::{CURRENT_SYMBOL, natural_size, normalize, place_xf, set_natural_size}; +use super::clipboard::Clipboard; +use super::clipboard::resources::SwatchChoices; +use super::swatch::str_list; +use super::swatchlib::{FileData, LibraryFile, LibraryInfo, stem}; +use super::*; + +/// The extension of saved symbol libraries: native documents. +pub const SYMBOLS_EXT: &str = vectorcraft_format::EXTENSION; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "symbol.libraries", + "Symbol Libraries", + [], + None, + "{} → {libraries: [{id, name, category: \"builtIn\"|\"user\" (User Defined: the .vectorcraft files in the user library folder, rescanned now)|\"loaded\" (symbol.loadLibrary), count, path?}], userFolder} every symbol library the library panel opens, in menu order", + always, + list + ), + cmd!( + query "symbol.library", + "Symbol Library", + [], + None, + "{library: id or name} → {id, name, category, symbols: [{name, size: [w, h], registration: 0..8, exportType}] (as symbol.list)}", + always, + get + ), + cmd!( + "symbol.addFromLibrary", + "Add to Symbols", + ["Window", "Symbol Libraries"], + None, + "{library: id or name, name? | names?: [symbol names in the library] (default: all of it), place?: false (also place an instance of the first one, its registration point at x, y: default the first artboard's centre), x?, y?, swatchConflict? (as edit.paste)} copy library symbols into the document's Symbols as one undo step (with the place), with their Symbol Options and the resources they use (the symbols they nest, patterns, swatches, brushes, graphic styles, images). A symbol the document already has (same name and art) isn't added again; a name another symbol has gets a number (\"Star 2\"). The first one becomes the current symbol → {library, names: [each symbol's name in the document], added: [names added], existing: [names already there], placed?: id}", + has_doc, + add + ), + cmd!( + "symbol.saveLibrary", + "Save Symbol Library…", + ["Window", "Symbols"], + None, + "{path?, names?: [symbol names] (default: all), name?: library name (default: the document's), user?: false (save into the user library folder, listed under User Defined)} save the document's symbols, with their Symbol Options and the resources they use, as a symbol library: a .vectorcraft document holding only them → {path, count, library?: id when saved to the user folder}; without path or user → {dataBase64: the file's bytes, count}", + has_doc, + save + ), + cmd!( + "symbol.loadLibrary", + "Other Library…", + ["Window", "Symbol Libraries"], + None, + "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} load the symbols of any document VectorCraft opens (see document.formats), a saved symbol library included, for the library panel (Window → Symbol Libraries lists it until the app quits; a file of the user library folder, or a file with the same bytes as one there, opens as that User Defined library) → {library: id, name, count}", + always, + load + ), + ] +} + +/// A symbol library: its name and a document holding its symbols (in panel order) with the +/// resources they use and their Symbol Options; the document has no art of its own. +#[derive(Clone, Debug)] +pub struct SymbolLibrary { + pub name: String, + pub doc: Document, +} + +impl Default for SymbolLibrary { + fn default() -> Self { + Self { name: String::new(), doc: Document::new(100.0, 100.0) } + } +} + +impl SymbolLibrary { + pub fn len(&self) -> usize { + self.doc.symbols.len() + } + pub fn is_empty(&self) -> bool { + self.doc.symbols.is_empty() + } + pub fn symbol(&self, name: &str) -> Option<&Symbol> { + self.doc.symbols.iter().find(|s| s.name == name) + } + + /// Document `src`'s symbols `names` (empty: all of them, in panel order) as a library named + /// `name`: the symbols, the symbols they nest and every other resource they use, with their + /// Symbol Options. + pub fn from_document(src: &Document, names: &[String], name: String) -> std::result::Result { + if let Some(n) = names.iter().find(|n| !src.symbols.iter().any(|s| s.name == **n)) { + return Err(format!("no symbol `{n}`")); + } + let picked = src.symbols.iter().filter(|s| names.is_empty() || names.contains(&s.name)); + let clip = Clipboard::gather(src, picked.map(|s| instance(&s.name)).collect()); + let mut doc = clip.to_document(); + for l in &mut doc.layers { + if let Some(ch) = Arc::make_mut(l).children_mut() { + ch.clear(); + } + } + // The panel order of the document they came from. + let order = |n: &str| src.symbols.iter().position(|s| s.name == n); + doc.symbols.sort_by_key(|s| order(&s.name)); + for s in &doc.symbols.clone() { + copy_options(src, &s.name, &mut doc, &s.name); + } + doc.title = name.clone(); + Ok(Self { name, doc }) + } +} + +impl LibraryFile for SymbolLibrary { + const EXTS: &'static [&'static str] = &[SYMBOLS_EXT]; + fn read(bytes: &[u8], stem: &str) -> std::result::Result { + let doc = vectorcraft_format::load(bytes).map_err(|e| e.to_string())?; + SymbolLibrary::from_document(&doc, &[], stem.to_string()) + } + fn name(&self) -> &str { + &self.name + } +} + +/// User Defined and loaded symbol libraries. +pub type Libraries = super::swatchlib::Libraries; + +/// A library generated in code. +pub struct BuiltinSymbolLibrary { + pub id: &'static str, + pub name: &'static str, + make: fn() -> SymbolLibrary, +} + +/// The built-in symbol libraries, in menu order: original art generated in code. +pub const BUILTIN: &[BuiltinSymbolLibrary] = &[BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes }]; + +/// Built-in library `id`, made once. +fn builtin_library(id: &str) -> Option> { + static MADE: LazyLock>> = LazyLock::new(|| BUILTIN.iter().map(|b| Arc::new((b.make)())).collect()); + BUILTIN.iter().position(|b| b.id == id).and_then(|i| MADE.get(i).cloned()) +} + +/// Basic Shapes: plain filled and outlined shapes, 40 pt (the arrow 48 × 32 pt). +fn basic_shapes() -> SymbolLibrary { + let square = Rect::new(-20.0, -20.0, 20.0, 20.0); + let c = Point::ZERO; + let arrow = [(-24.0, -6.0), (4.0, -6.0), (4.0, -16.0), (24.0, 0.0), (4.0, 16.0), (4.0, 6.0), (-24.0, 6.0)]; + let arrow: Vec = arrow.iter().map(|&(x, y)| Point::new(x, y)).collect(); + let shapes = [ + ("Circle", shapes::ellipse(square), "#2f80c9"), + ("Square", shapes::rectangle(square), "#d9534f"), + ("Rounded Square", shapes::rounded_rectangle(square, 8.0), "#f0ad4e"), + ("Triangle", shapes::polygon(c, 22.0, 3, 0.0), "#5cb85c"), + ("Diamond", shapes::polygon(c, 20.0, 4, 0.0), "#8e5bc6"), + ("Hexagon", shapes::polygon(c, 20.0, 6, 0.0), "#1fa39b"), + ("Star", shapes::star(c, 21.0, 9.0, 5, 0.0), "#f2c233"), + ("Burst", shapes::star(c, 21.0, 15.0, 12, 0.0), "#e8743b"), + ("Arrow", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&arrow, true)), "#4a5a6a"), + ]; + let mut doc = Document::new(100.0, 100.0); + for (name, path, hex) in shapes { + let fill = Color::from_hex(hex).unwrap_or(Color::BLACK); + // The outline: the fill, darkened. + let edge = fill.lerp(&Color::BLACK, 0.4); + let id = doc.alloc_id(); + let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.0)); + let Ok((art, size, _)) = normalize(art) else { continue }; + doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art) }); + set_natural_size(&mut doc, name, Some(size)); + } + doc.title = "Basic Shapes".into(); + SymbolLibrary { name: "Basic Shapes".into(), doc } +} + +/// An instance of symbol `name` (what the resource gathering follows). +fn instance(name: &str) -> Node { + Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY }) +} + +/// Symbol `from`'s Symbol Options in `src` (natural size, registration, export type, 9-slice +/// guides) given to symbol `to` of `d`. +fn copy_options(src: &Document, from: &str, d: &mut Document, to: &str) { + set_natural_size(d, to, Some(natural_size(src, from))); + d.set_symbol_registration(to, src.symbol_registration(from)); + d.set_symbol_export_type(to, src.symbol_export_type(from)); + d.set_symbol_nine_slice(to, src.symbol_nine_slice(from)); +} + +fn builtin_info(b: &BuiltinSymbolLibrary) -> LibraryInfo { + LibraryInfo { id: b.id.into(), name: b.name.into(), category: "builtIn" } +} + +/// Every symbol library, in menu order: built-in, User Defined, loaded. +pub fn libraries(s: &Session) -> Vec { + BUILTIN.iter().map(builtin_info).chain(s.symbol_libraries.infos().cloned()).collect() +} + +/// Library `key` (an id, or a name in any case) with its info. +pub fn library(s: &Session, key: &str) -> Option<(LibraryInfo, Arc)> { + if let Some(found) = s.symbol_libraries.get(key) { + return Some(found); + } + if let Some(b) = BUILTIN.iter().find(|b| b.id == key) { + return Some((builtin_info(b), builtin_library(key)?)); + } + let id = libraries(s).into_iter().find(|l| l.name.eq_ignore_ascii_case(key))?.id; + library(s, &id) +} + +fn library_param(s: &Session, p: &Value, cmd: &str) -> Result<(LibraryInfo, Arc)> { + let key = str_param(p, "library").ok_or_else(|| bad(cmd, "missing `library` (see symbol.libraries)"))?; + library(s, key).ok_or_else(|| bad(cmd, format!("no symbol library `{key}` (see symbol.libraries)"))) +} + +fn list(s: &mut Session, _: &Value) -> Result { + s.symbol_libraries.rescan(); + let libs: Vec = libraries(s) + .into_iter() + .map(|l| { + let count = library(s, &l.id).map_or(0, |(_, lib)| lib.len()); + json!({"id": l.id, "name": l.name, "category": l.category, "count": count, "path": s.symbol_libraries.path(&l.id)}) + }) + .collect(); + Ok(json!({ "libraries": libs, "userFolder": s.symbol_libraries.user_dir() })) +} + +fn get(s: &mut Session, p: &Value) -> Result { + let (info, lib) = library_param(s, p, "symbol.library")?; + let d = &lib.doc; + let symbols: Vec = d + .symbols + .iter() + .map(|sym| { + let (w, h) = natural_size(d, &sym.name); + json!({"name": sym.name, "size": [w, h], "registration": d.symbol_registration(&sym.name).0, "exportType": d.symbol_export_type(&sym.name).id()}) + }) + .collect(); + Ok(json!({"id": info.id, "name": info.name, "category": info.category, "symbols": symbols})) +} + +fn add(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.addFromLibrary"; + let (info, lib) = library_param(s, p, C)?; + let mut names = str_list(p, "names"); + names.extend(str_param(p, "name").map(str::to_string)); + if let Some(n) = names.iter().find(|n| lib.symbol(n).is_none()) { + return Err(bad(C, format!("no symbol `{n}` in `{}`", lib.name))); + } + let mut picked: Vec = if names.is_empty() { lib.doc.symbols.iter().map(|s| s.name.clone()).collect() } else { names }; + // Each once, in the order asked for. + let mut seen = HashSet::new(); + picked.retain(|n| seen.insert(n.clone())); + let place = bool_or(p, "place", false); + if place && picked.is_empty() { + return Err(bad(C, format!("`{}` has no symbols to place", lib.name))); + } + let choices = SwatchChoices::parse(C, p)?; + let clip = Clipboard::gather(&lib.doc, picked.iter().map(|n| instance(n)).collect()); + let st = s.doc()?; + let before: HashSet = st.doc.symbols.iter().map(|s| s.name.clone()).collect(); + // A library symbol's name in the document: its own, or the one the import gave it. + let target = |renamed: &[Value], n: &str| -> String { + let to = renamed.iter().find(|r| r["kind"] == "symbol" && r["from"] == n).and_then(|r| r["to"].as_str()); + to.unwrap_or(n).to_string() + }; + // Where the symbols land, worked out on a copy: nothing to add and nothing to place is no edit. + let mut probe = (*st.doc).clone(); + let imported = clip.import_into(&mut probe, &choices, false); + let doc_names: Vec = picked.iter().map(|n| target(&imported.renamed, n)).collect(); + let centre = st.doc.artboards.first().map(|a| a.rect.center()).unwrap_or(Point::ZERO); + let at = if place { Some(Point::new(number_or(p, "x", centre.x, C)?, number_or(p, "y", centre.y, C)?)) } else { None }; + let parent = if place { st.target_parent()? } else { None }; + let mut out = json!({ "library": info.id }); + if imported.added > 0 || place { + let placed = s.edit("Add to Symbols", |d, sel| { + let imported = clip.import_into(d, &choices, false); + // Every symbol brought in (nested ones too) takes its Symbol Options along. + for sym in &clip.symbols { + let to = target(&imported.renamed, &sym.name); + if !before.contains(&to) { + copy_options(&lib.doc, &sym.name, d, &to); + } + } + let Some(first) = doc_names.first() else { return Ok(None) }; + d.unknown.insert(CURRENT_SYMBOL.into(), json!(first)); + let Some(at) = at else { return Ok(None) }; + let xf = place_xf(at, natural_size(d, first), d.symbol_registration(first)); + let id = d.alloc_id(); + d.insert(parent, usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: first.clone(), xf }))?; + sel.set([id]); + Ok(Some(id)) + })?; + if let Some(id) = placed { + out["placed"] = json!(id.0); + } + } else if let Some(first) = doc_names.first() { + s.execute("symbol.setCurrent", &json!({ "name": first }))?; + } + let (added, existing): (Vec<&String>, Vec<&String>) = doc_names.iter().partition(|n| !before.contains(*n)); + out["names"] = json!(doc_names); + out["added"] = json!(added); + out["existing"] = json!(existing); + Ok(out) +} + +fn save(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.saveLibrary"; + let st = s.doc()?; + let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).map_or_else(|| stem(&st.title()).to_string(), str::to_string); + let lib = SymbolLibrary::from_document(&st.doc, &str_list(p, "names"), name).map_err(|e| bad(C, e))?; + if lib.is_empty() { + return Err(bad(C, "the document has no symbols to save")); + } + let mut out = json!({ "count": lib.len() }); + let bytes = vectorcraft_format::save_file(&lib.doc); + s.symbol_libraries.write(p, &lib.name, SYMBOLS_EXT, FileData::Binary(bytes), &mut out, C)?; + Ok(out) +} + +fn load(s: &mut Session, p: &Value) -> Result { + const C: &str = "symbol.loadLibrary"; + let (info, lib) = s.symbol_libraries.load(p, C, |bytes, file| { + let loaded = super::fileio::load(file, bytes).map_err(|e| bad(C, e.to_string()))?; + if loaded.preview_only { + return Err(bad(C, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning")); + } + SymbolLibrary::from_document(&loaded.doc, &[], stem(file).to_string()).map_err(|e| bad(C, e)) + })?; + Ok(json!({"library": info.id, "name": info.name, "count": lib.len()})) +} diff --git a/crates/engine/src/cmd/tests_lighting_presets.rs b/crates/engine/src/cmd/tests_lighting_presets.rs new file mode 100644 index 000000000..82426c5ea --- /dev/null +++ b/crates/engine/src/cmd/tests_lighting_presets.rs @@ -0,0 +1,93 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; +use vectorcraft_render::effects::{LIGHTING_PRESETS, matching_lighting_preset}; + +use crate::Session; + +fn setup(effect: &str, item: Value) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap(); + s.execute( + "effect.apply", + &json!({"effect":effect,"item":item,"params":{ + "rotationX":13,"rotationY":17,"rotationZ":9,"shade":false,"ambient":17, + "lights":[{"color":"#ff8040"},{"color":"#4080ff","rotation":-35}] + }}), + ) + .unwrap(); + (s, id) +} + +#[test] +fn lighting_presets_preserve_source_and_view_and_round_trip_in_one_undo_step() { + for effect in ["threeD.revolve", "threeD.extrude", "threeD.inflate", "threeD.rotate"] { + for (i, preset) in LIGHTING_PRESETS.iter().enumerate() { + let (mut s, id) = setup(effect, Value::Null); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + let p = json!({"index":0,"item":null,"preset":preset.id}); + s.check_params("effect.lightingPreset", &p).unwrap(); + s.execute("effect.lightingPreset", &p).unwrap(); + let after = s.doc().unwrap().doc.clone(); + let actual = &after.node(id).unwrap().appearance.effects[0].params; + let mut expected = before.node(id).unwrap().appearance.effects[0].params.clone(); + expected.as_object_mut().unwrap().extend(preset.params().as_object().unwrap().clone()); + assert_eq!(*actual, expected); + assert_eq!(matching_lighting_preset(actual), Some(i)); + assert_eq!(after.node(id).unwrap().path_data(), before.node(id).unwrap().path_data()); + assert_eq!(actual["rotationX"], 13); + assert_eq!(actual["shade"], false); + assert_eq!(s.doc().unwrap().history.undo.len(), history + 1); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&after, false)).unwrap(); + assert_eq!(reopened.node(id).unwrap().appearance.effects[0].params, *actual); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + assert!(!s.doc().unwrap().is_dirty()); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, after); + } + } +} + +#[test] +fn lighting_presets_target_fill_effects_and_reject_invalid_requests_atomically() { + let (mut s, id) = setup("threeD.rotate", json!(0)); + for p in [ + json!({"index":0,"item":0,"preset":"unknown"}), + json!({"index":0,"item":0,"preset":12}), + json!({"index":1,"item":0,"preset":"right"}), + json!({"index":0,"item":null,"preset":"right"}), + json!({"index":0,"item":0,"preset":"right","params":{"ambient":200}}), + ] { + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + assert!(s.execute("effect.lightingPreset", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(!s.doc().unwrap().is_dirty()); + } + s.execute("effect.lightingPreset", &json!({"ids":[id.0],"index":0,"item":0,"preset":"topLeft"})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + assert!(n.appearance.effects.is_empty()); + assert_eq!(n.appearance.effects_at(Some(0)).unwrap()[0].params["lightRotation"], 135.0); +} + +#[test] +fn lighting_presets_reject_a_mixed_selection_before_changing_any_effect() { + let (mut s, first) = setup("threeD.rotate", Value::Null); + let second = s.execute("shape.rectangle", &json!({"x":250,"y":100,"width":80,"height":80})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect":"path.offsetPath","item":null})).unwrap(); + s.execute("select.set", &json!({"ids":[first.0,second]})).unwrap(); + s.doc_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().doc.clone(); + let history = s.doc().unwrap().history.undo.len(); + assert!(s.execute("effect.lightingPreset", &json!({"index":0,"item":null,"preset":"right"})).is_err()); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), history); + assert!(!s.doc().unwrap().is_dirty()); +} diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index f188ecb45..0c29ac4e7 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -27,7 +27,7 @@ pub fn specs() -> Vec { "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), 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}", has_doc, set_range_style ), @@ -83,7 +83,7 @@ pub fn specs() -> Vec { Some("Cmd+Shift+."), "{by?: steps (1)} type.step {attribute: size}", has_selection, - |s, p| type_step(s, &json!({"attribute": "size", "by": f64_or(p, "by", 1.0)})) + |s, p| type_step(s, &json!({"attribute": "size", "by": number_or(p, "by", 1.0, "type.size.increase")?})) ), cmd!( "type.size.decrease", @@ -92,7 +92,7 @@ pub fn specs() -> Vec { Some("Cmd+Shift+,"), "{by?: steps (1)} type.step {attribute: size, by: -by}", has_selection, - |s, p| type_step(s, &json!({"attribute": "size", "by": -f64_or(p, "by", 1.0)})) + |s, p| type_step(s, &json!({"attribute": "size", "by": -number_or(p, "by", 1.0, "type.size.decrease")?})) ), cmd!( "type.size.increaseByFive", @@ -174,14 +174,18 @@ pub(crate) fn set_plain_text(d: &mut vectorcraft_doc::Document, id: NodeId, text Ok(()) } +/// The byte offset that `k` holds, read with [`index`]. fn byte_param(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_u64).map(|v| v as usize) + p.get(k).and_then(Value::as_u64).map(index) } -fn range_of(p: &Value, len: usize) -> (usize, usize) { +/// The bytes `start..end` of `p` in text of `len` bytes, clamped and ordered (without them, all of +/// it). A `start` or `end` that is not a number or null, such as `"2"`, is an error. +fn range_of(p: &Value, len: usize, cmd: &str) -> Result<(usize, usize)> { + check_numbers(p, &["start", "end"], cmd)?; let a = byte_param(p, "start").unwrap_or(0).min(len); let b = byte_param(p, "end").unwrap_or(len).min(len); - (a.min(b), a.max(b)) + Ok((a.min(b), a.max(b))) } fn edit_range(s: &mut Session, p: &Value) -> Result { @@ -189,7 +193,7 @@ fn edit_range(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); - let (a, b) = range_of(p, len); + let (a, b) = range_of(p, len, C)?; let styled: Option> = match p.get("runs") { None | Some(Value::Null) => None, Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(C, format!("bad `runs`: {e}")))?), @@ -208,6 +212,8 @@ fn edit_range(s: &mut Session, p: &Value) -> Result { Some(r) => edit::replace_range_styled(&mut t.runs, a, b, r), None => edit::replace_range(&mut t.runs, a, b, &insert), }; + // Ruby over the edited text moves with it (its ranges are plain-text bytes). + t.ruby_after_replace(a, b, inserted.len()); refresh_bounds(t); Ok(caret) })?; @@ -218,9 +224,9 @@ fn get_range(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("text.getRange", "missing `id`"))?; let t = text_ref(s, id)?; let len = edit::runs_len(&t.runs); - let (a, b) = range_of(p, len); + let (a, b) = range_of(p, len, "text.getRange")?; let mut runs = edit::slice_runs(&t.runs, a, b); - if p.get("effective").and_then(Value::as_bool).unwrap_or(false) { + if switch_or(p, "effective", false, "text.getRange")? { for run in &mut runs { run.style = t.effective_char_style(&run.style).ok_or_else(|| bad("text.getRange", "text transform is collapsed or unrepresentable"))?; } @@ -253,11 +259,27 @@ pub(crate) struct CharChange { position: Option, small_caps: Option>, char_align: Option, + char_direction: Option>, proportional_metrics: Option, + tate_chu_yoko: Option, + no_break: Option, digits: Option, font_axes: Option, } +/// `charDirection: "default"|"leftToRight"|"rightToLeft"` → the Character Direction to set (`None` +/// inside: Default). +pub(crate) fn char_direction_param(p: &Value, cmd: &str) -> Result>> { + use vectorcraft_doc::ParaDirection; + let Some(v) = p.get("charDirection").filter(|v| !v.is_null()) else { return Ok(None) }; + Ok(Some(match v.as_str() { + Some("default") => None, + Some("leftToRight") => Some(ParaDirection::LeftToRight), + Some("rightToLeft") => Some(ParaDirection::RightToLeft), + _ => return Err(bad(cmd, "`charDirection` must be \"default\", \"leftToRight\" or \"rightToLeft\"")), + })) +} + /// `digits: "default"|"arabic"|"hindi"|"farsi"` → the numeral system the digits are drawn in. pub(crate) fn digits_param(p: &Value, cmd: &str) -> Result> { use vectorcraft_doc::Digits; @@ -379,6 +401,8 @@ fn auto_or_num(p: &Value, k: &str, cmd: &str) -> Result>> { impl CharChange { /// `setup` gives the superscript, subscript and small caps proportions. pub(crate) fn parse(p: &Value, cmd: &str, setup: &vectorcraft_doc::DocSetup) -> Result { + check_numbers(p, &["size", "tracking", "baselineShift", "hScale", "vScale", "rotation", "strokeWidth"], cmd)?; + check_switches(p, &["underline", "strikethrough", "allCaps", "proportionalMetrics", "tateChuYoko", "noBreak"], cmd)?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let (position, small_caps) = super::docsetup::script_params(p, setup, cmd)?; let flag = |k: &str| p.get(k).and_then(Value::as_bool); @@ -411,7 +435,10 @@ impl CharChange { position, small_caps, char_align: char_align_param(p, cmd)?, + char_direction: char_direction_param(p, cmd)?, proportional_metrics: flag("proportionalMetrics"), + tate_chu_yoko: flag("tateChuYoko"), + no_break: flag("noBreak"), digits: digits_param(p, cmd)?, font_axes: font_axes_param(p, cmd)?, }; @@ -452,7 +479,10 @@ impl CharChange { && self.position.is_none() && self.small_caps.is_none() && self.char_align.is_none() + && self.char_direction.is_none() && self.proportional_metrics.is_none() + && self.tate_chu_yoko.is_none() + && self.no_break.is_none() && self.digits.is_none() && self.font_axes.is_none() } @@ -545,9 +575,18 @@ impl CharChange { if let Some(v) = self.char_align { st.char_align = v; } + if let Some(v) = self.char_direction { + st.char_direction = v; + } if let Some(v) = self.proportional_metrics { st.proportional_metrics = v; } + if let Some(v) = self.tate_chu_yoko { + st.tate_chu_yoko = v; + } + if let Some(v) = self.no_break { + st.no_break = v; + } if let Some(v) = self.digits { st.digits = v; } @@ -610,6 +649,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { }, }; let mut change = CharChange::parse(p, C, &s.doc()?.doc.setup)?; + super::paint::check_keep_model(p, C)?; change.store_colors(s, p); if change.is_empty() { return Err(bad(C, "nothing to change")); @@ -617,7 +657,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result { let protect = s.prefs.missing_glyph_protection && (change.font.is_some() || change.style.is_some()); let t = text_ref(s, id)?; change.localize(&t, C)?; - let (a, b) = range_of(p, edit::runs_len(&t.runs)); + let (a, b) = range_of(p, edit::runs_len(&t.runs), C)?; 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() { @@ -647,6 +687,7 @@ fn glyph_em(t: &TextObject, g: &vectorcraft_text::PositionedGlyph) -> Option Result { const C: &str = "text.touchType"; let id = id_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; + check_numbers(p, &["start"], C)?; let start = byte_param(p, "start").ok_or_else(|| bad(C, "missing `start` (the byte offset of a character)"))?; if !["dx", "dy", "hScale", "vScale", "rotation"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { return Err(bad(C, "nothing to change: give dx, dy, hScale, vScale or rotation")); @@ -674,7 +715,8 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { if dx != 0.0 && g.rtl { return Err(bad(C, "a right-to-left character moves only up and down (dy)")); } - let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.byte + h.len == a); + // A list marker's glyphs (length 0) stand for no character. + let prev = lay.glyphs.iter().find(|h| h.line == g.line && h.len > 0 && h.byte + h.len == a); let pr = prev.map(|h| (h.byte, h.byte + h.len)); let em = glyph_em(&t, g).ok_or_else(|| bad(C, "the character's size can't be measured"))?; let em_prev = match prev { @@ -730,7 +772,7 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { let mut trial = t.clone(); touch(&mut trial, None, None, to - shift); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - l.glyphs.iter().find(|h| h.byte == a).map(|h| h.xf * Point::ZERO) + l.glyphs.iter().find(|h| h.byte == a && h.len > 0).map(|h| h.xf * Point::ZERO) }; let per_step = match (pen_at(from + step), pen_at(from + 2.0 * step)) { (Some(p), Some(q)) => linear(t.xf, q - p).hypot() / step.abs(), @@ -747,7 +789,9 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // out alone, in point type of the same orientation, before and after the change. let grown = { let mut alone = TextObject { kind: TextKind::Point, runs: edit::slice_runs(&t.runs, a, b), paras: Vec::new(), ..t.clone() }; - let advance = |o: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().map(|h| h.advance).sum::(); + let advance = |o: &TextObject| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), o).glyphs.iter().filter(|h| h.len > 0).map(|h| h.advance).sum::() + }; let old = advance(&alone); edit::style_range(&mut alone.runs, 0, b - a, |st| change.apply(st)); (advance(&alone) - old) * lay.fit_scale @@ -755,15 +799,15 @@ fn touch_type(s: &mut Session, p: &Value) -> Result { // The character lands `moved` further on, and the character after it on its line keeps its // distance from it, wider or narrower only by the character's new scales. let goal = pen(g) + moved; - let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.line == g.line).map(|h| pen(h) + grown); + let next_goal = lay.glyphs.iter().find(|h| h.byte == b && h.len > 0 && h.line == g.line).map(|h| pen(h) + grown); // How far the character and the character after it on its line miss their places, along the // line, with the kerning `k` (before, own) added. let miss = |k: (Option, Option)| { let mut trial = t.clone(); touch(&mut trial, k.0, k.1, raise); let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &trial); - let h = l.glyphs.iter().find(|h| h.byte == a)?; - let next = match (l.glyphs.iter().find(|n| n.byte == b && n.line == h.line), next_goal) { + let h = l.glyphs.iter().find(|h| h.byte == a && h.len > 0)?; + let next = match (l.glyphs.iter().find(|n| n.byte == b && n.len > 0 && n.line == h.line), next_goal) { (Some(n), Some(at)) => pen(n) - at, _ => 0.0, }; @@ -830,6 +874,9 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { if !on_path && !path.is_closed() && path.bounds().is_none_or(|b| b.width() < 1.0 || b.height() < 1.0) { return Err(bad(C, "area type needs a path that encloses an area")); } + super::paint::check_keep_model(p, C)?; + check_numbers(p, &["size"], C)?; + check_switches(p, &["vertical", "placeholder"], C)?; let style = super::create::new_type_style(s, p); let text = str_param(p, "text").unwrap_or("").to_string(); let start = match point_param(p, "at") { @@ -842,6 +889,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { kind, xf: Affine::IDENTITY, runs: vec![TextRun { text, style, inline: None }], + ruby: Vec::new(), para: super::create::new_type_para(), paras: Vec::new(), area: Default::default(), @@ -850,6 +898,8 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + system_layout: false, + fractional_widths: true, cached_bounds: None, cached_baselines: Vec::new(), }; @@ -857,6 +907,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { if !on_path { t.area.fit = super::create::new_area_fit(s, p, C)?; } + t.system_layout = s.doc()?.doc.setup.system_layout; if bool_or(p, "placeholder", false) { super::typemenu::fill_with_placeholder(&mut t); } else { @@ -912,7 +963,7 @@ fn type_ranges( ) -> Result> { // (text, its range; None: all of it). let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") { - Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs))))], + Some(id) => vec![(id, Some(range_of(p, edit::runs_len(&text_ref(s, id)?.runs), c)?))], None => { let editing = editing_range(s)?.filter(|_| p.get("ids").filter(|v| !v.is_null()).is_none()); match editing { @@ -950,7 +1001,7 @@ fn type_step(s: &mut Session, p: &Value) -> Result { Some("baselineShift") => Step::BaselineShift, _ => return Err(bad(C, "attribute must be size|leading|tracking|kerning|baselineShift")), }; - let by = f64_or(p, "by", 1.0); + let by = number_or(p, "by", 1.0, C)?; if !by.is_finite() { return Err(bad(C, "by must be a number")); } @@ -981,12 +1032,35 @@ fn type_step(s: &mut Session, p: &Value) -> Result { 1.0 }; let mut runs = edit::slice_runs(&t.runs, a, b); - for run in &mut runs { + // Auto leading steps from the leading its paragraph's Justification › Auto Leading + // sets, so each run covers one paragraph and knows which. + let mut paras = vec![]; + if matches!(step, Step::Leading) { + let plain = t.plain_text(); + let mut para = plain.get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count()); + let mut split = Vec::with_capacity(runs.len()); + for run in runs { + if run.inline.is_some() || !run.text.contains('\n') { + paras.push(para); + para += run.text.bytes().filter(|&c| c == b'\n').count(); + split.push(run); + continue; + } + for piece in run.text.split_inclusive('\n') { + paras.push(para); + para += usize::from(piece.ends_with('\n')); + split.push(TextRun::new(piece, run.style.clone())); + } + } + runs = split; + } + for (i, run) in runs.iter_mut().enumerate() { let st = &mut run.style; match step { Step::Size => st.size = super::typecmd::local_type_value(t, st.size * points + inc, (0.1, 1296.0), false, C)?, Step::Leading => { - st.leading = Some(super::typecmd::local_type_value(t, st.effective_leading() * points + inc, (0.1, 5000.0), false, C)?); + let leading = t.para_at(paras.get(i).copied().unwrap_or(0)).effective_leading(st); + st.leading = Some(super::typecmd::local_type_value(t, leading * points + inc, (0.1, 5000.0), false, C)?); } Step::Tracking => st.tracking = (st.tracking + inc).clamp(-1000.0, 10000.0), Step::Kerning => st.kerning = Some((st.kerning.unwrap_or(0.0) + inc).clamp(-1000.0, 10000.0)), @@ -1062,6 +1136,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { kind: TextKind::Point, xf: Affine::IDENTITY, runs: head.clone(), + // Ruby over the counted part; the layout clamps it to `head`. + ruby: t.ruby.clone(), para: Default::default(), paras: Vec::new(), area: Default::default(), @@ -1070,6 +1146,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option { path_spacing: 0.0, wrap: Vec::new(), optical_margin: false, + system_layout: t.system_layout, + fractional_widths: t.fractional_widths, cached_bounds: None, cached_baselines: Vec::new(), }; @@ -1139,6 +1217,7 @@ fn discard_empty(s: &mut Session, p: &Value) -> Result { st.doc = created.doc.clone(); st.history.undo.truncate(k); st.selection.prune(&st.doc); + super::globaledit::follow_history(st); st.revision += 1; return Ok(json!({"removed": true})); } diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index f841aa25d..680024832 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -155,7 +155,10 @@ pub fn char_attrs(v: &CharStyle) -> Map { ("position", json!(v.position)), ("small_caps", json!(v.small_caps)), ("charAlign", json!(v.char_align)), + ("charDirection", json!(v.char_direction)), ("proportionalMetrics", json!(v.proportional_metrics)), + ("tateChuYoko", json!(v.tate_chu_yoko)), + ("noBreak", json!(v.no_break)), ("digits", json!(v.digits)), ] { m.insert(k.into(), x); @@ -165,7 +168,7 @@ pub fn char_attrs(v: &CharStyle) -> Map { /// Every paragraph attribute of `v` as a JSON map, without the style name: those the paragraph's /// JSON leaves out at their defaults (the Hyphenation and Justification options, the Every-line Composer, no tab -/// stops, Auto direction…) are written out, as [`char_attrs`] does for characters. +/// stops, no list, Auto direction…) are written out, as [`char_attrs`] does for characters. pub fn para_attrs(v: &ParaStyle) -> Map { let mut m = to_attrs(v); for (k, x) in [ @@ -173,12 +176,14 @@ pub fn para_attrs(v: &ParaStyle) -> Map { ("justification", json!(v.justification)), ("composer", json!(v.composer)), ("tabs", json!(v.tabs)), + ("list", json!(v.list)), ("mojikumi", json!(v.mojikumi)), ("direction", json!(v.direction)), ("leading_model", json!(v.leading_model)), ("burasagari", json!(v.burasagari)), ("kinsoku", json!(v.kinsoku)), ("roman_hanging", json!(v.roman_hanging)), + ("kashida", json!(v.kashida)), ] { m.insert(k.into(), x); } @@ -341,10 +346,10 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()), }; let Some(NodeKind::Text(t)) = id.and_then(|i| d.node(i)).map(|n| &n.kind) else { return Ok(None) }; - let at = p.get("start").and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX)); + let at = p.get("start").and_then(Value::as_u64).map_or(0, index); Ok(Some(match kind { Kind::Char => { - let style = match p.get("end").and_then(Value::as_u64).map(|v| usize::try_from(v).unwrap_or(usize::MAX)) { + let style = match p.get("end").and_then(Value::as_u64).map(index) { Some(end) => vectorcraft_text::edit::insertion_style(&t.runs, at.min(end), at.max(end)), None => vectorcraft_text::edit::style_at(&t.runs, at), }; @@ -485,12 +490,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result { match kind { Kind::Char => { let len = vectorcraft_text::edit::runs_len(&t.runs); - let (a, b) = match range { - Some(_) => { - let g = |k: &str, def: usize| p.get(k).and_then(Value::as_u64).map_or(def, |v| (v as usize).min(len)); - (g("start", 0), g("end", len)) - } - None => (0, len), + let (a, b) = match (range, text_range) { + (Some(_), Some(r)) => r.bytes(t), + _ => (0, len), }; let mut err = None; vectorcraft_text::edit::style_range(&mut t.runs, a.min(b), a.max(b), |st| match char_fn(st) { @@ -909,6 +911,7 @@ mod tests { /// resets the type that uses it. #[test] fn variable_font_settings_are_captured_explicitly() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); let (mut s, source) = session_with_text("Source"); @@ -932,6 +935,7 @@ mod tests { /// version and Variable Font settings, unless the style sets them. #[test] fn another_font_from_a_style_clears_the_old_fonts_version_and_settings() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); let (mut s, id) = session_with_text("Source"); diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index d599f60bb..e1583e129 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -1,6 +1,8 @@ -//! Type → Threaded Text: one story flowing through area-type frames in order. +//! Type → Threaded Text: one story flowing through frames in order: area type and type on a path, +//! in any mix. Illustrator links type objects of any shape, as long as their text is in an area or +//! along a path, not at a point (). //! -//! Frames are ordinary area text objects holding their slice of the story; the document lists the +//! Frames are ordinary text objects holding their slice of the story; the document lists the //! thread order. After every edit, threads whose frames changed are re-flowed ([`reflow`]). use serde_json::{Value, json}; @@ -17,7 +19,7 @@ pub fn specs() -> Vec { "Create", ["Type", "Threaded Text"], None, - "{} thread the selected area type (and closed paths, which become frames) in stacking order → {ids}", + "{} thread the selected area type and type on a path (and closed paths, which become area frames) in stacking order → {ids}", has_multi, create ), @@ -26,7 +28,7 @@ pub fn specs() -> Vec { "Thread Text", [], None, - "{from, to?, rect?: [x, y, width, height], at?: [x, y]} thread area type `from` (a click on its out port) into one more frame, put after it in its thread: closed path or area type `to`, a new rectangle `rect`, or a new frame of `from`'s shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected", + "{from, to?, rect?: [x, y, width, height], at?: [x, y], before?} thread area type or type on a path `from` (a click on its out port) into one more frame, put after it in its thread, or before it with `before: true` (a click on its in port: the story starts there and flows on into `from`): closed path, area type or type on a path `to`, a new rectangle `rect`, or a new frame of `from`'s kind, shape and size with its top-left at `at` (give one of the three) → {id}; the two frames are selected", has_doc, add ), @@ -64,9 +66,10 @@ fn has_thread_selection(s: &Session) -> std::result::Result<(), String> { } } +/// The text object `id` when it can be a frame of a thread: area type or type on a path. fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> { match d.node(id).map(|n| &n.kind) { - Some(NodeKind::Text(t)) if matches!(t.kind, TextKind::Area { .. }) => Some(t), + Some(NodeKind::Text(t)) if !matches!(t.kind, TextKind::Point) => Some(t), _ => None, } } @@ -78,7 +81,8 @@ thread_local! { } /// Re-flow every thread with a frame that changed between `before` and `doc` (structural sharing -/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones. +/// makes "changed" a pointer comparison). Deleted frames' text flows into the remaining ones; a +/// frame that is now point type leaves its thread with the text it holds. pub(crate) fn reflow(before: &Document, doc: &mut Document) { let threads = std::mem::take(&mut doc.text_threads); let mut kept = vec![]; @@ -113,6 +117,8 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) { sources.push(t.clone()); live.push(*id); } + // A frame converted to point type keeps the text it holds and leaves the thread. + (None, Some(_)) if matches!(doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(_))) => {} (None, Some(old)) => { story.extend(old.runs.iter().cloned()); later_wins.push(false); @@ -202,7 +208,7 @@ pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec { } /// A closed path converted to an empty area-type frame with `style`. -fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle) -> Option { +fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle, system_layout: bool) -> Option { let NodeKind::Path { path, .. } = &n.kind else { return None }; if !path.subpaths.first().is_some_and(|s| s.closed) { return None; @@ -210,6 +216,7 @@ fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle) -> Option let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style); t.kind = TextKind::Area { frame: path.clone() }; t.xf = vectorcraft_geom::Affine::IDENTITY; + t.system_layout = system_layout; refresh_bounds(&mut t); Some(Node::new(n.id, NodeKind::Text(Box::new(t)))) } @@ -219,7 +226,8 @@ fn create(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; let d = &s.doc()?.doc; let style = ids.iter().find_map(|i| text_of(d, *i)).map(|t| t.first_style()).unwrap_or_default(); - let ok = ids.iter().all(|i| text_of(d, *i).is_some() || d.node(*i).is_some_and(|n| frame_from_path(n, style.clone()).is_some())); + let system = d.setup.system_layout; + let ok = ids.iter().all(|i| text_of(d, *i).is_some() || d.node(*i).is_some_and(|n| frame_from_path(n, style.clone(), system).is_some())); if ids.len() < 2 || !ok || !ids.iter().any(|i| text_of(d, *i).is_some()) { return Err(bad(C, "select area type and closed paths (at least one area type object)")); } @@ -228,7 +236,7 @@ fn create(s: &mut Session, _: &Value) -> Result { for id in &ids { if text_of(d, *id).is_none() { let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?; - let f = frame_from_path(&n, style.clone()).ok_or_else(|| bad(C, "not a closed path"))?; + let f = frame_from_path(&n, style.clone(), d.setup.system_layout).ok_or_else(|| bad(C, "not a closed path"))?; *d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f; } } @@ -255,22 +263,24 @@ fn create(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": out.iter().map(|i| i.0).collect::>() })) } -/// `text.thread.add`: the out port's loaded text, placed. +/// `text.thread.add`: the loaded text of a port, placed. fn add(s: &mut Session, p: &Value) -> Result { const C: &str = "text.thread.add"; let given = |k: &str| p.get(k).filter(|v| !v.is_null()); let from = checked_id(s, given("from").unwrap_or(&Value::Null), C)?; + let before = switch_or(p, "before", false, C)?; let d = &s.doc()?.doc; - let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type"))?; + let src = text_of(d, from).ok_or_else(|| bad(C, "`from` must be area type or type on a path"))?; let style = src.first_style(); // The frame to thread into: an existing object (`to`), or a new one beside `from`. let (to, new) = match (given("to"), given("rect"), given("at")) { (Some(v), None, None) => { let to = checked_id(s, v, C)?; let parent = d.parent_of(to).and_then(|i| d.node(i)); - let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone()).is_some()); + let system = d.setup.system_layout; + let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone(), system).is_some()); if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) { - return Err(bad(C, "`to` must be a closed path or area type other than `from`")); + return Err(bad(C, "`to` must be a closed path, area type or type on a path other than `from`")); } if d.text_threads.iter().any(|t| t.contains(&to)) { return Err(bad(C, "`to` is already threaded")); @@ -282,14 +292,18 @@ fn add(s: &mut Session, p: &Value) -> Result { .filter(|[_, _, w, h]| *w > 0.0 && *h > 0.0) .ok_or_else(|| bad(C, "`rect` must be [x, y, width, height] with a positive size"))?; let frame = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, w, h)); - (NodeId(0), Some(empty_frame(src, style.clone(), frame, vectorcraft_geom::Affine::translate((x, y))))) + (NodeId(0), Some(empty_frame(src, style.clone(), TextKind::Area { frame }, vectorcraft_geom::Affine::translate((x, y))))) } (None, None, Some(v)) => { let [x, y] = finite_numbers(v).ok_or_else(|| bad(C, "`at` must be [x, y]"))?; - let TextKind::Area { frame } = &src.kind else { return Err(bad(C, "`from` must be area type")) }; - let b = frame.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?; + let shape = match &src.kind { + TextKind::Area { frame } => frame, + TextKind::OnPath { path, .. } => path, + TextKind::Point => return Err(bad(C, "`from` must be area type or type on a path")), + }; + let b = shape.bounds().map(|b| src.xf.transform_rect_bbox(b)).ok_or_else(|| bad(C, "`from` has an empty frame"))?; let xf = vectorcraft_geom::Affine::translate((x - b.x0, y - b.y0)) * src.xf; - (NodeId(0), Some(empty_frame(src, style.clone(), frame.clone(), xf))) + (NodeId(0), Some(empty_frame(src, style.clone(), src.kind.clone(), xf))) } _ => return Err(bad(C, "give one of `to`, `rect` or `at`")), }; @@ -309,16 +323,18 @@ fn add(s: &mut Session, p: &Value) -> Result { None => { if text_of(d, to).is_none() { let n = d.node(to).cloned().ok_or(EngineError::NoNode(to))?; - *d.node_mut(to).ok_or(EngineError::NoNode(to))? = frame_from_path(&n, style).ok_or_else(|| bad(C, "not a closed path"))?; + *d.node_mut(to).ok_or(EngineError::NoNode(to))? = + frame_from_path(&n, style, d.setup.system_layout).ok_or_else(|| bad(C, "not a closed path"))?; } to } }; match d.text_threads.iter_mut().find(|t| t.contains(&from)) { Some(t) => { - let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| i + 1); + let at = t.iter().position(|x| *x == from).map_or(t.len(), |i| if before { i } else { i + 1 }); t.insert(at, to); } + None if before => d.text_threads.push(vec![to, from]), None => d.text_threads.push(vec![from, to]), } // Force a re-flow of the thread on this edit: the edit leaves existing area type `to` @@ -330,12 +346,13 @@ fn add(s: &mut Session, p: &Value) -> Result { Ok(json!({ "id": id.0 })) } -/// An empty area-type frame `frame` placed by `xf`, with the type options of `src`. -fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, frame: vectorcraft_geom::PathData, xf: vectorcraft_geom::Affine) -> TextObject { +/// An empty frame of `kind` (area type or type on a path) placed by `xf`, with the type options of +/// `src`. +fn empty_frame(src: &TextObject, style: vectorcraft_doc::CharStyle, kind: TextKind, xf: vectorcraft_geom::Affine) -> TextObject { let mut t = src.clone(); t.runs = vec![TextRun { text: String::new(), style, inline: None }]; t.set_all_paras(src.para_at(0).clone()); - t.kind = TextKind::Area { frame }; + t.kind = kind; t.xf = xf; refresh_bounds(&mut t); t @@ -373,6 +390,16 @@ fn release(s: &mut Session, _: &Value) -> Result { refresh_bounds(t); } } + // The first frame that stays holds the whole story, and the others none, for the + // re-flow to share it out among them once. + let end_style = story.last().map_or_else(|| style.clone(), |r| r.style.clone()); + let last_para = paras.last().cloned().unwrap_or_default(); + for id in keep.iter().skip(1) { + if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + t.runs = vec![TextRun { text: String::new(), style: end_style.clone(), inline: None }]; + t.set_all_paras(last_para.clone()); + } + } if let Some(first) = keep.first() && let Some(NodeKind::Text(t)) = d.node_mut(*first).map(|n| &mut n.kind) { @@ -593,6 +620,77 @@ mod tests { assert_eq!(threads(&s), [[a, c, d]]); } + /// #2572: the text loaded from an in port threads back: the new frame or the path clicked comes + /// before the frame it was loaded from, so the story starts there and flows on into it; in the + /// middle of a thread the new frame goes in between, each in one undo step. + #[test] + fn add_before_threads_the_story_from_the_new_frame_into_the_clicked_one() { + let (mut s, a, b) = setup(); + let c = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [400, 200], "before": true})).unwrap()); + assert_eq!(threads(&s), [[c, a]]); + assert_eq!(frame_box(&s, c), vectorcraft_geom::Rect::new(400.0, 200.0, 550.0, 260.0), "the clicked frame's size at the click"); + assert!(TEXT.repeat(4).starts_with(&text(&s, c)) && !text(&s, c).is_empty(), "the story starts in the new frame: {:?}", text(&s, c)); + assert_eq!(format!("{}{}", text(&s, c), text(&s, a)), TEXT.repeat(4)); + assert_eq!(s.doc().unwrap().selection.objects, [NodeId(a), NodeId(c)]); + // In between the frame and the one before it. + let d = new_id(s.execute("text.thread.add", &json!({"from": a, "rect": [10, 100, 300, 150], "before": true})).unwrap()); + assert_eq!(threads(&s), [[c, d, a]]); + assert_eq!(format!("{}{}{}", text(&s, c), text(&s, d), text(&s, a)), TEXT.repeat(4)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[c, a]]); + // A closed path clicked with the in port's text comes first, with the start of the story. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(new_id(s.execute("text.thread.add", &json!({"from": a, "to": b, "before": true})).unwrap()), b); + assert_eq!(threads(&s), [[b, a]]); + assert!(TEXT.repeat(4).starts_with(&text(&s, b)) && !text(&s, b).is_empty()); + assert_eq!(format!("{}{}", text(&s, b), text(&s, a)), TEXT.repeat(4)); + // `before: false` is the out port's. + let e = new_id(s.execute("text.thread.add", &json!({"from": a, "at": [0, 0], "before": false})).unwrap()); + assert_eq!(threads(&s), [[b, a, e]]); + } + + /// Type on a path along a line beside the area type of [`setup`]. + fn type_on_a_line(s: &mut Session) -> u64 { + let line = new_id(s.execute("shape.line", &json!({"x1": 10, "y1": 250, "x2": 110, "y2": 250})).unwrap()); + new_id(s.execute("text.createInPath", &json!({"path": line, "mode": "onPath", "text": ""})).unwrap()) + } + + /// Type on a path threads as area type does, in any mix: a story flows from area type along a + /// path and on into area type, and a new frame threaded from type on a path at a point is type + /// on a path of the same shape. + #[test] + fn type_on_a_path_threads_with_area_type() { + let (mut s, a, b) = setup(); + let on_path = type_on_a_line(&mut s); + s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); + s.execute("text.thread.add", &json!({"from": on_path, "to": b})).unwrap(); + assert_eq!(threads(&s), [[a, on_path, b]]); + let shares = [a, on_path, b].map(|id| text(&s, id)); + assert!(shares.iter().all(|t| !t.is_empty()), "each frame takes a share: {shares:?}"); + assert_eq!(shares.concat(), TEXT.repeat(4)); + + let c = new_id(s.execute("text.thread.add", &json!({"from": on_path, "at": [300, 250]})).unwrap()); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(c)).unwrap().kind else { panic!("not text") }; + assert!(matches!(t.kind, TextKind::OnPath { .. }), "type on a path, as the frame it was threaded from"); + assert_eq!(threads(&s), [[a, on_path, c, b]]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, on_path, b]]); + } + + /// Type on a path released from its thread gives its text back to the frame before it. + #[test] + fn type_on_a_path_is_released_from_its_thread() { + let (mut s, a, _) = setup(); + let on_path = type_on_a_line(&mut s); + s.execute("text.thread.add", &json!({"from": a, "to": on_path})).unwrap(); + assert!(!text(&s, on_path).is_empty()); + s.execute("select.set", &json!({"ids": [on_path]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert!(threads(&s).is_empty()); + assert!(text(&s, on_path).is_empty()); + assert_eq!(text(&s, a), TEXT.repeat(4)); + } + /// Area type threaded into from an out port takes the overflow at once, before its own text. #[test] fn add_into_area_type_flows_the_story_into_it() { @@ -625,6 +723,8 @@ mod tests { json!({"from": a, "rect": [0, 0, 10, 0]}), json!({"from": a, "rect": [0, 0, 10]}), json!({"from": a, "rect": "big"}), + json!({"from": a, "at": [0, 0], "before": "yes"}), + json!({"from": a, "at": [0, 0], "before": 1}), ] { assert!(s.execute("text.thread.add", &p).is_err(), "{p} was accepted"); assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "{p} changed the document"); @@ -634,6 +734,71 @@ mod tests { assert!(s.execute("text.thread.add", &json!({"from": c, "to": b})).is_err(), "a frame already threaded"); } + /// A frame released from a thread that keeps two or more frames gives its text back to them: + /// the story flows through them once. + #[test] + fn release_from_a_longer_thread_keeps_the_story_once() { + let (mut s, a, b) = setup(); + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); + assert_eq!(threads(&s), [[a, c, b]]); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("text.thread.releaseSelection", &json!({})).unwrap(); + assert_eq!(threads(&s), [[a, b]]); + assert!(text(&s, c).is_empty()); + assert_eq!(format!("{}{}", text(&s, a), text(&s, b)), TEXT.repeat(4)); + } + + /// A thread of `n` frames (2 or 3), each showing part of the story: area type `a` of [`setup`], + /// for 3 an area type frame that is empty until it is threaded, then the rectangle `b`. + fn thread_of(n: usize) -> (Session, Vec) { + let (mut s, a, b) = setup(); + let frames = if n == 3 { + let c = new_id(s.execute("text.create", &json!({"x": 10, "y": 100, "text": "", "area": {"width": 150, "height": 40}})).unwrap()); + s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap(); + s.execute("text.thread.add", &json!({"from": c, "to": b})).unwrap(); + vec![a, c, b] + } else { + s.execute("text.thread.add", &json!({"from": a, "to": b})).unwrap(); + vec![a, b] + }; + assert_eq!(threads(&s), std::slice::from_ref(&frames)); + assert!(frames.iter().all(|id| !text(&s, *id).is_empty()), "each frame shows part of the story"); + (s, frames) + } + + /// Convert To Point Type on a frame of threaded text takes it out of its thread with the text + /// it holds, and the other frames keep the text they hold: the story holds each character once. + /// A thread left with one frame is no thread. Undo threads the frame again. + #[test] + fn a_frame_converted_to_point_type_leaves_its_thread_with_its_text() { + // Soft wraps of the point type become line breaks, so its characters are compared without + // white space. + let squeeze = |t: &str| t.split_whitespace().collect::(); + for n in [2, 3] { + for k in 0..n { + let (mut s, frames) = thread_of(n); + let shown: Vec = frames.iter().map(|id| text(&s, *id)).collect(); + let id = frames[k]; + s.execute("type.convertToPointType", &json!({"ids": [id]})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("not text") }; + assert!(matches!(t.kind, TextKind::Point), "{n} frames, frame {k}: point type"); + assert_eq!(squeeze(&text(&s, id)), squeeze(&shown[k]), "{n} frames, frame {k}: the point type keeps its text"); + for (f, before) in frames.iter().zip(&shown).filter(|(f, _)| **f != id) { + assert_eq!(text(&s, *f), *before, "{n} frames, frame {k}: frame {f} keeps its text"); + } + let rest: Vec = frames.iter().copied().filter(|f| *f != id).collect(); + assert_eq!(threads(&s), if rest.len() >= 2 { vec![rest] } else { vec![] }, "{n} frames, frame {k}"); + let all: String = frames.iter().map(|f| text(&s, *f)).collect(); + assert_eq!(squeeze(&all), squeeze(&TEXT.repeat(4)), "{n} frames, frame {k}: each character once"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(threads(&s), std::slice::from_ref(&frames)); + assert_eq!(frames.iter().map(|f| text(&s, *f)).collect::>(), shown, "{n} frames, frame {k}: undo"); + } + } + } + #[test] fn release_and_remove() { let (mut s, a, b) = setup(); diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index f95a52663..b8314b1dc 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, RubyAlign, RubyPosition, RubySpan, TextObject}; use vectorcraft_geom::{Affine, PathData, Vec2}; use super::edit::selected_roots; @@ -34,15 +34,42 @@ pub fn specs() -> Vec { has_selection, create_outlines ), + cmd!( + "type.scaleTextAttributes", + "Scale Text Attributes", + ["Type"], + None, + "{ids?|id?} bake each selected type object's transform scale into its character sizes (leading, baseline shift and horizontal scale included), leaving rotation and position in the affine → {ids, changed}", + scale_text_attributes_enabled, + scale_text_attributes + ), cmd!( "type.opticalMarginAlignment", "Optical Margin Alignment", ["Type"], None, - "{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every left-to-right paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both → {on, ids}", + "{ids?|id?, on?: bool} turn Optical Margin Alignment on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting): in every paragraph of point and area type, quotation marks, hyphens, full stops and commas at a line's start or end hang wholly outside the margin there; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their advance; a left-aligned line hangs at its left margin, a right-aligned line hangs at its right margin, and centered and justified lines hang at both; Auto aligns a right-to-left line at its right; type on a path hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered → {on, ids}", has_selection, optical_margin_alignment ), + cmd!( + "type.systemLayout", + "System Layout", + [], + None, + "{on?: bool} turn System Layout on or off for the document (on omitted: the opposite of the document's setting) and every text object in it. On, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. Off (the default, and what a document from before it reads as), spacing uses fractions of a point. New point, area and path type inherits the document's setting → {on}", + has_doc, + system_layout + ), + cmd!( + "type.fractionalWidths", + "Fractional Widths", + [], + None, + "{ids?|id?, on?: bool} turn Fractional Widths on or off for the selected type and every frame of its threads (on omitted: the opposite of the first object's setting). On (the default, and what a document from before it reads as), spacing between characters uses fractions of a point. Off, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. The setting is the whole type object, not a range of characters → {on, ids}", + has_selection, + fractional_widths + ), cmd!( "text.setText", "Set Text", @@ -88,6 +115,15 @@ pub fn specs() -> Vec { has_doc, set_style ), + cmd!( + "type.ruby", + "Ruby…", + [], + None, + "{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}", + has_selection, + ruby + ), ] } @@ -114,7 +150,7 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result { Some(NodeKind::Text(t)) => Some(t.optical_margin), _ => None, }); - let on = p.get("on").and_then(Value::as_bool).unwrap_or(!first.unwrap_or(false)); + let on = switch_or(p, "on", !first.unwrap_or(false), "type.opticalMarginAlignment")?; s.edit("Optical Margin Alignment", |d, _| { for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { @@ -128,6 +164,115 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result { Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::>()})) } +/// Character panel menu › System Layout: set `on` (without it, the opposite of the document's +/// setting) on the document and every text object, in one undo step. Off is the default. +fn system_layout(s: &mut Session, p: &Value) -> Result { + let current = s.doc()?.doc.setup.system_layout; + let on = switch_or(p, "on", !current, "type.systemLayout")?; + s.edit("System Layout", |d, _| { + d.setup.system_layout = on; + let mut ids = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + ids.push(n.id); + } + }); + for id in ids { + let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { + continue; + }; + t.system_layout = on; + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({ "on": on })) +} + +/// Character panel menu › Fractional Widths: set `on` (without it, the opposite of the first +/// object's setting) on every selected text object and every frame of their threads, in one undo +/// step. On is the default. +fn fractional_widths(s: &mut Session, p: &Value) -> Result { + let ids = super::threads::with_threads(&s.doc()?.doc, &text_targets(s, p, "Fractional Widths")?); + let first = ids.first().and_then(|id| match s.doc().ok()?.doc.node(*id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.fractional_widths), + _ => None, + }); + let on = switch_or(p, "on", !first.unwrap_or(true), "type.fractionalWidths")?; + s.edit("Fractional Widths", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { + return Err(EngineError::NoNode(*id)); + }; + t.fractional_widths = on; + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::>()})) +} + +/// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one +/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby +/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text). +fn ruby(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.ruby"; + let ids = text_targets(s, p, C)?; + let range = TextRange::parse(p, C)?; + let remove = switch_or(p, "remove", false, C)?; + let reading = str_param(p, "text").map(str::trim).filter(|t| !t.is_empty()).map(str::to_string); + if !remove && reading.is_none() { + return Err(bad(C, "give `text` (the reading) or `remove`")); + } + let position = match str_param(p, "position").map(str::to_ascii_lowercase).as_deref() { + None | Some("above") | Some("right") => RubyPosition::Above, + Some("below") | Some("left") => RubyPosition::Below, + Some(_) => return Err(bad(C, "`position` is above or below")), + }; + let align = match str_param(p, "align").map(str::to_ascii_lowercase).as_deref() { + None | Some("center") | Some("centre") => RubyAlign::Center, + Some("start") | Some("left") | Some("top") => RubyAlign::Start, + Some("end") | Some("right") | Some("bottom") => RubyAlign::End, + Some(_) => return Err(bad(C, "`align` is start, center or end")), + }; + let size = number_or(p, "size", 50.0, C)?; + if !size.is_finite() || !(1.0..=100.0).contains(&size) { + return Err(bad(C, "`size` is a percentage of the base text's size, 1 to 100")); + } + let overhang = switch_or(p, "overhang", true, C)?; + let mut first: Option = None; + s.edit("Ruby", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { + return Err(EngineError::NoNode(*id)); + }; + let (a, b) = match range { + Some(r) => r.bytes(t), + None => (0, vectorcraft_text::edit::runs_len(&t.runs)), + }; + // The ruby over the range goes; a new reading takes its place. + t.ruby.retain(|r| r.end <= a || r.start >= b); + if let Some(text) = &reading + && a < b + { + let mut span = RubySpan::new(a, b, text.clone()); + span.position = position; + span.align = align; + span.size_percent = size; + span.overhang = overhang; + t.ruby.push(span); + } + t.normalize_ruby(); + refresh_bounds(t); + if first.is_none() { + first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null)); + } + } + Ok(()) + })?; + Ok(json!({"ids": ids.iter().map(|id| id.0).collect::>(), "ruby": first.unwrap_or(Value::Null)})) +} + /// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing /// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type /// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo @@ -354,6 +499,42 @@ fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { out } +fn scale_text_attributes_enabled(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + let st = s.active().ok_or("no document open")?; + let ids = text_ids(&st.doc, &st.selection.objects); + if ids.iter().any(|id| { + st.doc.node(*id).is_some_and(|n| { + let NodeKind::Text(t) = &n.kind else { return false }; + t.style_scale().is_some_and(|sc| (sc.points - 1.0).abs() > 1e-12 || (sc.horizontal - 1.0).abs() > 1e-12) + }) + }) { + Ok(()) + } else { + Err("Scale Text Attributes: selected type has no scale to bake".into()) + } +} + +fn scale_text_attributes(s: &mut Session, p: &Value) -> Result { + const C: &str = "type.scaleTextAttributes"; + let ids = text_targets(s, p, C)?; + let changed = s.edit("Scale Text Attributes", |d, _| { + let mut n = 0usize; + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|node| &mut node.kind) else { continue }; + if t.scale_text_attributes() { + refresh_bounds(t); + n += 1; + } + } + if n == 0 { + return Err(EngineError::Other("Scale Text Attributes: selected type has no scale to bake".into())); + } + Ok(n) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>(), "changed": changed })) +} + fn create_outlines(s: &mut Session, _: &Value) -> Result { let roots = selected_roots(s)?; let ids = s.edit("Create Outlines", |d, sel| { @@ -378,6 +559,17 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { children.push(Arc::new(Node::new(id, NodeKind::SymbolInstance { symbol: art.symbol.clone(), xf }))); continue; } + // Colour bitmap glyphs (emoji) become embedded images, in place, their pixels taken + // into the document: glyphs that show the same image share them. + if let Some(image) = g.placed_image_node(t.xf) { + children.push(Arc::new(d.reid(&image))); + continue; + } + // COLR glyphs become groups of their layers, in place, with ids of the document's. + if let Some(art) = g.placed_colour(t.xf) { + children.push(Arc::new(d.reid(&art))); + continue; + } let path = PathData::from_bezpath(&g.outline).transformed(t.xf); if path.is_empty() { continue; @@ -455,9 +647,7 @@ impl TextRange { let get = |k: &str| -> Result> { match p.get(k) { None | Some(Value::Null) => Ok(None), - Some(v) => { - v.as_u64().map(|n| Some(usize::try_from(n).unwrap_or(usize::MAX))).ok_or_else(|| bad(cmd, format!("`{k}` must be a byte offset"))) - } + Some(v) => v.as_u64().map(|n| Some(index(n))).ok_or_else(|| bad(cmd, format!("`{k}` must be a byte offset"))), } }; let (start, end) = (get("start")?, get("end")?); @@ -505,6 +695,8 @@ fn set_text(s: &mut Session, p: &Value) -> Result { // Every paragraph of the new text takes the first paragraph's attributes. t.splice_paras(0, vectorcraft_text::edit::runs_len(&t.runs), &text); t.runs = vec![vectorcraft_doc::TextRun { text: text.clone(), style, inline: None }]; + // The text is new: the ruby of the old one is gone. + t.ruby.clear(); refresh_bounds(t); } Ok(()) @@ -544,6 +736,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be auto|left|center|right|justifyAll"))?), None => None, }; + super::paint::check_keep_model(p, C)?; let fill = match p.get("fill") { None | Some(Value::Null) => None, Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(vectorcraft_color::Paint::None), @@ -708,6 +901,7 @@ fn size_area(t: &mut TextObject, w: Option, h: Option) -> bool { /// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent` /// while the fit isn't Shrink Text). pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result> { + check_numbers(p, &["fitMinPercent"], c)?; let min = p.get("fitMinPercent").and_then(Value::as_f64); let cur_min = match cur { AreaFit::ShrinkText { min_percent } => Some(min_percent), @@ -760,6 +954,7 @@ const AREA_KEYS: [&str; 7] = ["rows", "columns", "gutter", "inset", "firstBaseli fn area_options(s: &mut Session, p: &Value) -> Result { const C: &str = "text.areaOptions"; + check_numbers(p, &["width", "height"], C)?; let ids: Vec = { let d = &s.doc()?.doc; text_targets(s, p, C)?.into_iter().filter(|i| area_text(d.node(*i)).is_some()).collect() @@ -1019,6 +1214,109 @@ mod area_tests { assert!(!serde_json::from_value::(old).unwrap().optical_margin); } + /// Character panel menu › System Layout: `type.systemLayout` sets the document and every text + /// object in one undo step. New type inherits it. Off by default and in a document from before + /// the flag, and saved only when on. On, each positive advance is a whole number of points. + #[test] + fn system_layout_sets_the_document_and_every_text_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); + let other = s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap(); + let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let setup = |s: &Session| s.doc().unwrap().doc.setup.clone(); + let advances = |s: &Session| { + vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).glyphs.iter().map(|g| g.advance).collect::>() + }; + assert!(!setup(&s).system_layout); + assert!(!text(&s, id).system_layout && !text(&s, other).system_layout); + assert!(serde_json::to_value(setup(&s)).unwrap().get("systemLayout").is_none()); + assert!(serde_json::to_value(text(&s, id)).unwrap().get("systemLayout").is_none()); + let fractional = advances(&s); + assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); + assert_eq!(s.execute("type.systemLayout", &json!({})).unwrap()["on"], true); + assert!(setup(&s).system_layout); + assert!(text(&s, id).system_layout && text(&s, other).system_layout); + let whole = advances(&s); + assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); + assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); + assert_eq!(serde_json::to_value(setup(&s)).unwrap()["systemLayout"], true); + assert_eq!(serde_json::to_value(text(&s, id)).unwrap()["systemLayout"], true); + // New type inherits the document's setting. + let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap(); + assert!(text(&s, made).system_layout); + // A boolean given as text is an error, and the setting stays on. + assert!(s.execute("type.systemLayout", &json!({"on": "no"})).is_err()); + assert!(setup(&s).system_layout && text(&s, id).system_layout); + assert_eq!(s.execute("type.systemLayout", &json!({"on": false})).unwrap()["on"], false); + assert!(!setup(&s).system_layout && !text(&s, id).system_layout && !text(&s, made).system_layout); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(setup(&s).system_layout && text(&s, id).system_layout); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(back.setup.system_layout); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(t.system_layout); + let mut old = serde_json::to_value(setup(&s)).unwrap(); + old.as_object_mut().unwrap().remove("systemLayout"); + assert!(!serde_json::from_value::(old).unwrap().system_layout); + } + + /// Character panel menu › Fractional Widths: `type.fractionalWidths` sets or toggles the flag + /// on the selected type in one undo step. New type and a document from before the flag are on, + /// and the flag is saved only when off. Off, each positive advance is a whole number of points. + #[test] + fn fractional_widths_is_set_per_object_and_saved_only_when_off() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let advances = + |s: &Session| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).glyphs.iter().map(|g| g.advance).collect::>(); + // New type: on, and not saved. + assert!(text(&s).fractional_widths); + assert!(serde_json::to_value(text(&s)).unwrap().get("fractionalWidths").is_none()); + let fractional = advances(&s); + assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); + // Nothing but a rectangle selected: an error. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [rect]})).unwrap(); + assert!(s.execute("type.fractionalWidths", &json!({})).is_err()); + // Toggled off: whole-point advances, and the key is written. + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], false); + assert!(!text(&s).fractional_widths); + let whole = advances(&s); + assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); + assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); + assert_eq!(serde_json::to_value(text(&s)).unwrap()["fractionalWidths"], false); + // A boolean given as text is an error, and the flag stays off. + assert!(s.execute("type.fractionalWidths", &json!({"on": "no"})).is_err()); + assert!(!text(&s).fractional_widths); + assert_eq!(s.execute("type.fractionalWidths", &json!({})).unwrap()["on"], true); + assert!(text(&s).fractional_widths); + assert_eq!(s.execute("type.fractionalWidths", &json!({"on": false})).unwrap()["on"], false); + assert!(!text(&s).fractional_widths); + // One undo step. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(text(&s).fractional_widths); + // Saved and reopened as it was; a document from before it reads as on. + s.execute("type.fractionalWidths", &json!({"on": false})).unwrap(); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert!(!t.fractional_widths); + let mut old = serde_json::to_value(text(&s)).unwrap(); + old.as_object_mut().unwrap().remove("fractionalWidths"); + assert!(serde_json::from_value::(old).unwrap().fractional_widths); + } + /// Paragraph panel menu › Hyphenation…: `text.setFormat` sets each paragraph's hyphenation /// options, rounded and clamped, in one undo step; they are saved only when not the defaults, /// and paragraph styles carry them. @@ -1098,6 +1396,129 @@ mod area_tests { assert!(text(&s).para_at(1).roman_hanging); } + /// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one + /// undo step, the paragraph is saved with it only when it is not None and opens with it, an + /// unknown value changes nothing, and paragraph styles carry it. + #[test] + fn insert_kashidas_is_set_per_paragraph_and_saved_only_when_set() { + use vectorcraft_doc::text::Kashida; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s + .execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "كتب محمد\nعلي", "area": {"width": 300, "height": 200}})) + .unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + assert_eq!(text(&s).para_at(0).kashida, Kashida::None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("kashida").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + // Only the first paragraph (the caret's range). + s.execute("text.setFormat", &json!({"kashida": "medium", "start": 0, "end": 0})).unwrap(); + assert_eq!((text(&s).para_at(0).kashida, text(&s).para_at(1).kashida), (Kashida::Medium, Kashida::None)); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["kashida"], "medium"); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).kashida, back.para_at(1).kashida), (Kashida::Medium, Kashida::None)); + assert!(s.execute("text.setFormat", &json!({"kashida": "wide"})).is_err()); + assert_eq!(text(&s).para_at(0).kashida, Kashida::Medium, "an unknown value changes nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s).para_at(0).kashida, Kashida::None); + // A paragraph style carries it. + s.execute("paraStyle.new", &json!({"name": "Stretched", "attrs": {"kashida": "stylistic"}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Stretched", "id": id})).unwrap(); + assert_eq!(text(&s).para_at(1).kashida, Kashida::Stylistic); + } + + /// Paragraph panel › Bullets: `text.setFormat {list, bullet}` makes the paragraphs it touches a + /// bulleted list and refuses unknown values. A paragraph that joins a list with no left or + /// first-line indent gets 18 pt and -18 pt, unless the call sets an indent, and one with indents + /// keeps them. The list is saved only when set and opens with the file, Return continues it, + /// "none" turns it off, and paragraph styles set it and turn it off. + #[test] + fn bulleted_lists_are_set_per_paragraph_saved_and_continued() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = + s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"] + .as_u64() + .unwrap(); + let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("text"), + }; + let list = |s: &Session, i: usize| text(s).para_at(i).list; + let indents = |s: &Session, i: usize| (text(s).para_at(i).left_indent, text(s).para_at(i).first_line_indent); + let dot = Some(ParaList::bulleted(Bullet::Dot)); + assert_eq!(list(&s, 0), None); + assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("list").is_none(), "no list is saved without one"); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"start": 4, "end": 4, "leftIndent": 30, "firstLineIndent": 5})).unwrap(); + // The first two paragraphs: the first gets the hanging indent, the second keeps its indents. + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 4})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dot, dot, None)); + assert_eq!((indents(&s, 0), indents(&s, 1), indents(&s, 2)), ((18.0, -18.0), (30.0, 5.0), (0.0, 0.0))); + // `bullet` picks the bullet, and `list: "bullet"` keeps it; the indents stay as they are. + s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap(); + s.execute("text.setFormat", &json!({"list": "bullet", "start": 0, "end": 0})).unwrap(); + let square = Some(ParaList::bulleted(Bullet::Square)); + assert_eq!((list(&s, 0), indents(&s, 0)), (square, (18.0, -18.0))); + for bad in [json!({"list": "numbers"}), json!({"bullet": "star"}), json!({"list": 1}), json!({"bullet": true})] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + } + assert_eq!((list(&s, 0), list(&s, 2)), (square, None), "an unknown value changes nothing"); + assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"})); + // A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know. + let read = |v: serde_json::Value| serde_json::from_value::(json!({ "list": v })).unwrap().list; + assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash)))); + let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") }; + assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None)); + // Return at the end of a list item starts another one. + s.execute("text.editRange", &json!({"id": id, "start": 3, "end": 3, "insert": "\nOne more"})).unwrap(); + assert_eq!((list(&s, 1), indents(&s, 1)), (square, (18.0, -18.0))); + s.execute("edit.undo", &json!({})).unwrap(); + // "none" turns the list off and leaves the indents. + s.execute("text.setFormat", &json!({"bullet": "none", "start": 0, "end": 0})).unwrap(); + assert_eq!((list(&s, 0), indents(&s, 0)), (None, (18.0, -18.0))); + s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap(); + assert_eq!(list(&s, 1), None); + // Indents the call sets win over the hanging indent. + s.execute("text.setFormat", &json!({"list": "bullet", "leftIndent": 0, "firstLineIndent": 0, "start": 8, "end": 8})).unwrap(); + assert_eq!((list(&s, 2), indents(&s, 2)), (dot, (0.0, 0.0))); + // A style that holds a list sets it; a style captured from a paragraph without one turns it off. + s.execute("paraStyle.new", &json!({"name": "Listed", "attrs": {"list": {"bullet": "dash"}}})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Listed", "id": id})).unwrap(); + let dash = Some(ParaList::bulleted(Bullet::Dash)); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (dash, dash, dash)); + let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap(); + s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap(); + s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap(); + assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); + } + + /// Type › Create Outlines makes a path of a list item's bullet as well as of its characters. + #[test] + fn create_outlines_makes_a_path_of_the_bullet() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let outlined = |s: &mut Session, list: bool| { + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One"})).unwrap()["id"].as_u64().unwrap(); + if list { + s.execute("text.setFormat", &json!({"ids": [id], "list": "bullet"})).unwrap(); + } + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let g = s.execute("type.createOutlines", &json!({})).unwrap()["ids"][0].as_u64().unwrap(); + s.doc().unwrap().doc.node(NodeId(g)).unwrap().children().unwrap().len() + }; + let plain = outlined(&mut s, false); + assert_eq!(outlined(&mut s, true), plain + 1, "one more path: the bullet"); + } + /// Paragraph panel menu › Justification…: `text.setFormat` sets each paragraph's word and /// letter spacing, glyph scaling, Auto Leading and Single Word Justification, clamped to their /// ranges, in one undo step; Minimum ≤ Desired ≤ Maximum must hold or nothing changes. They @@ -1281,6 +1702,47 @@ mod area_tests { } } + /// Character Direction (Character panel menu, Middle Eastern options): a character attribute + /// that `text.setFormat` and `text.setRangeStyle` set in one undo step each, saved only when + /// set and kept by a native save. + #[test] + fn character_direction_is_a_character_attribute_saved_only_when_set() { + use vectorcraft_doc::ParaDirection; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "שלום 21 10 2026"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + let set = |s: &Session| runs(s).iter().map(|r| (r.text.clone(), r.style.char_direction)).collect::>(); + assert_eq!(runs(&s)[0].style.char_direction, None); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("charDirection").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + for (key, want) in [("rightToLeft", Some(ParaDirection::RightToLeft)), ("leftToRight", Some(ParaDirection::LeftToRight)), ("default", None)] { + s.execute("text.setFormat", &json!({"charDirection": key})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction == want)); + let saved = serde_json::to_value(&runs(&s)[0].style).unwrap(); + assert_eq!(saved.get("charDirection").and_then(|v| v.as_str()), want.is_some().then_some(key)); + } + let err = s.execute("text.setFormat", &json!({"charDirection": "up"})).unwrap_err().to_string(); + assert!(err.contains("`charDirection` must be"), "{err}"); + // Characters selected with the Type tool: only the range takes it (שלום and the space are 9 bytes). + s.execute("text.setRangeStyle", &json!({"id": id, "start": 9, "end": 19, "charDirection": "leftToRight"})).unwrap(); + assert_eq!(set(&s), [("שלום ".to_string(), None), ("21 10 2026".to_string(), Some(ParaDirection::LeftToRight))]); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.char_direction.is_none())); + s.execute("edit.redo", &json!({})).unwrap(); + // Saved and reopened as it was. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let reopened: Vec<_> = t.runs.iter().map(|r| (r.text.clone(), r.style.char_direction)).collect(); + assert_eq!(reopened, set(&s)); + // A character style captured from the characters carries it, Default included. + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[1].style)["charDirection"], "leftToRight"); + assert_eq!(super::super::textstyles::char_attrs(&runs(&s)[0].style).get("charDirection"), Some(&serde_json::Value::Null)); + } + /// Proportional Metrics (#966): a character attribute, saved only when on. #[test] fn proportional_metrics_is_a_character_attribute_saved_only_when_on() { @@ -1309,6 +1771,64 @@ mod area_tests { assert!(!serde_json::from_value::(old).unwrap().proportional_metrics); } + /// Tate-chu-yoko (Character panel menu, East Asian options): a character attribute, saved only + /// when on. Documents written before it read as off. + #[test] + fn tate_chu_yoko_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "2026年"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.tate_chu_yoko); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("tateChuYoko").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"tateChuYoko": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.tate_chu_yoko)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["tateChuYoko"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.tate_chu_yoko); + // The year only (2026 is 4 bytes); 年 stays unmarked. + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "tateChuYoko": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.tate_chu_yoko)).collect(); + assert_eq!(on, [("2026".to_string(), true), ("年".to_string(), false)]); + let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); + assert!(old.get("tateChuYoko").is_none()); + assert!(!serde_json::from_value::(old).unwrap().tate_chu_yoko); + s.execute("text.setFormat", &json!({"tateChuYoko": false})).unwrap(); + assert!(runs(&s).iter().all(|r| !r.style.tate_chu_yoko)); + } + + /// No Break is a character attribute, saved only when on. + #[test] + fn no_break_is_a_character_attribute_saved_only_when_on() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab"})).unwrap()["id"].as_u64().unwrap(); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => t.runs.clone(), + _ => panic!("text"), + }; + assert!(!runs(&s)[0].style.no_break); + assert!(serde_json::to_value(&runs(&s)[0].style).unwrap().get("noBreak").is_none()); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("text.setFormat", &json!({"noBreak": true})).unwrap(); + assert!(runs(&s).iter().all(|r| r.style.no_break)); + assert_eq!(serde_json::to_value(&runs(&s)[0].style).unwrap()["noBreak"], true); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!runs(&s)[0].style.no_break); + s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 1, "noBreak": true})).unwrap(); + let on: Vec<_> = runs(&s).iter().map(|r| (r.text.clone(), r.style.no_break)).collect(); + assert_eq!(on, [("A".to_string(), true), ("b".to_string(), false)]); + let old = serde_json::to_value(&runs(&s)[1].style).unwrap(); + assert!(old.get("noBreak").is_none()); + assert!(!serde_json::from_value::(old).unwrap().no_break); + s.execute("text.setFormat", &json!({"noBreak": false})).unwrap(); + assert!(runs(&s).iter().all(|r| !r.style.no_break)); + } + /// Digits (Character panel menu, Middle Eastern options): a character attribute, saved only /// when set; the text keeps the digits that were typed. #[test] @@ -1347,6 +1867,7 @@ mod area_tests { /// when set, each value clamped to its axis's range, and cleared by another font or style. #[test] fn variable_font_settings_are_a_character_attribute_saved_only_when_set() { + let _fonts = crate::adding_fonts(); use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY as FAMILY, two_axis_font}; vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); let mut s = Session::new(); @@ -1582,3 +2103,116 @@ mod headline_tests { assert!(avail - w < 5.0, "{w} of {avail}"); } } + +#[cfg(test)] +mod ruby_tests { + use super::*; + + fn session_with_text() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "漢字です", "size": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + (s, id) + } + + fn text_of(s: &Session, id: u64) -> &TextObject { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text object") }; + t + } + + #[test] + fn ruby_command_sets_reports_removes_and_survives_undo() { + let (mut s, id) = session_with_text(); + let r = s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap(); + assert_eq!(r["ruby"][0]["start"], 0); + assert_eq!(r["ruby"][0]["end"], 6); + assert_eq!(r["ruby"][0]["text"], "かんじ"); + let t = text_of(&s, id); + assert_eq!(t.ruby.len(), 1); + assert_eq!(t.ruby[0].position, RubyPosition::Above); + assert_eq!(t.ruby[0].align, RubyAlign::Center); + assert!(t.ruby[0].overhang); + assert!(t.cached_bounds.unwrap().height() > 20.0, "bounds cover the ruby"); + // Options, and a new reading over the range replaces the old one. + s.execute("type.ruby", &json!({"text": "かん", "start": 0, "end": 3, "position": "below", "align": "end", "size": 80, "overhang": false})) + .unwrap(); + let t = text_of(&s, id); + assert_eq!(t.ruby.len(), 1); + assert_eq!(t.ruby[0].position, RubyPosition::Below); + assert_eq!(t.ruby[0].align, RubyAlign::End); + assert!((t.ruby[0].size_percent - 80.0).abs() < 1e-9 && !t.ruby[0].overhang); + // Remove takes the ruby off the range; Undo brings it back. + let r = s.execute("type.ruby", &json!({"remove": true, "start": 0, "end": 6})).unwrap(); + assert_eq!(r["ruby"].as_array().map(Vec::len), Some(0)); + assert!(text_of(&s, id).ruby.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text_of(&s, id).ruby.len(), 1); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(text_of(&s, id).ruby.is_empty()); + // Bad input changes nothing. + for p in [ + json!({"start": 0, "end": 6}), + json!({"text": "あ", "size": 0}), + json!({"text": "あ", "size": 101}), + json!({"text": "あ", "position": "sideways"}), + json!({"text": "あ", "align": "lean"}), + ] { + assert!(s.execute("type.ruby", &p).is_err(), "{p}"); + } + assert!(text_of(&s, id).ruby.is_empty()); + } + + #[test] + fn typed_text_before_a_ruby_moves_it() { + let (mut s, id) = session_with_text(); + s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap(); + s.execute("text.editRange", &json!({"id": id, "start": 0, "end": 0, "insert": "あ"})).unwrap(); + let t = text_of(&s, id); + assert_eq!((t.ruby[0].start, t.ruby[0].end), (3, 9), "the ruby follows the text"); + assert_eq!(t.plain_text(), "あ漢字です"); + // Setting all the text drops the old ruby (it isn't the same text any more). + s.execute("text.setText", &json!({"id": id, "text": "新しい"})).unwrap(); + assert!(text_of(&s, id).ruby.is_empty()); + } + + /// Ruby is written to SVG and PDF with its text: SVG outlines the type where ruby is present + /// (a reader's `` can't place the reading), PDF differs when the ruby is there. + #[test] + fn ruby_reaches_svg_and_pdf_exports() { + let (mut s, _) = session_with_text(); + let export = |s: &mut Session, format: &str| -> Vec { + let r = s.execute("document.export", &json!({"format": format})).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + }; + s.execute("type.ruby", &json!({"text": "かんじ", "start": 0, "end": 6})).unwrap(); + let svg_ruby = String::from_utf8(export(&mut s, "svg")).unwrap(); + let pdf_ruby = export(&mut s, "pdf"); + assert!(!svg_ruby.contains(" Vec { "Convert To Point Type", ["Type"], None, - "{ids?|id?} area type → point type; soft line wraps become line breaks → {ids}", + "{ids?|id?} area type → point type; soft line wraps become line breaks, and a frame of threaded text leaves its thread with the text it holds → {ids}", has_selection, to_point ), @@ -520,7 +520,9 @@ pub(crate) fn fill_with_placeholder(t: &mut TextObject) { TextKind::Area { frame } => { let b = frame.bounds().unwrap_or_default(); let chars_per_line = (b.width() / (style.size * 0.5)).max(1.0); - let lines = (b.height() / style.effective_leading()).max(1.0); + // The first paragraph's Auto Leading sets the leading of Auto type (a leading of 0 + // counts as a tenth of a point a line). + let lines = (b.height() / t.para_at(0).effective_leading(&style).max(0.1)).max(1.0); let want = (chars_per_line * lines) as usize; let mut out = String::new(); for (i, w) in PLACEHOLDER.split(' ').cycle().enumerate() { @@ -646,9 +648,10 @@ pub(crate) fn find_all(hay: &str, needle: &str, match_case: bool, whole_word: bo fn all_texts(d: &Document) -> Vec { let mut v = vec![]; + let locks = d.locked_artboards(); for l in d.layers.iter().filter(|l| l.visible && !l.locked) { l.walk(&mut |n| { - if matches!(n.kind, NodeKind::Text(_)) && n.visible && !n.locked { + if matches!(n.kind, NodeKind::Text(_)) && n.visible && !n.locked && !locks.holds_id(n.id) { v.push(n.id); } }); diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 2a1fba843..9864fc7d5 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -7,8 +7,9 @@ //! step. `dataset.import` / `dataset.export` move those rows as CSV (header: `name` //! then variable names); import also reads tab-separated files, files without the //! `name` column, and header cells that define new variables (`#` for visibility, `@` for -//! image, no prefix for text). Agents reach every command through `run_command`, like all engine -//! commands. +//! image, no prefix for text). `variable.loadLibrary` replaces the variables and data sets with the +//! text variables and data sets of a variable library, the XML file of the reference app's Save +//! Variable Library. Agents reach every command through `run_command`, like all engine commands. use std::collections::{BTreeMap, BTreeSet}; @@ -30,9 +31,18 @@ const MAX_CSV_BYTES: usize = 1_048_576; const MAX_CSV_ROWS: usize = 1_000; const MAX_CSV_COLS: usize = 256; +/// Caps on a variable library (paths and MCP payloads are untrusted): its size, its XML nodes, how +/// deep its elements nest, and the entities, variables and data sets it declares. +const MAX_LIBRARY_BYTES: usize = 8 << 20; +const MAX_LIBRARY_ENTITIES: usize = 64; +const MAX_LIBRARY_NODES: u32 = 2_000_000; +const MAX_LIBRARY_NESTING: usize = 256; +const MAX_LIBRARY_VARIABLES: usize = 256; +const MAX_LIBRARY_DATASETS: usize = 1_000; + /// Lock Variables refuses New Variable, Delete Variable, Rename Variable (Variable Options), -/// Make … Dynamic and an import whose header would define a variable; binding, unbinding and the -/// other dataset commands still run. +/// Make … Dynamic, an import whose header would define a variable and a Load Variable Library +/// that would delete or define one; binding, unbinding and the other dataset commands still run. fn variables_unlocked(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|st| st.doc.variables.locked) { Err("variables are locked".into()) } else { Ok(()) } @@ -891,6 +901,289 @@ fn dataset_step(s: &mut Session, cmd: &str, forward: bool) -> Result { apply_dataset(s, &name) } +/// Bytes of a variable library from `path`, `xml` or `dataBase64`. +fn library_bytes(p: &Value, cmd: &str) -> Result> { + let bytes = match (str_param(p, "path"), str_param(p, "xml"), str_param(p, "dataBase64")) { + // One byte past the cap is enough to refuse the file below. + (Some(path), ..) => read_file_head(&absolute_path(path), MAX_LIBRARY_BYTES + 1)?, + (None, Some(xml), _) => xml.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(cmd, "bad dataBase64"))?, + _ => return Err(bad(cmd, "give `path`, `xml` or `dataBase64`")), + }; + if bytes.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library is larger than {MAX_LIBRARY_BYTES} bytes"))); + } + Ok(bytes) +} + +/// `xml` without the document type declaration of its prolog, with each reference to a general +/// entity that the declaration defines replaced by the entity's value, its quotes and `>` escaped, +/// outside CDATA sections, comments and processing instructions. The declaration may hold an +/// external identifier and an internal subset of white space, comments, processing instructions and +/// up to [`MAX_LIBRARY_ENTITIES`] entity declarations, each with a name of at most [`MAX_NAME`] +/// bytes and a quoted value that holds no `<`, `&` or `%`. Anything else in it is refused, and so is +/// a text that the replacements make longer than [`MAX_LIBRARY_BYTES`]. The XML parser then refuses +/// any other document type declaration, so [`nesting`](super::place::docx::nesting) counts the +/// elements the parser reads. +fn without_doctype(xml: &str, cmd: &str) -> Result { + let b = xml.as_bytes(); + let past = |from: usize, end: &str| xml.get(from..).and_then(|rest| rest.find(end)).map(|at| from + at + end.len()); + let spaces = |from: usize| from + b.get(from..).map_or(0, |rest| rest.iter().take_while(|c| c.is_ascii_whitespace()).count()); + let at = |i: usize, s: &str| xml.get(i..).is_some_and(|rest| rest.starts_with(s)); + let refused = || bad(cmd, "the variable library's document type declaration holds something other than entities of plain text"); + // The prolog: an XML declaration, white space, comments and processing instructions. + let mut i = 0; + loop { + i = spaces(i); + if at(i, "").ok_or_else(refused)?; + } else if at(i, "").ok_or_else(refused)?; + } else { + break; + } + } + if !at(i, " Option<(usize, &str)> { + let quote = *b.get(i).filter(|c| matches!(c, b'"' | b'\''))?; + let len = b.get(i + 1..)?.iter().position(|c| *c == quote)?; + Some((i + len + 2, xml.get(i + 1..i + 1 + len)?)) + }; + // `' + i = spaces(i + "')).count()); + i = spaces(i); + for (id, literals) in [("SYSTEM", 1), ("PUBLIC", 2)] { + if at(i, id) { + i += id.len(); + for _ in 0..literals { + i = literal(spaces(i)).ok_or_else(refused)?.0; + } + i = spaces(i); + } + } + let mut entities: Vec<(&str, String)> = Vec::new(); + if at(i, "[") { + i += 1; + loop { + i = spaces(i); + if at(i, "]") { + i = spaces(i + 1); + break; + } else if at(i, "").ok_or_else(refused)?; + } else if at(i, "").ok_or_else(refused)?; + } else if at(i, "')).count()); + let name = xml.get(i..i + len).filter(|n| !n.is_empty() && n.len() <= MAX_NAME && !n.starts_with('%')).ok_or_else(refused)?; + let (end, value) = literal(spaces(i + len)).ok_or_else(refused)?; + if value.contains(['<', '&', '%']) { + return Err(refused()); + } + i = spaces(end); + if !at(i, ">") { + return Err(refused()); + } + i += 1; + if entities.len() == MAX_LIBRARY_ENTITIES { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_ENTITIES} entities"))); + } + entities.push((name, value.replace('"', """).replace('\'', "'").replace('>', ">"))); + } else { + return Err(refused()); + } + } + } + if !at(i, ">") { + return Err(refused()); + } + let mut out = String::with_capacity(xml.len()); + out.push_str(xml.get(..start).unwrap_or_default()); + // The rest, copied with the references replaced outside CDATA sections, comments and + // processing instructions, where the parser reads none. + let mut i = i + 1; + while let Some(c) = xml.get(i..).and_then(|rest| rest.chars().next()) { + let verbatim = [(""), (""), ("")].into_iter().find(|(open, _)| at(i, open)); + if let Some((open, close)) = verbatim { + let end = past(i + open.len(), close).unwrap_or(xml.len()); + out.push_str(xml.get(i..end).unwrap_or_default()); + i = end; + continue; + } + // Entity names are at most `MAX_NAME` bytes long, so the `;` is looked for that far. + let entity = if c == '&' { + let name = xml.get(i + 1..).and_then(|rest| rest.get(..rest.bytes().take(MAX_NAME + 1).position(|b| b == b';')?)); + name.and_then(|name| entities.iter().find(|(n, _)| *n == name)) + } else { + None + }; + match entity { + Some((name, value)) => { + out.push_str(value); + i += name.len() + 2; + if out.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + } + None => { + out.push(c); + i += c.len_utf8(); + } + } + } + if out.len() > MAX_LIBRARY_BYTES { + return Err(bad(cmd, format!("the variable library's entities expand to more than {MAX_LIBRARY_BYTES} bytes"))); + } + Ok(out) +} + +/// A variable library as Load Variable Library reads it: the names of its text variables in the +/// order it declares them, the name and `trait` of each variable of another kind, and its data +/// sets with their text values. +struct Library { + text: Vec, + other: Vec<(String, String)>, + datasets: Vec, +} + +/// Read a variable library: XML whose `variableSets` element holds `variableSet` elements, each +/// with a `variables` element of `variable` elements (`varName`, `trait`) and a `sampleDataSets` +/// element of `sampleDataSet` elements (`dataSetName`). Elements are matched by their local names. +/// A variable whose `trait` is `textcontent` is a text variable. In a data set, an element named +/// after a text variable gives its value: the text of its `p` elements, one paragraph each, and +/// none when it has no `p` element. Values for variables of other kinds are left out. A variable +/// or data set declared twice, a value for a variable the library doesn't declare, and a library +/// past the caps are refused. Entities that its document type declaration defines are read as +/// [`without_doctype`] says. +fn parse_library(xml: &str, cmd: &str) -> Result { + let xml = without_doctype(xml, cmd)?; + // roxmltree parses nested elements recursively. + if super::place::docx::nesting(&xml) > MAX_LIBRARY_NESTING { + return Err(bad(cmd, format!("the variable library nests elements more than {MAX_LIBRARY_NESTING} deep"))); + } + let opts = roxmltree::ParsingOptions { nodes_limit: MAX_LIBRARY_NODES, ..roxmltree::ParsingOptions::default() }; + let doc = + roxmltree::Document::parse_with_options(&xml, opts).map_err(|e| bad(cmd, format!("the variable library is not well-formed XML: {e}")))?; + let is = |n: &roxmltree::Node, name: &str| n.is_element() && n.tag_name().name() == name; + let Some(root) = doc.descendants().find(|n| is(n, "variableSets")) else { + return Err(bad(cmd, "the file holds no `variableSets` element")); + }; + let sets: Vec = root.children().filter(|n| is(n, "variableSet")).collect(); + let (mut text, mut other, mut declared) = (Vec::new(), Vec::new(), BTreeSet::new()); + for var in sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "variables")).flat_map(|v| v.children()).filter(|n| is(n, "variable")) { + let name = named(var.attribute("varName"), "varName", cmd)?; + if !declared.insert(name.clone()) { + return Err(bad(cmd, format!("variable `{name}` is declared twice"))); + } + if declared.len() > MAX_LIBRARY_VARIABLES { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_VARIABLES} variables"))); + } + match var.attribute("trait") { + Some("textcontent") => text.push(name), + Some(t) => other.push((name, t.chars().take(MAX_NAME).collect())), + None => return Err(bad(cmd, format!("variable `{name}` has no `trait`"))), + } + } + if declared.is_empty() { + return Err(bad(cmd, "the variable library declares no variables")); + } + let text_names: BTreeSet<&str> = text.iter().map(String::as_str).collect(); + let (mut datasets, mut seen) = (Vec::new(), BTreeSet::new()); + for row in + sets.iter().flat_map(|s| s.children()).filter(|n| is(n, "sampleDataSets")).flat_map(|d| d.children()).filter(|n| is(n, "sampleDataSet")) + { + let name = named(row.attribute("dataSetName"), "dataSetName", cmd)?; + if !seen.insert(name.clone()) { + return Err(bad(cmd, format!("data set `{name}` is declared twice"))); + } + if seen.len() > MAX_LIBRARY_DATASETS { + return Err(bad(cmd, format!("the variable library declares more than {MAX_LIBRARY_DATASETS} data sets"))); + } + let (mut values, mut held) = (BTreeMap::new(), BTreeSet::new()); + for el in row.children().filter(roxmltree::Node::is_element) { + let var = el.tag_name().name(); + if !declared.contains(var) { + return Err(bad(cmd, format!("data set `{name}` holds a value for `{var}`, which the library doesn't declare"))); + } + if !held.insert(var) { + return Err(bad(cmd, format!("data set `{name}` holds two values for `{var}`"))); + } + if !text_names.contains(var) { + continue; + } + let paras: Vec = el + .children() + .filter(|n| is(n, "p")) + .map(|p| p.descendants().filter(roxmltree::Node::is_text).filter_map(|t| t.text()).collect()) + .collect(); + if !paras.is_empty() { + values.insert(var.to_string(), DataValue::Text(paras.join("\n"))); + } + } + datasets.push(DataSet { name, values }); + } + Ok(Library { text, other, datasets }) +} + +/// Load Variable Library: replace the document's variables and data sets with a variable library's +/// ([`parse_library`]) in one undo step. Every variable of the document is deleted with its bindings +/// and values, and every data set is deleted. The library's text variables are then defined, +/// unbound, and its data sets are added. The library's variables of other kinds are left out, named +/// in `skipped` and in a warning. +fn load_library(s: &mut Session, p: &Value) -> Result { + const C: &str = "variable.loadLibrary"; + let bytes = library_bytes(p, C)?; + let xml = std::str::from_utf8(bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(&bytes)).map_err(|_| bad(C, "the variable library is not UTF-8"))?; + let lib = parse_library(xml, C)?; + let vars = &s.doc()?.doc.variables; + let deleted: Vec = vars.variables.iter().map(|v| v.name.clone()).collect(); + let deleted_sets: Vec = vars.datasets.iter().map(|d| d.name.clone()).collect(); + // Lock Variables refuses New Variable and Delete Variable, so the load is refused when it would + // delete or define a variable. + if !deleted.is_empty() || !lib.text.is_empty() { + variables_unlocked(s).map_err(|why| EngineError::Disabled(C.into(), why))?; + } + let added: Vec = lib.text.iter().map(|n| Variable { name: n.clone(), kind: VariableKind::Text }).collect(); + let names: Vec = lib.datasets.iter().map(|d| d.name.clone()).collect(); + s.edit("Load Variable Library", |d, _| { + d.variables.prune(&deleted); + d.variables.variables.extend(added); + d.variables.datasets = lib.datasets; + d.variables.active_dataset = None; + Ok(()) + })?; + // A highlight of a variable that is gone highlights nothing. + let st = s.doc_mut()?; + if st.variables_highlight.as_ref().is_some_and(|h| st.doc.variables.variable(h).is_none()) { + st.variables_highlight = None; + } + st.revision += 1; + let skipped: Vec = lib.other.iter().map(|(name, t)| json!({"name": name, "trait": t})).collect(); + let mut out = json!({ + "variables": lib.text, "deleted": deleted, "deletedDataSets": deleted_sets, "names": names, "skipped": skipped, + }); + // Each `trait` once, in the order the library first uses it, with how many variables have it. + let mut kinds: Vec<(&str, usize)> = Vec::new(); + for (_, t) in &lib.other { + match kinds.iter_mut().find(|(k, _)| k == t) { + Some((_, n)) => *n += 1, + None => kinds.push((t, 1)), + } + } + if !kinds.is_empty() { + let kinds = kinds.iter().map(|(k, n)| format!("{k} ({n})")).collect::>().join(", "); + out["warning"] = json!(format!("variables that aren't text were left out: {kinds}")); + } + Ok(out) +} + pub fn specs() -> Vec { vec![ cmd!( @@ -980,7 +1273,7 @@ pub fn specs() -> Vec { "Lock Variables", ["Window", "Variables"], None, - "{locked?: bool} lock or unlock the variables (default: toggle); saved with the document; a change is one undo step. While locked, variable.define, variable.delete, variable.rename and variable.make…Dynamic are refused; binding, unbinding and the dataset commands still run → {locked}", + "{locked?: bool} lock or unlock the variables (default: toggle); saved with the document; a change is one undo step. While locked, variable.define, variable.delete, variable.rename, variable.make…Dynamic and a variable.loadLibrary that would delete or define a variable are refused; binding, unbinding and the dataset commands still run → {locked}", has_doc, lock ), @@ -1075,5 +1368,14 @@ pub fn specs() -> Vec { has_doc, dataset_export ), + cmd!( + "variable.loadLibrary", + "Load Variable Library…", + ["Window", "Variables"], + None, + "{path? | xml? | dataBase64?} replace the document's variables and data sets with a variable library's (XML: `variableSets` › `variableSet` › `variables` of `variable` elements with `varName` and `trait`, and `sampleDataSets` of `sampleDataSet` elements with `dataSetName`) in one undo step. Every variable of the document is deleted with its bindings and values, and every data set is deleted; then variables whose `trait` is `textcontent` are defined as text variables, unbound, and each data set takes a value for a variable from the `p` elements of the element named after it, one paragraph each. Variables of other kinds are left out and named in `skipped` and `warning`. Refused while the variables are locked when it would delete or define a variable → {variables, deleted, deletedDataSets, names, skipped, warning?}", + has_doc, + load_library + ), ] } diff --git a/crates/engine/src/cmd/wand.rs b/crates/engine/src/cmd/wand.rs index 23589e6b3..362043c3d 100644 --- a/crates/engine/src/cmd/wand.rs +++ b/crates/engine/src/cmd/wand.rs @@ -141,9 +141,10 @@ fn paintable(doc: &Document) -> Vec { out.push(n.id); } }); + let editable = doc.editable(); out.retain(|id| { !doc.parent_of(*id).is_some_and(|p| compounds.contains(&p)) - && doc.is_editable(*id) + && editable(*id) && doc.is_visible(*id) && doc.node(*id).is_some_and(|n| n.visible) }); diff --git a/crates/engine/src/cmd/xform.rs b/crates/engine/src/cmd/xform.rs index c2a48a62e..67c480133 100644 --- a/crates/engine/src/cmd/xform.rs +++ b/crates/engine/src/cmd/xform.rs @@ -45,7 +45,7 @@ pub fn specs() -> Vec { "Move Artboard", [], None, - "{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art only with prefs moveLockedWithArtboard), several in one step; `copy` (Alt-drag) leaves them and moves copies → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}", + "{index | indices: [index…], dx, dy, moveArt?: bool, copy?: bool, patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} move artboards (and the art fully inside them; locked and hidden art, and art also on another locked artboard, only with prefs moveLockedWithArtboard), several in one step (an error when one is locked); `copy` (Alt-drag) leaves them and moves copies, which start unlocked → {index (the first), indices: the artboards moved or the copies, moved: the art moved or the copies}", has_doc, artboard_move ), @@ -345,7 +345,7 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { let (id, mut q) = match p.get("stop") { None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })), Some(v) => { - let i = v.as_u64().ok_or_else(|| bad(C, "`stop` must be a stop index"))? as usize; + let i = v.as_u64().map(index).ok_or_else(|| bad(C, "`stop` must be a stop index"))?; let color = super::paint::new_color(s, p, color_value(c).ok_or_else(|| bad(C, format!("bad color {c}")))?); let (fill, stroke_paint) = s.proxy_paints(); let Paint::Gradient(g) = (if stroke { stroke_paint } else { fill }) else { @@ -368,18 +368,23 @@ fn sample_color(s: &mut Session, p: &Value) -> Result { fn artboard_move(s: &mut Session, p: &Value) -> Result { let indices = super::panelcmds::artboard_indices(&s.doc()?.doc, p, "artboard.move")?; + check_numbers(p, &["dx", "dy"], "artboard.move")?; + check_switches(p, &["moveArt", "copy", "patterns"], "artboard.move")?; let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0)); let move_art = bool_or(p, "moveArt", false); + let copy = bool_or(p, "copy", false); + if !copy { + super::panelcmds::refuse_locked(&s.doc()?.doc, &indices)?; + } // Each artboard's art, an object counted once (artboards can overlap). let mut arts: Vec<(usize, Vec)> = vec![]; for &i in &indices { - let art = super::panelcmds::artboard_art(s, i, move_art)?; + let art = super::panelcmds::artboard_art(s, i, move_art, !copy)?; let art = art.into_iter().filter(|id| !arts.iter().any(|(_, a)| a.contains(id))).collect(); arts.push((i, art)); } let ids: Vec = arts.iter().flat_map(|(_, a)| a.iter().copied()).collect(); let sc = Scaling { patterns: super::object::transform_patterns(s, p, &ids)?, ..Scaling::default() }; - let copy = bool_or(p, "copy", false); let (index, moved) = s.edit(if copy { "Duplicate Artboard" } else { "Move Artboard" }, |d, _| { let (mut index, mut moved) = (vec![], vec![]); for (i, art) in &arts { diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 0c3f44d91..993006513 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -2,15 +2,16 @@ //! //! `vectorcraft-cli mcp --automation-read-root --automation-write-root `, and //! `vectorcraft --control ` with the same flags, confine the files an agent's commands -//! reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command -//! reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! reach; each flag may be given more than once (#3546). [`AutomationRoots`] holds the folders of +//! each kind. While they are in force, every path a command reads goes through [`check_read`] and +//! every path it writes through [`check_write`]: //! //! - The path is made absolute against the working directory, as the file system reads it. Its //! links are followed: for a file that doesn't exist yet, the links of its nearest existing //! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so //! is a link that leads nowhere). -//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside -//! `/work`), without regard to case on Windows. +//! - The result must lie inside one of the roots of its kind, compared by whole components +//! (`/work2` isn't inside `/work`), without regard to case on Windows. //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Roots are in force on a thread inside [`confine`], and in the whole process after @@ -35,11 +36,11 @@ pub enum Access { Write, } -/// The folders automation may read and write; a root left out grants no access of its kind. +/// The folders automation may read and write; a kind with no root grants no access of its kind. #[derive(Debug)] pub struct AutomationRoots { - read: Option, - write: Option, + read: Vec, + write: Vec, } #[derive(Debug)] @@ -65,48 +66,59 @@ impl Root { } impl AutomationRoots { - /// The roots `read` and `write`, which must be existing folders; `None` when neither is given - /// (nothing is confined then). - pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { - if read.is_none() && write.is_none() { + /// The roots `read` and `write`, which must be existing folders (one given twice counts once); + /// `None` when neither kind has any (nothing is confined then). + pub fn new(read: &[&Path], write: &[&Path]) -> Result>, String> { + if read.is_empty() && write.is_empty() { return Ok(None); } - let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; - let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; - Ok(Some(Arc::new(Self { read, write }))) - } - - /// The read root, absolute with its links followed. - pub fn read_root(&self) -> Option<&Path> { - self.read.as_ref().map(|r| r.shown.as_path()) - } - - /// The write root, absolute with its links followed. - pub fn write_root(&self) -> Option<&Path> { - self.write.as_ref().map(|r| r.shown.as_path()) - } - - /// May the file or folder at `path` be read (or written)? `Err` says why not. - pub fn check(&self, path: &str, access: Access) -> Result<(), String> { - let root = match access { - Access::Read => self.read.as_ref(), - Access::Write => self.write.as_ref(), + let open = |paths: &[&Path], access: Access| -> Result, String> { + let mut roots: Vec = Vec::with_capacity(paths.len()); + for path in paths { + let root = Root::open(path, access)?; + if !roots.iter().any(|r| r.resolved == root.resolved) { + roots.push(root); + } + } + Ok(roots) }; - let Some(Root { resolved: folder, shown }) = root else { + Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? }))) + } + + /// May the file or folder at `path` be read (or written)? It must lie inside one of the roots + /// of that kind. `Err` says why not. + pub fn check(&self, path: &str, access: Access) -> Result<(), String> { + let roots = match access { + Access::Read => &self.read, + Access::Write => &self.write, + }; + // The messages are spelled out whole, as the translation catalogs match them. + if roots.is_empty() { return match access { Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")), Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), }; - }; - if inside(&resolve(path)?, folder) { + } + let resolved = resolve(path)?; + if roots.iter().any(|r| inside(&resolved, &r.resolved)) { return Ok(()); } - let root = shown.display(); - match access { - Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")), - Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")), + let shown: Vec = roots.iter().map(|r| r.shown.display().to_string()).collect(); + match (shown.as_slice(), access) { + ([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {path}")), + ([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {path}")), + (_, Access::Read) => Err(format!("automation path rejected: outside the read roots {roots}: {path}", roots = shown.join(", "))), + (_, Access::Write) => Err(format!("automation path rejected: outside the write roots {roots}: {path}", roots = shown.join(", "))), } } + + /// The roots for people: `read A, B; write C` (`none` for a kind without any). + pub fn describe(&self) -> String { + let list = |roots: &[Root]| { + if roots.is_empty() { "none".to_string() } else { roots.iter().map(|r| r.shown.display().to_string()).collect::>().join(", ") } + }; + format!("read {}; write {}", list(&self.read), list(&self.write)) + } } /// Where the roots are in force on a thread ([`confine`], [`unconfined`]). @@ -240,8 +252,9 @@ fn resolve(path: &str) -> Result { } } -/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on -/// Windows, whose file systems ignore it)? +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case in a +/// Windows drive prefix only: resolved folder names have their real case, and a folder that differs +/// in case alone may be another, on a case-sensitive Windows folder)? fn inside(path: &Path, root: &Path) -> bool { let mut path = path.components(); root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) @@ -251,7 +264,7 @@ fn same_component(a: Component, b: Component) -> bool { if a == b { return true; } - if !cfg!(windows) { + if !cfg!(windows) || !matches!((a, b), (Component::Prefix(_), Component::Prefix(_))) { return false; } match (a.as_os_str().to_str(), b.as_os_str().to_str()) { @@ -280,7 +293,7 @@ mod tests { } std::fs::write(base.join("in/a.svg"), "").unwrap(); std::fs::write(base.join("outside/secret.svg"), "").unwrap(); - let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap(); (base, roots) } @@ -340,18 +353,20 @@ mod tests { #[test] fn a_missing_root_is_an_error_and_none_is_no_confinement() { let (base, _) = folders("missing"); - let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err(); assert!(e.starts_with("cannot open the automation read root"), "{e}"); - let e = AutomationRoots::new(None, Some(&base.join("in/a.svg"))).unwrap_err(); + let e = AutomationRoots::new(&[], &[&base.join("in/a.svg")]).unwrap_err(); assert!( e.starts_with("cannot open the automation write root") && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), "{e}" ); - assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); - assert!(AutomationRoots::new(None, None).unwrap().is_none()); + assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(&[], &[]).unwrap().is_none()); + // A missing folder among several is an error too. + assert!(AutomationRoots::new(&[&base.join("in"), &base.join("nope")], &[]).unwrap_err().starts_with("cannot open the automation read root")); // One root alone grants only its own access. - let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap(); let e = read_only.check(&s(base.join("in/x.svg")), Access::Write).unwrap_err(); assert!(e.contains("write authority is absent"), "{e}"); let _ = std::fs::remove_dir_all(base); @@ -388,11 +403,55 @@ mod tests { assert_eq!(roots.check(&s(base.join("in/alias/x.svg")), Access::Read), Ok(())); // A root given through a link is the folder it leads to. assert!(made(&base.join("in"), &base.join("in-link"))); - let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap(); assert_eq!(linked.check(&s(base.join("in/a.svg")), Access::Read), Ok(())); let _ = std::fs::remove_dir_all(base); } + /// #3546: several roots of a kind: a path inside any of them passes, one inside none is + /// refused naming them all, and read and write stay separate. + #[test] + fn several_roots_of_a_kind_each_grant_their_access() { + let (base, _) = folders("several"); + std::fs::create_dir_all(base.join("out2")).unwrap(); + std::fs::write(base.join("in2/b.svg"), "").unwrap(); + let roots = AutomationRoots::new(&[&base.join("in"), &base.join("in2")], &[&base.join("out"), &base.join("out2")]).unwrap().unwrap(); + assert_eq!(roots.check(&s(base.join("in/a.svg")), Access::Read), Ok(())); + assert_eq!(roots.check(&s(base.join("in2/b.svg")), Access::Read), Ok(())); + let e = roots.check(&s(base.join("outside/secret.svg")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read roots ") && e.contains(", "), "{e}"); + assert_eq!(roots.check(&s(base.join("out/x.png")), Access::Write), Ok(())); + assert_eq!(roots.check(&s(base.join("out2/new/x.png")), Access::Write), Ok(())); + assert!(roots.check(&s(base.join("outside/x.png")), Access::Write).unwrap_err().contains("outside the write roots")); + // Read and write access stay separate with several roots. + assert!(roots.check(&s(base.join("in2/b.svg")), Access::Write).is_err()); + assert!(roots.check(&s(base.join("out2/x.png")), Access::Read).is_err()); + // `..` out of one root into another root's sibling is still outside. + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.svg", s(base.join("in2"))); + assert!(roots.check(&up, Access::Read).is_err()); + // A folder given twice (here once through `..`) counts once. + let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/..")], &[]).unwrap().unwrap(); + assert!(!twice.describe().contains(", "), "{}", twice.describe()); + assert!(twice.describe().ends_with("; write none"), "{}", twice.describe()); + let _ = std::fs::remove_dir_all(base); + } + + /// A link inside one root that leads into another root of the same kind stays inside; one that + /// leads out of all of them is outside. + #[cfg(unix)] + #[test] + fn links_between_several_roots() { + let (base, _) = folders("several-links"); + std::fs::write(base.join("in2/b.svg"), "").unwrap(); + std::os::unix::fs::symlink(base.join("in2/b.svg"), base.join("in/to-in2.svg")).unwrap(); + std::os::unix::fs::symlink(base.join("outside/secret.svg"), base.join("in2/leak.svg")).unwrap(); + let roots = AutomationRoots::new(&[&base.join("in"), &base.join("in2")], &[]).unwrap().unwrap(); + assert_eq!(roots.check(&s(base.join("in/to-in2.svg")), Access::Read), Ok(())); + assert!(roots.check(&s(base.join("in2/leak.svg")), Access::Read).unwrap_err().contains("outside the read roots")); + let _ = std::fs::remove_dir_all(base); + } + #[cfg(windows)] #[test] fn windows_ignores_case_and_refuses_devices() { diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 20db02bce..d0a0baadd 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -128,6 +128,30 @@ pub fn document(s: &Session) -> Value { document_opts(s, SummaryOpts::default()) } +/// An artboard's name and rectangle, its background color (`#rrggbb`) when it has one, +/// `locked: true` when it is locked, `showCenterMark: true`, `showCrossHairs: true` and +/// `showVideoSafeAreas: true` when it shows them, and `videoRulerPixelAspectRatio` when it isn't 1. +fn artboard_summary(a: &vectorcraft_doc::Artboard) -> Value { + let mut v = json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()}); + if let Some(c) = a.color { + v["color"] = json!(c.to_hex()); + } + for (key, on) in [ + ("locked", a.locked), + ("showCenterMark", a.show_center_mark), + ("showCrossHairs", a.show_cross_hairs), + ("showVideoSafeAreas", a.show_video_safe_areas), + ] { + if on { + v[key] = json!(true); + } + } + if a.video_ruler_pixel_aspect_ratio != 1.0 { + v["videoRulerPixelAspectRatio"] = json!(a.video_ruler_pixel_aspect_ratio); + } + v +} + /// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and /// the rest always come whole): `depth: 0` is the skeleton — top layers with counts. pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value { @@ -239,13 +263,14 @@ fn document_base(s: &Session) -> Value { let Some(st) = s.active() else { return Value::Null }; let d = &st.doc; json!({ + "uid": st.uid, "title": st.title(), "path": st.path, "dirty": st.is_dirty(), "revision": st.revision, "units": d.units.label(), "colorMode": format!("{:?}", d.color_mode), - "artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::>(), + "artboards": d.artboards.iter().map(artboard_summary).collect::>(), "currentLayer": st.active_layer.map(|l| l.0), // The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`). "layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::>(), @@ -347,11 +372,26 @@ fn name_of(table: &[(T, &'static str)], v: T) -> &'static str { /// else the topmost stroke of its own (groups and layers too). None without a stroke. pub fn node_stroke(n: &Node) -> Option { match &n.kind { - NodeKind::Text(t) => Some(t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer())), + NodeKind::Text(t) => { + let mut st = t.runs.first().map_or_else(|| vectorcraft_doc::CharStyle::default().stroke_layer(), |r| r.style.stroke_layer()); + // Character strokes are drawn in text space: report the document-space weight. + if let Some(k) = vectorcraft_doc::Scaling::factor(t.xf).filter(|k| k.is_finite()) { + scale_layer(&mut st, k); + } + Some(st) + } _ => n.appearance.stroke().cloned(), } } +/// Scale a stroke's weight and dash lengths by `s`. +pub(crate) fn scale_layer(st: &mut StrokeLayer, s: f64) { + st.width *= s; + if let Some(d) = &mut st.dash { + d.scale(s); + } +} + /// A stroke's options in `stroke.set` terms: cap, join, miterLimit, align, dash (null when /// solid), dashOffset, alignDashes, startArrow/endArrow (null for none), arrowAlign and profile, /// plus widthPoints: the profile's `[t, left, right]` points (width factors; null when uniform) diff --git a/crates/engine/src/journal.rs b/crates/engine/src/journal.rs index 96cfe7ae3..230bcfba0 100644 --- a/crates/engine/src/journal.rs +++ b/crates/engine/src/journal.rs @@ -81,7 +81,7 @@ impl Journal { self.bytes += size; self.trim(); } - /// Drop the entries from number `len` on (a cancelled undo group's). + /// Drop the entries from number `len` on. pub fn truncate(&mut self, len: usize) { let keep = len.saturating_sub(self.dropped); self.entries.truncate(keep); @@ -89,11 +89,49 @@ impl Journal { self.bytes = self.sizes.iter().sum(); self.dropped = self.dropped.min(len); } + /// Drop the entries numbered `numbers` (sorted, a number may repeat; the entries of canceled undo + /// groups). The entries after them are numbered down to close the gaps. + pub fn remove(&mut self, numbers: &[usize]) { + let dropped = self.dropped; + let gone = |i: usize| numbers.binary_search(&(dropped + i)).is_ok(); + let mut i = 0; + self.entries.retain(|_| { + i += 1; + !gone(i - 1) + }); + let mut i = 0; + self.sizes.retain(|_| { + i += 1; + !gone(i - 1) + }); + self.bytes = self.sizes.iter().sum(); + // How many of the numbers are below `n`, each counted once. + let below = |n: usize| { + let mut last = None; + numbers.iter().take_while(|&&m| m < n).filter(|&&m| last.replace(m) != Some(m)).count() + }; + self.held = self.held.map(|h| h.saturating_sub(below(h))); + self.dropped = dropped.saturating_sub(below(dropped)); + } + /// Put `e` in place of entry number `i`, if kept. + pub fn replace(&mut self, i: usize, e: JournalEntry) { + let Some(k) = i.checked_sub(self.dropped) else { return }; + if let (Some(old), Some(size)) = (self.entries.get_mut(k), self.sizes.get_mut(k)) { + *size = entry_bytes(&e); + *old = e; + self.bytes = self.sizes.iter().sum(); + } + } /// Keep every entry from number `from` on (a recording in progress), or stop (`None`). pub fn hold(&mut self, from: Option) { self.held = from; self.trim(); } + /// The number the entries are kept from ([`Journal::hold`]). [`Journal::remove`] moves it down + /// with the entries it numbers down. + pub fn held(&self) -> Option { + self.held + } /// Let the oldest entries go while over the bounds (in batches: a quarter of the entry bound /// at a time, so a push doesn't shift the whole list every time). @@ -194,4 +232,25 @@ mod tests { j.truncate(29_990); assert_eq!((j.len(), j.last().map(|e| e.1["dx"].clone())), (29_990, Some(json!(29_989)))); } + + #[test] + fn removed_entries_leave_no_gap_in_the_numbers() { + let mut j = Journal::default(); + for i in 0..30_000 { + j.push(("object.move".into(), json!({"dx": i}))); + } + let first = j.first_kept(); + // A recording from entry 29,500 on: the entries removed before it move its start down. + j.hold(Some(29_500)); + // One entry that was let go already and two kept ones, two of them given twice. + j.remove(&[first - 1, first - 1, 29_000, 29_000, 29_998]); + assert_eq!((j.len(), j.first_kept(), j.held()), (29_997, first - 1, Some(29_498))); + let dx = |j: &Journal, i: usize| j.get(i).map(|e| e.1["dx"].clone()); + assert_eq!((dx(&j, 28_998), dx(&j, 28_999)), (Some(json!(28_999)), Some(json!(29_001)))); + assert_eq!(j.last().map(|e| e.1["dx"].clone()), Some(json!(29_999))); + assert_eq!(j.bytes, j.sizes.iter().sum::()); + // A replaced entry is counted at its new size. + j.replace(29_000, big(0)); + assert_eq!(j.bytes, j.iter().map(entry_bytes).sum::()); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4c848ecbc..bec7d9d9f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,8 +28,10 @@ use vectorcraft_tools::{PaintDefaults, Tool}; pub use cmd::EyedropperOptions; pub use cmd::clipboard::Clipboard; +pub use cmd::create::constrain_click; pub use cmd::distortcmds::perspective_click; pub use cmd::params::{check_keys, check_params}; +pub use cmd::pathops::auto_simplify_level; pub use cmd::rasterfx::{export_pdf, flatten_raster_effects}; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use journal::Journal; @@ -297,12 +299,27 @@ pub(crate) struct PreviewCopies { pub struct UndoGroup { /// The document before the group's first edit, as the undo step that edit recorded keeps it. first: Option>, - /// The journal's length when the group began: a cancelled group drops the entries after it. - journal: usize, + /// The numbers of the journal entries made in the group's document while it was open: a canceled + /// group drops them. An entry that changed the active document has the index of the document it + /// left active, and a canceled group turns it into that change. + journal: Vec<(usize, Option)>, /// The undo step's name; else the first edit's. label: Option, } +/// One undo step begun with [`Session::begin_undo_scope`], which [`Session::end_undo_scope`] ends. +#[derive(Clone, Debug)] +pub struct UndoScope { + /// The document ([`DocState::uid`]). + uid: u64, + /// The journal's length when the scope began. + journal: usize, + /// When the document's undo group was open already: the document, its selection and the document + /// that the group's undo step keeps, as they were when the scope began; `None` when the scope + /// opened the group. + joined: Option<(Arc, Selection, Option>)>, +} + /// Per-document editing state. #[derive(Clone, Debug)] pub struct DocState { @@ -331,6 +348,9 @@ pub struct DocState { pub variables_highlight: Option, /// Isolation mode container. pub isolation: Option, + /// Select › Start Global Edit: the objects edited together until Stop Global Edit or Escape + /// ([`cmd::globaledit`]). Editing state: not saved, not undoable. + pub global_edit: Option, pub interaction: Option, /// Edits made while this is open are one undo step (a scrubbed numeric field). pub undo_group: Option, @@ -534,6 +554,7 @@ impl DocState { layer_rows: vec![], variables_highlight: None, isolation: None, + global_edit: None, interaction: None, undo_group: None, last_transform: None, @@ -626,6 +647,8 @@ impl DocState { if let Some(it) = self.interaction.take() { self.doc = it.doc; self.selection = Arc::unwrap_or_clone(it.selection); + self.sync_new_art(); + cmd::globaledit::follow_history(self); self.active_layer = it.active_layer; self.layer_rows = it.layer_rows; self.variables_highlight = it.variables_highlight; @@ -637,6 +660,60 @@ impl DocState { pub fn mark_unsaved(&mut self) { self.saved_doc = Arc::new(Document::new(1.0, 1.0)); } + /// Deselect the art on a locked artboard, except new art ([`Document::new_art`]). New art + /// becomes the objects of layers on a locked artboard that hold selected objects (a group for + /// its selected members) and that an edit made (those not in `before`, the document before + /// it), or that held objects selected in `was` that were new art (`old`, the new art of that + /// selection), or were inside new art in `before` (the members of an ungrouped group). New art + /// that a selection leaves out, or that leaves the locked artboards, is protected from then + /// on. The selection and the document get the new art; the document's changes in place, so a + /// selection change doesn't replace the document. + fn protect_locked_artboards(&mut self, was: &[NodeId], old: &std::collections::HashSet, before: Option<&Document>) { + let new_art: std::collections::HashSet = { + let (doc, locks) = (&self.doc, self.doc.locked_artboards()); + if locks.is_empty() { + Default::default() + } else { + // `was` and `old` name the objects of the document they were selected in. + let then = before.unwrap_or(doc); + let was: std::collections::HashSet = + was.iter().filter_map(|o| then.layer_object(*o)).map(|n| n.id).filter(|o| old.contains(o)).collect(); + let selected: Vec<(NodeId, &vectorcraft_doc::Node)> = + self.selection.objects.iter().filter_map(|o| doc.layer_object(*o).map(|n| (*o, n))).collect(); + // One walk of the document before the edit finds where the selected objects were; + // the objects the edit made aren't in it. + let paths = before.filter(|_| !selected.is_empty()).map(|b| b.index_paths(selected.iter().flat_map(|(o, n)| [*o, n.id]))); + let new = |o: NodeId, object: NodeId| { + was.contains(&object) + || before.zip(paths.as_ref()).is_some_and(|(b, p)| { + !p.contains_key(&object) || p.get(&o).and_then(|p| b.layer_object_at(p)).is_some_and(|n| was.contains(&n.id)) + }) + }; + selected.into_iter().filter(|(o, n)| new(*o, n.id) && locks.overlaps(n)).map(|(_, n)| n.id).collect() + } + }; + if new_art != *self.selection.new_art { + self.selection.new_art = Arc::new(new_art); + } + self.sync_new_art(); + self.selection.deselect_on_locked_artboards(&self.doc); + } + /// Give the document the selection's new art ([`Selection::new_art`]), less the objects the + /// selection no longer holds: after Undo, Redo or a failed batch brings back a document and + /// a selection, and after a command that changed the selection directly. + pub(crate) fn sync_new_art(&mut self) { + if !self.selection.new_art.is_empty() { + let held: std::collections::HashSet = + self.selection.objects.iter().filter_map(|o| self.doc.layer_object(*o)).map(|n| n.id).collect(); + if !self.selection.new_art.is_subset(&held) { + self.selection.new_art = Arc::new(self.selection.new_art.intersection(&held).copied().collect()); + } + } + let doc = self.doc.new_art.get(); + if !Arc::ptr_eq(&doc, &self.selection.new_art) && *doc != *self.selection.new_art { + self.doc.new_art.set(self.selection.new_art.clone()); + } + } pub fn title(&self) -> String { let name = self .path @@ -789,16 +866,22 @@ const _: () = assert!(vectorcraft_doc::MAX_DEPTH == 32, "TOO_DEEP (and its catal /// Cheap sanity check after an edit: artboards and the objects just touched (the selection) must /// have finite, in-range geometry, so saved files always reload and renderers never see NaN/∞. fn doc_sane(d: &Document, sel: &Selection) -> bool { - let ok = |r: vectorcraft_geom::Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD); d.ruler_origin.is_finite() && d.ruler_origin.x.abs() <= MAX_COORD && d.ruler_origin.y.abs() <= MAX_COORD - && d.artboards.iter().all(|a| ok(a.rect)) + && d.artboards.iter().all(|a| rect_in_range(a.rect)) && d.guides.iter().all(|g| g.pos.is_finite() && g.pos.abs() <= MAX_COORD) - && { - let paths = d.index_paths(sel.objects.iter().copied()); - sel.objects.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(ok)) - } + && objects_in_range(d, &sel.objects) +} + +fn rect_in_range(r: vectorcraft_geom::Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) +} + +/// Do the objects `ids` have finite geometry within [`MAX_COORD`] (the ones not there pass)? +fn objects_in_range(d: &Document, ids: &[NodeId]) -> bool { + let paths = d.index_paths(ids.iter().copied()); + ids.iter().all(|id| paths.get(id).and_then(|p| d.node_at(p)).and_then(|n| n.geometric_bounds()).is_none_or(rect_in_range)) } /// Where new art goes (Illustrator's drawing modes, Shift+D cycles). @@ -898,7 +981,10 @@ pub struct Prefs { pub anchor_path_labels: bool, pub measurement_labels: bool, pub spacing_guides: bool, + pub distance_guides: bool, pub snapping_tolerance: f64, + pub snap_tangent: bool, + pub snap_perpendicular: bool, // Slices pub show_slice_numbers: bool, pub slice_line_color: String, @@ -908,7 +994,7 @@ pub struct Prefs { /// Type › Options › Additional Fonts Folder: a folder (read with its subfolders) whose fonts /// are listed and used as if installed (#683); empty for none. pub fonts_folder: String, - // Performance & Storage (Plug-ins & Scratch Disks) + // Plug-ins & Scratch Disks pub plugins_folder: String, pub scratch_primary: String, pub scratch_secondary: String, @@ -1147,7 +1233,10 @@ impl Default for Prefs { anchor_path_labels: true, measurement_labels: true, spacing_guides: true, + distance_guides: true, snapping_tolerance: 4.0, + snap_tangent: true, + snap_perpendicular: true, show_slice_numbers: true, slice_line_color: s("#ff3f3f"), hyphenation_language: s("English: USA"), @@ -1246,9 +1335,18 @@ pub struct Session { /// lends the other tools. None until one is chosen. pub(crate) last_selection_tool: Option<&'static str>, pub(crate) last_view: ViewInfo, + /// The selection tools' measurements for the active document as it is: a pointer move or a + /// cursor asked for again over an unchanged document and selection measures nothing again. + pub(crate) preview_memo: vectorcraft_tools::select::PreviewMemo, depth: u32, /// Set when the active tool panicked (see [`guard`]); reported by the next tool event. tool_panic: Option, + /// How many times switching or closing documents, or replacing the active one, has started the + /// tool afresh ([`Session::tool_resets`]). + tool_resets: u64, + /// Whether the tool's options were set after it last started afresh + /// ([`Session::tool_set_since_reset`]). + tool_set_since_reset: bool, /// Draw Normal / Behind / Inside (toolbar drawing modes). pub draw_mode: DrawMode, /// The path new art is drawn inside (Draw Inside). @@ -1279,6 +1377,8 @@ pub struct Session { pub(crate) freeform_point: Option<(usize, cmd::gradient::StopOwner)>, /// User Defined and loaded graphic style libraries (Window → Graphic Style Libraries); not saved. pub style_libraries: cmd::stylelib::Libraries, + /// User Defined and loaded symbol libraries (Window → Symbol Libraries); not saved. + pub symbol_libraries: cmd::symbollib::Libraries, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session @@ -1290,6 +1390,9 @@ pub struct Session { /// Where the plug-ins `plugin.install` installs are kept; `None`: only for the process /// ([`Session::attach_plugin_store`]). plugin_dir: Option, + /// A filter's output that a run made in the background handed in for `plugin.run` to apply + /// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it. + plugin_output: Option, /// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved. pub recent_urls: Vec, /// The language the UI is drawn in (a language code, never `auto`), set by the UI each frame; @@ -1305,6 +1408,23 @@ pub struct Session { /// While `command.batch` runs: the documents its steps closed or replaced (Revert), which an /// error brings back; `None` otherwise. pub(crate) batch_stash: Option>, + /// While [`Session::place_read`] runs `file.place`: the file read for it, which the command + /// places instead of reading the file; `None` otherwise. + pub(crate) place_hand_off: Option, + /// While [`Session::place_queue_read`] runs `file.place.queue`: the files read for it, which + /// the command loads the place cursor with instead of reading them; `None` otherwise. + pub(crate) queue_hand_off: Option, + /// Whether a click or drag of the place cursor asks the frontend to place its file + /// ([`UiRequest::Place`]) instead of running `file.place`: the desktop app sets it to read the + /// file off the UI thread. False: the file is placed at once. + pub place_cursor_requests: bool, + /// While [`Session::prepare_package`] runs `file.package`: the package the command prepared, + /// which it leaves here instead of writing it; `None` otherwise. + pub(crate) package_hand_off: Option>, + /// While [`Session::prepare_screen_export`] runs `document.exportForScreens` or + /// `assets.export`: the export the command prepared, which it leaves here instead of writing + /// it; `None` otherwise. + pub(crate) screens_hand_off: Option>, /// Where Data Recovery keeps its copies ([`cmd::recovery`]). pub recovery: cmd::recovery::Recovery, /// While the active tool's actions run: their commands aren't "a command from outside the @@ -1320,10 +1440,13 @@ pub struct Session { pub(crate) liquify_stroke: Option>, /// A press on the Plane Switching Widget is under way: its drag and release are the widget's. pub(crate) plane_widget_press: bool, + /// The pointer button is held: the last pointer event was a press or a drag, not a release or + /// a move ([`Session::pointer_held`]). + pub(crate) pointer_held: bool, /// A guide being dragged out of a ruler ([`Session::ruler_guide`]). pub(crate) ruler_guide: Option, - /// Which selected type overflows its frame, for the markers every tool but the Type tools shows. - pub(crate) overflow_marks: vectorcraft_tools::text::OverflowMarks, + /// Which selected type overflows its frame, for the ports every tool but the Type tools shows. + pub(crate) port_marks: vectorcraft_tools::text::PortMarks, /// The last search for the files of missing fonts (`text.findFontFiles`), until another /// starts; dropping it stops it. pub(crate) font_search: Option, @@ -1333,6 +1456,9 @@ pub struct Session { /// Where folder searches may go; `None`: this computer's rules ([`cmd::findfiles::Rules`]). /// Tests set it. pub search_rules: Option, + /// Canvas feedback cached for the pending deferred preview ([`Session::overlays`]); cleared + /// when the preview params change or the interaction ends. + pub(crate) deferred_overlay_cache: Option<(String, Value, Vec)>, /// The walker threads a folder search starts; `None`: [`cmd::findfiles::threads`]. Tests set /// it. pub search_threads: Option, @@ -1341,6 +1467,9 @@ pub struct Session { pub link_stamps: std::collections::HashMap<(u64, String), link_watch::Stamp>, /// The stamps being taken on a worker thread ([`Session::start_link_scan`]). pub link_scan: Option, + /// The Global Edit Options Start Global Edit matches with (`select.globalEditOptions`); not + /// saved. + pub global_edit_options: cmd::globaledit::Options, } impl Default for Session { @@ -1350,12 +1479,23 @@ impl Default for Session { } impl Session { + /// A session with no document open, the default preferences and the Selection tool. It also + /// installs the hooks the document crate calls back into: the loader and file reader of placed + /// documents, and where an image's effects draw its fills and strokes, for clicks. pub fn new() -> Self { // Placed documents are read by the native format's loader. vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document); vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again); + vectorcraft_doc::placed_document::set_read_check(|path| file_access::check_read(path).is_ok()); + // The background workers of placed documents read files in the scope that asked. + vectorcraft_render::placed_document::set_scope_capture(|| { + let scope = file_access::ScopeHandle::capture(); + Box::new(move |f: &mut dyn FnMut()| scope.run(f)) + }); // Clicks find an image's own fills and strokes where their effects draw them. vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); + // OpenType SVG glyphs are drawn as the SVG importer reads their documents. + vectorcraft_text::set_svg_glyph_reader(vectorcraft_svg::glyph_art); Self { docs: vec![], active: None, @@ -1368,8 +1508,11 @@ impl Session { lender: None, last_selection_tool: None, last_view: ViewInfo::default(), + preview_memo: Default::default(), depth: 0, tool_panic: None, + tool_resets: 0, + tool_set_since_reset: false, draw_mode: DrawMode::Normal, draw_inside: None, untitled_counter: 0, @@ -1383,29 +1526,39 @@ impl Session { swatch_libraries: Default::default(), freeform_point: None, style_libraries: Default::default(), + symbol_libraries: Default::default(), libraries: Default::default(), pdf_preset_dir: None, profile_dir: None, plugin_dir: None, + plugin_output: None, recent_urls: vec![], ui_language: None, journal_note: Default::default(), note_depth: 1, batch_stash: None, + place_hand_off: None, + queue_hand_off: None, + place_cursor_requests: false, + package_hand_off: None, + screens_hand_off: None, recovery: Default::default(), in_tool_actions: false, previewing: false, envelope_defaults: None, liquify_stroke: None, plane_widget_press: false, + pointer_held: false, ruler_guide: None, - overflow_marks: Default::default(), + port_marks: Default::default(), font_search: None, link_search: None, search_rules: None, + deferred_overlay_cache: None, search_threads: None, link_stamps: Default::default(), link_scan: None, + global_edit_options: Default::default(), } } @@ -1449,6 +1602,8 @@ impl Session { } self.keep_tool_settings(); self.tool = self.make_tool(self.tool.id()); + self.tool_resets = self.tool_resets.wrapping_add(1); + self.tool_set_since_reset = false; } pub fn set_active(&mut self, index: usize) -> bool { if index < self.docs.len() { @@ -1491,6 +1646,7 @@ impl Session { Self::refresh_text_bounds(&mut doc); // Guides from files saved before guides had layers go on a layer. doc.adopt_guides(); + cmd::dimension::conform(&mut doc); self.reset_tool_for_doc_switch(); let mut st = DocState::new(doc, path); st.history.limit = self.prefs.history_states as usize; @@ -1508,6 +1664,7 @@ impl Session { doc.limit_depth(); Self::refresh_text_bounds(&mut doc); doc.adopt_guides(); + cmd::dimension::conform(&mut doc); if self.active == Some(index) { // Pending tool work (typing, a drag) belongs to the content being thrown away. self.reset_tool_for_doc_switch(); @@ -1586,14 +1743,29 @@ impl Session { self.note_depth = 1; self.batch_stash = None; } - // A selection-only command given `ids` selects them first, in the same undo step (#2130). + // A selection-only command given `ids` selects them first, in the same undo step (#2130); + // when the command then fails, the selection (and the current layer selecting them made + // current) goes back to how it was, as when the ids are refused. let select_first = SELECTS_GIVEN_IDS.contains(&id).then(|| params.get("ids")).flatten(); + let selected_before = select_first.and(self.active()).map(|d| (d.uid, d.selection.clone(), d.active_layer)); let run = |s: &mut Session| { if let Some(ids) = select_first { s.execute("select.set", &serde_json::json!({ "ids": ids }))?; } - (spec.run)(s, params) + let r = (spec.run)(s, params); + if r.is_err() + && let Some((uid, selection, layer)) = selected_before + && let Some(st) = s.docs.iter_mut().find(|d| d.uid == uid) + { + st.selection = selection; + st.active_layer = layer; + st.revision += 1; + } + r }; + // The entries the command journals belong to the undo groups of the documents it edits, and to + // the group of the document it starts in when it ends there too. + let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); let r = if self.depth == 0 { self.run_guarded(id, run) } else { self.run_nested(run) }; let r = r?; if self.depth == 0 { @@ -1604,6 +1776,7 @@ impl Session { self.journal.push((id.to_string(), p)); } if self.depth == 0 { + self.journal_in_undo_groups(doc, &grouped, journal); self.after_command(); } Ok(r) @@ -1676,7 +1849,7 @@ impl Session { fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone())); let active = self.active; - match guard::catch_panic(|| self.run_nested(f)) { + let r = match guard::catch_panic(|| self.run_nested(f)) { Ok(r) => r, Err(msg) => { self.depth = 0; @@ -1687,6 +1860,7 @@ impl Session { st.doc = doc; st.selection = selection; st.interaction = interaction; + cmd::globaledit::follow_history(st); st.revision += 1; } if active.is_some_and(|i| i < self.docs.len()) { @@ -1694,7 +1868,13 @@ impl Session { } Err(EngineError::Internal { cmd: id.to_string(), msg }) } + }; + // Undo, Redo and a failed batch bring back documents with their selections: each document + // gets its selection's new art on a locked artboard. + for st in &mut self.docs { + st.sync_new_art(); } + r } /// Run `f` on a mutable copy of the active document. Outside an interaction this records one @@ -1710,8 +1890,14 @@ impl Session { let before = st.doc.clone(); let before_sel = st.selection.clone(); let doc = Arc::make_mut(&mut st.doc); + // The Global Edit members the edit reached besides the selection. + let mut spread = vec![]; // Objects attached to a path follow it, and dimensions follow the art they measure. let result = match f(doc, &mut st.selection).and_then(|v| { + // Global Edit: the other members take what the edit changed about the one it edited. + if let Some(ge) = &st.global_edit { + spread = cmd::globaledit::propagate(&before, doc, ge, [&before_sel.objects, &st.selection.objects]); + } cmd::objects_on_path::refresh(&before, doc); cmd::dimension::refresh(&before, doc)?; Ok(v) @@ -1744,7 +1930,7 @@ impl Session { if st.doc.too_deep(Some(&before)) { // Deeper trees overflow the stack of what walks them. Err(EngineError::Other(TOO_DEEP.into())) - } else if doc_sane(&st.doc, &st.selection) { + } else if doc_sane(&st.doc, &st.selection) && objects_in_range(&st.doc, &spread) { Ok(v) } else { Err(EngineError::Other("result would exceed the canvas (coordinates out of range)".into())) @@ -1760,6 +1946,9 @@ impl Session { if guides_locked { deselect_guides(&st.doc, &mut st.selection); } + // The art on a locked artboard can't stay selected, however it got there, except + // the new art this edit made and new art still selected. + st.protect_locked_artboards(&before_sel.objects, &before_sel.new_art, Some(&*before)); st.revision += 1; if st.interaction.is_none() { let selection = match st.history.undo.last() { @@ -1773,6 +1962,7 @@ impl Session { Err(e) => { st.doc = before; st.selection = before_sel; + cmd::globaledit::follow_history(st); Err(e) } } @@ -1811,12 +2001,16 @@ impl Session { let guides_locked = self.menu.guides_locked; let st = self.doc_mut()?; let before = st.selection.objects.clone(); + let new_art = st.selection.new_art.clone(); f(&st.doc, &mut st.selection); st.selection.prune(&st.doc); // Lock Guides locks the paths made into guides too (#1534), whatever selects them. if guides_locked { deselect_guides(&st.doc, &mut st.selection); } + // The art on a locked artboard can't be selected, whatever selects it, except new art + // that was selected and still is. + st.protect_locked_artboards(&before, &new_art, None); // Selecting art makes its layer (or sublayer) the current one, as in the Layers panel of // the reference app: new art then goes beside it. if st.selection.objects != before @@ -1836,12 +2030,23 @@ impl Session { // ---------- interactions (live drags) ---------- + pub(crate) fn clear_deferred_overlay_cache(&mut self) { + self.deferred_overlay_cache = None; + } + + /// The interaction's latest preview command when the document is still its snapshot (deferred + /// Real-time Drawing), regardless of the preference at commit or overlay time. + pub(crate) fn unapplied_interaction_preview(&self) -> Option<(String, Value)> { + self.active().and_then(|d| d.interaction.as_ref().filter(|it| std::sync::Arc::ptr_eq(&it.doc, &d.doc)).and_then(|it| it.preview.clone())) + } + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { // Typing in progress is kept first: a drag or a dialog's preview would otherwise start // from the snapshot taken before the typing, and drop it (#1881). if label != "Typing" && cmd::typing_in_progress(self) { cmd::settle_interaction(self)?; } + self.clear_deferred_overlay_cache(); let st = self.doc_mut()?; if st.interaction.is_some() { return Ok(()); @@ -1876,10 +2081,11 @@ impl Session { if selection_changed { st.selection.clone_from(&it.selection); } + cmd::globaledit::follow_history(st); } // The previous preview's paint is undone with it. self.pending_paint = None; - let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params); + let rw = cmd::distortcmds::perspective_rewrite(self, cmd, params).or_else(|| cmd::bounds_rewrite(self, cmd, params)); let (cmd, params) = rw.as_ref().map_or((cmd, params), |(c, p)| (c.as_str(), p)); let outer = std::mem::replace(&mut self.previewing, true); let r = self.execute(cmd, params); @@ -1895,6 +2101,7 @@ impl Session { } pub fn commit_interaction(&mut self) -> Result<()> { + self.clear_deferred_overlay_cache(); self.remember_pending_paint(); let st = self.doc_mut()?; let Some(mut it) = st.interaction.take() else { return Ok(()) }; @@ -1933,7 +2140,9 @@ impl Session { if perspective_again.is_some() { st.last_perspective = perspective_again; } + let (doc, journal, grouped) = (self.active().map(|d| d.uid), self.journal.len(), self.undo_group_docs()); self.journal.push(preview); + self.journal_in_undo_groups(doc, &grouped, journal); Ok(()) } @@ -1945,6 +2154,7 @@ impl Session { } pub fn cancel_interaction(&mut self) -> Result<()> { + self.clear_deferred_overlay_cache(); self.pending_paint = None; let guides_locked = self.menu.guides_locked; let st = self.doc_mut()?; @@ -1972,18 +2182,48 @@ impl Session { /// [`Session::begin_undo_group`] with the undo step named `label` (else after its first edit). pub fn begin_named_undo_group(&mut self, label: Option) { - let journal = self.journal.len(); if let Some(st) = self.active_mut() && st.undo_group.is_none() { - st.undo_group = Some(UndoGroup { first: None, journal, label }); + st.undo_group = Some(UndoGroup { first: None, journal: vec![], label }); + } + } + + /// The documents with an undo group open ([`DocState::uid`]), each with the document it holds. + fn undo_group_docs(&self) -> Vec<(u64, *const Document)> { + self.docs.iter().filter(|st| st.undo_group.is_some()).map(|st| (st.uid, Arc::as_ptr(&st.doc))).collect() + } + + /// Note the journal entries from number `from` on in the undo groups they belong to: the group of + /// each document in `grouped` ([`Session::undo_group_docs`] before the entries) that they changed, + /// each group they opened and edited, and the group of the document with [`DocState::uid`] `doc` + /// when they started in it and it is still the active one. A cancel drops them. The last entry of a command that changed the + /// active document keeps the index of the document it left active. + fn journal_in_undo_groups(&mut self, doc: Option, grouped: &[(u64, *const Document)], from: usize) { + let to = self.journal.len(); + let active = self.active().map(|d| d.uid); + let switched = if active == doc { None } else { self.active }; + for st in &mut self.docs { + let Some(g) = &mut st.undo_group else { continue }; + let started = doc == Some(st.uid) && active == doc; + // A group the entries opened has an edit of theirs once it has a first edit. + let edited = match grouped.iter().find(|&&(uid, _)| uid == st.uid) { + Some(&(_, d)) => !std::ptr::eq(d, Arc::as_ptr(&st.doc)), + None => g.first.is_some(), + }; + if started || edited { + // A committed interaction inside the command noted its entry already. + let noted = g.journal.partition_point(|&(n, _)| n < from); + g.journal.truncate(noted); + g.journal.extend((from..to).map(|n| (n, switched.filter(|_| n + 1 == to)))); + } } } /// Close the open undo groups: their edits stay one undo step or, `cancel`led (Escape), are /// undone and dropped from the journal. pub fn end_undo_group(&mut self, cancel: bool) { - let mut journal = None; + let mut journal: Vec<(usize, Option)> = vec![]; for st in &mut self.docs { let Some(g) = st.undo_group.take() else { continue }; // Only while the group's step is still the newest one. @@ -1997,13 +2237,92 @@ impl Session { if self.menu.guides_locked { deselect_guides(&st.doc, &mut st.selection); } + st.sync_new_art(); + cmd::globaledit::follow_history(st); st.revision += 1; - journal = Some(g.journal); + // The journal drops only the canceled groups' own entries and keeps the ones made in other documents meanwhile. + journal.extend(g.journal); } } - if let Some(len) = journal { - self.journal.truncate(len); + // A batch that edited two of the documents is in both groups. + journal.sort_unstable(); + journal.dedup_by_key(|&mut (n, _)| n); + // An entry that changed the active document becomes that change, so the entries after it run + // in the same document when the journal is replayed. + for &(n, index) in &journal { + if let Some(index) = index { + self.journal.replace(n, ("document.activate".to_string(), serde_json::json!({ "index": index }))); + } } + let gone: Vec = journal.into_iter().filter(|(_, index)| index.is_none()).map(|(n, _)| n).collect(); + self.journal.remove(&gone); + } + + /// [`Session::end_undo_group`] for the open document with [`DocState::uid`] `uid` only (it may + /// have closed since): its open undo group closes, and undo groups open in other documents stay + /// open. + fn end_document_undo_group(&mut self, uid: u64, cancel: bool) { + // The other documents' groups are set aside while `end_undo_group` closes this one's. + let others: Vec<(u64, UndoGroup)> = + self.docs.iter_mut().filter(|st| st.uid != uid).filter_map(|st| Some((st.uid, st.undo_group.take()?))).collect(); + self.end_undo_group(cancel); + for (other, g) in others { + if let Some(st) = self.document_mut(other) { + st.undo_group = Some(g); + } + } + } + + /// Make the edits that follow in the active document one undo step, until + /// [`Session::end_undo_scope`]: open an undo group there or, when one is open already, join it, + /// so that the edits are part of its step. `None` without a document. + pub fn begin_undo_scope(&mut self) -> Option { + let journal = self.journal.len(); + let st = self.active()?; + let uid = st.uid; + let joined = st.undo_group.as_ref().map(|g| (st.doc.clone(), st.selection.clone(), g.first.clone())); + if joined.is_none() { + self.begin_undo_group(); + } + Some(UndoScope { uid, journal, joined }) + } + + /// End `scope` in its own document, whichever document is active now; undo groups open in other + /// documents stay open. The undo group the scope opened closes as [`Session::end_undo_group`] + /// closes it. A scope that joined an open group leaves the group open; with `cancel`, it puts the + /// document and its selection back as they were when the scope began and cuts the journal back to + /// where the scope began (only while the group's step is still the newest one), and an undo step + /// that the scope's first edit recorded goes too. + pub fn end_undo_scope(&mut self, scope: UndoScope, cancel: bool) { + let Some((doc, selection, first)) = scope.joined else { + self.end_document_undo_group(scope.uid, cancel); + return; + }; + let guides_locked = self.menu.guides_locked; + let Some(st) = self.document_mut(scope.uid) else { return }; + let Some(g) = &mut st.undo_group else { return }; + let newest = g.first.as_ref().is_some_and(|f| st.history.undo.last().is_some_and(|e| Arc::ptr_eq(&e.doc, f))); + if !cancel || !newest { + return; + } + match &first { + // The scope's first edit recorded the group's step. + None => { + st.history.undo.pop(); + g.first = None; + } + Some(f) if g.first.as_ref().is_some_and(|now| Arc::ptr_eq(now, f)) => {} + Some(_) => return, + } + st.doc = doc; + st.selection = selection; + cmd::globaledit::follow_history(st); + if guides_locked { + deselect_guides(&st.doc, &mut st.selection); + } + st.history.recost_last(&st.doc); + st.revision += 1; + self.journal.truncate(scope.journal); } /// Commands with enablement (for menus, palette, MCP `list_commands`). @@ -2037,6 +2356,15 @@ fn refresh_tree_text_bounds(n: &mut Arc) { } } +/// Held by the tests that add fonts to the shared [`vectorcraft_text::FontDb::global`] for as long +/// as they run: adding a font clears the database's caches, which would change how another such +/// test's type resolves between its layout and its export. +#[cfg(test)] +pub(crate) fn adding_fonts() -> std::sync::MutexGuard<'static, ()> { + static FONTS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + FONTS.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests; #[cfg(test)] @@ -2086,6 +2414,14 @@ mod tests_colormgmt; #[cfg(test)] mod tests_colorthemes; #[cfg(test)] +mod tests_colourfonts; +#[cfg(test)] +mod tests_compoundpaint; +#[cfg(test)] +mod tests_constrain_new; +#[cfg(test)] +mod tests_constrain_tools; +#[cfg(test)] mod tests_containers; #[cfg(test)] mod tests_css; @@ -2134,6 +2470,8 @@ mod tests_fontlist; #[cfg(test)] mod tests_freeform; #[cfg(test)] +mod tests_globaledit; +#[cfg(test)] mod tests_gradient; #[cfg(test)] mod tests_gradpanel; @@ -2294,6 +2632,8 @@ mod tests_shaper; #[cfg(test)] mod tests_shortcutset; #[cfg(test)] +mod tests_simplify_part; +#[cfg(test)] mod tests_slices; #[cfg(test)] mod tests_smartguides; @@ -2322,6 +2662,8 @@ mod tests_swatchlib; #[cfg(test)] mod tests_symbolbounds; #[cfg(test)] +mod tests_symbollib; +#[cfg(test)] mod tests_targeting; #[cfg(test)] mod tests_textcombos; @@ -2336,8 +2678,12 @@ mod tests_tints; #[cfg(test)] mod tests_toolsettings; #[cfg(test)] +mod tests_transformfx; +#[cfg(test)] mod tests_transparencygrid; #[cfg(test)] +mod tests_trap; +#[cfg(test)] mod tests_typearea; #[cfg(test)] mod tests_typescale; @@ -2382,6 +2728,7 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[ "object.pathfinder.minusBack", "object.pathfinder.minusFront", "object.pathfinder.outline", + "object.pathfinder.trap", "object.pathfinder.trim", "object.pathfinder.unite", "object.reflect", diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 7e3a860a3..0e85391fc 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -204,6 +204,7 @@ impl Session { } /// Write the profile `name` (its file's `bytes`) to the store; `false` when it didn't go there. + #[cfg(not(target_arch = "wasm32"))] pub(crate) fn store_profile(&self, name: &str, bytes: &[u8]) -> bool { self.profile_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PROFILE_EXT}", file_stem(name)), bytes)) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ec05b5534..f0c081ef6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -500,6 +500,60 @@ fn align_without_a_direction_is_refused() { assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds().unwrap().x0, 50.0, "nothing moved"); } +/// Align, Distribute and Distribute Spacing refuse a `patterns` given as text or as another value +/// that is not true or false, naming the key: nothing moves and no undo step is recorded. True, +/// false and null still work. +#[test] +fn align_and_distribute_refuse_a_boolean_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0); + let c = rect(&mut s, 70.0, 60.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let state = |s: &Session| { + let d = &s.doc().unwrap().doc; + (history(s), [b, c].map(|id| d.node(id).unwrap().geometric_bounds().unwrap().x0)) + }; + let before = state(&s); + let calls = [ + ("object.align", json!({"horizontal": "left", "patterns": "true"})), + ("object.distribute", json!({"horizontal": "left", "patterns": "false"})), + ("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 5, "patterns": 1})), + ]; + for (cmd, p) in calls { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`patterns` must be true or false"), "{cmd} {p}: {r:?}"); + assert_eq!(state(&s), before, "{cmd} {p}"); + } + s.execute("object.align", &json!({"horizontal": "left", "patterns": null})).unwrap(); + s.execute("object.distribute", &json!({"vertical": "top", "patterns": false})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": "vertical", "patterns": true})).unwrap(); + assert_eq!(state(&s).1, [0.0, 0.0]); +} + +/// Align, Distribute and Distribute Spacing to an artboard refuse an `artboard` given as text or as +/// another value that is not a number, naming the key: nothing moves and no undo step is recorded. +/// A null is read as the key left out. +#[test] +fn align_and_distribute_refuse_an_artboard_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 50.0, 30.0, 10.0, 10.0); + let c = rect(&mut s, 70.0, 60.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap(); + let calls = [ + ("object.align", json!({"horizontal": "right", "to": "artboard", "artboard": "0"})), + ("object.distribute", json!({"horizontal": "left", "to": "artboard", "artboard": [0]})), + ("object.distributeSpacing", json!({"axis": "horizontal", "to": "artboard", "artboard": true})), + ] + .map(|(cmd, p)| not_a_number(cmd, p, "artboard")); + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + s.execute("object.align", &json!({"horizontal": "right", "to": "artboard", "artboard": null})).unwrap(); + let right = |id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x1; + assert_eq!([right(a), right(b), right(c)], [s.doc().unwrap().doc.artboards[0].rect.x1; 3]); +} + /// Align, Distribute and Distribute Spacing return an error that lists the choices of a param /// given a value outside them: nothing moves and no undo step is recorded. A null value counts as /// not given, and Distribute Spacing without an `axis` spaces the objects horizontally. @@ -771,12 +825,58 @@ fn selection_tool_drag_is_one_undo_step() { assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap().x0, 100.0); } +/// The pointer button is held from a press or a drag until a release or a move. The Dimension +/// tool stays busy after its first click with the button released, waiting for the second one. +#[test] +fn the_pointer_is_held_from_a_press_until_a_release_or_a_move() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("dimension", v).unwrap(); + let mut held = vec![]; + for (kind, x) in [(PointerKind::Down, 100.0), (PointerKind::Drag, 100.0), (PointerKind::Up, 100.0)] { + s.pointer(&PointerEvent::new(kind, x, 100.0), v).unwrap(); + held.push(s.pointer_held()); + } + assert!(s.tool_busy(), "the first point waits for the second"); + for (kind, x) in [(PointerKind::Move, 200.0), (PointerKind::Down, 300.0), (PointerKind::Drag, 310.0), (PointerKind::Move, 320.0)] { + s.pointer(&PointerEvent::new(kind, x, 100.0), v).unwrap(); + held.push(s.pointer_held()); + } + assert_eq!(held, [true, true, false, false, true, true, false]); +} + /// How many steps there are to undo and to redo, and whether the document has unsaved changes. pub(crate) fn history(s: &Session) -> (usize, usize, bool) { let st = s.doc().unwrap(); (st.history.undo.len(), st.history.redo.len(), st.is_dirty()) } +/// A call to `cmd` with `p` that must fail with "`key` must be true or false". +pub(crate) fn not_a_switch(cmd: &str, p: serde_json::Value, key: &str) -> (String, serde_json::Value, String) { + (cmd.to_string(), p, format!("`{key}` must be true or false")) +} + +/// A call to `cmd` with `p` that must fail with "`key` must be a number, not ", the value +/// `p` gives for `key`. +pub(crate) fn not_a_number(cmd: &str, p: serde_json::Value, key: &str) -> (String, serde_json::Value, String) { + let msg = format!("`{key}` must be a number, not {}", p[key]); + (cmd.to_string(), p, msg) +} + +/// The calls among `calls` (a command, its params and the error it must give) that do not fail +/// with that error, or that change the document or its history, as "command params". +pub(crate) fn not_refused(s: &mut Session, calls: &[(String, serde_json::Value, String)]) -> Vec { + calls + .iter() + .filter(|(cmd, p, want)| { + let (before, h) = (s.doc().unwrap().doc.clone(), history(s)); + let r = s.execute(cmd, p); + !(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want) && s.doc().unwrap().doc == before && history(s) == h) + }) + .map(|(cmd, p, _)| format!("{cmd} {p}")) + .collect() +} + /// Select `ids` with a move of them left to redo, and mark the document saved. pub(crate) fn select_with_a_step_to_redo(s: &mut Session, ids: &[NodeId]) { s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); @@ -1058,6 +1158,142 @@ fn a_string_for_an_optional_transform_number_is_an_error() { assert!((bounds(&s).width() - 60.0).abs() < 1e-9); } +/// A transform option given as text or as another value that is not true or false (`"false"`, 1) +/// is an error naming the key, with Scale Strokes & Effects on. The object, its stroke and its undo +/// history stay as they were, and no copy is made. False and null still work: `object.scale` with +/// `strokes: false` scales the object and leaves its stroke width as it is. +#[test] +fn a_boolean_given_as_text_for_a_transform_is_an_error() { + let mut s = session(); + s.execute("prefs.set", &json!({"key": "scaleStrokes", "value": true})).unwrap(); + let a = rect(&mut s, 10.0, 10.0, 50.0, 40.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 5})).unwrap(); + let bounds = |s: &Session| s.doc().unwrap().doc.bounds_of(&[a], false).unwrap(); + let state = |s: &Session| { + let d = &s.doc().unwrap().doc; + (history(s), bounds(s), d.node(a).unwrap().appearance.stroke_width(), d.layers[0].children().unwrap().len()) + }; + let before = state(&s); + let calls = [ + ("object.transform", json!({"matrix": [2, 0, 0, 2, 0, 0], "strokes": "false"}), "strokes"), + ("object.transform", json!({"matrix": [1, 0, 0, 1, 5, 0], "anchors": "true"}), "anchors"), + ("object.move", json!({"dx": 5, "copy": "true"}), "copy"), + ("object.move", json!({"dx": 5, "objects": 0}), "objects"), + ("object.nudge", json!({"dx": 1, "big": "true"}), "big"), + ("object.rotate", json!({"angle": 45, "absolute": "true"}), "absolute"), + ("object.scale", json!({"sx": 200, "strokes": "false"}), "strokes"), + ("object.scale", json!({"sx": 200, "corners": [true]}), "corners"), + ("object.reflect", json!({"patterns": "true"}), "patterns"), + ("object.shear", json!({"angle": 10, "typeAreas": 1}), "typeAreas"), + ("object.transformAgain", json!({"copy": "true"}), "copy"), + ("object.setBounds", json!({"width": 100, "proportional": "true"}), "proportional"), + ("object.setBounds", json!({"width": 100, "copy": "false"}), "copy"), + ]; + for (cmd, p, key) in calls { + let r = s.execute(cmd, &p); + let want = format!("`{key}` must be true or false"); + assert!(matches!(&r, Err(EngineError::BadParams { cmd: c, msg }) if c == cmd && *msg == want), "{cmd} {p}: {r:?}"); + assert_eq!(state(&s), before, "{cmd} {p}"); + } + s.execute("object.scale", &json!({"sx": 200, "strokes": false, "corners": null, "copy": false})).unwrap(); + let (b, w) = (bounds(&s), s.doc().unwrap().doc.node(a).unwrap().appearance.stroke_width()); + assert_eq!((b.width(), w), (100.0, before.2)); +} + +/// Object Properties' opacity, Live Shape Properties' corner radius and polygon sides, and the +/// Transform panel's reference point given as text or as another value that is not a number are an +/// error naming the key, and so is Live Shape Properties' relative rounding given as anything but +/// true or false; the objects and the undo history stay as they were. Numbers and null still work. +#[test] +fn object_properties_live_shapes_and_bounds_refuse_a_number_or_boolean_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let poly = NodeId(s.execute("shape.polygon", &json!({"cx": 100, "cy": 100, "radius": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let mut calls = [ + ("object.setProps", json!({"id": a.0, "opacity": "50"}), "opacity"), + ("object.setLiveShape", json!({"ids": [a.0], "radius": "5"}), "radius"), + ("object.setLiveShape", json!({"ids": [poly.0], "sides": "8"}), "sides"), + ("object.setBounds", json!({"width": 40, "reference": "0"}), "reference"), + ] + .map(|(cmd, p, key)| not_a_number(cmd, p, key)) + .to_vec(); + calls.push(not_a_switch("object.setLiveShape", json!({"ids": [a.0], "radius": 5, "relativeRadii": "true"}), "relativeRadii")); + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + s.execute("object.setProps", &json!({"id": a.0, "opacity": 50})).unwrap(); + s.execute("object.setBounds", &json!({"width": 40, "reference": 0, "x": null})).unwrap(); + let n = s.doc().unwrap().doc.node(a).unwrap().clone(); + assert_eq!((n.opacity, n.geometric_bounds().unwrap()), (0.5, vectorcraft_geom::Rect::new(0.0, 0.0, 40.0, 20.0))); +} + +/// The shape tools' commands and the Pen's `path.create` refuse a number or an on/off option given +/// as text or as another value of the wrong kind, naming the key, and draw nothing; null is read as +/// the key left out. +#[test] +fn shape_commands_refuse_a_number_or_boolean_given_as_text() { + let mut s = session(); + let star = json!({"cx": 100, "cy": 100, "radius1": 20}); + let flare = json!({"cx": 100, "cy": 100}); + let spiral = json!({"cx": 100, "cy": 100, "radius": 40}); + let with = |base: &serde_json::Value, key: &str, v: serde_json::Value| { + let mut p = base.clone(); + p[key] = v; + p + }; + let mut calls: Vec<_> = [ + ("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "radius": "2"}), "radius"), + ("shape.polygon", json!({"cx": 100, "cy": 100, "radius": 20, "rotation": "45"}), "rotation"), + ("shape.polygon", json!({"cx": 100, "cy": 100, "radius": 20, "sides": "8"}), "sides"), + ("shape.star", with(&star, "radius2", json!("10")), "radius2"), + ("shape.star", with(&star, "rotation", json!([45])), "rotation"), + ("shape.star", with(&star, "points", json!("7")), "points"), + ("shape.flare", with(&flare, "diameter", json!("100")), "diameter"), + ("shape.flare", with(&flare, "opacity", json!("50")), "opacity"), + ("shape.flare", with(&flare, "largest", json!(true)), "largest"), + ("shape.flare", with(&flare, "rays", json!("15")), "rays"), + ("shape.flare", json!({"cx": 100, "cy": 100, "x2": "300", "y2": 300}), "x2"), + ("shape.flare", with(&flare, "direction", json!("45")), "direction"), + ("shape.flare", with(&flare, "seed", json!("7")), "seed"), + ("shape.spiral", with(&spiral, "decay", json!("80")), "decay"), + ("shape.spiral", with(&spiral, "segments", json!("10")), "segments"), + ] + .into_iter() + .map(|(cmd, p, key)| not_a_number(cmd, p, key)) + .collect(); + calls.extend( + [ + ("shape.spiral", with(&spiral, "clockwise", json!("false")), "clockwise"), + ("shape.arc", json!({"x1": 0, "y1": 0, "x2": 50, "y2": 50, "closed": "true"}), "closed"), + ("path.create", json!({"anchors": [{"x": 0, "y": 0}, {"x": 50, "y": 0}], "closed": 1}), "closed"), + ] + .map(|(cmd, p, key)| not_a_switch(cmd, p, key)), + ); + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + s.execute("shape.star", &json!({"cx": 100, "cy": 100, "radius1": 20, "radius2": null, "points": null, "rotation": null})).unwrap(); + s.execute("shape.flare", &json!({"cx": 100, "cy": 100, "diameter": 50, "x2": null, "seed": 7})).unwrap(); + s.execute("shape.arc", &json!({"x1": 0, "y1": 0, "x2": 50, "y2": 50, "closed": false})).unwrap(); +} + +/// `path.create` given path data (`d`) refuses a `closed` other than true, false or null, as it does +/// with `anchors`, and draws nothing; with true, false or null it draws the path data, which alone +/// decides whether the path is closed. +#[test] +fn path_create_with_path_data_refuses_closed_given_as_text() { + let mut s = session(); + let d = "M0 0 L10 0 L10 10"; + let calls = [json!("true"), json!(1), json!([true])].map(|v| not_a_switch("path.create", json!({"d": d, "closed": v}), "closed")); + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + for closed in [json!(true), json!(false), json!(null)] { + let id = NodeId(s.execute("path.create", &json!({"d": d, "closed": closed})).unwrap()["id"].as_u64().unwrap()); + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") }; + assert_eq!((path.subpaths.len(), path.anchor_count(), path.is_closed()), (1, 3, false), "closed: {closed}"); + } +} + /// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H /// (`object.setBounds`) are the box around those anchors, and only they move or scale. #[test] @@ -1084,6 +1320,45 @@ fn set_bounds_acts_on_direct_selected_anchors() { assert_eq!(points(&s), [(10.0, 0.0), (140.0, 20.0), (60.0, 100.0), (10.0, 100.0)]); } +/// `object.setBounds` reads a `reference` past 8 as 8, the bottom-right point, as +/// `object.transformEach` does, for a path selected whole and for direct-selected anchors. Before, +/// 9 read as the bottom-left point, 2^32 as the bottom center, and a 32-bit build read 2^32 as the +/// top-left point. +#[test] +fn set_bounds_reads_a_reference_past_8_as_8() { + for reference in [9, 1u64 << 32] { + for anchors in [false, true] { + let mut s = session(); + let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true}); + let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap()); + if anchors { + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1], [0, 2], [0, 3]]})).unwrap(); + } else { + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + } + s.execute("object.setBounds", &json!({"width": 50, "height": 50, "reference": reference})).unwrap(); + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") }; + let points: Vec<(f64, f64)> = path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect(); + assert_eq!(points, [(50.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0)], "reference {reference}, anchors {anchors}"); + } + } +} + +/// `cmd::index` keeps a number that fits in a `usize` and reads a larger one (2^32 or more on a +/// 32-bit build) as `usize::MAX`. +#[test] +fn index_reads_a_number_past_usize_max_as_usize_max() { + let past_u32 = u64::from(u32::MAX) + 1; + #[cfg(target_pointer_width = "64")] + let expected = 1usize << 32; + #[cfg(not(target_pointer_width = "64"))] + let expected = usize::MAX; + assert_eq!(cmd::index(0), 0); + assert_eq!(cmd::index(7), 7); + assert_eq!(cmd::index(past_u32), expected); + assert_eq!(cmd::index(u64::MAX), usize::MAX); +} + /// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and /// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to /// Current Layer on objects already on top of the current layer. @@ -1249,6 +1524,33 @@ fn object_properties_already_set_record_no_undo_step() { assert!(s.execute("object.setProps", &json!({"ids": [999999], "visible": true})).is_err()); } +/// Object Properties refuses `visible`, `locked`, `isolate` or `knockoutShape` given as text or as +/// another value that is not true or false, naming the key, and leaves the object and the undo +/// history as they were. True and false still set the value, and null leaves it as it is. +#[test] +fn object_properties_refuse_a_boolean_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let props = |s: &Session| { + let n = s.doc().unwrap().doc.node(a).unwrap().clone(); + (n.visible, n.locked, n.isolate, n.knockout_shape) + }; + let before = (history(&s), props(&s)); + for (key, p) in [ + ("visible", json!({"id": a.0, "visible": "false"})), + ("locked", json!({"id": a.0, "locked": "true"})), + ("isolate", json!({"id": a.0, "opacity": 50, "isolate": 1})), + ("knockoutShape", json!({"id": a.0, "knockoutShape": [true]})), + ] { + let r = s.execute("object.setProps", &p); + let want = format!("`{key}` must be true or false"); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{p}: {r:?}"); + assert_eq!((history(&s), props(&s)), before, "{p}"); + } + s.execute("object.setProps", &json!({"id": a.0, "visible": false, "locked": null, "isolate": true, "knockoutShape": null})).unwrap(); + assert_eq!(props(&s), (false, false, true, false)); +} + /// Live Shape Properties that a shape already has record no undo step: a polygon's sides, angle, /// radius, side length and equal sides, an ellipse's pie, Invert Pie on a whole ellipse, and the /// radius and kind its corners have (given corners, the Direct-Selected one, or all). A value that @@ -1301,7 +1603,7 @@ fn live_shape_properties_already_set_record_no_undo_step() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(r).unwrap().kind else { panic!("not a live rectangle") }; let xf = Affine::scale_non_uniform(2.0, 1.0); - **live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = vectorcraft_doc::LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), radii_relative: false, xf }; *path = live.to_path(); } let shown = vectorcraft_doc::LiveCorners::of(s.doc().unwrap().doc.node(r).unwrap()).unwrap().radius(0); @@ -1784,8 +2086,8 @@ fn bounding_box_handles_snap_to_the_grid() { } } -/// A Shift-constrained move with Snap to Grid on stays level: the square's center lands on a -/// gridline across, and its top stays where it was, off the grid. +/// A Shift-constrained move with Snap to Grid on stays level: the square's left or right edge +/// lands on a gridline across, and its top stays where it was, off the grid. #[test] fn a_shift_move_snapped_to_the_grid_stays_on_its_line() { let mut s = session(); @@ -1799,7 +2101,7 @@ fn a_shift_move_snapped_to_the_grid_stays_on_its_line() { s.pointer(&PointerEvent::new(kind, x, y).with_mods(shift), v).unwrap(); } let b = s.doc().unwrap().doc.node(a).unwrap().geometric_bounds().unwrap(); - assert!(on_grid(b.center().x) && (b.y0 - 101.0).abs() < 1e-9, "{b:?}, grid step {step}"); + assert!((on_grid(b.x0) || on_grid(b.x1)) && (b.y0 - 101.0).abs() < 1e-9, "{b:?}, grid step {step}"); } /// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all @@ -2035,6 +2337,88 @@ fn isolating_an_object_that_doesnt_exist_says_so() { assert!(!e.to_string().contains("type"), "{e}"); } +/// `object.isolate` refuses a locked or hidden group, a group in a locked or hidden layer, a locked +/// or hidden member of a group, and a guide path while Lock Guides is on. Isolation mode stays off, +/// and a Selection tool drag over the art does what it does without the call. A group that is neither is isolated, and the drag moves its +/// member. +#[test] +fn isolate_refuses_locked_or_hidden_art_and_locked_guides() { + const HIDDEN_OR_LOCKED: &str = "invalid parameters for `object.isolate`: the object is hidden or locked"; + let x0 = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + // A drag 30 pt to the right from the left edge of `a`. + let drag = |s: &mut Session| { + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 101.0, 115.0), v).unwrap(); + for x in [111.0, 121.0, 131.0] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, 115.0), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 131.0, 115.0), v).unwrap(); + }; + // A group of `a` and another rectangle, on the only layer. + let group = |s: &mut Session| { + let a = rect(s, 100.0, 100.0, 30.0, 30.0); + let b = rect(s, 200.0, 100.0, 30.0, 30.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + (NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()), a) + }; + // Each case locks, hides or guides something, given the layer, the group and `a`, and returns + // the id to isolate. + type Case = (&'static str, fn(&mut Session, NodeId, NodeId, NodeId) -> NodeId, &'static str); + let cases: [Case; 7] = [ + ("locked group", |s, _, g, _| set_then(s, "object.setProps", g, "locked", true, g), HIDDEN_OR_LOCKED), + ("hidden group", |s, _, g, _| set_then(s, "object.setProps", g, "visible", false, g), HIDDEN_OR_LOCKED), + ("locked layer", |s, l, g, _| set_then(s, "layer.setProps", l, "locked", true, g), HIDDEN_OR_LOCKED), + ("hidden layer", |s, l, g, _| set_then(s, "layer.setProps", l, "visible", false, g), HIDDEN_OR_LOCKED), + ("locked member", |s, _, _, a| set_then(s, "object.setProps", a, "locked", true, a), HIDDEN_OR_LOCKED), + ("hidden member", |s, _, _, a| set_then(s, "object.setProps", a, "visible", false, a), HIDDEN_OR_LOCKED), + ( + "guide path", + |s, _, _, a| { + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("view.guides.make", &json!({})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + a + }, + "guides are locked", + ), + ]; + // Each case runs with and without `object.isolate` before the drag. Every case runs, and the + // wrong ones are listed: (case, what isolating gave, the isolated id, where the drag left `a`, + // where it leaves `a` with no isolating). + let mut wrong = vec![]; + for (name, make, error) in cases { + let run = |isolate: bool| { + let mut s = session(); + let (g, a) = group(&mut s); + let layer = s.doc().unwrap().doc.layers[0].id; + let target = make(&mut s, layer, g, a); + let r = isolate.then(|| s.execute("object.isolate", &json!({"id": target.0})).map_err(|e| e.to_string())); + let isolation = s.doc().unwrap().isolation; + drag(&mut s); + (r, isolation, x0(&s, a)) + }; + let ((r, isolation, x), (_, _, without)) = (run(true), run(false)); + if (&r, isolation, x) != (&Some(Err(error.to_string())), None, without) { + wrong.push((name, r, isolation, x, without)); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); + // Neither locked nor hidden: isolated, and the drag moves the member. + let mut s = session(); + let (g, a) = group(&mut s); + s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(g)); + drag(&mut s); + assert_eq!(x0(&s, a), 130.0, "the member moved"); +} + +/// Sets `key` of `id` to `on` with `cmd`, and gives back `then`. +fn set_then(s: &mut Session, cmd: &str, id: NodeId, key: &str, on: bool, then: NodeId) -> NodeId { + s.execute(cmd, &json!({"ids": [id.0], key: on})).unwrap(); + then +} + /// A ring (a compound path with a hole) and its isolated paths. fn isolated_ring(s: &mut Session) -> (NodeId, NodeId, NodeId) { let outer = rect(s, 0.0, 0.0, 40.0, 40.0); @@ -2475,6 +2859,32 @@ fn object_mosaic_refuses_a_size_or_spacing_given_as_text() { assert_eq!(r["tiles"], json!(2)); } +/// Gray or Delete Raster given as text or as another value that is not true or false is an error naming the key. No +/// mosaic is made and the image stays. True and null still work. +#[test] +fn object_mosaic_refuses_a_boolean_given_as_text() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 100.0, 50.0); + s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let images = |s: &Session| { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().filter(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))).count() + }; + let before = (undo(&s), images(&s)); + for (p, msg) in [ + (json!({"gray": "true"}), "`gray` must be true or false"), + (json!({"columns": 2, "gray": 1}), "`gray` must be true or false"), + (json!({"deleteRaster": "false"}), "`deleteRaster` must be true or false"), + (json!({"deleteRaster": [true]}), "`deleteRaster` must be true or false"), + ] { + let r = s.execute("object.createObjectMosaic", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}"); + assert_eq!((undo(&s), images(&s)), before, "{p}"); + } + let r = s.execute("object.createObjectMosaic", &json!({"columns": 2, "rows": 1, "gray": true, "deleteRaster": null})).unwrap(); + assert_eq!((r["tiles"].clone(), images(&s)), (json!(2), 1)); +} + #[test] fn snap_to_pixel_rounds_drawing_and_moves() { let mut s = session(); @@ -2755,7 +3165,7 @@ fn file_list_lists_a_folder_within_the_read_root() { json!([{"name": "a.pdf", "kind": "file", "size": 4}, {"name": "b.svg", "kind": "file", "size": 6}, {"name": "sub", "kind": "folder"}]) ); assert_eq!(r["truncated"], false); - let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap(); crate::file_access::confine(Some(&roots), || { assert!(s.execute("file.list", &json!({"path": path("in/sub")})).is_ok()); assert!(s.execute("file.list", &json!({"path": path("outside")})).is_err(), "outside the read root"); diff --git a/crates/engine/src/tests_adjust.rs b/crates/engine/src/tests_adjust.rs index 9649e12b3..fc444ba95 100644 --- a/crates/engine/src/tests_adjust.rs +++ b/crates/engine/src/tests_adjust.rs @@ -42,6 +42,7 @@ fn red_image(s: &mut Session) -> NodeId { xf: vectorcraft_geom::Affine::scale(10.0), link: None, placement: Default::default(), + pixels: None, }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); diff --git a/crates/engine/src/tests_appearance.rs b/crates/engine/src/tests_appearance.rs index bc2a05dcb..d2045f7e1 100644 --- a/crates/engine/src/tests_appearance.rs +++ b/crates/engine/src/tests_appearance.rs @@ -420,6 +420,32 @@ fn appearance_commands_that_change_nothing_record_no_undo_step() { } } +/// The Appearance panel's commands refuse a weight or opacity that is not a number and an on/off +/// option that is not true or false, given as text or as another value of the wrong kind; the +/// objects and the undo history stay as they were. Numbers, true, false and null still work. +#[test] +fn appearance_commands_refuse_a_number_or_boolean_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let mut s = session(); + let a = rect(&mut s, 50.0); + let b = rect(&mut s, 200.0); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let calls = [ + not_a_number("appearance.setItem", json!({"index": 1, "weight": "3"}), "weight"), + not_a_number("appearance.setItem", json!({"index": 0, "opacity": "50"}), "opacity"), + not_a_switch("appearance.setItem", json!({"index": 0, "visible": "false"}), "visible"), + not_a_switch("appearance.transfer", json!({"source": b.0, "target": a.0, "copy": "true"}), "copy"), + not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "reverse": "true"}), "reverse"), + not_a_switch("appearance.copyFrom", json!({"source": b.0, "ids": [a.0], "append": 1}), "append"), + ]; + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + run(&mut s, "appearance.setItem", json!({"index": 1, "weight": 3, "visible": null})); + run(&mut s, "appearance.transfer", json!({"source": b.0, "target": a.0, "copy": true})); + assert_eq!((stroke(&node(&s, a), 1).width, fill_paint(&node(&s, a), 0)), (stroke(&node(&s, b), 1).width, fill_paint(&node(&s, b), 0))); +} + #[test] fn graphic_style_apply_replaces_the_stack() { let mut s = session(); diff --git a/crates/engine/src/tests_attributes.rs b/crates/engine/src/tests_attributes.rs index 17bcb26f9..b354e9fdb 100644 --- a/crates/engine/src/tests_attributes.rs +++ b/crates/engine/src/tests_attributes.rs @@ -1,5 +1,6 @@ -//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`) -//! and the links SVG export writes for objects with a URL. +//! Window → Attributes (`attributes.set`, `attributes.info`, `path.setFillRule`, `path.reverse`), +//! the links SVG export writes for objects with a URL, and what objects keep for other software +//! (their `data-*` attributes and their attributes in other applications' namespaces). use serde_json::{Value, json}; use vectorcraft_doc::{ImageMap, NodeKind}; @@ -214,3 +215,33 @@ fn object_data_is_set_saved_and_written_to_svg() { let back = vectorcraft_format::load(&native).unwrap(); assert_eq!(back.node(a).unwrap().extras().attrs, s.doc().unwrap().doc.node(a).unwrap().extras().attrs); } + +/// An object's attributes in other applications' XML namespaces are its own, as its `data-*` +/// attributes are: a pasted copy and a duplicate keep them, deleting the object takes them with +/// it, and the native file and SVG export keep them. +#[test] +fn other_applications_attributes_go_with_their_object() { + let mut s = Session::new(); + let svg = r#" + "#; + run(&mut s, "document.open", json!({"name": "pens.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})); + // The `plotter:pen` values on the document's objects. + let pens = |d: &vectorcraft_doc::Document| { + let mut out = vec![]; + d.walk(|n| out.extend(n.extras().attrs.iter().flat_map(|a| a.foreign.iter().map(|f| f.value.clone())))); + out + }; + assert_eq!(pens(&s.doc().unwrap().doc), ["2"]); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + run(&mut s, "edit.paste", json!({})); + assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2"], "a pasted copy keeps them"); + run(&mut s, "edit.duplicate", json!({})); + assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2", "2"], "so does a duplicate"); + run(&mut s, "edit.clear", json!({})); + assert_eq!(pens(&s.doc().unwrap().doc), ["2", "2"], "a deleted object leaves none behind"); + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(pens(&back), ["2", "2"], "the native file keeps them"); + let out = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string(); + assert_eq!((out.matches("xmlns:plotter=\"https://example.org/plotter\"").count(), out.matches("plotter:pen=\"2\"").count()), (1, 2), "{out}"); +} diff --git a/crates/engine/src/tests_blendopts.rs b/crates/engine/src/tests_blendopts.rs index ab70333d2..4682ba1ea 100644 --- a/crates/engine/src/tests_blendopts.rs +++ b/crates/engine/src/tests_blendopts.rs @@ -153,3 +153,79 @@ fn start_points_round_trip_and_old_blends_load() { assert!(old.starts.is_empty() && old.start(0).is_none()); assert!(serde_json::to_value(&old).unwrap().get("starts").is_none()); } + +#[test] +fn easing_is_set_validated_saved_and_old_blends_are_even() { + let mut s = session(); + assert!(s.execute("object.blend.options", &json!({"ramp": 50})).is_err()); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeIn", "ramp": 40})).unwrap()); + assert_eq!(spec(&s, g).1.ease.ramp, 0.4); + sel(&mut s, &[g]); + for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] { + s.execute("object.blend.options", &json!({"easingType": kind, "colorShift": -50})).unwrap(); + let info = s.execute("object.blend.info", &json!({})).unwrap(); + assert_eq!((&info["easingType"], &info["ramp"], &info["colorShift"]), (&json!(kind), &json!(40.0), &json!(-50.0))); + } + let before = spec(&s, g).1; + let depth = s.doc().unwrap().history.undo.len(); + for bad in [json!({"ramp": 101}), json!({"ramp": -1}), json!({"colorShift": "x"}), json!({"colorShift": 1e308}), json!({"easingType": "unknown"})] + { + assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}"); + assert_eq!(spec(&s, g).1, before); + assert_eq!(s.doc().unwrap().history.undo.len(), depth); + } + let d = &s.doc().unwrap().doc; + let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap(); + assert_eq!(&back.layers, &d.layers); + s.execute("object.blend.options", &json!({"ramp": 0})).unwrap(); + let zero = spec(&s, g).1; + let back: BlendSpec = serde_json::from_value(serde_json::to_value(&zero).unwrap()).unwrap(); + assert_eq!(back.ease, zero.ease, "a chosen Type survives at Ramp 0"); + let old: BlendSpec = serde_json::from_value(json!({"spacing": {"steps": 4}})).unwrap(); + assert!(old.ease.is_linear() && old.color_shift == 0.0); + let v = serde_json::to_value(&old).unwrap(); + assert!(v.get("ease").is_none() && v.get("colorShift").is_none()); +} + +#[test] +fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "easingType": "easeOut", "ramp": 40, "colorShift": -50})).unwrap()); + let text = String::from_utf8(vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let blend_of = |doc: &vectorcraft_doc::Document| match doc.node(g).unwrap().kind.clone() { + NodeKind::Blend { spec, .. } => *spec, + _ => panic!("not a blend"), + }; + for (ramp, shift, want) in [("12.5", "-9000.0", (1.0, -100.0)), ("-3.0", "250.0", (0.0, 100.0)), ("null", "\"NaN\"", (0.0, 0.0))] { + let t = text.replacen("\"ramp\":0.4", &format!("\"ramp\":{ramp}"), 1).replacen("\"colorShift\":-50.0", &format!("\"colorShift\":{shift}"), 1); + assert!(t != text && t.contains("\"ramp\":") && t.contains(shift), "the saved blend carries ramp and colorShift"); + let doc = vectorcraft_format::load(t.as_bytes()).expect("one bad blend number must not fail the document"); + let spec = blend_of(&doc); + assert_eq!((spec.ease.ramp, spec.color_shift), want, "{ramp} / {shift}"); + } + // The commands still refuse the same values and leave the blend alone. + sel(&mut s, &[g]); + let before = spec(&s, g).1; + for bad in [json!({"ramp": 1250}), json!({"ramp": -3}), json!({"colorShift": 250}), json!({"colorShift": "NaN"}), json!({"colorShift": 1e308})] { + assert!(s.execute("object.blend.options", &bad).is_err(), "{bad}"); + assert_eq!(spec(&s, g).1, before); + } +} + +#[test] +fn native_blend_easing_values_clamp_and_non_finite_become_default() { + for (bad, ramp, shift) in [ + (json!({"ease": {"ramp": 1e308}}), 1.0, 0.0), + (json!({"ease": {"ramp": -0.1}}), 0.0, 0.0), + (json!({"colorShift": 101}), 0.0, 100.0), + (json!({"colorShift": -101}), 0.0, -100.0), + (json!({"ease": {"ramp": null}, "colorShift": "NaN"}), 0.0, 0.0), + ] { + let spec: BlendSpec = serde_json::from_value(bad.clone()).unwrap_or_else(|e| panic!("{bad}: {e}")); + assert_eq!((spec.ease.ramp, spec.color_shift), (ramp, shift), "{bad}"); + } +} diff --git a/crates/engine/src/tests_blendspine.rs b/crates/engine/src/tests_blendspine.rs index 3fef36771..3a9009a18 100644 --- a/crates/engine/src/tests_blendspine.rs +++ b/crates/engine/src/tests_blendspine.rs @@ -137,6 +137,32 @@ fn moving_a_key_point_moves_the_key_and_the_other_way_round() { assert!(s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 0, "x": f64::MAX, "y": 0})).is_err()); } +/// Moving a spine point refuses an `independent`, and reshaping a spine segment a `perpendicular`, +/// given as text or as another value that is not true or false, and the blend stays as it was; null +/// is read as the key left out. +#[test] +fn spine_edits_refuse_a_boolean_given_as_text() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap(); + s.execute("object.blend.spine.convertAnchor", &json!({"id": g.0, "anchor": 1, "to": "smooth", "x": 75, "y": 5})).unwrap(); + let before = node(&s, g); + for (cmd, key, p) in [ + ("object.blend.spine.moveAnchor", "independent", json!({"id": g.0, "anchor": 1, "handle": "out", "x": 75, "y": 40, "independent": "true"})), + ("object.blend.spine.moveAnchor", "independent", json!({"id": g.0, "anchor": 1, "x": 55, "y": 60, "independent": 1})), + ("object.blend.spine.reshapeSegment", "perpendicular", json!({"id": g.0, "segment": 0, "dx": 10, "dy": 40, "perpendicular": "true"})), + ("object.blend.spine.reshapeSegment", "perpendicular", json!({"id": g.0, "segment": 0, "dy": 40, "perpendicular": [false]})), + ] { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be true or false")), "{cmd} {p}: {r:?}"); + assert_eq!(node(&s, g), before, "{cmd} {p}"); + } + s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "handle": "out", "x": 75, "y": 40, "independent": null})).unwrap(); + let a = spec(&s, g).spine.unwrap().subpaths[0].anchors[1]; + assert_eq!((a.h_out, a.kind), (Point::new(75.0, 40.0), vectorcraft_geom::AnchorKind::Smooth), "the point stays smooth"); + s.execute("object.blend.spine.reshapeSegment", &json!({"id": g.0, "segment": 0, "dy": 40, "perpendicular": null})).unwrap(); +} + #[test] fn only_points_without_a_key_can_be_deleted() { let mut s = session(); @@ -170,6 +196,27 @@ fn spine_commands_refuse_an_index_that_is_not_a_whole_number() { assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1].p, Point::new(55.0, 60.0)); } +/// Reshape Spine Segment refuses a `t`, `dx` or `dy` given as text or as another value that is not +/// a number, instead of bending the spine by 0 or at the segment's middle, and the blend stays as +/// it was; null is read as the key left out. +#[test] +fn spine_reshape_segment_refuses_a_number_given_as_text() { + let mut s = session(); + let (_, _, g) = blend(&mut s); + let before = node(&s, g); + for key in ["t", "dx", "dy"] { + for v in [json!("10"), json!([10]), json!(true)] { + let mut p = json!({"id": g.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 40}); + p[key] = v.clone(); + let r = s.execute("object.blend.spine.reshapeSegment", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == format!("`{key}` must be a number, not {v}")), "{p}: {r:?}"); + assert_eq!(node(&s, g), before, "{p}"); + } + } + s.execute("object.blend.spine.reshapeSegment", &json!({"id": g.0, "segment": 0, "t": null, "dx": null, "dy": 40})).unwrap(); + assert!(center(&steps(&s, g)[1]).y > 20.0, "the step follows the bent spine"); +} + #[test] fn info_lists_the_spine() { let mut s = session(); diff --git a/crates/engine/src/tests_brushsym.rs b/crates/engine/src/tests_brushsym.rs index d3cf6f38b..43df06f14 100644 --- a/crates/engine/src/tests_brushsym.rs +++ b/crates/engine/src/tests_brushsym.rs @@ -123,6 +123,136 @@ fn new_brushes_from_params_and_selection() { assert!(s.execute("brush.new", &json!({"type": "nope"})).is_err()); } +/// The brush of every stroke in symbol "Mark", graph design "Bar", pattern `pattern`, graphic style +/// "Linked", the opacity mask in the layers and the art of brush "Holder", each as ": +/// " ("-" for a stroke without one). +fn brushes_outside_the_layers(s: &Session, pattern: &str) -> Vec { + let d = &s.doc().unwrap().doc; + let mut out = vec![]; + let mut items = |place: &str, items: &[vectorcraft_doc::AppearanceItem]| { + for it in items { + if let vectorcraft_doc::AppearanceItem::Stroke(st) = it { + out.push(format!("{place}: {}", st.brush.as_deref().unwrap_or("-"))); + } + } + }; + let mut tree = |place: &str, n: &Node| n.walk(&mut |m| items(place, &m.appearance.items)); + tree("symbol", &d.symbols.iter().find(|x| x.name == "Mark").unwrap().art); + tree("design", &d.graph_designs.iter().find(|x| x.name == "Bar").unwrap().art); + d.pattern(pattern).unwrap().art.iter().for_each(|n| tree("pattern", n)); + let mut masks = vec![]; + d.walk(|n| masks.extend(n.extras().mask.as_deref().map(|m| m.art.clone()))); + masks.iter().for_each(|n| tree("mask", n)); + let holder = vectorcraft_brush::find(d, "Holder").unwrap(); + holder.art().into_iter().for_each(|n| tree("brush art", n)); + items("style", &d.graphic_styles.iter().find(|x| x.name == "Linked").unwrap().appearance.items); + out +} + +/// Renaming a brush in Brush Options and deleting it reach every stroke that paints with it, not +/// only those in the layers: strokes in symbols, graph designs, pattern tiles, graphic styles, +/// opacity masks and the art of other brushes are renamed, or become plain strokes, in the same +/// undo step. +#[test] +fn brush_rename_and_delete_reach_strokes_outside_the_layers() { + let mut s = session(); + let brushed = |s: &mut Session| { + let id = rect(s, 10.0, 10.0, 40.0, 20.0); + s.execute("brush.apply", &json!({"name": "Charcoal", "ids": [id.0]})).unwrap(); + id + }; + let clear = |s: &mut Session, id: NodeId| { + if s.doc().unwrap().doc.node(id).is_some() { + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + } + }; + brushed(&mut s); + let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + clear(&mut s, NodeId(sym["id"].as_u64().unwrap())); + let id = brushed(&mut s); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + clear(&mut s, id); + let id = brushed(&mut s); + let pattern = s.execute("object.pattern.make", &json!({"edit": false})).unwrap()["name"].as_str().unwrap().to_string(); + clear(&mut s, id); + let id = brushed(&mut s); + s.execute("graphicStyle.new", &json!({"name": "Linked", "id": id.0})).unwrap(); + clear(&mut s, id); + let id = brushed(&mut s); + s.execute("brush.new", &json!({"type": "art", "name": "Holder"})).unwrap(); + clear(&mut s, id); + let masked = rect(&mut s, 100.0, 100.0, 60.0, 60.0); + let mask = brushed(&mut s); + select(&mut s, &[masked, mask]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let a = brushed(&mut s); + let plain = rect(&mut s, 200.0, 10.0, 40.0, 20.0); + let shared = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().find(|c| c.id == plain).unwrap().clone(); + let plain_before = shared(&s); + let before = brushes_outside_the_layers(&s, &pattern); + for place in ["symbol", "design", "pattern", "mask", "brush art", "style"] { + assert!(before.contains(&format!("{place}: Charcoal")), "{place} in {before:?}"); + } + let with = |to: &str| -> Vec { before.iter().map(|e| e.replace(": Charcoal", &format!(": {to}"))).collect() }; + let undo = s.doc().unwrap().history.undo.len(); + s.execute("brush.options", &json!({"name": "Charcoal", "newName": "Chalk"})).unwrap(); + assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Chalk"), with("Chalk"))); + assert!(std::sync::Arc::ptr_eq(&shared(&s), &plain_before), "an object without the brush is not copied"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Charcoal"), before.clone())); + s.execute("brush.delete", &json!({"name": "Charcoal"})).unwrap(); + assert_eq!((brush_of(&s, a), brushes_outside_the_layers(&s, &pattern)), (None, with("-"))); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // A brush made later with the deleted brush's name does not paint the strokes that used it. + s.execute("brush.new", &json!({"type": "calligraphic", "name": "Charcoal"})).unwrap(); + assert_eq!(brushes_outside_the_layers(&s, &pattern), with("-")); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((brush_of(&s, a).as_deref(), brushes_outside_the_layers(&s, &pattern)), (Some("Charcoal"), before)); +} + +/// A brush renamed while a pattern is being edited keeps its new name in the tile that pattern +/// editing's Cancel puts back. +#[test] +fn brush_rename_reaches_the_tile_that_pattern_cancel_puts_back() { + let mut s = session(); + let id = rect(&mut s, 10.0, 10.0, 40.0, 20.0); + s.execute("brush.apply", &json!({"name": "Charcoal", "ids": [id.0]})).unwrap(); + let pattern = s.execute("object.pattern.make", &json!({"edit": false})).unwrap()["name"].as_str().unwrap().to_string(); + s.execute("object.pattern.edit", &json!({"name": pattern})).unwrap(); + s.execute("brush.options", &json!({"name": "Charcoal", "newName": "Chalk"})).unwrap(); + s.execute("object.pattern.cancel", &json!({})).unwrap(); + let mut brushes = vec![]; + for n in &s.doc().unwrap().doc.pattern(&pattern).unwrap().art { + n.walk(&mut |m| brushes.extend(m.appearance.stroke().and_then(|st| st.brush.clone()))); + } + assert_eq!(brushes, ["Chalk"]); +} + +/// `brush.selectionArt` gives the art `brush.new` takes: the selected object, a group of the selected +/// objects, the object of `ids`, or null when nothing is selected. It changes nothing. +#[test] +fn selection_art_is_the_art_new_brush_takes() { + let mut s = session(); + let art = |s: &mut Session, p: Value| s.execute("brush.selectionArt", &p).unwrap()["art"].clone(); + s.select(|_, sel| sel.clear()).unwrap(); + assert_eq!(art(&mut s, json!({})), Value::Null); + let a = rect(&mut s, 0.0, 0.0, 40.0, 8.0); + let b = rect(&mut s, 0.0, 20.0, 40.0, 8.0); + let steps = s.doc().unwrap().history.undo.len(); + select(&mut s, &[a, b]); + let group: Node = serde_json::from_value(art(&mut s, json!({}))).unwrap(); + assert!(matches!(&group.kind, NodeKind::Group { children, .. } if children.len() == 2), "{group:?}"); + let one: Node = serde_json::from_value(art(&mut s, json!({"ids": [a.0]}))).unwrap(); + assert_eq!(one.geometric_bounds(), Some(bounds(&s, a))); + // The art brush `brush.new` makes from the same selection holds the same art. + s.execute("brush.new", &json!({"type": "art", "name": "Two"})).unwrap(); + let made: Node = serde_json::from_value(s.execute("brush.get", &json!({"name": "Two"})).unwrap()["art"].clone()).unwrap(); + assert_eq!(made.geometric_bounds(), group.geometric_bounds()); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "only brush.new is an undo step"); +} + #[test] fn brush_options_rename_duplicate_and_delete_update_strokes() { let mut s = session(); @@ -140,6 +270,62 @@ fn brush_options_rename_duplicate_and_delete_update_strokes() { assert!(s.execute("brush.delete", &json!({"name": "5 pt. Round"})).is_err()); } +/// `brush.move`, which the Brushes panel runs when a brush is dragged, reorders brushes among those of +/// their type in one undo step and refuses to move one into another type. +#[test] +fn brush_move_reorders_brushes_within_their_type() { + let mut s = session(); + let names = |s: &mut Session, ty: &str| -> Vec { + let list = s.execute("brush.list", &json!({})).unwrap(); + let of_type = list["brushes"].as_array().unwrap().iter().filter(|b| ty.is_empty() || b["type"] == ty); + of_type.map(|b| b["name"].as_str().unwrap().to_string()).collect() + }; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = names(&mut s, ""); + assert_eq!(names(&mut s, "calligraphic"), ["3 pt. Round", "6 pt. Flat", "10 pt. Oval"]); + let steps = undo(&s); + // To index 0 of the calligraphic brushes: before the first; the other types stay where they are. + let r = s.execute("brush.move", &json!({"name": "10 pt. Oval", "to": 0})).unwrap(); + assert_eq!(r["moved"], json!(1)); + assert_eq!(names(&mut s, "")[..3], ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]); + assert_eq!(names(&mut s, "")[3..], before[3..]); + assert_eq!(undo(&s), steps + 1); + // Several go in the order given, as one step. + s.execute("brush.move", &json!({"names": ["6 pt. Flat", "3 pt. Round"], "to": 0, "type": "calligraphic"})).unwrap(); + assert_eq!(names(&mut s, "calligraphic"), ["6 pt. Flat", "3 pt. Round", "10 pt. Oval"]); + assert_eq!(undo(&s), steps + 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&mut s, "calligraphic"), ["10 pt. Oval", "3 pt. Round", "6 pt. Flat"]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(names(&mut s, ""), before); + // Into the scatter brushes, with a scatter brush, or with a `to` that isn't a whole number: an + // error, and nothing moves. + for (p, why) in [ + (json!({"name": "3 pt. Round", "type": "scatter"}), "brushes move only within their type"), + (json!({"names": ["3 pt. Round", "Dots"], "to": 0}), "brushes move only within their type"), + (json!({"name": "3 pt. Round", "to": "1"}), "`to` must be a number, not \"1\""), + (json!({"name": "3 pt. Round", "to": 1.5}), "`to` must be a whole number"), + (json!({"name": "Nope", "to": 0}), "no brush named `Nope`"), + ] { + let r = s.execute("brush.move", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == why), "{p}: {r:?}"); + } + assert_eq!((names(&mut s, ""), undo(&s)), (before.clone(), steps)); + // A move that keeps the order records no step. + s.execute("brush.move", &json!({"name": "3 pt. Round", "to": 0})).unwrap(); + assert_eq!((names(&mut s, ""), undo(&s)), (before.clone(), steps)); + // Without `to`, or past the last one: after the last brush of the type, even one listed after + // brushes of other types. + s.execute("brush.new", &json!({"type": "calligraphic", "name": "Quill"})).unwrap(); + s.execute("brush.move", &json!({"name": "3 pt. Round"})).unwrap(); + assert_eq!(names(&mut s, "calligraphic"), ["6 pt. Flat", "10 pt. Oval", "Quill", "3 pt. Round"]); + assert_eq!(names(&mut s, "").last().map(String::as_str), Some("3 pt. Round")); + s.execute("brush.move", &json!({"name": "6 pt. Flat", "to": 99})).unwrap(); + assert_eq!(names(&mut s, "calligraphic"), ["10 pt. Oval", "Quill", "3 pt. Round", "6 pt. Flat"]); + s.execute("brush.move", &json!({"name": "Leaves", "to": 1})).unwrap(); + assert_eq!(names(&mut s, "scatter"), ["Dots", "Leaves", "Confetti"]); +} + /// New Brush, Duplicate Brush and Brush Options trim the name they are given, as the Brush Options /// dialog does; the trimmed name is the one compared with the brushes there are, and a blank name /// gives the name the command gives when there is none. @@ -166,6 +352,54 @@ fn new_duplicate_and_renamed_brushes_trim_their_names() { assert_eq!(names[names.len() - 6..], ["Nib", " Old ", "New Calligraphic Brush", "Quill", "Nib copy", "Old copy"]); } +/// New Brush and Bristle Brush Options take a bristle brush name of at most 31 characters, counted +/// in Unicode characters (31 é's fit). A longer name that a document already holds is kept, and the +/// other brush types take a name of any length. +#[test] +fn bristle_brush_names_have_at_most_31_characters() { + let mut s = session(); + let library = |s: &Session| vectorcraft_brush::library(&s.doc().unwrap().doc); + let refused = |s: &mut Session, cmd: &str, p: Value| s.execute(cmd, &p).unwrap_err().to_string(); + let (long, fits) = ("b".repeat(32), "é".repeat(31)); + let e = refused(&mut s, "brush.new", json!({"type": "bristle", "name": long})); + assert!(e.contains("a bristle brush name can have at most 31 characters: `bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb` has 32"), "{e}"); + assert!(!library(&s).iter().any(|b| b.name == long)); + assert_eq!(s.execute("brush.new", &json!({"type": "bristle", "name": fits})).unwrap()["name"], fits.as_str()); + // A taken name is numbered, and the numbered name is the one counted. + let e = refused(&mut s, "brush.new", json!({"type": "bristle", "name": fits})); + assert!(e.contains(&format!("`{fits} 2` has 33")), "{e}"); + let e = refused(&mut s, "brush.options", json!({"name": "Bristle Round", "newName": long})); + assert!(e.contains("at most 31 characters"), "{e}"); + let short = "c".repeat(31); + assert_eq!(s.execute("brush.options", &json!({"name": "Bristle Round", "newName": short})).unwrap()["name"], short.as_str()); + // Calligraphic brushes take a longer name. + assert_eq!(s.execute("brush.new", &json!({"type": "calligraphic", "name": long})).unwrap()["name"], long.as_str()); + let longer = format!("{long}x"); + assert_eq!(s.execute("brush.options", &json!({"name": long, "newName": longer})).unwrap()["name"], longer.as_str()); + // Bristle brushes that already have longer names, one of them with white space around it, as a + // saved file can hold. + let (old, spaced) = ("d".repeat(40), format!(" {} ", "e".repeat(40))); + let mut lib = library(&s); + lib.iter_mut().filter(|b| b.name == short).for_each(|b| b.name = old.clone()); + lib.iter_mut().filter(|b| b.name == fits).for_each(|b| b.name = spaced.clone()); + vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib); + let l = line(&mut s); + s.execute("brush.apply", &json!({"name": old, "ids": [l.0]})).unwrap(); + // Brush Options without a new name, with the same name, or with the name only trimmed keep them. + s.execute("brush.options", &json!({"name": old, "params": {"size": 9}})).unwrap(); + assert_eq!(s.execute("brush.options", &json!({"name": old, "newName": old, "params": {"density": 20}})).unwrap()["name"], old.as_str()); + assert_eq!(s.execute("brush.options", &json!({"name": spaced, "newName": spaced})).unwrap()["name"], spaced.trim()); + assert_eq!(s.execute("brush.get", &json!({"name": old})).unwrap()["size"], json!(9.0)); + assert_eq!(s.execute("brush.duplicate", &json!({"name": old})).unwrap()["name"], format!("{old} copy")); + // Saved and opened again, the brush and the stroke painted with it keep the name. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert!(vectorcraft_brush::find(&back, &old).is_some_and(|b| b.kind.type_id() == "bristle")); + assert_eq!(back.node(l).unwrap().appearance.stroke().unwrap().brush.as_deref(), Some(old.as_str())); + let mut s2 = Session::new(); + s2.add_document(back, None); + assert_eq!(s2.execute("brush.options", &json!({"name": old, "params": {"size": 4}})).unwrap()["name"], old.as_str()); +} + /// The Brushes panel's multi-selection: `names` deletes or duplicates several brushes in one undo /// step, and an unknown name changes nothing. #[test] @@ -202,6 +436,86 @@ fn brushes_delete_and_duplicate_by_names() { assert_eq!((names(&mut s), brush_of(&s, b).as_deref()), (before, Some("6 pt. Flat"))); } +fn unused(s: &mut Session) -> Vec { + serde_json::from_value(s.execute("brush.unused", &json!({})).unwrap()["names"].clone()).unwrap() +} + +/// Select All Unused: `brush.unused` lists the brushes no stroke paints with, in library order, +/// without an undo step. +#[test] +fn brush_unused_lists_the_brushes_no_stroke_paints_with() { + let mut s = session(); + let mut lib = vectorcraft_brush::library(&s.doc().unwrap().doc); + lib.retain(|b| ["3 pt. Round", "6 pt. Flat", "Charcoal"].contains(&b.name.as_str())); + vectorcraft_brush::store(std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc), &lib); + let a = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + s.execute("brush.apply", &json!({"name": "6 pt. Flat"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + assert_eq!(unused(&mut s), ["3 pt. Round", "Charcoal"]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + s.execute("brush.remove", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(unused(&mut s), ["3 pt. Round", "6 pt. Flat", "Charcoal"]); +} + +/// A brush counts as used when a stroke paints with it in an opacity mask, a symbol, a graph +/// design, a pattern tile, a graphic style, or the art of a brush that is used. The art of a brush +/// that is not used, its own strokes included, does not count. +#[test] +fn brush_unused_counts_strokes_in_masks_symbols_designs_patterns_styles_and_used_brush_art() { + let mut s = session(); + // Makes a rectangle painted with `brush` and leaves it selected; the test clears it once it + // has been used. + let brushed = |s: &mut Session, brush: &str| { + let id = rect(s, 10.0, 10.0, 40.0, 20.0); + s.execute("brush.apply", &json!({"name": brush, "ids": [id.0]})).unwrap(); + id + }; + let clear = |s: &mut Session, id: NodeId| { + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + }; + brushed(&mut s, "Charcoal"); + let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap(); + clear(&mut s, NodeId(sym["id"].as_u64().unwrap())); + let id2 = brushed(&mut s, "Dots"); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + clear(&mut s, id2); + let id3 = brushed(&mut s, "Arrow"); + s.execute("object.pattern.make", &json!({"edit": false})).unwrap(); + if s.doc().unwrap().doc.node(id3).is_some() { + clear(&mut s, id3); + } + let id4 = brushed(&mut s, "Chain"); + s.execute("graphicStyle.new", &json!({"name": "Linked", "id": id4.0})).unwrap(); + clear(&mut s, id4); + let masked = rect(&mut s, 100.0, 100.0, 60.0, 60.0); + let mask = brushed(&mut s, "6 pt. Flat"); + select(&mut s, &[masked, mask]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + // "Holder" is used, and its art paints with "10 pt. Oval"; "Idle" is not, and its art paints + // with "Bristle Round". "Loop" paints its own art with itself and is not used anywhere else. + let id5 = brushed(&mut s, "10 pt. Oval"); + s.execute("brush.new", &json!({"type": "art", "name": "Holder"})).unwrap(); + clear(&mut s, id5); + let l = line(&mut s); + s.execute("brush.apply", &json!({"name": "Holder", "ids": [l.0]})).unwrap(); + let id6 = brushed(&mut s, "Bristle Round"); + s.execute("brush.new", &json!({"type": "art", "name": "Idle"})).unwrap(); + clear(&mut s, id6); + let id7 = rect(&mut s, 10.0, 10.0, 40.0, 20.0); + s.execute("brush.new", &json!({"type": "art", "name": "Loop"})).unwrap(); + s.execute("brush.apply", &json!({"name": "Loop", "ids": [id7.0]})).unwrap(); + let art = serde_json::to_value(node(&s, id7)).unwrap(); + s.execute("brush.options", &json!({"name": "Loop", "params": {"art": art}})).unwrap(); + clear(&mut s, id7); + let left = unused(&mut s); + for used in ["Charcoal", "Dots", "Arrow", "Chain", "6 pt. Flat", "Holder", "10 pt. Oval"] { + assert!(!left.iter().any(|n| n == used), "{used} in {left:?}"); + } + for name in ["Idle", "Bristle Round", "Loop", "3 pt. Round"] { + assert!(left.iter().any(|n| n == name), "{name} not in {left:?}"); + } +} + #[test] fn expand_brush_replaces_path_with_art_group() { let mut s = session(); @@ -430,6 +744,69 @@ fn scatter_art_and_pattern_brush_options_update_strokes() { assert_eq!((&b["scale"], &b["spacing"]), (&json!(10000.0), &json!(0.0))); } +/// Hue Shift given without a key color takes the color the art's fills and strokes use most, in +/// New Brush and in Brush Options, and a Brush Options edit that gives only the method keeps the +/// brush's key color. +#[test] +fn hue_shift_without_a_key_takes_the_most_used_art_color() { + let mut s = session(); + let paint = |s: &mut Session, id: NodeId, fill: &str, stroke: Option<&str>| { + s.execute("paint.setFill", &json!({"color": fill, "ids": [id.0]})).unwrap(); + match stroke { + Some(c) => s.execute("paint.setStroke", &json!({"color": c, "ids": [id.0]})).unwrap(), + None => s.execute("paint.setStroke", &json!({"none": true, "ids": [id.0]})).unwrap(), + }; + }; + // Blue comes first; red paints two fills and a stroke. + let a = rect(&mut s, 10.0, 200.0, 20.0, 8.0); + paint(&mut s, a, "#0000ff", None); + let b = rect(&mut s, 40.0, 200.0, 20.0, 8.0); + paint(&mut s, b, "#ff0000", Some("#ff0000")); + let c = rect(&mut s, 70.0, 200.0, 20.0, 8.0); + paint(&mut s, c, "#ff0000", None); + let color = |r: f32, g: f32, b: f32| serde_json::to_value(vectorcraft_color::Color::rgb(r, g, b)).unwrap(); + let (red, green) = (color(1.0, 0.0, 0.0), color(0.0, 1.0, 0.0)); + let hue_shift = json!({"colorization": {"method": "hueShift"}}); + let r = s.execute("brush.new", &json!({"type": "scatter", "name": "Key", "ids": [a.0, b.0, c.0], "params": hue_shift})).unwrap(); + assert_eq!(r["name"], "Key"); + let colorization = |s: &mut Session| s.execute("brush.get", &json!({"name": "Key"})).unwrap()["colorization"].clone(); + assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": red})); + let options = |s: &mut Session, params: Value| s.execute("brush.options", &json!({"name": "Key", "params": params})).unwrap(); + // Brush Options switches from Tints to Hue Shift without a key. + options(&mut s, json!({"colorization": {"method": "tints"}})); + assert_eq!(colorization(&mut s), json!({"method": "tints"})); + options(&mut s, hue_shift.clone()); + assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": red})); + // A key color that was set stays when an edit gives only the method. + options(&mut s, json!({"colorization": {"method": "hueShift", "key": green}})); + options(&mut s, hue_shift.clone()); + assert_eq!(colorization(&mut s), json!({"method": "hueShift", "key": green})); + // Art with no solid color takes black. + let d = rect(&mut s, 100.0, 200.0, 20.0, 8.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}, "ids": [d.0]})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true, "ids": [d.0]})).unwrap(); + let r = s.execute("brush.new", &json!({"type": "art", "name": "Plain", "ids": [d.0], "params": hue_shift})).unwrap(); + assert_eq!(r["name"], "Plain"); + let b = s.execute("brush.get", &json!({"name": "Plain"})).unwrap(); + assert_eq!(b["colorization"], json!({"method": "hueShift", "key": color(0.0, 0.0, 0.0)})); +} + +/// Brush Options merges an art brush's `scale` field by field: an edit that gives only the start +/// guide keeps the end guide. +#[test] +fn art_brush_options_keep_the_scale_fields_an_edit_leaves_out() { + let mut s = session(); + let r = rect(&mut s, 10.0, 200.0, 40.0, 8.0); + s.execute("brush.new", &json!({"type": "art", "name": "Bar", "ids": [r.0]})).unwrap(); + s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "betweenGuides", "start": 0.2, "end": 0.6}}})).unwrap(); + s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "betweenGuides", "start": 0.3}}})).unwrap(); + let b = s.execute("brush.get", &json!({"name": "Bar"})).unwrap(); + assert_eq!(b["scale"], json!({"mode": "betweenGuides", "start": 0.3, "end": 0.6})); + // A scale that becomes a plain mode drops the guides. + s.execute("brush.options", &json!({"name": "Bar", "params": {"scale": {"mode": "stretch"}}})).unwrap(); + assert_eq!(s.execute("brush.get", &json!({"name": "Bar"})).unwrap()["scale"], json!({"mode": "stretch"})); +} + // ---------- symbols ---------- fn make_symbol(s: &mut Session) -> (NodeId, String, Rect) { @@ -669,6 +1046,66 @@ fn symbol_stainer_rgb_color_in_a_cmyk_document_separates_through_the_color_setti assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red))); } +/// In a CMYK document the Symbol Stainer refuses a `keepModel` given as text or as another value that +/// is not true or false, and the instance and the undo history stay as they were. Null is read as +/// `keepModel` left out. +#[test] +fn symbol_stainer_refuses_a_keep_model_given_as_text() { + use vectorcraft_color::{Color, Paint, cms}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300, "colorMode": "cmyk"})).unwrap(); + let (inst, _, b) = make_symbol(&mut s); + let at = json!([[b.center().x, b.center().y]]); + let state = |s: &Session| (node(s, inst), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for keep in [json!("true"), json!(1), json!("false"), json!([true])] { + let p = json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10, "keepModel": keep}); + let r = s.execute("symbol.adjust", &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{p}: {r:?}"); + assert_eq!(state(&s), before, "{p}"); + } + s.execute("symbol.adjust", &json!({"tool": "stain", "points": at, "color": "#ff0000", "intensity": 10, "keepModel": null})).unwrap(); + let red = cms::active().convert(&Color::rgb(1.0, 0.0, 0.0), cms::Model::Cmyk, cms::active().settings().intent); + assert_eq!(node(&s, inst).appearance.fill().map(|f| f.paint.clone()), Some(Paint::solid(red))); +} + +/// Place Symbol Instance, Delete Symbol, the Symbol Sprayer and the symbolism tools refuse a number +/// or an on/off option given as text or as another value of the wrong kind, naming the key; the +/// document and the undo history stay as they were. Numbers, true, false and null still work. +#[test] +fn symbol_commands_refuse_a_number_or_boolean_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let mut s = session(); + let (_, name, b) = make_symbol(&mut s); + let at = json!([[b.center().x, b.center().y]]); + let mut calls: Vec<_> = [ + ("symbol.place", json!({"name": name, "x": "200"}), "x"), + ("symbol.place", json!({"name": name, "x": 200, "y": [100]}), "y"), + ("symbol.spray", json!({"name": name, "points": at, "radius": "40"}), "radius"), + ("symbol.spray", json!({"name": name, "points": at, "density": "5"}), "density"), + ("symbol.spray", json!({"name": name, "points": at, "scale": true}), "scale"), + ("symbol.adjust", json!({"tool": "size", "points": at, "radius": "40"}), "radius"), + ("symbol.adjust", json!({"tool": "size", "points": at, "intensity": "10"}), "intensity"), + ] + .into_iter() + .map(|(cmd, p, key)| not_a_number(cmd, p, key)) + .collect(); + calls.extend( + [ + ("symbol.spray", json!({"name": name, "points": at, "alt": "true"}), "alt"), + ("symbol.adjust", json!({"tool": "size", "points": at, "alt": 1}), "alt"), + ("symbol.delete", json!({"name": name, "expandInstances": "false"}), "expandInstances"), + ] + .map(|(cmd, p, key)| not_a_switch(cmd, p, key)), + ); + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + s.execute("symbol.place", &json!({"name": name, "x": 200, "y": null})).unwrap(); + s.execute("symbol.spray", &json!({"name": name, "points": at, "radius": 40, "alt": false})).unwrap(); + s.execute("symbol.delete", &json!({"name": name, "expandInstances": true})).unwrap(); + assert!(s.doc().unwrap().doc.symbols.is_empty()); +} + #[test] fn symbol_sprayer_tool_gesture_is_one_undo_step() { let mut s = session(); diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index e7bda4d88..3464c3c77 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -5,9 +5,9 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{AppearanceItem, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeKind}; -use vectorcraft_geom::{Affine, FillRule, PathData, Rect}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use vectorcraft_testkit::pdf::{PdfPage, pdf}; -use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; use super::*; @@ -140,6 +140,131 @@ fn shape_builder_miss_is_an_error_and_undo_restores() { assert!(s.execute("shapeBuilder.merge", &json!({})).is_err()); } +/// A 3×3 grid of touching 100 pt squares from (0, 0) to (300, 300), each in its own gray, selected. +fn grid(s: &mut Session) -> Vec { + let ids: Vec = (0..9) + .map(|k| { + let id = rect(s, (k % 3) as f64 * 100.0, (k / 3) as f64 * 100.0, 100.0, 100.0); + let gray = k as f32 / 10.0; + set_fill(s, id, Color::rgb(gray, gray, gray)); + id + }) + .collect(); + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + ids +} + +/// `rect`: the regions a marquee reaches merge into one shape, which takes the look of the square +/// where the marquee started, else of the square where it ended. +#[test] +fn shape_builder_rect_merges_every_region_the_marquee_reaches() { + let mut s = session(); + let squares = grid(&mut s); + let r = s.execute("shapeBuilder.merge", &json!({"rect": [250, 250, -200, -200]})).unwrap(); + assert_eq!(r["regions"], 9); + let out = ids(&r); + assert_eq!(out.len(), 1, "{r}"); + assert!((area(&s, out[0]) - 90000.0).abs() < 1.0, "{}", area(&s, out[0])); + assert_eq!(outline(&node(&s, out[0])).0.subpaths.len(), 1, "one path, no seams"); + assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.8, 0.8, 0.8)), "the bottom-right square's look"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": squares.iter().map(|i| i.0).collect::>()})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"rect": [50, 50, 200, 200]})).unwrap(); + assert_eq!(node(&s, ids(&r)[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 0.0)), "the top-left square's look"); + s.execute("edit.undo", &json!({})).unwrap(); + // Started outside every square: the look of the middle square, where it ended. + s.execute("select.set", &json!({"ids": squares.iter().map(|i| i.0).collect::>()})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"rect": [-50, -50, 200, 200]})).unwrap(); + assert_eq!(r["regions"], 4); + let merged = NodeId(r["merged"].as_u64().unwrap()); + assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(0.4, 0.4, 0.4)), "the middle square's look"); + s.execute("edit.undo", &json!({})).unwrap(); + // Points as well add the regions along them; the marquee touches the left column only. + s.execute("select.set", &json!({"ids": squares.iter().map(|i| i.0).collect::>()})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"rect": [40, 50, 20, 200], "points": [[150, 50], [250, 50]]})).unwrap(); + assert_eq!(r["regions"], 5); + let merged = NodeId(r["merged"].as_u64().unwrap()); + assert!((area(&s, merged) - 50000.0).abs() < 1.0, "{}", area(&s, merged)); +} + +/// `rect` with `erase`: the regions the marquee reaches are deleted. +#[test] +fn shape_builder_rect_with_erase_deletes_the_regions_the_marquee_reaches() { + let mut s = session(); + grid(&mut s); + let r = s.execute("shapeBuilder.merge", &json!({"rect": [140, 50, 20, 200], "erase": true})).unwrap(); + assert_eq!((&r["regions"], &r["merged"]), (&json!(3), &Value::Null)); + let out = ids(&r); + assert_eq!(out.len(), 6, "{r}"); + assert!(out.iter().all(|i| (area(&s, *i) - 10000.0).abs() < 1.0)); + let outside_the_middle = |i: &NodeId| node(&s, *i).geometric_bounds().is_some_and(|b| b.x1 <= 100.0 + 1e-6 || b.x0 >= 200.0 - 1e-6); + assert!(out.iter().all(outside_the_middle), "the middle column is gone"); +} + +#[test] +fn shape_builder_rect_must_be_four_finite_numbers() { + let mut s = session(); + grid(&mut s); + let undo_before = s.doc().unwrap().history.undo.len(); + for rect in [json!([0, 0, 10]), json!("big"), json!([0, 0, 10, f64::MAX]), json!([0, 0, "10", 10])] { + let e = s.execute("shapeBuilder.merge", &json!({"rect": rect})).unwrap_err().to_string(); + assert!(e.contains("rect must be [x, y, width, height]"), "{rect}: {e}"); + } + // Outside every region: nothing to merge. + assert!(s.execute("shapeBuilder.merge", &json!({"rect": [400, 400, 50, 50]})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + // A null rect is left out, and points are needed again. + assert!(s.execute("shapeBuilder.merge", &json!({"rect": null})).is_err()); +} + +/// With `rect`, `splitStroke` splits nothing, even with one point on a stroke: the marquee's +/// regions merge. +#[test] +fn shape_builder_rect_merges_and_does_not_split_a_stroke() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"rect": [40, -5, 20, 20], "points": [[50, -1]], "splitStroke": true})).unwrap(); + assert_eq!((r.get("split"), r["regions"].as_u64(), r["edges"].as_u64()), (None, Some(1), Some(0)), "{r}"); + let merged = NodeId(r["merged"].as_u64().unwrap()); + assert_eq!(shapes_of(&s, &[merged]), vec![(true, 4, 400.0)]); +} + +/// Past the segment limit only the filled areas count, so a stroke-only line along y = 150 cuts no +/// square: a marquee above the line merges the three whole squares of the middle row, as a drag +/// above it does. Under the limit the line cuts the middle row, and both merge only the halves +/// above it. +#[test] +fn shape_builder_rect_past_the_segment_limit_merges_whole_regions_as_a_drag_does() { + let merged_area = |segments: usize, params: Value| { + let mut s = session(); + let mut ids = grid(&mut s); + let pts: Vec = (0..=segments).map(|k| Point::new(-10.0 + 320.0 * k as f64 / segments as f64, 150.0)).collect(); + let look = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + let l = s + .edit("t", |d, _| { + let id = d.alloc_id(); + let top = d.children(d.default_layer()).map_or(0, |c| c.len()); + d.insert(d.default_layer(), top, Node::path(id, PathData::single(vectorcraft_geom::SubPath::polyline(&pts, false)), look))?; + Ok(id) + }) + .unwrap(); + ids.push(l); + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); + let r = s.execute("shapeBuilder.merge", ¶ms).unwrap(); + area(&s, NodeId(r["merged"].as_u64().unwrap())) + }; + let past = vectorcraft_pathops::SHAPE_BUILDER_MAX_SEGMENTS; + let marquee = json!({"rect": [20, 120, 260, 20]}); + let drag = json!({"points": [[50, 130], [250, 130]]}); + for (segments, want) in [(2, 15000.0), (past, 30000.0)] { + for params in [&marquee, &drag] { + let got = merged_area(segments, params.clone()); + assert!((got - want).abs() < 1.0, "{segments} segments, {params}: {got}"); + } + } +} + #[test] fn shape_builder_regions_query() { let mut s = session(); @@ -350,14 +475,63 @@ fn shape_builder_fills_an_area_lines_enclose() { let r = s.execute("shapeBuilder.regions", &json!({})).unwrap(); assert_eq!(r["regions"].as_array().unwrap().len(), 1); assert_eq!(r["regions"][0]["sources"], json!([])); + let top_line = node(&s, l3).appearance.clone(); let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]]})).unwrap(); let merged = NodeId(r["merged"].as_u64().unwrap()); assert!((area(&s, merged) - 900.0).abs() < 1e-6, "{}", area(&s, merged)); - assert_eq!(node(&s, merged).appearance.fill_paint(), s.paint.fill, "the current fill"); - assert!(!node(&s, merged).appearance.stroke_paint().is_none(), "the lines' stroke"); + assert_eq!(node(&s, merged).appearance, top_line, "no object at the click: the look of the front-most selected line"); assert_eq!(ids(&r).len(), 4, "the triangle + the three lines, which only bound it"); } +/// Two diagonals and a crossbar that stops about 3 pt short of each: the triangle they bound. +fn lines_with_gaps(s: &mut Session) { + let l1 = line(s, (0.0, 0.0), (100.0, 100.0)); + let l2 = line(s, (100.0, 0.0), (0.0, 100.0)); + let l3 = line(s, (24.0, 80.0), (76.0, 80.0)); + s.execute("select.set", &json!({"ids": [l1.0, l2.0, l3.0]})).unwrap(); +} + +#[test] +fn shape_builder_gap_detection_closes_small_gaps_between_lines() { + let mut s = session(); + lines_with_gaps(&mut s); + let regions = |s: &mut Session, p: Value| s.execute("shapeBuilder.regions", &p).unwrap()["regions"].as_array().unwrap().len(); + assert_eq!(regions(&mut s, json!({})), 0, "off by default"); + assert_eq!(regions(&mut s, json!({"gap": 2})), 0, "the gap is longer"); + assert_eq!(regions(&mut s, json!({"gap": 3})), 1); + assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]]})).is_err(), "no gap, no region"); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 70]], "gap": 3})).unwrap(); + let merged = NodeId(r["merged"].as_u64().unwrap()); + let a = area(&s, merged); + assert!(a > 850.0 && a < 900.0, "the triangle and its two bridges: {a}"); + assert_eq!(ids(&r).len(), 4, "the bridges are no art: the merged shape and the three lines"); + assert_eq!(open_paths(&s, &ids(&r)).len(), 3); +} + +#[test] +fn shape_builder_tool_gap_detection_option() { + let mut s = session(); + lines_with_gaps(&mut s); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + assert_eq!((s.tool_options()["gapDetection"].clone(), s.tool_options()["gapLength"].clone()), (json!(false), json!(3.0))); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 70.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 70.0), v).unwrap(); + }; + click(&mut s); + assert_eq!(layer_children(&s), 3, "Gap Detection is off"); + s.set_tool_option_cmd(&json!({"values": {"gapDetection": true, "gapLength": 6.0}})).unwrap(); + click(&mut s); + assert_eq!(layer_children(&s), 4, "the triangle"); + // The options are kept when another tool is chosen. + s.select_tool("selection", v).unwrap(); + assert_eq!( + (s.tool_options_of("shapeBuilder")["gapDetection"].clone(), s.tool_options_of("shapeBuilder")["gapLength"].clone()), + (json!(true), json!(6.0)) + ); +} + #[test] fn shape_builder_closes_filled_open_paths() { let mut s = session(); @@ -380,6 +554,282 @@ fn shape_builder_closes_filled_open_paths() { assert_eq!(r["lines"].as_array().unwrap().len(), 1); } +/// Consider Open Filled Paths as Closed off: a filled open path is only an edge, as an unfilled +/// one is, so it forms no region and a click inside it merges nothing. +#[test] +fn shape_builder_open_filled_paths_cut_without_open_filled_as_closed() { + let mut s = session(); + let r = s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}]})).unwrap(); + let p = NodeId(r["id"].as_u64().unwrap()); + set_fill(&mut s, p, Color::rgb(0.0, 1.0, 0.0)); + s.execute("select.set", &json!({"ids": [p.0]})).unwrap(); + let r = s.execute("shapeBuilder.regions", &json!({"openFilledAsClosed": false})).unwrap(); + assert_eq!(r["regions"], json!([])); + assert_eq!(r["lines"].as_array().unwrap().len(), 1); + assert!(s.execute("shapeBuilder.merge", &json!({"points": [[75, 25]], "openFilledAsClosed": false})).is_err()); + // On (the default), the click makes the area the fill closes a shape. + let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 25]]})).unwrap(); + assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 5000.0).abs() < 1e-6); +} + +/// Pick Color From Color Swatches: the merged shape takes the current fill, not the fill of the +/// object under the first point; setting the current fill without `ids` leaves the selection's. +#[test] +fn shape_builder_pick_color_from_swatches_fills_with_the_current_fill() { + let mut s = session(); + let (a, _) = two(&mut s); + s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap(); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its fill"); + let green = s.paint.fill.clone(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]], "pickColorFrom": "swatches"})).unwrap(); + assert_eq!(node(&s, NodeId(r["merged"].as_u64().unwrap())).appearance.fill_paint(), green); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]], "pickColorFrom": "artwork"})).unwrap(); + assert_eq!(node(&s, NodeId(r["merged"].as_u64().unwrap())).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + assert!(s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]], "pickColorFrom": "layers"})).is_err()); +} + +/// Four #888888 bars round a hole (20..80 on both axes), and in front of them a red square +/// (90..140 × 30..70) with a 5 pt stroke that overlaps the right bar. Returns the bars, back to +/// front, and the square. +fn frame_and_red_square(s: &mut Session) -> (Vec, NodeId) { + let gray = Color::from_hex("#888888").unwrap(); + let mut bars = vec![]; + for (x, y, w, h) in [(0.0, 0.0, 20.0, 100.0), (80.0, 0.0, 20.0, 100.0), (0.0, 0.0, 100.0, 20.0), (0.0, 80.0, 100.0, 20.0)] { + let id = rect(s, x, y, w, h); + set_fill(s, id, gray); + bars.push(id); + } + let red = rect(s, 90.0, 30.0, 50.0, 40.0); + s.edit("t", |d, _| { + let a = &mut d.node_mut(red).unwrap().appearance; + a.set_fill(Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + a.set_stroke(Paint::solid(Color::rgb(0.0, 0.0, 0.0))); + a.stroke_mut().unwrap().width = 5.0; + Ok(()) + }) + .unwrap(); + (bars, red) +} + +fn select_ids(s: &mut Session, list: &[NodeId]) { + s.execute("select.set", &json!({"ids": list.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn merged_look(s: &Session, r: &Value) -> vectorcraft_doc::Shared { + node(s, NodeId(r["merged"].as_u64().unwrap())).appearance +} + +/// Pick Color From Artwork: the merged shape takes the look of the object where the drag starts, +/// else of the object where it ends, else of the front-most selected object, as the combine page +/// states. A drag that starts in an object keeps that object's look. +#[test] +fn shape_builder_artwork_takes_the_look_where_the_drag_starts_then_ends_then_the_top_object() { + let mut s = session(); + let (bars, red) = frame_and_red_square(&mut s); + s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap(); + let gray = node(&s, bars[3]).appearance.clone(); + let red_look = node(&s, red).appearance.clone(); + let all: Vec = bars.iter().copied().chain([red]).collect(); + let merge = |s: &mut Session, list: &[NodeId], points: Value| { + select_ids(s, list); + let r = s.execute("shapeBuilder.merge", &json!({ "points": points })).unwrap(); + let look = merged_look(s, &r); + s.execute("edit.undo", &json!({})).unwrap(); + (r["regions"].as_u64().unwrap(), look) + }; + // A click in the hole, where no object is: the front-most selected object's look, not the + // current fill. + assert_eq!(merge(&mut s, &bars, json!([[50, 50]])), (1, gray.clone()), "the front bar's look"); + assert_eq!(merge(&mut s, &all, json!([[50, 50]])), (1, red_look.clone()), "the square is in front"); + // A drag from the hole across the right bar into the square: the look where it ends, not + // the look of the first object it crosses. + assert_eq!(merge(&mut s, &all, json!([[50, 50], [120, 50]])), (4, red_look.clone())); + // Ending on the right bar, the drag takes the bar's look, though the square is in front. + let right_bar = node(&s, bars[1]).appearance.clone(); + assert_eq!(merge(&mut s, &all, json!([[50, 50], [85, 50]])), (2, right_bar.clone())); + // A drag from outside every region: the look where it ends. + assert_eq!(merge(&mut s, &all, json!([[-10, 50], [120, 50]])).1, red_look); + // A drag that starts on the right bar: the bar's look. + assert_eq!(merge(&mut s, &all, json!([[85, 50], [120, 50]])).1, right_bar); + // From the square back into the hole: the square's look. + assert_eq!(merge(&mut s, &all, json!([[120, 50], [50, 50]])).1, red_look); + + // A drag from blue into red takes blue, where it starts. + let mut s = session(); + let (_, b) = two(&mut s); + let blue = node(&s, b).appearance.clone(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[125, 50], [25, 50]]})).unwrap(); + assert_eq!(merged_look(&s, &r), blue); +} + +/// With Pick Color From Color Swatches, the merged shape takes the current fill, and the rest of +/// its look from the front-most object over the first region the drag touches that an object +/// covers. +#[test] +fn shape_builder_swatches_keep_the_current_fill_and_the_first_covered_objects_look() { + let mut s = session(); + let (bars, red) = frame_and_red_square(&mut s); + s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap(); + let green = s.paint.fill.clone(); + let bar = node(&s, bars[1]).appearance.clone(); + select_ids(&mut s, &bars.iter().copied().chain([red]).collect::>()); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [120, 50]], "pickColorFrom": "swatches"})).unwrap(); + let look = merged_look(&s, &r); + assert_eq!(look.fill_paint(), green); + assert_eq!((look.stroke_paint(), look.stroke().map(|k| k.width)), (bar.stroke_paint(), bar.stroke().map(|k| k.width)), "the right bar's stroke"); +} + +/// The Shape Builder tool sends the point where the drag started first and the point where it +/// ended last, so a drag from the hole into the square takes the square's look. +#[test] +fn shape_builder_tool_drag_from_the_hole_takes_the_look_where_it_ends() { + let mut s = session(); + let (bars, red) = frame_and_red_square(&mut s); + let red_look = node(&s, red).appearance.clone(); + select_ids(&mut s, &bars.iter().copied().chain([red]).collect::>()); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 85.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 120.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 120.0, 50.0), v).unwrap(); + assert_eq!(s.journal.last().unwrap().0, "shapeBuilder.merge"); + let d = &s.doc().unwrap().doc; + let kids = d.children(d.default_layer()).unwrap(); + assert_eq!(kids.iter().filter(|n| n.appearance == red_look).count(), 1, "the merged shape has the square's look"); +} + +/// The Shape Builder Tool Options reach what the tool does: Straight Line sends the drag's first +/// and last points, and the other options go with them to `shapeBuilder.merge`. +#[test] +fn shape_builder_tool_options_reach_the_command() { + let mut s = session(); + two(&mut s); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + let values = json!({"openFilledAsClosed": false, "pickColorFrom": "swatches", "selection": "straightLine"}); + s.set_tool_option_cmd(&json!({"tool": "shapeBuilder", "values": values})).unwrap(); + // A drag that bends up through (60, 20) on its way. + for (kind, x, y) in + [(PointerKind::Down, 25.0, 50.0), (PointerKind::Drag, 60.0, 20.0), (PointerKind::Drag, 75.0, 50.0), (PointerKind::Up, 75.0, 50.0)] + { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + let (cmd, p) = s.journal.last().unwrap().clone(); + assert_eq!(cmd, "shapeBuilder.merge"); + assert_eq!( + (&p["points"], &p["openFilledAsClosed"], &p["pickColorFrom"]), + (&json!([[25.0, 50.0], [75.0, 50.0]]), &json!(false), &json!("swatches")) + ); +} + +/// A Shift-drag with the tool across the grid merges all nine squares into one shape; a drag along +/// the middle row without Shift merges the three squares it passes over. +#[test] +fn shape_builder_tool_shift_drag_merges_the_grid_into_one_shape() { + let v = ViewInfo::default(); + let drag = |s: &mut Session, mods: Mods, pts: [(f64, f64); 3]| { + s.select_tool("shapeBuilder", v).unwrap(); + for (kind, (x, y)) in [PointerKind::Down, PointerKind::Drag, PointerKind::Up].into_iter().zip(pts) { + s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap(); + } + s.journal.last().unwrap().clone() + }; + let mut s = session(); + grid(&mut s); + let (cmd, p) = drag(&mut s, Mods { shift: true, ..Default::default() }, [(50.0, 50.0), (150.0, 150.0), (250.0, 250.0)]); + assert_eq!((cmd.as_str(), &p["rect"]), ("shapeBuilder.merge", &json!([50.0, 50.0, 200.0, 200.0]))); + assert_eq!(layer_children(&s), 1); + let only = { + let d = &s.doc().unwrap().doc; + d.children(d.default_layer()).unwrap()[0].id + }; + assert!((area(&s, only) - 90000.0).abs() < 1.0, "{}", area(&s, only)); + let mut s = session(); + grid(&mut s); + let (_, p) = drag(&mut s, Mods::default(), [(50.0, 150.0), (150.0, 150.0), (250.0, 150.0)]); + assert_eq!((p.get("rect"), &p["points"]), (None, &json!([[50.0, 150.0], [150.0, 150.0], [250.0, 150.0]]))); + assert_eq!(layer_children(&s), 7, "the middle row merged, six squares left"); +} + +/// Highlight Fill off: hovering a region in merge mode highlights nothing; erase mode still shows +/// what a click would delete. +#[test] +fn shape_builder_highlight_fill_off_leaves_regions_unshaded() { + let mut s = session(); + two(&mut s); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0), v).unwrap(); + assert!(!s.overlays(v).is_empty()); + s.set_tool_option_cmd(&json!({"key": "highlightFill", "value": false})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Move, 76.0, 50.0), v).unwrap(); + assert!(s.overlays(v).is_empty()); + let alt = Mods { alt: true, ..Default::default() }; + s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0).with_mods(alt), v).unwrap(); + assert!(!s.overlays(v).is_empty()); +} + +/// Pick Color From Color Swatches: Left and Right step the current fill through the document's +/// color swatches (the selection keeps its fills), and Cursor Swatch Preview shows the current fill +/// between the swatches before and after it. +#[test] +fn shape_builder_arrow_keys_step_the_current_fill_through_the_swatches() { + let mut s = session(); + let (a, _) = two(&mut s); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + let chips = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Swatch { .. })).count(); + assert!(!s.tool_claims_key(ToolKey::Right, v), "Artwork leaves the arrows to nudging"); + let values = json!({"pickColorFrom": "swatches", "cursorSwatchPreview": true}); + s.set_tool_option_cmd(&json!({"tool": "shapeBuilder", "values": values})).unwrap(); + assert!(s.tool_claims_key(ToolKey::Right, v)); + let (first, swatches): (String, Vec) = { + let list = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc); + (list[0].name.clone(), list.iter().take(3).map(|sw| sw.paint.clone()).collect()) + }; + assert_eq!(swatches.len(), 3); + s.execute("paint.setFill", &json!({"swatch": first, "ids": []})).unwrap(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, swatches[2]); + s.tool_key(ToolKey::Left, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, swatches[1]); + assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "A keeps its fill"); + s.pointer(&PointerEvent::new(PointerKind::Move, 75.0, 50.0), v).unwrap(); + assert_eq!(chips(&mut s), 3); + s.set_tool_option_cmd(&json!({"key": "cursorSwatchPreview", "value": false})).unwrap(); + assert_eq!(chips(&mut s), 0); +} + +/// Two swatches of the same color next to each other: the arrow keys step through both and on to +/// the swatch after them. +#[test] +fn shape_builder_arrow_keys_pass_swatches_of_the_same_color() { + let mut s = session(); + two(&mut s); + for name in ["Twin A", "Twin B"] { + s.execute("swatch.new", &json!({"name": name, "color": "#123456"})).unwrap(); + } + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + s.set_tool_option_cmd(&json!({"tool": "shapeBuilder", "values": {"pickColorFrom": "swatches"}})).unwrap(); + let names: Vec = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc).iter().map(|sw| sw.name.clone()).collect(); + let a = names.iter().position(|n| n == "Twin A").unwrap(); + assert_eq!(names[a + 1], "Twin B"); + s.execute("paint.setFill", &json!({"swatch": "Twin A", "ids": []})).unwrap(); + let twin = s.paint.fill.clone(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + assert_eq!(s.paint.fill, twin, "Twin B"); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + let after = vectorcraft_tools::builder::color_swatches(&s.doc().unwrap().doc)[(a + 2) % names.len()].paint.clone(); + assert_eq!(s.paint.fill, after, "the swatch after Twin B"); +} + #[test] fn shape_builder_tool_alt_deletes_line_pieces() { let mut s = session(); @@ -434,6 +884,21 @@ fn shape_builder_erase_click_on_an_edge_opens_its_path() { assert_eq!(shapes_of(&s, &back), vec![(true, 4, 400.0); 2], "one undo step"); } +/// An even-odd compound path opened by an erase click on one of its edges keeps its fill rule. +#[test] +fn shape_builder_erase_click_on_an_edge_keeps_the_fill_rule() { + let mut s = session(); + let c = even_odd_ring(&mut s); + let b = rect(&mut s, 50.0, 50.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [c.0, b.0]})).unwrap(); + // A's side inside B (1 pt off it). + let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap(); + assert_eq!((r["regions"].as_u64(), r["edges"].as_u64()), (Some(0), Some(1))); + let out = ids(&r); + assert_eq!(shapes_of(&s, &out[..1]), vec![(false, 9, 500.0)], "A open where B crosses it, with H"); + assert_eq!(outline(&node(&s, out[0])).1, FillRule::EvenOdd); +} + #[test] fn shape_builder_erase_drag_along_edges_deletes_each() { let mut s = session(); @@ -458,6 +923,450 @@ fn shape_builder_erase_drag_into_regions_deletes_them_not_the_edges_it_crosses() assert_eq!(node(&s, out[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); } +/// In Merge Mode, Clicking Stroke Splits the Path: a click on a lone rectangle's top side, 1 pt +/// outside it, splits the rectangle into that side and the rest. Without the option, the same click +/// is refused as outside every region and leaves the rectangle as it was. +#[test] +fn shape_builder_click_on_a_stroke_splits_its_path() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + set_fill(&mut s, a, Color::rgb(1.0, 0.0, 0.0)); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let undo_before = s.doc().unwrap().history.undo.len(); + assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]]})).is_err()); + assert!(s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": false})).is_err()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + assert_eq!(shapes_of(&s, &[a]), vec![(true, 4, 400.0)]); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap(); + let out = ids(&r); + assert_eq!((r["merged"].clone(), r["regions"].as_u64(), r["edges"].as_u64()), (Value::Null, Some(0), Some(1))); + // The rest of the outline (open, the other three sides) comes first, then the top side in + // front of it; both are red. + assert_eq!(shapes_of(&s, &out), vec![(false, 4, 300.0), (false, 2, 100.0)]); + let split = NodeId(r["split"].as_u64().unwrap()); + assert_eq!(split, out[1]); + assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 0.0)); + assert!(out.iter().all(|i| node(&s, *i).appearance.fill_paint() == Paint::solid(Color::rgb(1.0, 0.0, 0.0)))); + assert_eq!(s.doc().unwrap().selection.objects, out); + // One undo step puts the rectangle back. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_children(&s), 1); + assert_eq!(shapes_of(&s, &[a]), vec![(true, 4, 400.0)]); +} + +/// A click on a stroke that is the whole of its path (a lone line, or a filled open path of one +/// curve) has nothing to split off: with splitStroke it is refused as outside every region, as it +/// is without the option, and the path keeps its geometry and its stroke. +#[test] +fn shape_builder_click_on_a_whole_open_path_does_not_split_it() { + let mut s = session(); + let l = line(&mut s, (0.0, 0.0), (100.0, 0.0)); + let r = s.execute("path.create", &json!({"anchors": [{"x": 200, "y": 0, "out": [200, -100]}, {"x": 300, "y": 0, "in": [300, -100]}]})).unwrap(); + let arch = NodeId(r["id"].as_u64().unwrap()); + set_fill(&mut s, arch, Color::rgb(1.0, 0.0, 0.0)); + let strokes = |s: &Session| [l, arch].map(|i| node(s, i).appearance.stroke_paint()); + let before = (shapes_of(&s, &[l, arch]), strokes(&s)); + let undo_before = s.doc().unwrap().history.undo.len(); + // The line 0.5 pt off it, then the top of the arch 1 pt above it. + for (id, at) in [(l, [50.0, 0.5]), (arch, [250.0, -76.0])] { + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [at], "splitStroke": true, "fill": "#00ff00"})); + let err = r.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("no region under the pointer"), "{id:?}: {err}"); + } + assert_eq!(layer_children(&s), 2); + assert_eq!((shapes_of(&s, &[l, arch]), strokes(&s)), before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); +} + +/// A click on a stroke whose run would leave no more of the path than the cut tolerance (0.05 pt +/// for a 100 pt path) does not split it: here the line's last 0.01 pt is all that would be left. +/// The command refuses the click as outside every region, and the tool shows no scissors and no +/// highlight there and runs no command on a click. +#[test] +fn shape_builder_click_on_a_stroke_does_not_split_off_all_but_a_speck() { + let mut s = session(); + let r = s.execute("path.create", &json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100.01, "y": 0}]})).unwrap(); + let p = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [p.0]})).unwrap(); + let undo_before = s.doc().unwrap().history.undo.len(); + let err = s.execute("shapeBuilder.merge", &json!({"points": [[50, 1]], "splitStroke": true})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("no region under the pointer"), "{err}"); + assert_eq!((layer_children(&s), shapes_of(&s, &[p])), (1, vec![(false, 3, 100.01)])); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_before); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true}})).unwrap(); + let journal = s.journal.len(); + s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 1.0), v).unwrap(); + assert!(s.overlays(v).is_empty(), "{:?}", s.overlays(v)); + assert_eq!(s.cursor(vectorcraft_geom::Point::new(50.0, 1.0), Mods::default(), v), vectorcraft_tools::Cursor::ShapeBuilder); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 1.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 1.0), v).unwrap(); + assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "the click runs no command"); +} + +/// The stroke a click splits ends at the points where other paths cross it as well as at the +/// path's anchors: A's right side inside B, then a line piece inside a square. A click away from +/// every stroke merges and an erase click deletes, as they do without the option. +#[test] +fn shape_builder_stroke_split_stops_at_crossings_and_anchors() { + let mut s = session(); + let (a, b) = corner(&mut s); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "splitStroke": true})).unwrap(); + let out = ids(&r); + assert_eq!(shapes_of(&s, &out), vec![(false, 5, 350.0), (false, 2, 50.0), (true, 4, 400.0)]); + assert_eq!(node(&s, out[1]).geometric_bounds().unwrap(), Rect::new(100.0, 50.0, 100.0, 100.0)); + assert_eq!(node(&s, out[2]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), "B as it was"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + // A click inside a region away from the strokes merges it; erase mode deletes the edge. + let r = s.execute("shapeBuilder.merge", &json!({"points": [[75, 75]], "splitStroke": true})).unwrap(); + assert_eq!((r["regions"].as_u64(), r.get("split")), (Some(1), None)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true, "splitStroke": true})).unwrap(); + assert_eq!(shapes_of(&s, &ids(&r)), vec![(false, 5, 300.0), (true, 4, 400.0)]); + + // A click on the line inside the square splits off the piece between the square's sides and + // leaves the square whole. + let mut s = session(); + square_and_line(&mut s); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "splitStroke": true})).unwrap(); + let out = ids(&r); + assert_eq!(r["lines"].as_u64(), Some(1)); + let split = NodeId(r["split"].as_u64().unwrap()); + assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(50.0, 0.0, 50.0, 100.0)); + let lengths: Vec = out.iter().map(|i| (outline(&node(&s, *i)).0.length() * 1e6).round() / 1e6).collect(); + assert_eq!(lengths, vec![400.0, 40.0, 100.0], "the square, the line's two loose ends, the piece"); +} + +/// The run a click splits off goes the way its path goes, so arrowheads and the other settings that +/// follow a stroke's direction stay at the ends they were at: a line and a rectangle, each as drawn +/// and reversed. +#[test] +fn shape_builder_stroke_split_keeps_the_path_direction() { + let ends = |s: &Session, id: NodeId| { + let (p, _) = outline(&node(s, id)); + let sp = &p.subpaths[0]; + (sp.anchors.first().unwrap().p, sp.anchors.last().unwrap().p) + }; + let pt = vectorcraft_geom::Point::new; + for reverse in [false, true] { + let mut s = session(); + let (a, l) = square_and_line(&mut s); + if reverse { + s.execute("path.reverse", &json!({"ids": [l.0]})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + } + let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "splitStroke": true})).unwrap(); + let split = NodeId(r["split"].as_u64().unwrap()); + let want = if reverse { (pt(50.0, 100.0), pt(50.0, 0.0)) } else { (pt(50.0, 0.0), pt(50.0, 100.0)) }; + assert_eq!(ends(&s, split), want, "the line, reversed: {reverse}"); + + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + if reverse { + s.execute("path.reverse", &json!({"ids": [a.0]})).unwrap(); + } + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let (p, _) = outline(&node(&s, a)); + let sp = &p.subpaths[0]; + let n = sp.anchors.len(); + // The rectangle's top side, from the anchor the path reaches it at. + let want = (0..n).map(|k| (sp.anchors[k].p, sp.anchors[(k + 1) % n].p)).find(|(x, y)| x.y == 0.0 && y.y == 0.0).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap(); + let split = NodeId(r["split"].as_u64().unwrap()); + assert_eq!(ends(&s, split), want, "the rectangle, reversed: {reverse}"); + } +} + +/// A split replaces only the path it splits: the two paths take its place in the stacking order and +/// in its group, and the other selected objects stay as they are. A path in a Live Paint group does +/// not split. +#[test] +fn shape_builder_stroke_split_leaves_the_other_objects_in_place() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + let c = rect(&mut s, 0.0, 200.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap(); + let split = NodeId(r["split"].as_u64().unwrap()); + let order = |s: &Session| -> Vec { + let d = &s.doc().unwrap().doc; + d.children(d.default_layer()).unwrap().iter().map(|n| n.id).collect() + }; + let o = order(&s); + assert_eq!((o.len(), o[1], o[2], o[3]), (4, split, b, c), "the rest and the run where A was, under B and C"); + assert_eq!(ids(&r), vec![o[0], split, c]); + assert_eq!(s.doc().unwrap().selection.objects, ids(&r)); + // In a group, the two paths stay in the group, and the group stays selected. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, c.0]})).unwrap(); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap(); + assert_eq!(ids(&r), vec![g]); + assert_eq!(node(&s, g).children().unwrap().len(), 3); + assert_eq!(order(&s), vec![b, g]); + // A Live Paint group's edge: the click merges as it does without the option. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let lp = NodeId(s.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 1]], "splitStroke": true})).unwrap(); + assert_eq!((r.get("split"), r["regions"].as_u64()), (None, Some(1))); + assert!(s.doc().unwrap().doc.node(lp).is_none(), "merged as before"); +} + +/// A split and an erase click cut a small path with a tolerance from its own size, however far +/// away the other selected objects lie. +#[test] +fn shape_builder_split_and_erase_cut_a_small_path_beside_a_far_object() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 5.0, 5.0, 10.0, 10.0); + let far = rect(&mut s, 1_000_000.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, far.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[5, -0.5]], "splitStroke": true})).unwrap(); + let split = NodeId(r["split"].as_u64().unwrap_or_else(|| panic!("no split: {r}"))); + assert_eq!(node(&s, split).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 10.0, 0.0)); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0, far.0]})).unwrap(); + // A's side inside B (0.5 pt off it). + let r = s.execute("shapeBuilder.merge", &json!({"points": [[10.5, 7.5]], "erase": true})).unwrap(); + assert_eq!(shapes_of(&s, &ids(&r)[..1]), vec![(false, 5, 30.0)], "A open where B crosses it"); +} + +/// Pick Color From Color Swatches: the stroke split off takes the current fill as its stroke. +#[test] +fn shape_builder_stroke_split_with_swatches_paints_the_stroke() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let before = node(&s, a).appearance.stroke_paint(); + s.execute("paint.setFill", &json!({"color": "#00ff00", "ids": []})).unwrap(); + let green = s.paint.fill.clone(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true, "pickColorFrom": "swatches"})).unwrap(); + let out = ids(&r); + assert_eq!(node(&s, NodeId(r["split"].as_u64().unwrap())).appearance.stroke_paint(), green); + assert_eq!(node(&s, out[0]).appearance.stroke_paint(), before, "the rest keeps its stroke"); +} + +/// Gives `id` a red-to-blue linear gradient, fitted to its bounds, as its fill and its stroke, and +/// returns that fill and stroke fixed where they fit `id` now. +fn paint_with_gradients(s: &mut Session, id: NodeId) -> (Paint, Paint) { + use vectorcraft_color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + s.edit("t", |d, _| { + let a = &mut d.node_mut(id).unwrap().appearance; + a.set_fill(gradient.clone()); + a.set_stroke(gradient); + Ok(()) + }) + .unwrap(); + let mut n = node(s, id); + n.pin_gradients(); + (n.appearance.fill_paint(), n.appearance.stroke_paint()) +} + +/// The two paths a split leaves keep the gradients of the path they come from where they were on +/// it: a gradient fitted to the rectangle's bounds is not fitted again to the side split off or to +/// what is left. +#[test] +fn shape_builder_stroke_split_keeps_the_gradients_where_they_were() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let want = paint_with_gradients(&mut s, a); + // The rectangle's left side. + let r = s.execute("shapeBuilder.merge", &json!({"points": [[-1, 50]], "splitStroke": true})).unwrap(); + let out = ids(&r); + assert_eq!(node(&s, out[1]).geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 0.0, 100.0)); + for i in out { + let n = node(&s, i); + assert_eq!((n.appearance.fill_paint(), n.appearance.stroke_paint()), want, "{i:?}"); + } +} + +/// A red even-odd compound path, square A (0..100) with the square H (25..75) inside it, selected. +fn even_odd_ring(s: &mut Session) -> NodeId { + let a = rect(s, 0.0, 0.0, 100.0, 100.0); + let h = rect(s, 25.0, 25.0, 50.0, 50.0); + set_fill(s, a, Color::rgb(1.0, 0.0, 0.0)); + s.execute("select.set", &json!({"ids": [a.0, h.0]})).unwrap(); + let c = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + s.execute("path.setFillRule", &json!({"rule": "evenOdd"})).unwrap(); + assert_eq!(outline(&node(s, c)).1, FillRule::EvenOdd); + c +} + +/// A path split off an even-odd compound path, and the rest of it, keep its fill rule. +#[test] +fn shape_builder_stroke_split_keeps_the_fill_rule() { + let mut s = session(); + even_odd_ring(&mut s); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap(); + let out = ids(&r); + assert_eq!(shapes_of(&s, &out), vec![(false, 8, 500.0), (false, 2, 100.0)], "A's other three sides with H, then A's top side"); + assert_eq!(out.iter().map(|i| outline(&node(&s, *i)).1).collect::>(), vec![FillRule::EvenOdd; 2]); +} + +/// A split, and an Alt-click that opens a path on one of its edges, keep the path's name and opacity +/// mask on what is left of it. +#[test] +fn shape_builder_stroke_split_and_erase_keep_the_name_and_opacity_mask() { + let mut s = session(); + let (a, b) = corner(&mut s); + let m = rect(&mut s, 0.0, 0.0, 50.0, 50.0); + s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, m.0]})).unwrap(); + s.edit("t", |d, _| { + d.node_mut(a).unwrap().name = Some("A".into()); + Ok(()) + }) + .unwrap(); + let mask = node(&s, a).extras().mask.clone(); + assert!(mask.is_some()); + let kept = |s: &Session, ids: &[NodeId]| { + ids.iter().all(|i| { + let n = node(s, *i); + n.name.as_deref() == Some("A") && n.extras().mask == mask + }) + }; + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, -1]], "splitStroke": true})).unwrap(); + let out = ids(&r); + assert_eq!(out.len(), 2); + assert!(kept(&s, &out), "the rest and the run"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[101, 75]], "erase": true})).unwrap(); + let out = ids(&r); + assert_eq!(shapes_of(&s, &out[..1]), vec![(false, 5, 300.0)]); + assert!(kept(&s, &out[..1]), "A opened where B crosses it"); +} + +/// What is left of a path after an Alt-click or a merge, and the pieces of an open path that an +/// Alt-click leaves, keep the path's gradients where they were on it: A without its region outside +/// B, B without the regions merged into A, and the line without its piece inside the square. +#[test] +fn shape_builder_erase_and_merge_keep_the_gradients_where_they_were() { + let mut s = session(); + let (a, b) = two(&mut s); + let want = paint_with_gradients(&mut s, a); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]], "erase": true})).unwrap(); + let out = ids(&r); + assert_eq!(node(&s, out[0]).geometric_bounds().unwrap(), Rect::new(50.0, 0.0, 100.0, 100.0), "A inside B"); + assert_eq!((node(&s, out[0]).appearance.fill_paint(), node(&s, out[0]).appearance.stroke_paint()), want); + + let mut s = session(); + let (a, b) = two(&mut s); + let want = paint_with_gradients(&mut s, b); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap(); + let merged = NodeId(r["merged"].as_u64().unwrap()); + let rest = ids(&r).into_iter().find(|i| *i != merged).unwrap(); + assert_eq!(node(&s, rest).geometric_bounds().unwrap(), Rect::new(100.0, 0.0, 150.0, 100.0), "B outside A"); + assert_eq!((node(&s, rest).appearance.fill_paint(), node(&s, rest).appearance.stroke_paint()), want); + + let mut s = session(); + let (a, l) = square_and_line(&mut s); + let want = paint_with_gradients(&mut s, l); + s.execute("select.set", &json!({"ids": [a.0, l.0]})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[51, 50]], "erase": true})).unwrap(); + let ends: Vec = ids(&r).into_iter().filter(|i| !outline(&node(&s, *i)).0.is_closed()).collect(); + assert_eq!(ends.len(), 2, "the line's two loose ends"); + for i in ends { + let n = node(&s, i); + assert_eq!((n.appearance.fill_paint(), n.appearance.stroke_paint()), want, "{i:?}"); + } +} + +/// The tool with In Merge Mode, Clicking Stroke Splits the Path: over a stroke, the pointer shows +/// scissors and the run a click splits is drawn in the highlight color (none with Highlight Stroke +/// When Editable off), and a click splits it. Off, the same click leaves the rectangle as it was. +#[test] +fn shape_builder_tool_click_on_a_stroke_splits_with_the_option() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + let o = s.tool_options(); + assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(false), &json!(true), &json!("#ff4f4f"))); + let click = |s: &mut Session| { + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, -1.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, -1.0), v).unwrap(); + }; + let journal = s.journal.len(); + s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap(); + assert!(s.overlays(v).is_empty()); + click(&mut s); + assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "off, the click runs no command"); + s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true, "highlightColor": "Light Blue"}})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap(); + let ov = s.overlays(v); + let [vectorcraft_tools::Overlay::Path { path, color: [0x4f, 0x80, 0xff], .. }] = ov.as_slice() else { panic!("{ov:?}") }; + assert_eq!(vectorcraft_geom::Shape::bounding_box(path), Rect::new(0.0, 0.0, 100.0, 0.0)); + assert_eq!(s.cursor(vectorcraft_geom::Point::new(50.0, -1.0), Mods::default(), v), vectorcraft_tools::Cursor::Scissors); + s.set_tool_option_cmd(&json!({"values": {"highlightStroke": false}})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, -1.0), v).unwrap(); + assert!(s.overlays(v).is_empty()); + click(&mut s); + let (cmd, p) = s.journal.last().unwrap().clone(); + assert_eq!((cmd.as_str(), &p["splitStroke"], &p["points"]), ("shapeBuilder.merge", &json!(true), &json!([[50.0, -1.0]]))); + assert_eq!(layer_children(&s), 2); + // The options are kept when another tool is chosen. + s.select_tool("selection", v).unwrap(); + let o = s.tool_options_of("shapeBuilder"); + assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(false), &json!("#4f80ff"))); +} + +/// The tool with In Merge Mode, Clicking Stroke Splits the Path over a lone line, which a click +/// cannot split: the pointer does not show scissors, the line is not highlighted, and a click runs no +/// command. +#[test] +fn shape_builder_tool_does_not_offer_to_split_a_lone_line() { + let mut s = session(); + let l = line(&mut s, (0.0, 0.0), (100.0, 0.0)); + s.execute("select.set", &json!({"ids": [l.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true}})).unwrap(); + let journal = s.journal.len(); + s.pointer(&PointerEvent::new(PointerKind::Move, 50.0, 1.0), v).unwrap(); + assert!(s.overlays(v).is_empty(), "{:?}", s.overlays(v)); + assert_eq!(s.cursor(vectorcraft_geom::Point::new(50.0, 1.0), Mods::default(), v), vectorcraft_tools::Cursor::ShapeBuilder); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 1.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 1.0), v).unwrap(); + assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "the click runs no command"); +} + +/// The tool over a side shorter than the cut tolerance (0.05 pt for a 100 pt path), which +/// `shapeBuilder.merge` cannot cut out: the pointer does not show scissors, the side is not +/// highlighted, and a click outside the path runs no command. +#[test] +fn shape_builder_tool_does_not_offer_to_split_a_side_too_short_to_cut() { + let mut s = session(); + let anchors = [(0.0, 0.0), (100.0, 0.0), (100.0, 0.03), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| json!({"x": x, "y": y})); + let r = s.execute("path.create", &json!({"anchors": anchors, "closed": true})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("shapeBuilder", v).unwrap(); + s.set_tool_option_cmd(&json!({"values": {"clickStrokeSplitsPath": true}})).unwrap(); + let journal = s.journal.len(); + let at = (101.0, 0.015); + s.pointer(&PointerEvent::new(PointerKind::Move, at.0, at.1), v).unwrap(); + assert!(s.overlays(v).is_empty(), "{:?}", s.overlays(v)); + assert_eq!(s.cursor(vectorcraft_geom::Point::new(at.0, at.1), Mods::default(), v), vectorcraft_tools::Cursor::ShapeBuilder); + s.pointer(&PointerEvent::new(PointerKind::Down, at.0, at.1), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, at.0, at.1), v).unwrap(); + assert_eq!((s.journal.len(), layer_children(&s)), (journal, 1), "the click runs no command"); +} + // ---------- Live Paint ---------- fn live(s: &mut Session) -> NodeId { @@ -531,6 +1440,101 @@ fn live_paint_stroke_edge() { assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [300, 300]})).is_err()); } +/// Shape Builder refuses an on/off option that is not true or false and a tolerance or gap that is +/// not a number, given as text or as another value of the wrong kind, naming the key; the document +/// and the undo history stay as they were. True, false and null still work. +#[test] +fn shape_builder_refuses_a_number_or_boolean_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let mut s = session(); + two(&mut s); + let merge = |extra: Value| { + let mut p = json!({"points": [[75, 50]]}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + let calls = [ + not_a_switch("shapeBuilder.merge", merge(json!({"erase": "true"})), "erase"), + not_a_switch("shapeBuilder.merge", merge(json!({"openFilledAsClosed": "false"})), "openFilledAsClosed"), + not_a_switch("shapeBuilder.merge", merge(json!({"splitStroke": "true"})), "splitStroke"), + not_a_number("shapeBuilder.merge", merge(json!({"erase": true, "tolerance": "2"})), "tolerance"), + not_a_number("shapeBuilder.merge", merge(json!({"gap": "5"})), "gap"), + not_a_switch("shapeBuilder.regions", json!({"openFilledAsClosed": 1}), "openFilledAsClosed"), + not_a_number("shapeBuilder.regions", json!({"gap": [5]}), "gap"), + ]; + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + s.execute("shapeBuilder.merge", &merge(json!({"erase": false, "openFilledAsClosed": null}))).unwrap(); +} + +/// Live Paint and Image Trace refuse an on/off option that is not true or false and a width or +/// tolerance that is not a number, given as text or as another value of the wrong kind, naming the +/// key; the document and the undo history stay as they were. Numbers and null still work. +#[test] +fn live_paint_and_image_trace_refuse_a_number_or_boolean_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let mut s = session(); + let g = live(&mut s); + let calls = [ + not_a_switch("livePaint.gapOptions", json!({"closeGaps": "true"}), "closeGaps"), + not_a_number("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "width": "3"}), "width"), + not_a_number("livePaint.strokeEdge", json!({"group": g.0, "point": [100, 50], "tolerance": [4]}), "tolerance"), + not_a_switch("imageTrace.make", json!({"enhanced": "true"}), "enhanced"), + ]; + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100, 50], "width": 3, "tolerance": null})).unwrap(); + assert_eq!(node(&s, NodeId(r["edge"].as_u64().unwrap())).appearance.stroke_width(), 3.0); +} + +#[test] +fn live_paint_stroke_edge_skips_hidden_edges() { + let mut s = session(); + let g = live(&mut s); + // The vertical edge at x=100 is hidden on its own; the group stays visible. + let vertical_at = |s: &Session, x: f64| -> Vec { + vectorcraft_tools::builder::edges(&node(s, g)) + .iter() + .filter(|e| e.geometric_bounds().is_some_and(|bb| (bb.x0 - x).abs() < 1e-6 && (bb.x1 - x).abs() < 1e-6)) + .map(|e| e.id.0) + .collect() + }; + let hidden = vertical_at(&s, 100.0); + assert!(!hidden.is_empty()); + s.execute("object.setProps", &json!({"ids": hidden, "visible": false})).unwrap(); + // 1 pt from the hidden edge, the nearest visible edge within the tolerance is the one at x=50. + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50], "tolerance": 60})).unwrap(); + assert!(vertical_at(&s, 50.0).contains(&r["edge"].as_u64().unwrap()), "{r}"); + // With only the hidden edge within the tolerance, no edge is stroked. + assert!(s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [99, 50]})).is_err()); +} + +#[test] +fn live_paint_fill_skips_hidden_faces() { + let mut s = session(); + let g = live(&mut s); + // The face from x=50 to x=100 is hidden on its own; the group stays visible. + let hidden = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(75.0, 50.0)).unwrap().id; + s.execute("object.setProps", &json!({"ids": [hidden.0], "visible": false})).unwrap(); + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let children = node(&s, g).children().unwrap().len(); + // livePaint.fill at a point on the hidden face returns an error and fills no face, with `group` + // and with the group in `ids`, and it does not rebuild the group. + for p in [json!({"group": g.0, "point": [75, 50], "color": "#00ff00"}), json!({"ids": [g.0], "point": [75, 50], "color": "#00ff00"})] { + assert!(s.execute("livePaint.fill", &p).is_err(), "{p}"); + let face = node(&s, hidden); + assert!(!face.visible && face.appearance.fill_paint() != green, "{p}"); + assert_eq!(node(&s, g).children().unwrap().len(), children, "{p}"); + } + // Given another Live Paint group with a face at that point as well, that face is filled. + let other = rect(&mut s, 60.0, 40.0, 20.0, 20.0); + let other = NodeId(s.execute("livePaint.make", &json!({"ids": [other.0]})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("livePaint.fill", &json!({"ids": [g.0, other.0], "point": [75, 50], "color": "#00ff00"})).unwrap(); + assert_eq!(r["group"], other.0); + assert_eq!(node(&s, NodeId(r["face"].as_u64().unwrap())).appearance.fill_paint(), green); + assert!(!node(&s, hidden).visible); +} + #[test] fn live_paint_release_and_expand() { let mut s = session(); @@ -577,6 +1581,83 @@ fn live_paint_merge_keeps_paint_and_adds_faces() { assert_eq!(layer_children(&s), 1); } +/// Give Live Paint group `g` a lock, hidden state, appearance, knockout, opacity mask and +/// graphic style link, and return it as it then is. +fn dress_live_group(s: &mut Session, g: NodeId) -> Node { + use vectorcraft_doc::{Knockout, OpacityMask}; + let mask = Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), Default::default()); + s.edit("t", |d, _| { + let n = d.node_mut(g).unwrap(); + n.locked = true; + n.visible = false; + n.isolate = true; + n.knockout = Knockout::On; + n.appearance.set_stroke(Paint::solid(Color::rgb(0.0, 0.5, 0.0))); + let mut e = n.extras_mut(); + e.mask = Some(Box::new(OpacityMask::new(mask, true))); + e.graphic_style = Some(7); + Ok(()) + }) + .unwrap(); + node(s, g) +} + +/// Asserts that `after` is `before` with other children and Gap Options. +fn same_group_but_children(before: &Node, after: &Node) { + assert_eq!((after.locked, after.visible, after.isolate), (true, false, true)); + assert_eq!(after.appearance, before.appearance, "the group's own appearance"); + assert_eq!(after.extras().mask, before.extras().mask, "the opacity mask"); + assert_eq!(after.extras().graphic_style, Some(7), "the graphic style link"); + let mut want = before.clone(); + *want.children_mut().unwrap() = after.children().unwrap().clone(); + want.extras_mut().live_paint_gaps = after.extras().live_paint_gaps.clone(); + assert_eq!(after, &want); +} + +#[test] +fn live_paint_merge_keeps_the_groups_lock_visibility_mask_and_graphic_style() { + let mut s = session(); + let g = live(&mut s); + let before = dress_live_group(&mut s, g); + let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0); + let r = s.execute("livePaint.merge", &json!({"ids": [g.0, c.0]})).unwrap(); + assert_eq!(NodeId(r["id"].as_u64().unwrap()), g); + let after = node(&s, g); + same_group_but_children(&before, &after); + assert_eq!(vectorcraft_tools::builder::sources(&after).unwrap().children().unwrap().len(), 3, "the rectangle joined the sources"); + // One undo step brings back the group as it was, and the rectangle beside it. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, g), before); + assert_eq!(layer_children(&s), 2); +} + +#[test] +fn live_paint_make_and_fill_with_a_live_paint_group_selected_add_the_paths_to_it() { + let green = Paint::solid(Color::from_hex("#00ff00").unwrap()); + let fill_at = |s: &Session, g: NodeId, x: f64, y: f64| { + vectorcraft_tools::builder::face_at(&node(s, g), vectorcraft_geom::Point::new(x, y)).map(|f| f.appearance.fill_paint()) + }; + // Make, and the Live Paint Bucket on a path beside the group, each keep the group, its + // properties and the paint of its faces. + for (cmd, params) in [("livePaint.make", json!({})), ("livePaint.fill", json!({"point": [170, 30], "color": "#0000ff"}))] { + let mut s = session(); + let g = live(&mut s); + s.execute("livePaint.fill", &json!({"group": g.0, "point": [25, 50], "color": "#00ff00"})).unwrap(); + let before = dress_live_group(&mut s, g); + let c = rect(&mut s, 120.0, 20.0, 100.0, 20.0); + let mut p = params; + p["ids"] = json!([g.0, c.0]); + let r = s.execute(cmd, &p).unwrap(); + let id = r.get("id").or_else(|| r.get("group")).and_then(Value::as_u64); + assert_eq!(id, Some(g.0), "{cmd}"); + same_group_but_children(&before, &node(&s, g)); + assert_eq!(fill_at(&s, g, 25.0, 50.0), Some(green.clone()), "{cmd}"); + assert_eq!(layer_children(&s), 1, "{cmd}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, g), before, "{cmd}"); + } +} + /// A Live Paint group of one open path round the square 0..100, its end 2 pt short of its start. fn live_square_with_a_gap(s: &mut Session) -> NodeId { let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(x, y)| json!({"x": x, "y": y})); @@ -707,7 +1788,7 @@ fn a_gradient_face_split_by_a_closed_gap_keeps_one_gradient_across_both_halves() let mut s = session(); let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0); let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); s.edit("t", |d, _| { d.node_mut(r).unwrap().appearance.set_fill(gradient); Ok(()) @@ -895,7 +1976,15 @@ fn add_image(s: &mut Session, r: &vectorcraft_trace::Raster, xf: Affine) -> Node 0, Node::new( id, - NodeKind::Image(Box::new(ImageObject { key: "img1".into(), width: w, height: h, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key: "img1".into(), + width: w, + height: h, + xf, + link: None, + placement: Default::default(), + pixels: None, + })), ), )?; sel.set([id]); diff --git a/crates/engine/src/tests_clip.rs b/crates/engine/src/tests_clip.rs index f3ea91f8c..2cc16daee 100644 --- a/crates/engine/src/tests_clip.rs +++ b/crates/engine/src/tests_clip.rs @@ -170,3 +170,47 @@ fn imported_multi_shape_and_even_odd_clip_paths_clip() { assert!(!opaque(&r, 70, 75), "the even-odd centre"); assert_outputs_agree(&mut s, &[(15, 20), (75, 20), (45, 20), (15, 43), (70, 56), (70, 75)]); } + +/// The children of node `g`, bottom first. +fn child_ids(s: &Session, g: NodeId) -> Vec { + s.doc().unwrap().doc.children(Some(g)).unwrap().iter().map(|n| n.id).collect() +} + +#[test] +fn arranging_inside_a_clip_group_keeps_the_clipping_path_at_the_bottom() { + // #3188: Send to Back on clipped art made it the clipping path, and the group showed nothing. + let mut s = session(); + let rect = + |s: &mut Session, x: f64, y: f64, w: f64| id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": w})).unwrap()); + let a = rect(&mut s, 0.0, 0.0, 50.0); + let b = rect(&mut s, 50.0, 50.0, 50.0); + let clip = ellipse(&mut s, 20.0, 60.0); + select(&mut s, &[a, b, clip]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + assert_eq!(child_ids(&s, g), [clip, a, b]); + let send = |s: &mut Session, ids: &[NodeId], cmd: &str| { + select(s, ids); + s.execute(cmd, &json!({})).unwrap(); + }; + + send(&mut s, &[b], "object.arrange.sendToBack"); + assert_eq!(child_ids(&s, g), [clip, b, a], "the art goes just above the clipping path"); + let r = render(&s); + assert!(opaque(&r, 40, 40) && opaque(&r, 60, 60), "both pieces of art still show inside the clip"); + assert!(!opaque(&r, 5, 5), "and are still clipped"); + + send(&mut s, &[a], "object.arrange.sendBackward"); + assert_eq!(child_ids(&s, g), [clip, a, b]); + send(&mut s, &[a], "object.arrange.sendBackward"); + assert_eq!(child_ids(&s, g), [clip, a, b], "the lowest art stays above the clipping path"); + send(&mut s, &[a, b], "object.arrange.sendToBack"); + assert_eq!(child_ids(&s, g), [clip, a, b], "several pieces of art keep their order"); + + // The clipping path itself stays where it is: moving it up would make art the clip. + for cmd in ["object.arrange.bringForward", "object.arrange.bringToFront"] { + send(&mut s, &[clip], cmd); + assert_eq!(child_ids(&s, g), [clip, a, b], "{cmd}"); + } + send(&mut s, &[clip, a], "object.arrange.bringToFront"); + assert_eq!(child_ids(&s, g), [clip, b, a], "the art selected with it still moves"); +} diff --git a/crates/engine/src/tests_clipboard.rs b/crates/engine/src/tests_clipboard.rs index b373a7973..b32b1c44f 100644 --- a/crates/engine/src/tests_clipboard.rs +++ b/crates/engine/src/tests_clipboard.rs @@ -83,8 +83,20 @@ fn renamed(v: &Value) -> Vec<(String, String, String)> { // ---------- M4.22: resources ---------- +/// Verify ordinary paste retains image, symbol and pattern resources and their rendered appearance. #[test] fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() { + resource_transfer(false); +} + +/// Verify a translated artwork drop preserves editable resources, pattern phase, opacity and clipboard contents. +#[test] +fn dropped_artwork_preserves_editable_images_symbols_patterns_and_clipboard() { + resource_transfer(true); +} + +/// Exercise paste or tab-drop resource transfer and compare source and destination rendering. +fn resource_transfer(drag: bool) { let mut s = session(); // Pattern "Dots" painting a rectangle. let dot = rect(&mut s, 0.0, 0.0, 10.0, 10.0); @@ -107,21 +119,37 @@ fn an_image_symbol_and_pattern_rect_pasted_into_a_new_document_render_the_same() sel(&mut s, &[e]); s.execute("object.rasterize", &json!({"ppi": 72})).unwrap(); s.execute("select.all", &json!({})).unwrap(); + s.execute("transparency.set", &json!({"opacity":45})).unwrap(); s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(s.clipboard.nodes.len(), 3); assert_eq!((s.clipboard.symbols.len(), s.clipboard.patterns.len(), s.clipboard.images.len()), (1, 1, 1)); let before = render(&s); - + let source = s.doc().unwrap().clone(); + let ids = source.selection.objects.clone(); new_doc(&mut s); assert!(before != render(&s)); - let r = s.execute("edit.pasteInPlace", &json!({})).unwrap(); + let r = if drag { + s.clipboard = Clipboard::default(); + s.execute("edit.dropSelection", &json!({"sourceDocument": source.uid, "ids": ids, "grab": [0,0], "at": [13,17]})).unwrap() + } else { + s.execute("edit.pasteInPlace", &json!({})).unwrap() + }; + if drag { + assert_eq!(s.clipboard, Clipboard::default()); + } + assert_eq!(s.documents()[0].doc, source.doc); // The pattern (its swatch comes along), the symbol and the image blob. assert_eq!((r["added"].as_u64(), r["merged"].as_u64()), (Some(3), Some(0)), "{r}"); let d = doc(&s); assert!(d.pattern("Dots").is_some() && d.swatch("Dots").is_some()); assert!(d.symbols.iter().any(|x| x.name == "Badge")); assert_eq!(d.images.len(), 1); - assert!(before == render(&s), "the pasted art renders as it did in its own document"); + let after = if drag { + vectorcraft_render::Renderer::new().render_region(doc(&s), Rect::new(13.0, 17.0, 413.0, 317.0), 1.0, true).pixels + } else { + render(&s) + }; + assert!(before == after, "the transferred art, its transparency and pattern alignment render as in the source"); } #[test] @@ -649,3 +677,107 @@ fn paste_remembers_layers_survives_save_and_old_files_load_without_it() { assert!(v.get("paste_remembers_layers").is_none(), "off is not written"); assert!(!vectorcraft_format::load(v.to_string().as_bytes()).unwrap().paste_remembers_layers); } + +/// Verify document-scoped object IDs, grab-point placement and one destination Undo/Redo step. +#[test] +fn artwork_drop_uses_source_ids_hotspot_and_one_target_undo_step() { + let mut s = session(); + let r = swatched_rect(&mut s, "Brand", "#ff0000"); + let source = s.doc().unwrap().clone(); + new_doc(&mut s); + let other = swatched_rect(&mut s, "Brand", "#0000ff"); + assert_eq!(r, other, "node ids collide across documents"); + copy(&mut s, &[other]); + let clip = s.clipboard.clone(); + let target = s.doc().unwrap().clone(); + let result = s.execute("edit.dropSelection", &json!({"sourceDocument": source.uid, "ids": [r, r], "grab": [25,30], "at": [175,130]})).unwrap(); + let ids = ids_of(&result); + assert_eq!(ids.len(), 1); + assert_eq!(fill(&s, ids[0]), linked(hex("#ff0000"), "Brand 2", 1.0)); + assert_eq!(fill(&s, other), linked(hex("#0000ff"), "Brand", 1.0)); + assert_eq!(node(&s, ids[0]).geometric_bounds().unwrap(), Rect::new(170.0, 120.0, 210.0, 160.0)); + assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len() + 1); + assert_eq!(s.clipboard, clip); + assert_eq!(s.documents()[0].doc, source.doc); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, target.doc); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, ids); +} + +/// Verify malformed, oversized, stale and overflowing drop requests leave document and clipboard state intact. +#[test] +fn invalid_artwork_drop_is_atomic() { + let mut s = session(); + let r = rect(&mut s, 10.0, 10.0, 40.0, 40.0); + let uid = s.execute("document.inspect", &json!({})).unwrap()["uid"].as_u64().unwrap(); + new_doc(&mut s); + let target = s.doc().unwrap().clone(); + let clipboard = s.clipboard.clone(); + for patch in [ + json!({"sourceDocument": u64::MAX}), + json!({"sourceDocument": -1}), + json!({"sourceDocument": 1.5}), + json!({"sourceDocument": null}), + json!({"ids": vec![r;10001]}), + json!({"ids": null}), + json!({"ids": [r,-1]}), + json!({"ids": [r,1.5]}), + json!({"grab": [0,0,0]}), + json!({"grab": ["0",0]}), + json!({"ids": []}), + json!({"ids": [r, 9999999]}), + json!({"ids": ["1"]}), + json!({"grab": [0]}), + json!({"at": [null, 0]}), + json!({"at": [1e308, 0]}), + ] { + let mut p = json!({"sourceDocument": uid, "ids": [r], "grab": [0,0], "at": [50,60]}); + p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone()); + assert!(s.execute("edit.dropSelection", &p).is_err(), "{p}"); + assert_eq!(s.doc().unwrap().doc, target.doc); + assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len()); + assert_eq!(s.doc().unwrap().selection, target.selection); + assert_eq!(s.clipboard, clipboard); + } + s.execute("file.close", &json!({"index":0})).unwrap(); + assert!(s.execute("edit.dropSelection", &json!({"sourceDocument": uid,"ids":[r],"grab":[0,0],"at":[50,60]})).is_err()); + assert_eq!(s.doc().unwrap().doc, target.doc); +} + +/// Verify refusing a locked insertion context preserves the target document and its undo history. +#[test] +fn artwork_drop_into_a_locked_destination_is_atomic() { + let mut s = session(); + let r = swatched_rect(&mut s, "Brand", "#ff0000"); + let uid = s.doc().unwrap().uid; + new_doc(&mut s); + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("layer.setProps", &json!({"id":layer,"locked":true})).unwrap(); + let target = s.doc().unwrap().clone(); + assert!(s.execute("edit.dropSelection", &json!({"sourceDocument":uid,"ids":[r],"grab":[0,0],"at":[100,100]})).is_err()); + assert_eq!(s.doc().unwrap().doc, target.doc); + assert_eq!(s.doc().unwrap().selection, target.selection); + assert_eq!(s.doc().unwrap().history.undo.len(), target.history.undo.len()); +} + +/// Verify tab drops use the editable current layer despite Paste Remembers Layers and source-layer name collisions. +#[test] +fn artwork_drop_uses_the_current_layer_even_when_paste_remembers_hidden_or_locked_source_layers() { + for hidden in [true, false] { + let mut s = session(); + let r = rect(&mut s, 10.0, 10.0, 40.0, 40.0); + let uid = s.doc().unwrap().uid; + new_doc(&mut s); + let blocked = doc(&s).layers[0].id; + s.execute("layer.setProps", &json!({"id":blocked,"visible":!hidden,"locked":!hidden})).unwrap(); + let current = id_of(&s.execute("layer.new", &json!({"name":"Flyer"})).unwrap()); + s.execute("layer.pasteRemembersLayers", &json!({"on":true})).unwrap(); + let blocked_before = node(&s, blocked); + let placed = ids_of(&s.execute("edit.dropSelection", &json!({"sourceDocument":uid,"ids":[r],"grab":[0,0],"at":[100,100]})).unwrap()); + assert_eq!(doc(&s).parent_of(placed[0]), Some(current)); + assert_eq!(node(&s, blocked), blocked_before); + assert_eq!(doc(&s).layers.len(), 2); + assert!(doc(&s).paste_remembers_layers, "the preference still applies to ordinary Paste"); + } +} diff --git a/crates/engine/src/tests_cmdsplit.rs b/crates/engine/src/tests_cmdsplit.rs index 12e7f56d6..767b82b4c 100644 --- a/crates/engine/src/tests_cmdsplit.rs +++ b/crates/engine/src/tests_cmdsplit.rs @@ -77,6 +77,12 @@ fn leaf_targets_expand_groups_and_stop_at_compound_paths() { s.execute("select.set", &json!({"ids": [c.0, d.0]})).unwrap(); let comp = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); assert_eq!(leaf_targets(&s, &[comp]).unwrap(), vec![comp]); + // A member stands for its compound path, listed once (#3692). + let members: Vec = s.doc().unwrap().doc.node(comp).unwrap().children().into_iter().flatten().map(|m| m.id).collect(); + assert_eq!(members.len(), 2); + assert_eq!(leaf_targets(&s, &[members[0]]).unwrap(), vec![comp]); + assert_eq!(leaf_targets(&s, &[members[0], comp, members[1]]).unwrap(), vec![comp]); + assert_eq!(leaf_targets(&s, &[members[1], members[0]]).unwrap(), vec![comp]); // `paint_targets` reads `ids`, then `id`, then the selection. assert_eq!(paint_targets(&s, &json!({"ids": [g.0]})).unwrap(), vec![a, b]); assert_eq!(paint_targets(&s, &json!({"id": comp.0})).unwrap(), vec![comp]); diff --git a/crates/engine/src/tests_colourfonts.rs b/crates/engine/src/tests_colourfonts.rs new file mode 100644 index 000000000..7ea341aef --- /dev/null +++ b/crates/engine/src/tests_colourfonts.rs @@ -0,0 +1,266 @@ +//! Colour fonts through the session: the SVG importer it registers reads OpenType SVG glyphs, +//! which then draw, export and outline as art. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_text::test_fonts::{SVG_CHAR, source_sans_gid, svg_font}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +/// `font` with licence flags (OS/2 `fsType`) that forbid embedding it. +fn not_embeddable(mut font: Vec) -> Vec { + let tables = usize::from(u16::from_be_bytes([font[4], font[5]])); + let os2 = (0..tables).map(|i| 12 + 16 * i).find(|r| &font[*r..*r + 4] == b"OS/2").unwrap(); + let at = u32::from_be_bytes(font[os2 + 8..os2 + 12].try_into().unwrap()) as usize; + font[at + 8..at + 10].copy_from_slice(&0x0002u16.to_be_bytes()); + font +} + +/// The fills of `pdf`'s art read back, and where each is. +fn fills_of(pdf: &[u8]) -> Vec<(vectorcraft_color::Paint, vectorcraft_geom::Rect)> { + let back = vectorcraft_pdf::import_with_report(pdf, &vectorcraft_pdf::ImportOptions::default()).unwrap().document; + let mut fills = vec![]; + for l in &back.layers { + l.walk(&mut |n| { + if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) + && let Some(b) = n.geometric_bounds() + { + fills.push((n.appearance.fill_paint(), b)); + } + }); + } + fills +} + +/// A session with `family`, the SVG test font of `document` for [`SVG_CHAR`]'s glyph, and 32 pt +/// type of `SVG_CHAR` and `F` in it at (10, 50). +fn with_svg_type(family: &str, document: &str) -> Session { + let g = source_sans_gid(SVG_CHAR).unwrap(); + let mut s = session(); + vectorcraft_text::FontDb::global().add_font(svg_font(family, &[(g, g, document)]).unwrap()); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{SVG_CHAR}F"), "size": 32, "font": family})).unwrap(); + s +} + +/// OpenType SVG glyphs become groups of the art their documents draw, through the SVG importer the +/// session registers. +#[test] +fn create_outlines_turns_svg_glyphs_into_groups() { + let _fonts = crate::adding_fonts(); + let g = source_sans_gid(SVG_CHAR).unwrap(); + let doc = format!( + r##""## + ); + let mut s = with_svg_type("Svgtest Outlines", &doc); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let ch = node(&s, r[0]).children().unwrap().clone(); + assert!(matches!(ch[0].kind, NodeKind::Group { .. }), "the glyph's art: {:?}", ch[0].kind_label()); + let mut gradient = false; + ch[0].walk(&mut |n| gradient |= matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::Gradient(_))); + assert!(gradient, "with its gradient"); + // Half an em wide at 32 pt, standing 0.7 em on the baseline at y 50. + let b = ch[0].geometric_bounds().unwrap(); + assert!((b.width() - 16.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6 && (b.height() - 22.4).abs() < 1e-6, "{b:?}"); + assert!(matches!(ch[1].kind, NodeKind::Path { .. } | NodeKind::Compound { .. }), "the `F` an outline"); +} + +/// In a PDF with real text, an SVG glyph is text like any other (selectable, searchable), which +/// draws nothing itself, and its art is drawn where it sits: no outline of the font's covers it. +#[test] +fn pdf_real_text_draws_svg_glyphs_as_art_and_keeps_their_characters() { + let _fonts = crate::adding_fonts(); + let g = source_sans_gid(SVG_CHAR).unwrap(); + let doc = format!(r##""##); + let s = with_svg_type("Svgtest Pdf", &doc); + let d = s.doc().unwrap().doc.clone(); + let pdf = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); + // The glyphs each text-showing line writes (the font's codes are octal escapes), and whether + // it is invisible. + let raw = String::from_utf8_lossy(&pdf); + let shown: Vec<(bool, usize)> = + raw.lines().filter(|l| l.contains("Tj") || l.contains("TJ")).map(|l| (l.contains("3 Tr"), l.matches("\\0").count())).collect(); + assert_eq!(shown, [(false, 2)], "both characters once, as text"); + // The glyphs as the font draws them (its Type 3 glyph procedures, ` 0 d0` and what + // they paint): the SVG glyph's draws nothing, rather than the font's plain outline over the + // art; the `F` its outline. + let procs: Vec<&str> = raw.split("stream\n").filter(|c| c.contains(" d0")).collect(); + assert_eq!(procs.len(), 2, "{procs:?}"); + assert_eq!(procs.iter().filter(|c| !c.contains(" Do")).count(), 1, "one glyph draws nothing: {procs:?}"); + let back = vectorcraft_pdf::import_with_report(&pdf, &vectorcraft_pdf::ImportOptions::default()).unwrap().document; + let mut red = false; + for l in &back.layers { + l.walk(&mut |n| red |= n.appearance.fill_paint() == vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0))); + } + assert!(red, "the glyph's art"); +} + +/// An SVG glyph whose document holds no art for it keeps its outline in PDF real text too (its +/// text draws nothing, as SVG glyphs' do). +#[test] +fn pdf_real_text_draws_the_outline_of_an_svg_glyph_without_art() { + let _fonts = crate::adding_fonts(); + let doc = r##""##; + let s = with_svg_type("Svgtest Bare Pdf", doc); + let d = s.doc().unwrap().doc.clone(); + let pdf = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); + // The `E`'s outline: from x 10 (its side bearing past it), 0.66 em tall, on the baseline at 50. + let e = vectorcraft_geom::Rect::new(12.0, 32.0, 20.0, 48.0); + assert!(fills_of(&pdf).iter().any(|(_, b)| b.intersect(e).area() > 10.0), "the glyph's outline"); +} + +/// In a font that may not be embedded, an SVG glyph's art is drawn once. +#[test] +fn pdf_draws_an_svg_glyphs_art_once_when_its_font_cant_be_embedded() { + let _fonts = crate::adding_fonts(); + let g = source_sans_gid(SVG_CHAR).unwrap(); + let doc = format!(r##""##); + let mut s = session(); + vectorcraft_text::FontDb::global().add_font(not_embeddable(svg_font("Svgtest Locked", &[(g, g, &doc)]).unwrap())); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{SVG_CHAR}F"), "size": 32, "font": "Svgtest Locked"})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + let pdf = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); + let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0)); + assert_eq!(fills_of(&pdf).iter().filter(|(p, _)| *p == red).count(), 1); +} + +/// Expand Appearance of type with an effect of its own (drawn reshaped as its outlines) keeps its +/// colour glyphs, as their art. +#[test] +fn expand_appearance_keeps_the_colour_glyphs_of_type_with_effects() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font}; + vectorcraft_text::FontDb::global().add_font(colr_font(true).unwrap()); + let mut s = session(); + let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F"), "size": 32, "font": colr_family(true)})).unwrap(); + let t = NodeId(r["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"ids": [t.0], "effect": "distort.transform", "params": {"moveH": 5}})).unwrap(); + let r = ids(&s.execute("effect.expandAppearance", &json!({})).unwrap()); + let mut gradients = 0; + for id in r { + node(&s, id).walk(&mut |n| gradients += usize::from(matches!(n.appearance.fill_paint(), vectorcraft_color::Paint::Gradient(_)))); + } + assert!(gradients > 0, "the colour glyph's gradient layers"); + vectorcraft_testkit::invariants::check_document(&s.doc().unwrap().doc).unwrap(); +} + +/// The canvas draws an SVG glyph as its art: its own colours, and the type's fill where it uses +/// `currentColor`. +#[test] +fn the_canvas_draws_svg_glyphs_as_their_art() { + let _fonts = crate::adding_fonts(); + let g = source_sans_gid(SVG_CHAR).unwrap(); + // The left half of a 500 by 700 box red, the right half in the text's colour. + let doc = format!( + r##""## + ); + let s = with_svg_type("Svgtest Canvas", &doc); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, vectorcraft_geom::Affine::IDENTITY, &opts); + // At 32 pt from x 10 on the baseline at y 50: 16 pt wide, 22.4 pt tall. + assert_eq!(img.pixel(14, 40), [255, 0, 0, 255], "its own red"); + assert_eq!(img.pixel(22, 40), [0, 0, 0, 255], "the type's black fill"); +} + +/// Expand Appearance of type with an effect keeps its emoji as images, their pixels taken into +/// the document; the PDF of the type before expanding draws them too. +#[test] +fn expand_appearance_keeps_the_emoji_of_type_with_effects() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font, solid_png}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut s = session(); + let r = s + .execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F"), "size": 32, "font": colour_bitmap_family(false)})) + .unwrap(); + let t = NodeId(r["id"].as_u64().unwrap()); + // Turned, not only moved: type that a Transform only moves stays type in exports. + s.execute("effect.apply", &json!({"ids": [t.0], "effect": "distort.transform", "params": {"rotate": 10}})).unwrap(); + let pdf = vectorcraft_pdf::export_with_report(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); + assert!(pdf.warnings.is_empty(), "{:?}", pdf.warnings); + assert!(pdf.bytes.windows(14).any(|w| w == b"/Subtype/Image"), "the emoji, in the PDF"); + let r = ids(&s.execute("effect.expandAppearance", &json!({})).unwrap()); + let mut images = vec![]; + for id in r { + node(&s, id).walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + images.push((**im).clone()); + } + }); + } + let d = &s.doc().unwrap().doc; + let glyph = vectorcraft_doc::ImageBlob::png(solid_png(COLOUR_PPEM.into(), COLOUR_LARGE)); + assert_eq!(images.len(), 1, "the emoji"); + assert!(images[0].pixels.is_none(), "the document holds them"); + assert_eq!(d.images[&images[0].key].bytes, glyph.bytes); + vectorcraft_testkit::invariants::check_document(d).unwrap(); +} + +/// Emoji type in a warp envelope: SVG export writes each emoji's pixels once for all the warp's +/// pieces, and Object › Expand takes them into the document. +#[test] +fn an_envelope_shares_and_expands_the_emoji_of_its_type() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut s = session(); + let r = s + .execute("text.create", &json!({"x": 10, "y": 50, "text": COLOUR_CHAR.to_string(), "size": 32, "font": colour_bitmap_family(false)})) + .unwrap(); + s.execute("select.set", &json!({"ids": [r["id"]]})).unwrap(); + let e = NodeId(s.execute("object.envelope.makeWithWarp", &json!({"style": "arch", "bend": 50})).unwrap()["id"].as_u64().unwrap()); + let svg = vectorcraft_svg::export_full(&s.doc().unwrap().doc, &Default::default(), None).svg; + assert_eq!(svg.matches("data:image/png").count(), 1, "one copy of the pixels"); + s.execute("object.envelope.expand", &json!({})).unwrap(); + let mut images = vec![]; + node(&s, e).walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + images.push((**im).clone()); + } + }); + let d = &s.doc().unwrap().doc; + assert!(images.len() > 1, "the warp's pieces"); + assert!(images.iter().all(|im| im.pixels.is_none() && d.images.contains_key(&im.key)), "the document holds them"); + vectorcraft_testkit::invariants::check_document(d).unwrap(); +} + +/// An image in an SVG glyph's document draws on the canvas, and Create Outlines takes its pixels +/// into the document. +#[test] +fn svg_glyph_images_draw_and_outline() { + let g = source_sans_gid(SVG_CHAR).unwrap(); + let png = vectorcraft_text::test_fonts::solid_png(4, [0, 0, 255, 255]); + let doc = format!( + r##""##, + vectorcraft_svg::base64_encode(&png) + ); + let mut s = with_svg_type("Svgtest Images", &doc); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let img = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 100, 100, vectorcraft_geom::Affine::IDENTITY, &opts); + // At 32 pt from x 10 on the baseline at y 50: 16 pt wide, 22.4 pt tall. + assert_eq!(img.pixel(18, 40), [0, 0, 255, 255], "the glyph's image"); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let mut images = vec![]; + node(&s, r[0]).walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + images.push((**im).clone()); + } + }); + let d = &s.doc().unwrap().doc; + assert_eq!(images.len(), 1); + assert!(images[0].pixels.is_none() && d.images[&images[0].key].bytes.as_slice() == png.as_slice(), "the document holds its pixels"); + vectorcraft_testkit::invariants::check_document(d).unwrap(); +} diff --git a/crates/engine/src/tests_compoundpaint.rs b/crates/engine/src/tests_compoundpaint.rs new file mode 100644 index 000000000..8c81ef2ce --- /dev/null +++ b/crates/engine/src/tests_compoundpaint.rs @@ -0,0 +1,89 @@ +//! Fill and stroke given to a path of a compound path paint the compound path (#3692): the +//! compound holds the paint, so a member's own appearance is never drawn. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; + +use super::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap() +} + +fn rect(s: &mut Session, x: f64) -> NodeId { + NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 40, "height": 40}))["id"].as_u64().unwrap()) +} + +/// A compound path of two black rectangles and a loose black rectangle → (compound, members, loose). +fn compound() -> (Session, NodeId, Vec, NodeId) { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 100})); + let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 10.0)); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + run(&mut s, "object.compoundPath.make", json!({})); + let compound = s.doc().unwrap().selection.objects[0]; + let loose = rect(&mut s, 100.0); + run(&mut s, "paint.setFill", json!({"color": "#000000", "ids": [compound.0, loose.0]})); + run(&mut s, "paint.setStroke", json!({"color": "#000000", "ids": [compound.0, loose.0]})); + let doc = &s.doc().unwrap().doc; + let members: Vec = doc.node(compound).unwrap().children().into_iter().flatten().map(|m| m.id).collect(); + assert_eq!(members.len(), 2); + (s, compound, members, loose) +} + +/// The hex of `id`'s own top fill (`fill`) or stroke, `None` when it has none or it isn't solid. +fn paint(s: &Session, id: NodeId, fill: bool) -> Option { + match s.doc().unwrap().doc.node(id).unwrap().appearance.paint_at(None, fill)? { + Paint::Solid { color, .. } => Some(color.to_hex()), + _ => None, + } +} + +fn solid(hex: &str) -> Option { + Some(hex.to_string()) +} + +#[test] +fn a_fill_given_to_a_member_id_paints_its_compound() { + let (mut s, compound, members, loose) = compound(); + let member_paint = paint(&s, members[0], true); + run(&mut s, "paint.setFill", json!({"color": "#ff00ff", "ids": [members[0].0, members[1].0, loose.0]})); + assert_eq!(paint(&s, compound, true), solid("#ff00ff"), "the compound path takes the fill"); + assert_eq!(paint(&s, loose, true), solid("#ff00ff")); + assert_eq!(paint(&s, members[0], true), member_paint, "the member's own appearance is left alone"); + assert_eq!(paint(&s, compound, false), solid("#000000"), "the stroke stays"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(paint(&s, compound, true), solid("#000000"), "one undo step"); +} + +#[test] +fn a_stroke_given_to_a_selected_member_paints_its_compound() { + let (mut s, compound, members, _) = compound(); + run(&mut s, "select.set", json!({"ids": [members[1].0]})); + run(&mut s, "paint.setStroke", json!({"color": "#00ff00"})); + assert_eq!(paint(&s, compound, false), solid("#00ff00")); + run(&mut s, "paint.setFill", json!({"color": "#0000ff"})); + assert_eq!(paint(&s, compound, true), solid("#0000ff")); +} + +#[test] +fn the_members_of_one_compound_are_one_target() { + let (mut s, compound, members, _) = compound(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [compound.0]})); + // Swapped once per id, the two members would swap the compound path back. + run(&mut s, "paint.swap", json!({"ids": [members[0].0, members[1].0]})); + assert_eq!(paint(&s, compound, true), solid("#000000"), "swapped once, not once per member"); + assert_eq!(paint(&s, compound, false), solid("#ff0000")); +} + +#[test] +fn a_group_with_a_compound_still_paints_the_compound_once() { + let (mut s, compound, _, loose) = compound(); + run(&mut s, "select.set", json!({"ids": [compound.0, loose.0]})); + let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [group.0]})); + run(&mut s, "paint.swap", json!({"ids": [group.0]})); + assert_eq!(paint(&s, compound, true), solid("#000000")); + assert_eq!(paint(&s, compound, false), solid("#ff0000")); + assert_eq!(paint(&s, loose, false), solid("#ff0000")); +} diff --git a/crates/engine/src/tests_constrain_new.rs b/crates/engine/src/tests_constrain_new.rs new file mode 100644 index 000000000..3a53333c5 --- /dev/null +++ b/crates/engine/src/tests_constrain_new.rs @@ -0,0 +1,392 @@ +//! General › Constrain Angle sets the angle new objects are drawn at (G12, #394, second batch): +//! type, grids, polygons and stars at their base orientation, shapes made by a click through the +//! size dialogs, and the Line Segment tool's Shift steps. 0° and whole turns draw exactly as +//! before; quarter turns are exact. + +use serde_json::{Value, json}; +use vectorcraft_doc::{LiveShape, NodeKind, TextKind}; +use vectorcraft_geom::{PathData, Point, Rect}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +use PointerKind::{Down, Drag, Up}; + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn set_angle(s: &mut Session, deg: f64) { + s.execute("prefs.set", &json!({"key": "constrainAngle", "value": deg})).unwrap(); +} + +fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) { + for (k, x, y) in events { + s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), ViewInfo { smart_guides: false, ..ViewInfo::default() }).unwrap(); + } +} + +fn last_id(s: &Session) -> NodeId { + *s.doc().unwrap().selection.objects.first().unwrap() +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +/// The angles (degrees, counter-clockwise on the page, in 0..90) of the straight segments of `p`. +fn segment_angles(p: &PathData) -> Vec { + p.subpaths + .iter() + .flat_map(|sp| sp.anchors.windows(2).map(|w| w[1].p - w[0].p)) + .filter(|v| v.hypot() > 1e-9) + .map(|v| (-v.y).atan2(v.x).to_degrees().rem_euclid(90.0)) + .collect() +} + +/// The paths of the group `id` (a grid), on the page. +fn grid_paths(s: &Session, id: NodeId) -> Vec { + let n = s.doc().unwrap().doc.node(id).unwrap(); + n.children() + .unwrap() + .iter() + .map(|c| match &c.kind { + NodeKind::Path { path, .. } => path.clone(), + other => panic!("a path: {other:?}"), + }) + .collect() +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap() +} + +/// Rectangular and polar grids are drawn along the turned axes: at 30° the frame's sides run at +/// 30° and 120°, at 90° the grid covers the drag exactly, 20 wide and 60 high along the turned +/// axes; at 0° and a whole turn the grid is the same as before, bit for bit. +#[test] +fn constrain_angle_draws_grids_along_the_turned_axes() { + let mut s = new_doc(); + let draw = |s: &mut Session, tool: &str, deg: f64| { + set_angle(s, deg); + s.select_tool(tool, ViewInfo::default()).unwrap(); + gesture(s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]); + last_id(s) + }; + for tool in ["rectangularGrid", "polarGrid"] { + let flat = draw(&mut s, tool, 0.0); + let flat_paths = grid_paths(&s, flat); + for turn in [360.0, -360.0] { + let id = draw(&mut s, tool, turn); + assert_eq!(grid_paths(&s, id), flat_paths, "{tool} at {turn}°: as at 0°"); + } + let b = bounds(&s, flat); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (100.0, 100.0, 160.0, 120.0), "{tool} at 0°"); + // A quarter turn: the drag's box exactly, no floating-point noise. + let id = draw(&mut s, tool, 90.0); + let b = bounds(&s, id); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (100.0, 100.0, 160.0, 120.0), "{tool} at 90°: {b:?}"); + } + // At 30° the rectangular grid's lines run along the turned axes. + let id = draw(&mut s, "rectangularGrid", 30.0); + for p in grid_paths(&s, id) { + for a in segment_angles(&p) { + assert!(close(a, 30.0), "a grid line at {a}°"); + } + } + // Through the command: `rotation` turns a grid about its centre. + let id = NodeId( + s.execute("shape.rectangularGrid", &json!({"x": 0, "y": 0, "width": 40, "height": 20, "rotation": -90})).unwrap()["id"].as_u64().unwrap(), + ); + let b = bounds(&s, id); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, -10.0, 30.0, 30.0)); +} + +/// Shift keeps a polygon or star at its base orientation, which is upright turned by the +/// Constrain Angle: at 30° the polygon is turned 30° counter-clockwise, at 90° the star's first +/// point is exactly left of its centre, at 0° and whole turns both are upright as before. +#[test] +fn constrain_angle_turns_the_base_orientation_of_polygons_and_stars() { + let mut s = new_doc(); + let shift = Mods { shift: true, ..Mods::default() }; + let draw = |s: &mut Session, tool: &str, deg: f64| { + set_angle(s, deg); + s.select_tool(tool, ViewInfo::default()).unwrap(); + gesture(s, shift, &[(Down, 100.0, 100.0), (Drag, 130.0, 140.0), (Drag, 130.0, 140.0), (Up, 130.0, 140.0)]); + last_id(s) + }; + let polygon_turn = |s: &Session, id: NodeId| { + let NodeKind::Path { live: Some(live), .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a polygon") }; + let LiveShape::Polygon { xf, .. } = &**live else { panic!("a polygon") }; + let [a, b, ..] = xf.as_coeffs(); + -b.atan2(a).to_degrees() + }; + let path = |s: &Session, id: NodeId| match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Path { path, .. } => path.clone(), + other => panic!("a path: {other:?}"), + }; + let flat = draw(&mut s, "polygon", 0.0); + assert_eq!(polygon_turn(&s, flat), 0.0); + let flat_star = draw(&mut s, "star", 0.0); + for turn in [360.0, -360.0] { + let id = draw(&mut s, "polygon", turn); + assert_eq!(path(&s, id), path(&s, flat), "polygon at {turn}°"); + let id = draw(&mut s, "star", turn); + assert_eq!(path(&s, id), path(&s, flat_star), "star at {turn}°"); + } + let id = draw(&mut s, "polygon", 30.0); + assert!(close(polygon_turn(&s, id), 30.0), "{}", polygon_turn(&s, id)); + let id = draw(&mut s, "polygon", 90.0); + assert!(close(polygon_turn(&s, id), 90.0)); + // The 50 pt star's first point: straight up at 0°, straight left at 90°, exactly. + assert_eq!(path(&s, flat_star).subpaths[0].anchors[0].p, Point::new(100.0, 50.0)); + let id = draw(&mut s, "star", 90.0); + assert_eq!(path(&s, id).subpaths[0].anchors[0].p, Point::new(50.0, 100.0)); + // Without Shift the drag turns them, whatever the preference. + set_angle(&mut s, 30.0); + s.select_tool("polygon", ViewInfo::default()).unwrap(); + gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 150.0, 100.0), (Up, 150.0, 100.0)]); + assert!(close(polygon_turn(&s, last_id(&s)), -90.0), "the drag points the polygon right"); +} + +/// New type is set along the turned axes: point type turns about its first baseline's start, an +/// area's box is measured along the axes; 0° and whole turns as before, 90° exact. +#[test] +fn constrain_angle_sets_new_type_along_the_turned_axes() { + let mut s = new_doc(); + let xf = |s: &Session, id: NodeId| match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Text(t) => (t.xf, t.kind.clone()), + other => panic!("type: {other:?}"), + }; + // Each at its own place, so no click lands in type drawn before. + let type_at = |s: &mut Session, deg: f64, area: bool, y: f64| { + set_angle(s, deg); + s.select_tool("type", ViewInfo::default()).unwrap(); + if area { + gesture(s, Mods::default(), &[(Down, 20.0, y), (Drag, 70.0, y + 50.0), (Drag, 120.0, y + 60.0), (Up, 120.0, y + 60.0)]); + } else { + gesture(s, Mods::default(), &[(Down, 300.0, y), (Up, 300.0, y)]); + } + let id = last_id(s); + s.select_tool("selection", ViewInfo::default()).unwrap(); + let (m, kind) = xf(s, id); + // Where it was put, and how it is turned and shaped there. + (m.as_coeffs()[4..].to_vec(), m.as_coeffs()[..4].to_vec(), kind, m) + }; + let (at, flat_point, ..) = type_at(&mut s, 0.0, false, 20.0); + assert_eq!(at, [300.0, 20.0]); + let (at, flat_area, flat_frame, _) = type_at(&mut s, 0.0, true, 20.0); + assert_eq!(at, [20.0, 20.0]); + for (turn, y) in [(360.0, 110.0), (-360.0, 200.0)] { + let (at, turned, ..) = type_at(&mut s, turn, false, y); + assert_eq!((at, turned), (vec![300.0, y], flat_point.clone()), "point type at {turn}°"); + let (at, turned, frame, _) = type_at(&mut s, turn, true, y); + assert_eq!((at, turned, frame), (vec![20.0, y], flat_area.clone(), flat_frame.clone()), "area type at {turn}°"); + } + // Point type at 30°: its baseline runs 30° up from the horizontal, from the click. + let (at, turned, ..) = type_at(&mut s, 30.0, false, 290.0); + assert!(close(-turned[1].atan2(turned[0]).to_degrees(), 30.0) && at == [300.0, 290.0], "{turned:?} at {at:?}"); + // Area type at 90°: 100 right and 60 down on the page is 60 by 100 along the turned axes, and + // the frame covers the drag exactly. + let (_, _, kind, m) = type_at(&mut s, 90.0, true, 290.0); + let TextKind::Area { frame } = kind else { panic!("area type") }; + let local = frame.bounds().unwrap(); + assert_eq!((local.width(), local.height()), (60.0, 100.0)); + let page = frame.transformed(m).bounds().unwrap(); + assert_eq!((page.x0, page.y0, page.x1, page.y1), (20.0, 290.0, 120.0, 350.0)); + // Through the command. + let r = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "", "rotation": -90})).unwrap(); + let (m, _) = xf(&s, NodeId(r["id"].as_u64().unwrap())); + assert_eq!(m.as_coeffs(), [0.0, -1.0, 1.0, 0.0, 10.0, 20.0]); + assert!(s.execute("text.create", &json!({"x": 10, "y": 20, "text": "", "rotation": "x"})).is_err()); +} + +/// The top-left corner `x`, `y` of a size dialog's params: the point it places at the click. +fn corner(p: &Value) -> Point { + Point::new(p["x"].as_f64().unwrap_or(0.0), p["y"].as_f64().unwrap_or(0.0)) +} + +/// A click's size dialog draws along the turned axes too ([`constrain_click`]): the box keeps +/// its corner at the click, a polygon or star turns about its centre; at 0° and whole turns the +/// params are as they were. +#[test] +fn constrain_angle_turns_shapes_made_by_a_click() { + let mut s = new_doc(); + let rect = json!({"x": 10, "y": 200, "width": 100, "height": 50}); + for turn in [0.0, 360.0, -360.0] { + set_angle(&mut s, turn); + for (cmd, p) in [("shape.rectangle", rect.clone()), ("shape.polygon", json!({"cx": 10, "cy": 10, "radius": 50, "sides": 6}))] { + assert_eq!(constrain_click(&s, cmd, p.clone(), corner(&p)), p, "{cmd} at {turn}°"); + } + } + set_angle(&mut s, 90.0); + let run = |s: &mut Session, cmd: &str, p: Value| { + let at = corner(&p); + let p = constrain_click(s, cmd, p, at); + NodeId(s.execute(cmd, &p).unwrap()["id"].as_u64().unwrap()) + }; + for cmd in ["shape.rectangle", "shape.ellipse", "shape.rectangularGrid", "shape.polarGrid"] { + // 100 along the turned x axis (up the page) and 50 along the turned y (right). + let id = run(&mut s, cmd, rect.clone()); + let b = bounds(&s, id); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (10.0, 100.0, 60.0, 200.0), "{cmd} at 90°"); + } + let p = constrain_click(&s, "shape.star", json!({"cx": 0, "cy": 0, "radius1": 50, "radius2": 25, "points": 5}), Point::ZERO); + assert_eq!(p["rotation"], json!(-90.0)); + // An explicit rotation is the caller's. + assert_eq!(constrain_click(&s, "shape.polygon", json!({"rotation": 5}), Point::ZERO)["rotation"], json!(5)); + // Malformed params go through as they are, for the command to refuse. + assert_eq!(constrain_click(&s, "shape.polygon", json!([1]), Point::ZERO), json!([1])); + assert_eq!(constrain_click(&s, "shape.rectangle", json!({"x": "a"}), Point::ZERO), json!({"x": "a"})); + set_angle(&mut s, 30.0); + let id = run(&mut s, "shape.rectangle", rect.clone()); + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!() }; + // The click's corner stays put, the sides run along 30° and 120°. + assert!(path.subpaths[0].anchors.iter().any(|a| close(a.p.x, 10.0) && close(a.p.y, 200.0)), "{path:?}"); + for a in segment_angles(path) { + assert!(close(a, 30.0), "a side at {a}°"); + } +} + +/// The Line Segment tool's Shift steps of 45° count from the Constrain Angle. +#[test] +fn constrain_angle_steps_the_line_segment_tool() { + let mut s = new_doc(); + let shift = Mods { shift: true, ..Mods::default() }; + let line = |s: &mut Session, deg: f64, (x, y): (f64, f64)| { + set_angle(s, deg); + s.select_tool("lineSegment", ViewInfo::default()).unwrap(); + gesture(s, shift, &[(Down, 100.0, 100.0), (Drag, 150.0, 102.0), (Drag, x, y), (Up, x, y)]); + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(last_id(s)).unwrap().kind else { panic!() }; + let v = path.subpaths[0].anchors[1].p - path.subpaths[0].anchors[0].p; + (-v.y).atan2(v.x).to_degrees() + }; + let level = (200.0, 105.0); + assert_eq!(line(&mut s, 0.0, level), 0.0); + assert_eq!(line(&mut s, 360.0, level), 0.0); + // Nearly level: the step nearest it, counted from 30°, is −15°. + assert!(close(line(&mut s, 30.0, level), -15.0)); + assert!(close(line(&mut s, 30.0, (200.0, 45.0)), 30.0), "along the turned x axis"); + assert!(close(line(&mut s, 90.0, level), 0.0)); +} + +fn selection_rotation(s: &Session) -> f64 { + // What `document.inspect` reports as `selectionRotation`. + let st = s.doc().unwrap(); + st.doc.bbox_angle(&st.selection.objects) +} + +fn bbox_angle(s: &Session, id: NodeId) -> f64 { + s.doc().unwrap().doc.node(id).unwrap().bbox_angle +} + +/// Objects drawn along a Constrain Angle keep a bounding box turned with them, as after the Rotate +/// tool: at 30° the selection's rotation is 30°, the same as a rectangle turned 30° by +/// `object.rotate`; at 0° and whole turns the box stays on the page (exactly 0, not −0). +#[test] +fn constrain_angle_turns_the_new_objects_bounding_box() { + let mut s = new_doc(); + s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 40, "height": 20})).unwrap(); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + let rotated = selection_rotation(&s); + assert!(close(rotated, 30.0), "{rotated}"); + let shift = Mods { shift: true, ..Mods::default() }; + type Gesture = [(PointerKind, f64, f64); 4]; + let draws: [(&str, Mods, Gesture); 6] = [ + ("rectangle", Mods::default(), [(Down, 20.0, 20.0), (Drag, 50.0, 40.0), (Drag, 80.0, 60.0), (Up, 80.0, 60.0)]), + ("ellipse", Mods::default(), [(Down, 20.0, 120.0), (Drag, 50.0, 140.0), (Drag, 80.0, 160.0), (Up, 80.0, 160.0)]), + ("polygon", shift, [(Down, 150.0, 50.0), (Drag, 170.0, 70.0), (Drag, 180.0, 80.0), (Up, 180.0, 80.0)]), + ("star", shift, [(Down, 150.0, 150.0), (Drag, 170.0, 170.0), (Drag, 180.0, 180.0), (Up, 180.0, 180.0)]), + ("rectangularGrid", Mods::default(), [(Down, 220.0, 20.0), (Drag, 250.0, 40.0), (Drag, 280.0, 60.0), (Up, 280.0, 60.0)]), + ("polarGrid", Mods::default(), [(Down, 220.0, 120.0), (Drag, 250.0, 140.0), (Drag, 280.0, 160.0), (Up, 280.0, 160.0)]), + ]; + for deg in [0.0, 360.0, 30.0] { + set_angle(&mut s, deg); + for (tool, mods, events) in &draws { + s.select_tool(tool, ViewInfo::default()).unwrap(); + gesture(&mut s, *mods, events); + let id = last_id(&s); + if deg == 30.0 { + assert!(close(selection_rotation(&s), rotated), "{tool}: {}", selection_rotation(&s)); + } else { + assert_eq!(bbox_angle(&s, id).to_bits(), 0.0f64.to_bits(), "{tool} at {deg}°"); + } + } + } + // A polygon turned by the drag's direction, not the Constrain Angle, keeps its box on the page. + s.select_tool("polygon", ViewInfo::default()).unwrap(); + gesture(&mut s, Mods::default(), &[(Down, 150.0, 250.0), (Drag, 160.0, 260.0), (Drag, 190.0, 270.0), (Up, 190.0, 270.0)]); + assert_eq!(bbox_angle(&s, last_id(&s)).to_bits(), 0.0f64.to_bits(), "a polygon turned by the drag"); + // A grid's lines turn their boxes with it, and so does new type. + let id = + NodeId(s.execute("shape.polarGrid", &json!({"x": 0, "y": 0, "width": 40, "height": 20, "rotation": -30})).unwrap()["id"].as_u64().unwrap()); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + assert!(n.children().unwrap().iter().all(|c| close(c.bbox_angle, 30.0))); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi", "rotation": -30})).unwrap(); + assert!(close(selection_rotation(&s), 30.0)); + s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi"})).unwrap(); + assert_eq!(selection_rotation(&s).to_bits(), 0.0f64.to_bits()); +} + +/// The grid commands refuse a `rotation` that is not a number. +#[test] +fn grid_rotation_must_be_a_number() { + let mut s = new_doc(); + for cmd in ["shape.rectangularGrid", "shape.polarGrid"] { + for bad in [json!("x"), Value::Null] { + assert!(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 40, "height": 20, "rotation": bad})).is_err(), "{cmd}: {bad}"); + } + } +} + +/// Area type dragged at 30°: its frame runs along the turned axes with the drag's start and end +/// at opposite corners; vertical type takes a rotation as horizontal type does. +#[test] +fn constrain_angle_sets_area_and_vertical_type() { + let mut s = new_doc(); + set_angle(&mut s, 30.0); + s.select_tool("type", ViewInfo::default()).unwrap(); + gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 150.0, 150.0), (Drag, 250.0, 200.0), (Up, 250.0, 200.0)]); + let id = last_id(&s); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("type") }; + let [a, b, ..] = t.xf.as_coeffs(); + assert!(close(-b.atan2(a).to_degrees(), 30.0), "{:?}", t.xf); + let TextKind::Area { frame } = &t.kind else { panic!("area type") }; + // The drag (150 right, 100 down) measured along axes turned 30° counter-clockwise. + let r = 30f64.to_radians(); + let (w, h) = (150.0 * r.cos() - 100.0 * r.sin(), 150.0 * r.sin() + 100.0 * r.cos()); + let local = frame.bounds().unwrap(); + assert!(close(local.width(), w.abs()) && close(local.height(), h.abs()), "{local:?} vs {w} × {h}"); + let corners: Vec = [(local.x0, local.y0), (local.x1, local.y0), (local.x0, local.y1), (local.x1, local.y1)] + .iter() + .map(|&(x, y)| t.xf * Point::new(x, y)) + .collect(); + for p in [Point::new(100.0, 100.0), Point::new(250.0, 200.0)] { + assert!(corners.iter().any(|c| close(c.x, p.x) && close(c.y, p.y)), "{p:?} in {corners:?}"); + } + assert!(close(selection_rotation(&s), 30.0)); + s.select_tool("selection", ViewInfo::default()).unwrap(); + // Vertical type with a rotation. + let r = s.execute("text.create", &json!({"x": 300, "y": 50, "text": "Up", "vertical": true, "rotation": -90})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("type") }; + assert!(t.vertical); + assert_eq!(t.xf.as_coeffs(), [0.0, -1.0, 1.0, 0.0, 300.0, 50.0]); + assert_eq!(bbox_angle(&s, id), 90.0); +} + +/// A grid dialog placed by its reference point keeps that point at the click when the grid turns +/// along the Constrain Angle: at 90° with the centre as the reference, the box turns about it. +#[test] +fn constrain_angle_turns_a_click_made_grid_about_its_reference_point() { + let mut s = new_doc(); + set_angle(&mut s, 90.0); + // A 100 × 50 grid centred on the click at (200, 200). + let p = json!({"x": 150, "y": 175, "width": 100, "height": 50}); + let p = constrain_click(&s, "shape.rectangularGrid", p, Point::new(200.0, 200.0)); + let id = NodeId(s.execute("shape.rectangularGrid", &p).unwrap()["id"].as_u64().unwrap()); + let b = bounds(&s, id); + assert_eq!((b.x0, b.y0, b.x1, b.y1), (175.0, 150.0, 225.0, 250.0), "turned about the click: {b:?}"); +} diff --git a/crates/engine/src/tests_constrain_tools.rs b/crates/engine/src/tests_constrain_tools.rs new file mode 100644 index 000000000..ca6999c79 --- /dev/null +++ b/crates/engine/src/tests_constrain_tools.rs @@ -0,0 +1,179 @@ +//! General › Constrain Angle turns the axes the arrow keys, Shift-constrained drags and the +//! Rectangle, Rounded Rectangle and Ellipse tools work along (G12, #394, first batch). + +use serde_json::{Value, json}; +use vectorcraft_doc::{LiveShape, NodeKind}; +use vectorcraft_geom::{Point, Vec2}; +use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; + +use super::*; + +fn new_doc() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + s +} + +fn set_pref(s: &mut Session, key: &str, value: Value) { + s.execute("prefs.set", &json!({"key": key, "value": value})).unwrap(); +} + +fn rect(s: &mut Session, x: f64, y: f64) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()) +} + +fn centre(s: &Session, id: NodeId) -> Point { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().center() +} + +fn gesture(s: &mut Session, mods: Mods, events: &[(PointerKind, f64, f64)]) { + for (k, x, y) in events { + s.pointer(&PointerEvent::new(*k, *x, *y).with_mods(mods), ViewInfo { smart_guides: false, ..ViewInfo::default() }).unwrap(); + } +} + +/// The live rectangle or ellipse `id`: (width, height, rotation in degrees counter-clockwise on +/// the page, corner radius). +fn live_box(s: &Session, id: NodeId) -> (f64, f64, f64, f64) { + let n = s.doc().unwrap().doc.node(id).unwrap(); + let NodeKind::Path { live: Some(live), .. } = &n.kind else { panic!("a live shape: {:?}", n.kind) }; + let (w, h, xf, r) = match &**live { + LiveShape::Rectangle { w, h, xf, radii, .. } => (*w, *h, *xf, radii[0]), + LiveShape::Ellipse { w, h, xf, .. } => (*w, *h, *xf, 0.0), + other => panic!("a rectangle or ellipse: {other:?}"), + }; + let [a, b, ..] = xf.as_coeffs(); + (w, h, -b.atan2(a).to_degrees(), r) +} + +fn last_id(s: &Session) -> NodeId { + *s.doc().unwrap().selection.objects.first().unwrap() +} + +fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +/// General › Constrain Angle turns the arrow keys' axes: at 30°, → moves the art 30° up from the +/// horizontal and ↓ 30° round from straight down; ruler guides keep to the page's axes. +#[test] +fn constrain_angle_turns_the_arrow_keys() { + let mut s = new_doc(); + let id = rect(&mut s, 100.0, 100.0); + set_pref(&mut s, "keyboardIncrement", json!(10)); + let at = centre(&s, id); + s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); + assert!(close(centre(&s, id).x - at.x, 10.0) && close(centre(&s, id).y, at.y), "0°: straight right"); + set_pref(&mut s, "constrainAngle", json!(30)); + let at = centre(&s, id); + s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); + let d = centre(&s, id) - at; + assert!(close(d.x, 10.0 * 30f64.to_radians().cos()) && close(d.y, -5.0), "→ at 30°: {d:?}"); + let at = centre(&s, id); + s.execute("object.nudge", &json!({"dx": 0, "dy": 1})).unwrap(); + let d = centre(&s, id) - at; + assert!(close(d.x, 5.0) && close(d.y, 10.0 * 30f64.to_radians().cos()), "↓ at 30°: {d:?}"); + // Ruler guides: along the page. + let g = s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap()["index"].as_u64().unwrap(); + s.execute("guide.select", &json!({"indexes": [g]})).unwrap(); + s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); + let pos = s.doc().unwrap().doc.guides[g as usize].pos; + assert!(close(pos, 60.0), "a vertical guide moves 10 right: {pos}"); +} + +/// General › Constrain Angle holds Shift-drags to the turned axes: a Selection-tool move dragged +/// 2° off the 30° axis lands on it. +#[test] +fn constrain_angle_holds_shift_drags_to_the_turned_axes() { + use PointerKind::{Down, Drag, Up}; + let mut s = new_doc(); + let id = rect(&mut s, 100.0, 200.0); + s.select_tool("selection", ViewInfo::default()).unwrap(); + let shift = Mods { shift: true, ..Mods::default() }; + let drag = |s: &mut Session| { + let at = centre(s, id); + let a = 32f64.to_radians(); + let to = at + Vec2::new(a.cos(), -a.sin()) * 100.0; + gesture(s, shift, &[(Down, at.x, at.y), (Drag, at.x + 20.0, at.y - 5.0), (Drag, to.x, to.y), (Up, to.x, to.y)]); + let d = centre(s, id) - at; + s.execute("edit.undo", &json!({})).unwrap(); + -d.y.atan2(d.x).to_degrees() + }; + let a = drag(&mut s); + assert!(close(a, 45.0), "0°: 45° steps from the page's axes, {a}"); + set_pref(&mut s, "constrainAngle", json!(30)); + let a = drag(&mut s); + assert!(close(a, 30.0), "30°: the drag keeps to the turned x axis, {a}"); +} + +/// General › Constrain Angle: rectangles, rounded rectangles and ellipses are drawn along the +/// turned axes (the drag's width and height measured along them). +#[test] +fn constrain_angle_draws_boxes_along_the_turned_axes() { + use PointerKind::{Down, Drag, Up}; + let mut s = new_doc(); + set_pref(&mut s, "constrainAngle", json!(90)); + for tool in ["rectangle", "ellipse"] { + s.select_tool(tool, ViewInfo::default()).unwrap(); + // 60 right and 20 down on the page: along axes turned 90°, 20 wide and 60 high. + gesture(&mut s, Mods::default(), &[(Down, 100.0, 100.0), (Drag, 130.0, 110.0), (Drag, 160.0, 120.0), (Up, 160.0, 120.0)]); + let id = last_id(&s); + let (w, h, rot, _) = live_box(&s, id); + assert!(close(w, 20.0) && close(h, 60.0) && close(rot.rem_euclid(360.0), 90.0), "{tool}: {w} × {h} at {rot}°"); + let b = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + assert!(close(b.x0, 100.0) && close(b.y0, 100.0) && close(b.x1, 160.0) && close(b.y1, 120.0), "{tool}: covers the drag, {b:?}"); + } + set_pref(&mut s, "constrainAngle", json!(0)); + s.select_tool("rectangle", ViewInfo::default()).unwrap(); + gesture(&mut s, Mods::default(), &[(Down, 100.0, 200.0), (Drag, 130.0, 210.0), (Drag, 160.0, 220.0), (Up, 160.0, 220.0)]); + let (w, h, rot, _) = live_box(&s, last_id(&s)); + assert!(close(w, 60.0) && close(h, 20.0) && close(rot, 0.0), "0°: along the page, {w} × {h} at {rot}°"); +} + +/// General › Constrain Angle of a whole turn (±360°) is no turn: the arrow keys move along +/// the page exactly as at 0°, with no floating-point noise. +#[test] +fn constrain_angle_whole_turns_nudge_as_zero() { + let mut s = new_doc(); + let id = rect(&mut s, 100.0, 100.0); + set_pref(&mut s, "keyboardIncrement", json!(10)); + let moved = |s: &mut Session, dx: i32, dy: i32| { + let at = centre(s, id); + s.execute("object.nudge", &json!({"dx": dx, "dy": dy})).unwrap(); + centre(s, id) - at + }; + let flat = [moved(&mut s, 1, 0), moved(&mut s, 0, 1)]; + // The preference takes −360…360. + for turn in [360, -360] { + set_pref(&mut s, "constrainAngle", json!(turn)); + assert_eq!([moved(&mut s, 1, 0), moved(&mut s, 0, 1)], flat, "{turn}°"); + } + // A quarter turn is exact too: → moves straight up. + set_pref(&mut s, "constrainAngle", json!(90)); + assert_eq!(moved(&mut s, 1, 0), Vec2::new(0.0, -10.0)); +} + +/// General › Constrain Angle turns only the art's arrow-key axes: ruler guides selected with the +/// art keep to the page's axes. At 90°, → moves the art straight up, a vertical guide 10 right and +/// a horizontal guide not at all; ↓ moves the horizontal guide 10 down. Copies alike. +#[test] +fn constrain_angle_keeps_guides_selected_with_art_on_the_page_axes() { + let mut s = new_doc(); + let id = rect(&mut s, 100.0, 100.0); + set_pref(&mut s, "keyboardIncrement", json!(10)); + set_pref(&mut s, "constrainAngle", json!(90)); + let v = s.execute("guide.add", &json!({"vertical": true, "pos": 50})).unwrap()["index"].as_u64().unwrap() as usize; + let h = s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap()["index"].as_u64().unwrap() as usize; + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("guide.select", &json!({"indexes": [v, h], "toggle": true})).unwrap(); + let pos = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(); + let at = centre(&s, id); + s.execute("object.nudge", &json!({"dx": 1, "dy": 0})).unwrap(); + assert_eq!(centre(&s, id) - at, Vec2::new(0.0, -10.0), "the art moves along the turned axis"); + assert_eq!(pos(&s), [60.0, 50.0], "→: the vertical guide moves right, the horizontal one stays"); + s.execute("object.nudge", &json!({"dx": 0, "dy": 1})).unwrap(); + assert_eq!(pos(&s), [60.0, 60.0], "↓: the horizontal guide moves down"); + // With a copy, the guides' copies keep to the page too. + s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "copy": true})).unwrap(); + assert_eq!(pos(&s), [60.0, 60.0, 70.0, 60.0]); +} diff --git a/crates/engine/src/tests_distort.rs b/crates/engine/src/tests_distort.rs index 319e9fe29..7f326fc4b 100644 --- a/crates/engine/src/tests_distort.rs +++ b/crates/engine/src/tests_distort.rs @@ -70,6 +70,18 @@ fn close(a: Point, b: Point, tol: f64) -> bool { a.distance(b) <= tol } +/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false. +fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) { + let r = s.execute(cmd, p); + let want = format!("`{key}` must be true or false"); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); +} + +/// Values for an on/off param that are not true, false or null. +fn not_booleans() -> [serde_json::Value; 4] { + [json!("true"), json!(1), json!("false"), json!([true])] +} + // ---------- width ---------- #[test] @@ -110,6 +122,22 @@ fn width_profile_set_and_clear() { assert_eq!(profile(&s, id), None); } +/// Width Profile refuses a point with a place or width given as text, null or another value that is +/// not a number, such as `[0.5, "1", 2, 2]`, and leaves the profile and the undo history as they were. +#[test] +fn width_profile_set_refuses_a_width_given_as_text() { + let mut s = session(); + let id = line(&mut s); + s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": [[0, 1, 1], [1, 1, 1]]})).unwrap(); + let before = (profile(&s, id), undo_len(&s)); + for points in [json!([[0.5, "1", 2, 2]]), json!([[0.5, 1, 2, "3"]]), json!([[0.5, null, 2, 2]]), json!([[true, 0.5, 2, 2]])] { + let r = s.execute("stroke.widthProfile.set", &json!({"ids": [id.0], "points": points})); + let want = "points must be [t, left, right] with non-negative widths"; + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{points}: {r:?}"); + assert_eq!((profile(&s, id), undo_len(&s)), before, "{points}"); + } +} + #[test] fn width_tool_gesture_is_one_undo_step() { let mut s = session(); @@ -178,6 +206,26 @@ fn width_commands_that_change_nothing_record_no_undo_step() { assert_eq!(history(&s), (before.0 + 1, 0, true)); } +/// Width Point refuses an `adjustAdjoining` given as text or as another value that is not true or +/// false, for a new point and for a point given by its index: the profile and the undo history stay +/// as they were. Null is read as the key left out, and the points either side keep their widths. +#[test] +fn width_point_set_refuses_adjust_adjoining_given_as_text() { + let mut s = session(); + let id = line(&mut s); + s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.5, "left": 10, "right": 10})).unwrap(); + let before = (profile(&s, id), undo_len(&s)); + for v in not_booleans() { + let added = json!({"id": id.0, "t": 0.25, "left": 4, "right": 4, "adjustAdjoining": v}); + refuses_non_boolean(&mut s, "stroke.widthPoint.set", &added, "adjustAdjoining"); + let moved = json!({"id": id.0, "index": 1, "t": 0.5, "left": 4, "right": 4, "adjustAdjoining": v}); + refuses_non_boolean(&mut s, "stroke.widthPoint.set", &moved, "adjustAdjoining"); + assert_eq!((profile(&s, id), undo_len(&s)), before, "{v}"); + } + s.execute("stroke.widthPoint.set", &json!({"id": id.0, "t": 0.25, "left": 4, "right": 4, "adjustAdjoining": null})).unwrap(); + assert_eq!(profile(&s, id).unwrap(), vec![(0.0, 1.0, 1.0), (0.25, 0.8, 0.8), (0.5, 2.0, 2.0), (1.0, 1.0, 1.0)]); +} + // ---------- liquify ---------- fn warp_params() -> serde_json::Value { @@ -217,6 +265,22 @@ fn liquify_without_selection_hits_paths_under_the_brush() { assert!(path(&s, b).bounds().unwrap().width() < 100.0); } +/// With nothing selected, a stroke leaves the art on a locked artboard as it is. +#[test] +fn liquify_without_selection_leaves_the_art_on_a_locked_artboard() { + let mut s = session(); + let b = rect(&mut s, 500.0, 400.0, 100.0, 100.0); + s.execute("artboard.new", &json!({"x": 450, "y": 350, "width": 300, "height": 300})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let pucker = json!({"tool": "pucker", "points": [[550, 450]], "diameter": 150, "intensity": 1}); + let r = s.execute("object.liquify", &pucker); + assert_eq!(path(&s, b).bounds().unwrap(), Rect::new(500.0, 400.0, 600.0, 500.0), "{r:?}"); + s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap(); + s.execute("object.liquify", &pucker).unwrap(); + assert!(path(&s, b).bounds().unwrap().width() < 100.0, "unlocked"); +} + #[test] fn liquify_tool_replays_from_the_journal() { let mut s = session(); @@ -237,6 +301,54 @@ fn liquify_tool_replays_from_the_journal() { assert_eq!(path(&t, a), after); } +/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than a number. +fn refuses_non_number(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) { + let r = s.execute(cmd, p); + let want = format!("`{key}` must be a number, not {}", p[key]); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); +} + +/// Liquify refuses a brush size, angle, intensity, detail, simplify, rate, complexity or wrinkle +/// amount given as text or as another value that is not a number, with the tool that uses the key and +/// with one that does not: the path and the undo history stay as they were. Null is read as the key +/// left out. +#[test] +fn liquify_refuses_a_number_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let before = (path(&s, a), undo_len(&s)); + let with = |tool: &str, key: &str, v: serde_json::Value| { + let mut p = warp_params(); + p["tool"] = json!(tool); + p[key] = v; + (key.to_string(), p) + }; + let calls = [ + with("warp", "diameter", json!("40")), + with("warp", "width", json!("80")), + with("pucker", "height", json!([80])), + with("twirl", "angle", json!("45")), + with("bloat", "intensity", json!("80%")), + with("warp", "detail", json!(true)), + with("warp", "simplify", json!("50")), + with("twirl", "rate", json!("90")), + with("crystallize", "complexity", json!("3")), + with("wrinkle", "horizontal", json!("50")), + with("wrinkle", "vertical", json!({"value": 50})), + with("warp", "complexity", json!("3")), + ]; + for (key, p) in calls { + refuses_non_number(&mut s, "object.liquify", &p, &key); + assert_eq!((path(&s, a), undo_len(&s)), before, "{p}"); + } + let mut p = warp_params(); + p["diameter"] = json!(null); + p["intensity"] = json!(null); + assert_eq!(s.execute("object.liquify", &p).unwrap()["ids"], json!([a.0])); + assert_eq!(undo_len(&s), before.1 + 1); +} + /// A Liquify stroke that reshapes no path (the brush reaches none) records no undo step, run as a /// command or dragged with the tool, and the command replies with no ids. #[test] @@ -254,6 +366,32 @@ fn liquify_that_reshapes_no_path_records_no_undo_step() { assert_eq!(path(&s, a).bounds().unwrap(), Rect::new(100.0, 100.0, 300.0, 300.0)); } +/// Liquify refuses a `usePressure`, `simplifyOn`, `affectAnchors`, `affectIn` or `affectOut` given as +/// text or as another value that is not true or false, whichever tool it is given with: the path and +/// the undo history stay as they were. Null is read as the key left out. +#[test] +fn liquify_refuses_a_boolean_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 200.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let before = (path(&s, a), undo_len(&s)); + let crystallize = json!({"tool": "crystallize", "points": [[300, 150]], "diameter": 100, "intensity": 100}); + let keys = [("usePressure", warp_params()), ("simplifyOn", warp_params()), ("affectAnchors", crystallize.clone())]; + let keys = keys.into_iter().chain([("affectIn", crystallize.clone()), ("affectOut", crystallize), ("affectOut", warp_params())]); + for (key, mut p) in keys { + for v in not_booleans() { + p[key] = v; + refuses_non_boolean(&mut s, "object.liquify", &p, key); + } + } + assert_eq!((path(&s, a), undo_len(&s)), before, "nothing changed"); + let mut p = warp_params(); + p["usePressure"] = json!(null); + p["simplifyOn"] = json!(null); + assert_eq!(s.execute("object.liquify", &p).unwrap()["ids"], json!([a.0])); + assert_eq!(undo_len(&s), before.1 + 1); +} + fn session_with_rect() -> Session { let mut s = session(); rect(&mut s, 100.0, 100.0, 200.0, 200.0); @@ -283,6 +421,32 @@ fn puppet_warp_satisfies_pins_and_keeps_area() { assert!(s.execute("object.puppetWarp", &json!({"id": a.0, "pins": [[1, 1]], "moved": []})).is_err()); } +/// Puppet Warp and Puppet Warp Pins refuse an `expand` given as text or as another value that is not +/// a number, and Puppet Warp refuses an angle that is not a number or null, such as "90": the art and +/// the undo history stay as they were. A null `expand` is read as the key left out. +#[test] +fn puppet_warp_refuses_a_number_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0); + let before = (path(&s, a), undo_len(&s)); + let with = |key: &str, v: serde_json::Value| { + let mut p = json!({"id": a.0, "pins": [[120, 150], [380, 150]], "moved": [[120, 150], [370, 90]]}); + p[key] = v; + p + }; + refuses_non_number(&mut s, "object.puppetWarp", &with("expand", json!("5")), "expand"); + refuses_non_number(&mut s, "object.puppetWarp", &with("expand", json!([5])), "expand"); + refuses_non_number(&mut s, "object.puppetWarp.pins", &json!({"id": a.0, "expand": "5"}), "expand"); + for angles in [json!(["90", null]), json!([null, true])] { + let r = s.execute("object.puppetWarp", &with("angles", angles.clone())); + let want = "angles must be a list as long as pins (degrees or null)"; + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{angles}: {r:?}"); + } + assert_eq!((path(&s, a), undo_len(&s)), before); + s.execute("object.puppetWarp", &with("expand", json!(null))).unwrap(); + assert_eq!(undo_len(&s), before.1 + 1); +} + #[test] fn puppet_tool_drag_warps_selection() { let mut s = session(); @@ -342,6 +506,23 @@ fn puppet_warp_that_moves_nothing_records_no_undo_step() { } } +/// Puppet Warp refuses a `rest` given as text or as another value that is not true or false: the art +/// and the undo history stay as they were. Null is read as the key left out. +#[test] +fn puppet_warp_refuses_rest_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 300.0, 100.0); + let before = (path(&s, a), undo_len(&s)); + let warp = |rest| json!({"id": a.0, "pins": [[120, 150], [250, 150], [380, 150]], "moved": [[120, 150], [250, 150], [370, 90]], "rest": rest}); + for v in not_booleans() { + refuses_non_boolean(&mut s, "object.puppetWarp", &warp(v), "rest"); + } + assert_eq!((path(&s, a), undo_len(&s)), before, "nothing changed"); + s.execute("object.puppetWarp", &warp(json!(null))).unwrap(); + assert_eq!(undo_len(&s), before.1 + 1); + assert!(path(&s, a).bounds().unwrap().y0 < 80.0); +} + // ---------- perspective ---------- fn grid(s: &Session) -> PerspectiveGrid { @@ -468,6 +649,24 @@ fn rectangle_tool_draws_in_perspective_on_the_active_plane() { assert!(s.doc().unwrap().doc.node(id).is_none()); } +/// Draw in Perspective refuses a `snap` given as text or as another value that is not true or false +/// before it draws: no shape is made and the undo history stays as it was. Null is read as the key +/// left out. +#[test] +fn perspective_draw_refuses_snap_given_as_text() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + s.execute("perspective.plane.set", &json!({"plane": "left"})).unwrap(); + let before = (history(&s), s.doc().unwrap().selection.objects.clone()); + let draw = |snap| json!({"command": "shape.rectangle", "params": {"x": 300, "y": 320, "width": 50, "height": 80}, "snap": snap}); + for v in not_booleans() { + refuses_non_boolean(&mut s, "perspective.draw", &draw(v), "snap"); + } + assert_eq!((history(&s), s.doc().unwrap().selection.objects.clone()), before, "nothing drawn"); + let r = s.execute("perspective.draw", &draw(json!(null))).unwrap(); + assert_eq!((r["plane"].clone(), history(&s).0), (json!("left"), before.0.0 + 1)); +} + #[test] fn perspective_selection_moves_within_the_plane() { let mut s = session(); @@ -500,6 +699,43 @@ fn perspective_move_attaches_loose_objects_to_the_given_plane() { assert!(s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "none"})).is_err()); } +/// Transform in Perspective refuses a `depth`, Nudge in Perspective refuses a `dx` or `dy`, and Move Plane +/// refuses an `offset` or `by` given as text or as another value that is not a number, such as "10". Draw in +/// Perspective refuses an `at` that is not two numbers. The object, the grid and the undo history stay as they +/// were. A null `depth` or `at` is read as the key left out. +#[test] +fn perspective_commands_refuse_a_number_given_as_text() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + s.execute("perspective.grid.snap", &json!({"on": false})).unwrap(); + let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0); + s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let before = (path(&s, a), grid(&s).definition_json(), undo_len(&s)); + let calls = [ + ("perspective.transform", json!({"matrix": [1, 0, 0, 1, 0, 0], "depth": "10"}), "depth"), + ("perspective.nudge", json!({"dx": "1", "dy": 0}), "dx"), + ("perspective.nudge", json!({"dx": 0, "dy": [1]}), "dy"), + ("perspective.plane.move", json!({"plane": "right", "offset": "10"}), "offset"), + ("perspective.plane.move", json!({"plane": "right", "offset": "10", "by": 5}), "offset"), + ("perspective.plane.move", json!({"plane": "right", "by": true}), "by"), + ]; + for (c, p, key) in calls { + refuses_non_number(&mut s, c, &p, key); + assert_eq!((path(&s, a), grid(&s).definition_json(), undo_len(&s)), before, "{c} {p}"); + } + let draw = |at: serde_json::Value| json!({"command": "shape.rectangle", "params": {"x": 450, "y": 380, "width": 20, "height": 20}, "plane": "right", "at": at}); + for at in [json!(["450", 380]), json!("450, 380"), json!([450]), json!([450, 380, 0])] { + let r = s.execute("perspective.draw", &draw(at.clone())); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`at` must be [x, y]"), "at {at}: {r:?}"); + assert_eq!((path(&s, a), grid(&s).definition_json(), undo_len(&s)), before, "at {at}"); + } + s.execute("perspective.transform", &json!({"matrix": [1, 0, 0, 1, 10, 0], "depth": null})).unwrap(); + assert_eq!(undo_len(&s), before.2 + 1); + s.execute("perspective.draw", &draw(json!(null))).unwrap(); + assert_eq!(undo_len(&s), before.2 + 2); +} + /// With the grid defined, Define Perspective Grid with the values the grid has (Reproject or not), /// Attach to Active Plane with no object, Release with Perspective on art not in perspective, Move /// Plane to where the plane is (its objects moved with it or not), and Move Plane to Match Object for @@ -536,6 +772,28 @@ fn perspective_grid_commands_that_change_nothing_record_no_undo_step() { assert_eq!(history(&s), (before.0 + 1, 0, true)); } +/// Define Perspective Grid refuses a `reproject`, and Show Perspective Grid a `visible`, given as text +/// or as another value that is not true or false: the grid, the object on it and the undo history +/// stay as they were. A null `reproject` leaves the object where it is, and a null `visible` toggles +/// the grid, as the keys left out do. +#[test] +fn perspective_grid_commands_refuse_a_boolean_given_as_text() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0); + s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); + let state = |s: &Session| (grid(s).definition_json(), grid(s).visible, path(s, a), history(s)); + let before = state(&s); + for v in not_booleans() { + refuses_non_boolean(&mut s, "perspective.grid.set", &json!({"horizon": 250, "reproject": v}), "reproject"); + refuses_non_boolean(&mut s, "perspective.grid.show", &json!({"visible": v}), "visible"); + assert_eq!(state(&s), before, "{v}"); + } + s.execute("perspective.grid.set", &json!({"horizon": 250, "reproject": null})).unwrap(); + assert_eq!((grid(&s).horizon, path(&s, a)), (250.0, before.2)); + assert_eq!(s.execute("perspective.grid.show", &json!({"visible": null})).unwrap(), json!({"visible": !before.1})); +} + /// For an object in perspective, Move in Perspective by 0 (along its plane or its normal), /// Transform in Perspective by the identity, Transform Again after each, and a Perspective Selection /// drag that ends where it started record no undo step. So do a Move by 0 and a 1 pt Nudge that @@ -599,3 +857,38 @@ fn perspective_moves_that_move_nothing_record_no_undo_step() { assert_ne!(r, json!({"ids": [a.0]})); assert_eq!(history(&s), (before.0 + 1, 0, true)); } + +/// Move in Perspective refuses a `copy`, `perpendicular` or `snap`, Transform in Perspective a `copy`, +/// and Nudge in Perspective a `big` or `copy`, given as text or as another value that is not true or +/// false: the object stays where it is, nothing is copied, and the undo history stays as it was. Null +/// is read as the key left out. +#[test] +fn perspective_moves_refuse_a_boolean_given_as_text() { + let mut s = session(); + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let a = rect(&mut s, 450.0, 380.0, 60.0, 60.0); + s.execute("perspective.attach", &json!({"ids": [a.0], "plane": "right"})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let state = |s: &Session| (path(s, a), history(s), s.doc().unwrap().selection.objects.clone()); + let before = state(&s); + let (from, to) = (json!([480, 410]), json!([500, 420])); + for v in not_booleans() { + for key in ["copy", "perpendicular", "snap"] { + refuses_non_boolean(&mut s, "perspective.move", &json!({"from": from, "to": to, key: v}), key); + } + refuses_non_boolean(&mut s, "perspective.transform", &json!({"matrix": [1.5, 0, 0, 1.5, 0, 0], "copy": v}), "copy"); + for key in ["big", "copy"] { + refuses_non_boolean(&mut s, "perspective.nudge", &json!({"dx": 1, "dy": 0, key: v}), key); + } + assert_eq!(state(&s), before, "{v}"); + } + let calls = [ + ("perspective.move", json!({"from": from, "to": to, "copy": null, "perpendicular": null, "snap": null})), + ("perspective.transform", json!({"matrix": [1.5, 0, 0, 1.5, 0, 0], "copy": null})), + ("perspective.nudge", json!({"dx": 1, "dy": 0, "big": null, "copy": null})), + ]; + for (i, (c, p)) in calls.into_iter().enumerate() { + assert_eq!(s.execute(c, &p).unwrap(), json!({"ids": [a.0]}), "{c} {p}"); + assert_eq!(history(&s).0, before.1.0 + i + 1, "{c} {p}"); + } +} diff --git a/crates/engine/src/tests_draw2.rs b/crates/engine/src/tests_draw2.rs index 4fd0df21b..cdd1b476b 100644 --- a/crates/engine/src/tests_draw2.rs +++ b/crates/engine/src/tests_draw2.rs @@ -116,6 +116,31 @@ fn pencil_alt_closes_path() { assert!(ps[0].1.is_closed()); } +/// #3799: with Shift held, a wavy Pencil drag draws one straight segment at 45° steps from the +/// Constrain Angle (here 30°: a drag at about 60° comes out at 75°). +#[test] +fn pencil_with_shift_draws_a_straight_line() { + let mut s = session(); + s.execute("prefs.set", &json!({"key": "constrainAngle", "value": 30})).unwrap(); + let v = view(); + let shift = Mods { shift: true, ..Default::default() }; + s.select_tool("pencil", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 400.0).with_mods(shift), v).unwrap(); + for (x, y) in [(120.0, 380.0), (125.0, 330.0), (160.0, 320.0), (150.0, 313.4)] { + s.pointer(&PointerEvent::new(PointerKind::Drag, x, y).with_mods(shift), v).unwrap(); + } + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 313.4).with_mods(shift), v).unwrap(); + let ps = paths(&s); + assert_eq!(ps.len(), 1); + let sp = &ps[0].1.subpaths[0]; + assert_eq!(sp.anchors.len(), 2, "{sp:?}"); + assert!(sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p), "no handles: {sp:?}"); + let (a, b) = (sp.anchors[0].p, sp.anchors[1].p); + assert!(a.distance(Point::new(100.0, 400.0)) < 1e-6, "{a:?}"); + let angle = (-(b.y - a.y)).atan2(b.x - a.x).to_degrees(); + assert!((angle - 75.0).abs() < 1e-6, "{angle}"); +} + #[test] fn pencil_continues_selected_open_path() { let mut s = session(); @@ -818,13 +843,17 @@ fn polar_grid_options_compound_fill_and_skew() { assert!(tips(&inward)[1..].iter().all(|t| t.x < 50.0), "counterclockwise of the top: {:?}", tips(&inward)); } -/// The grid options are checked: a skew is a number and a switch true or false, or the command -/// fails without an undo step. +/// The grid options are checked: a divider count or a skew is a number and a switch true or false, +/// or the command fails without an undo step. #[test] fn grid_commands_reject_options_of_the_wrong_type() { let mut s = session(); let size = json!({"x": 0, "y": 0, "width": 10, "height": 10}); for (cmd, key, value) in [ + ("shape.rectangularGrid", "rows", json!("3")), + ("shape.rectangularGrid", "columns", json!([4])), + ("shape.polarGrid", "concentric", json!(true)), + ("shape.polarGrid", "radial", json!("6")), ("shape.rectangularGrid", "rowSkew", json!("50")), ("shape.rectangularGrid", "columnSkew", json!(true)), ("shape.rectangularGrid", "frame", json!(1)), @@ -867,6 +896,72 @@ fn draw2_commands_reject_bad_params() { } } +/// The Pencil refuses a `fidelity` or a redraw `within` given as text or as another value that is +/// not a number, and draws or redraws nothing; null is read as the key left out. +#[test] +fn pencil_refuses_a_fidelity_or_within_given_as_text() { + let mut s = session(); + let id = line(&mut s, 100.0, 100.0, 300.0, 100.0); + let stroke = json!({"points": [[100, 100], [150, 140], [200, 140], [250, 100]]}); + refuses_a_number_given_as_text(&mut s, "path.freehand", &stroke, "fidelity"); + for v in [json!("12"), json!([12]), json!(false)] { + let p = with(&stroke, json!({"redraw": {"id": id.0, "within": v}})); + refuses_with(&mut s, "path.freehand", &p, &format!("`within` must be a number, not {v}")); + } + let before = path(&s, id); + s.execute("path.freehand", &with(&stroke, json!({"fidelity": null, "redraw": {"id": id.0, "within": null}}))).unwrap(); + assert!(path(&s, id) != before, "the stroke redraws the line within 12 pt"); +} + +/// The drawing tools' commands refuse a point whose x or y is not a number, such as "10" or a point +/// written as text, and a pen pressure given as text, instead of leaving the point out or drawing it +/// at full pressure; the document stays as it was. +#[test] +fn drawing_commands_refuse_a_point_or_pressure_given_as_text() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let good = [json!([90, 150]), json!([150, 150]), json!([210, 150])]; + let cmds = ["path.freehand", "path.knife", "path.eraseRegion", "path.eraseSegments", "path.smoothRegion", "path.joinScrub", "path.blob"]; + for cmd in cmds.into_iter().chain(["path.curvature"]) { + for bad in [json!(["150", 150]), json!({"x": 150, "y": "150"}), json!("150, 150"), json!([150]), json!(null)] { + let points: Vec<_> = good.iter().cloned().chain([bad]).collect(); + refuses_with(&mut s, cmd, &json!({"points": points}), "x and y must both be numbers"); + } + } + for cmd in cmds { + for bad in [json!([150, 150, "0.5"]), json!({"x": 150, "y": 150, "pressure": "0.5"})] { + let points: Vec<_> = good.iter().cloned().chain([bad]).collect(); + refuses_with(&mut s, cmd, &json!({"points": points}), "`pressure` must be a number, not \"0.5\""); + } + } + let pressed = json!({"points": [[100, 400], [150, 420, null], {"x": 200, "y": 400, "pressure": 0.5}], "keepSelected": false}); + s.execute("path.freehand", &pressed).unwrap(); +} + +/// The Eraser, Path Eraser, Smooth, Join and Blob Brush commands refuse a size, width, radius, +/// fidelity or tolerance given as text or as another value that is not a number, and change +/// nothing; null is read as the key left out. +#[test] +fn drawing_commands_refuse_a_size_given_as_text() { + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let stroke = json!({"points": [[90, 150], [150, 150], [210, 150]]}); + for (cmd, key) in [ + ("path.eraseRegion", "size"), + ("path.eraseSegments", "width"), + ("path.smoothRegion", "radius"), + ("path.smoothRegion", "fidelity"), + ("path.joinScrub", "tolerance"), + ("path.blob", "size"), + ] { + refuses_a_number_given_as_text(&mut s, cmd, &stroke, key); + } + let erased = s.execute("path.eraseRegion", &with(&stroke, json!({"size": null}))).unwrap(); + assert_eq!(erased["ids"].as_array().map(Vec::len), Some(2), "the default 10 pt Eraser cuts the square in two: {erased}"); +} + /// A path of two open subpaths, each of three anchors from x 100 to x 300 with a middle anchor that /// has two handles: subpath 0 at y 100, subpath 1 at y 300. fn two_subpaths(s: &mut Session) -> NodeId { @@ -898,6 +993,33 @@ fn refuses_an_index_that_is_not_whole(s: &mut Session, cmd: &str, p: &serde_json } } +/// Runs `cmd` with `p` and `key` set to each of "10", [10] and true: each call fails with +/// "`key` must be a number, not ", and the document and the undo history stay as they were. +fn refuses_a_number_given_as_text(s: &mut Session, cmd: &str, p: &serde_json::Value, key: &str) { + for v in [json!("10"), json!([10]), json!(true)] { + let mut p = p.clone(); + p[key] = v.clone(); + let before = s.doc().unwrap().doc.clone(); + let undo = undo_steps(s); + let r = s.execute(cmd, &p); + let want = format!("`{key}` must be a number, not {v}"); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); + assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document"); + assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step"); + } +} + +/// Runs `cmd` with `p`: the call fails with `want`, and the document and the undo history stay as +/// they were. +fn refuses_with(s: &mut Session, cmd: &str, p: &serde_json::Value, want: &str) { + let before = s.doc().unwrap().doc.clone(); + let undo = undo_steps(s); + let r = s.execute(cmd, p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{cmd} {p}: {r:?}"); + assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document"); + assert_eq!(undo_steps(s), undo, "{cmd} {p} left an undo step"); +} + /// Runs `cmd` with `p` on path `id` (made by [`two_subpaths`]) with `subpath: 1`, without `subpath` /// and with `subpath: null`, undoing each: the first changes subpath 1 alone, and the others change /// subpath 0 alone. @@ -974,6 +1096,50 @@ fn split_refuses_a_subpath_anchor_or_segment_that_is_not_a_whole_number() { } } +/// A subpath, anchor or segment of 2^32 + k fails, where k names one that exists, and the document +/// and the undo history stay as they were. A 32-bit build read the number as k. +#[test] +fn path_commands_refuse_a_subpath_anchor_or_segment_past_u32() { + let mut s = session(); + let id = two_subpaths(&mut s); + let at_anchor = json!({"id": id.0, "anchor": 1}); + let at_segment = json!({"id": id.0, "segment": 0, "t": 0.5}); + let curvature_move = json!({"id": id.0, "op": "move", "anchor": 1, "x": 200, "y": 150}); + let curvature_insert = json!({"id": id.0, "op": "insert", "segment": 0, "t": 0.5}); + let convert = json!({"id": id.0, "anchor": 1, "to": "corner"}); + let reshape = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20}); + let handle = json!({"id": id.0, "anchor": 0, "which": "out", "x": 140, "y": 250}); + let moved = json!({"id": id.0, "anchor": 0, "x": 140, "y": 250}); + for (cmd, p, key) in [ + ("path.curvatureEdit", &curvature_move, "subpath"), + ("path.curvatureEdit", &curvature_move, "anchor"), + ("path.curvatureEdit", &curvature_insert, "segment"), + ("path.removeAnchor", &at_anchor, "subpath"), + ("path.removeAnchor", &at_anchor, "anchor"), + ("path.convertAnchor", &convert, "subpath"), + ("path.convertAnchor", &convert, "anchor"), + ("path.reshapeSegment", &reshape, "subpath"), + ("path.reshapeSegment", &reshape, "segment"), + ("path.split", &at_anchor, "anchor"), + ("path.split", &at_segment, "segment"), + ("path.setHandle", &handle, "subpath"), + ("path.setHandle", &handle, "anchor"), + ("path.moveAnchor", &moved, "subpath"), + ("path.moveAnchor", &moved, "anchor"), + ("path.insertAnchor", &at_segment, "subpath"), + ("path.insertAnchor", &at_segment, "segment"), + ] { + let mut p = p.clone(); + let k = if key == "subpath" { 1 } else { p[key].as_u64().unwrap() }; + p[key] = json!((1u64 << 32) + k); + let before = s.doc().unwrap().doc.clone(); + let undo = undo_steps(&s); + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p} succeeded"); + assert!(s.doc().unwrap().doc == before, "{cmd} {p} changed the document"); + assert_eq!(undo_steps(&s), undo, "{cmd} {p} left an undo step"); + } +} + #[test] fn set_handle_refuses_a_subpath_that_is_not_a_whole_number() { let mut s = session(); @@ -1010,6 +1176,123 @@ fn insert_anchor_refuses_a_subpath_that_is_not_a_whole_number() { edits_the_subpath_given(&mut s, "path.insertAnchor", &p, id); } +/// `path.reshapeSegment` refuses a `t`, `dx` or `dy` given as text or as another value that is not +/// a number, instead of bending the segment by 0 or at its middle; `dy: 20` bends it, with a null +/// `t` read as the middle. +#[test] +fn reshape_segment_refuses_a_number_given_as_text() { + let mut s = session(); + let id = curve(&mut s); + let p = json!({"id": id.0, "segment": 0, "t": 0.5, "dx": 0, "dy": 20}); + for key in ["t", "dx", "dy"] { + refuses_a_number_given_as_text(&mut s, "path.reshapeSegment", &p, key); + } + let before = path(&s, id); + s.execute("path.reshapeSegment", &with(&p, json!({"t": null}))).unwrap(); + assert!(path(&s, id) != before, "the segment bends"); +} + +/// `path.convertAnchor` refuses an `x` or `y` given as text, and an `x` without a `y` or a `y` +/// without an `x`, instead of converting the anchor without a handle position; both as numbers +/// place the out handle there. +#[test] +fn convert_anchor_refuses_a_handle_position_given_as_text_or_alone() { + let mut s = session(); + let id = curve(&mut s); + let p = json!({"id": id.0, "anchor": 1, "to": "smooth", "x": 330, "y": 150}); + for key in ["x", "y"] { + refuses_a_number_given_as_text(&mut s, "path.convertAnchor", &p, key); + } + for lone in [json!({"y": null}), json!({"x": null}), json!({"y": null, "to": "corner"})] { + refuses_with(&mut s, "path.convertAnchor", &with(&p, lone), "x and y must be given together"); + } + s.execute("path.convertAnchor", &p).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors[1].h_out, Point::new(330.0, 150.0)); +} + +/// The Curvature tool's edits and Add Anchor Point refuse a `t`, `x` or `y` given as text or as +/// another value that is not a number, instead of inserting at the middle of the segment or leaving +/// the new anchor where it was inserted. +#[test] +fn curvature_edit_and_insert_anchor_refuse_a_number_given_as_text() { + let mut s = session(); + let id = curve(&mut s); + let insert = json!({"id": id.0, "op": "insert", "segment": 0, "t": 0.25}); + let moved = json!({"id": id.0, "op": "insert", "segment": 0, "t": 0.25, "x": 150, "y": 160}); + refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &insert, "t"); + for key in ["x", "y"] { + refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &moved, key); + refuses_a_number_given_as_text(&mut s, "path.curvatureEdit", &json!({"id": id.0, "op": "move", "anchor": 1, "x": 300, "y": 150}), key); + } + refuses_a_number_given_as_text(&mut s, "path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": 0.25}), "t"); + s.execute("path.curvatureEdit", &moved).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 160.0)); + s.execute("path.insertAnchor", &json!({"id": id.0, "segment": 0, "t": null})).unwrap(); + assert_eq!(path(&s, id).subpaths[0].anchors.len(), 4, "a null t inserts at the middle"); +} + +/// Move Anchors, the Reshape tool's command and Close Path refuse a number or handle given as text +/// or as another value that is not a number, and change nothing; null is read as the key left out. +#[test] +fn move_anchors_reshape_and_close_refuse_a_number_given_as_text() { + let mut s = session(); + let id = curve(&mut s); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap(); + let nudge = json!({"dx": 10, "dy": 0}); + for key in ["dx", "dy"] { + refuses_a_number_given_as_text(&mut s, "path.moveAnchors", &nudge, key); + } + let reshape = json!({"id": id.0, "x": 200, "y": 100, "dx": 0, "dy": 30, "tol": 3, "radius": 50}); + for key in ["dx", "dy", "tol", "radius"] { + refuses_a_number_given_as_text(&mut s, "path.reshape", &reshape, key); + } + for h in [json!("200, 150"), json!([200]), json!([200, "150"]), json!({"x": 200, "y": 150})] { + refuses_with(&mut s, "path.close", &json!({"id": id.0, "in": h}), "`in` must be [x, y]"); + } + let before = path(&s, id); + s.execute("path.moveAnchors", &json!({"dx": 10, "dy": null})).unwrap(); + s.execute("path.reshape", &with(&reshape, json!({"radius": null}))).unwrap(); + s.execute("path.close", &json!({"id": id.0, "in": null})).unwrap(); + // The Reshape tool's grab adds an anchor between the two, so the moved anchor is the last. + let after = path(&s, id); + let moved = after.subpaths[0].anchors.last().map(|a| a.p.x); + assert!(after.subpaths[0].closed && moved == Some(before.subpaths[0].anchors[1].p.x + 10.0), "{after:?}"); +} + +/// Close Path, Set Handle, Move Anchors and the Pencil, Curvature, Reshape Segment and Blob Brush +/// commands refuse an on/off option given as text or as another value that is not true or false, +/// naming the key, and change nothing; true, false and null still work. +#[test] +fn path_commands_refuse_a_boolean_given_as_text() { + let mut s = session(); + let id = curve(&mut s); + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap(); + let stroke = json!([[100, 300], [150, 340], [200, 300]]); + let calls = [ + ("path.close", json!({"id": id.0, "independent": "true"}), "independent"), + ("path.setHandle", json!({"id": id.0, "anchor": 0, "x": 160, "y": 90, "independent": 1}), "independent"), + ("path.moveAnchors", json!({"dx": 10, "patterns": "true"}), "patterns"), + ("path.freehand", json!({"points": stroke, "closed": "true"}), "closed"), + ("path.freehand", json!({"points": stroke, "keepSelected": "false"}), "keepSelected"), + ("path.freehand", json!({"points": stroke, "fill": [true]}), "fill"), + ("path.curvature", json!({"points": stroke, "closed": "true"}), "closed"), + ("path.curvature", json!({"points": [{"x": 100, "y": 300, "corner": "true"}, [150, 340], [200, 300]]}), "corner"), + ("path.curvatureEdit", json!({"id": id.0, "op": "move", "anchor": 1, "x": 300, "y": 150, "corner": "true"}), "corner"), + ("path.reshapeSegment", json!({"id": id.0, "segment": 0, "dy": 20, "perpendicular": "true"}), "perpendicular"), + ("path.blob", json!({"points": stroke, "keepSelected": "true"}), "keepSelected"), + ("path.blob", json!({"points": stroke, "mergeOnlySelection": 1}), "mergeOnlySelection"), + ("path.blob", json!({"points": stroke, "merge": "false"}), "merge"), + ] + .map(|(cmd, p, key)| super::tests::not_a_switch(cmd, p, key)); + let wrong = super::tests::not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + s.execute("path.setHandle", &json!({"id": id.0, "anchor": 0, "x": 160, "y": 90, "independent": true})).unwrap(); + s.execute("path.close", &json!({"id": id.0, "independent": null})).unwrap(); + s.execute("path.freehand", &json!({"points": stroke, "closed": false, "keepSelected": null, "fill": true})).unwrap(); + s.execute("path.blob", &json!({"points": stroke, "merge": false, "keepSelected": null, "mergeOnlySelection": false})).unwrap(); + assert!(path(&s, id).subpaths[0].closed); +} + #[test] fn path_commands_refuse_a_delta_or_point_of_1e9_pt_or_more() { // `path.reshapeSegment` refuses a delta of 1e9 pt or longer, and `path.convertAnchor` and @@ -1608,6 +1891,36 @@ fn pen_with_cmd_borrows_direct_selection_and_goes_on_drawing() { assert_eq!(s.tool_id(), "rectangle"); } +#[test] +fn cmd_alt_lends_group_selection_while_direct_selection_is_active() { + let (cmd, cmd_alt) = (Mods { cmd: true, ..Mods::default() }, Mods { cmd: true, alt: true, ..Mods::default() }); + let v = view(); + let mut s = session(); + // Cmd+Alt while the Pen borrows Direct Selection keeps Direct Selection (Alt is copy/subtract). + s.select_tool("directSelection", v).unwrap(); + s.select_tool("pen", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0).with_mods(cmd_alt), v).unwrap(); + assert_eq!(s.tool_id(), "directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Up, 50.0, 50.0).with_mods(cmd_alt), v).unwrap(); + assert_eq!(s.tool_id(), "pen"); + // Cmd alone still lends Direct Selection. + s.pointer(&PointerEvent::new(PointerKind::Down, 60.0, 60.0).with_mods(cmd), v).unwrap(); + assert_eq!(s.tool_id(), "directSelection"); + s.pointer(&PointerEvent::new(PointerKind::Up, 60.0, 60.0).with_mods(cmd), v).unwrap(); + // While Direct Selection is active, Cmd+Alt lends Group Selection for the gesture. + s.select_tool("directSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 70.0, 70.0).with_mods(cmd_alt), v).unwrap(); + assert_eq!(s.tool_id(), "groupSelection"); + s.pointer(&PointerEvent::new(PointerKind::Up, 70.0, 70.0).with_mods(cmd_alt), v).unwrap(); + assert_eq!(s.tool_id(), "directSelection"); + // Selection last: Cmd+Alt still lends Selection, not Group Selection. + s.select_tool("selection", v).unwrap(); + s.select_tool("pen", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 80.0, 80.0).with_mods(cmd_alt), v).unwrap(); + assert_eq!(s.tool_id(), "selection"); + s.pointer(&PointerEvent::new(PointerKind::Up, 80.0, 80.0).with_mods(cmd_alt), v).unwrap(); +} + #[test] fn the_pen_drops_the_new_path_asterisk_once_a_path_is_started() { // The Pen's asterisk means a click starts a new path: it shows until the first anchor is diff --git a/crates/engine/src/tests_editcolors.rs b/crates/engine/src/tests_editcolors.rs index 0dd51c98e..ee4c050a8 100644 --- a/crates/engine/src/tests_editcolors.rs +++ b/crates/engine/src/tests_editcolors.rs @@ -178,6 +178,7 @@ fn image_node(s: &mut Session) -> NodeId { xf: Default::default(), link: None, placement: Default::default(), + pixels: None, }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); diff --git a/crates/engine/src/tests_envelope_distort.rs b/crates/engine/src/tests_envelope_distort.rs index b02fdb121..00b2bf6e9 100644 --- a/crates/engine/src/tests_envelope_distort.rs +++ b/crates/engine/src/tests_envelope_distort.rs @@ -122,7 +122,7 @@ fn red_image(s: &mut Session) -> NodeId { d.images.insert("pic".into(), vectorcraft_doc::ImageBlob::png(png)); let id = d.alloc_id(); let xf = Affine::translate((100.0, 100.0)) * Affine::scale(10.0); - let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default() }; + let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 4, height: 4, xf, link: None, placement: Default::default(), pixels: None }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) diff --git a/crates/engine/src/tests_expand.rs b/crates/engine/src/tests_expand.rs index 571c6c6d3..3d7e0baea 100644 --- a/crates/engine/src/tests_expand.rs +++ b/crates/engine/src/tests_expand.rs @@ -84,6 +84,117 @@ fn two_fills_and_an_offset_stroke_become_three_paths_that_look_the_same() { assert_eq!(node(&s, id).appearance.items.len(), 3); } +/// The objects [`random_transforms`] makes. +pub(super) const RANDOM_CASES: [&str; 5] = ["copies", "group copies", "nested group copies", "crop marks", "group crop marks"]; + +/// Selected art with Random in Transform effects, from 50 pt squares at y 60 with an 8 pt black +/// stroke, as `case` says. "copies": a square at x 20 filled with a gradient, a random Transform +/// effect on its stroke and a Transform effect that copies it 80 pt right. "group copies": the +/// same square without the second effect, in a group that has it. "nested group copies": the same +/// square in a group with a random Transform effect, in a group with the second effect. "crop +/// marks": a red square at x 20 with a random Transform effect and crop marks. "group crop marks": +/// red squares at x 20 and x 110 in a group with a random Transform effect and crop marks. Returns +/// the object's id. +pub(super) fn random_transforms(s: &mut Session, case: &str) -> NodeId { + let random = json!({"moveH": 15, "moveV": 15, "rotate": 40, "random": true}); + let square = |s: &mut Session, x: f64, fill: Value| { + let id = run(s, "shape.rectangle", json!({"x": x, "y": 60, "width": 50, "height": 50}))["id"].as_u64().unwrap(); + run(s, "paint.setFill", fill); + run(s, "paint.setStroke", json!({"color": "#000000"})); + run(s, "stroke.set", json!({"weight": 8})); + id + }; + let red = json!({"color": "#ff0000"}); + let id = match case { + "copies" | "group copies" | "nested group copies" => { + let a = square(s, 20.0, json!({"gradient": {"kind": "linear"}})); + run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": 1})); + if case == "nested group copies" { + run(s, "object.group", json!({})); + run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null})); + } + if case == "copies" { a } else { run(s, "object.group", json!({}))["id"].as_u64().unwrap() } + } + "crop marks" => square(s, 20.0, red), + _ => { + let a = square(s, 20.0, red.clone()); + let b = square(s, 110.0, red); + run(s, "select.set", json!({"ids": [a, b]})); + run(s, "object.group", json!({}))["id"].as_u64().unwrap() + } + }; + if case.ends_with("copies") { + run(s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 80, "copies": 1}, "item": null})); + } else { + run(s, "effect.apply", json!({"effect": "distort.transform", "params": random, "item": null})); + run(s, "effect.apply", json!({"effect": "cropMarks", "item": null})); + } + NodeId(id) +} + +/// Random in a Transform effect takes its seed from the object's id. The expanded art draws as the +/// canvas drew each of [`random_transforms`]' objects. +#[test] +fn expanded_art_keeps_the_random_transforms_the_canvas_drew() { + let mut differ = vec![]; + for case in RANDOM_CASES { + let mut s = session(); + let id = random_transforms(&mut s, case); + let before = render(&s); + let r = run(&mut s, "effect.expandAppearance", json!({})); + differ.push((case, differing(&before, &render(&s), 64))); + let mut art = node(&s, id); + // Crop marks: the reply names the group of the object and its marks, then the object. + if case.ends_with("crop marks") { + art = (*art.children().unwrap()[0]).clone(); + assert_eq!((&r["ids"][0], &r["ids"][1]), (&json!(id.0), &json!(art.id.0)), "{case}"); + } + // No object keeps an effect, and in the copies cases no object keeps a stroke: every copy + // is expanded. + art.walk(&mut |n| { + let stroked = case.ends_with("copies") && n.appearance.stroke().is_some_and(|st| !st.paint.is_none()); + assert!(n.appearance.effects.is_empty() && !stroked, "{case}: {n:?}"); + }); + } + assert_eq!(differ, RANDOM_CASES.map(|case| (case, 0))); +} + +/// The reply lists each object whose appearance expanded once, by the id it has in the document, +/// starting with the selected object. In "group copies" the two copies of the square share an id +/// until Expand Appearance gives them ids of their own, and the reply names the group, then each +/// copy by its new id. +#[test] +fn the_reply_names_each_expanded_copy_by_its_own_id() { + for case in RANDOM_CASES { + let mut s = session(); + let id = random_transforms(&mut s, case); + let r = run(&mut s, "effect.expandAppearance", json!({})); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids.first(), Some(&id.0), "{case}: {ids:?}"); + let unique: std::collections::HashSet<_> = ids.iter().collect(); + assert_eq!(unique.len(), ids.len(), "{case}: {ids:?}"); + assert!(ids.iter().all(|&i| s.doc().unwrap().doc.node(NodeId(i)).is_some()), "{case}: {ids:?}"); + if case == "group copies" { + let art = node(&s, id); + let copies: Vec = art.children().unwrap()[0].children().unwrap().iter().map(|c| c.id.0).collect(); + assert_eq!(ids, [vec![id.0], copies].concat(), "{case}"); + } + } +} + +/// A square with a color adjustment and crop marks: the reply names the group of the square and its +/// marks once. The adjusted square has a basic appearance, so the reply does not name it. +#[test] +fn the_reply_names_an_adjusted_object_with_crop_marks_once() { + let mut s = session(); + let id = square(&mut s); + run(&mut s, "effect.apply", json!({"effect": "adjust.hueSaturation", "params": {"hue": 120}})); + run(&mut s, "effect.apply", json!({"effect": "cropMarks", "item": null})); + let r = run(&mut s, "effect.expandAppearance", json!({})); + assert!(node(&s, id).children().is_some_and(|c| c.len() > 1)); + assert_eq!(r["ids"], json!([id.0])); +} + #[test] fn a_shadow_becomes_an_image_under_the_art_and_transparency_stays_on_the_group() { let mut s = session(); diff --git a/crates/engine/src/tests_eyedropper.rs b/crates/engine/src/tests_eyedropper.rs index fb69609e9..4ef5773ca 100644 --- a/crates/engine/src/tests_eyedropper.rs +++ b/crates/engine/src/tests_eyedropper.rs @@ -160,7 +160,7 @@ fn clicking_an_image_samples_its_pixel() { let l = d.layers[0].id; let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); - let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: "px".into(), width: 2, height: 1, xf, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string())) }) .unwrap(); @@ -186,7 +186,7 @@ fn cmyk_doc_with_image(px: &[[u8; 4]]) -> Session { let l = d.layers[0].id; let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((300.0, 0.0)) * vectorcraft_geom::Affine::scale(100.0); - let im = ImageObject { key: "px".into(), width: w, height: 1, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: "px".into(), width: w, height: 1, xf, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| crate::EngineError::Other(e.to_string())) }) .unwrap(); @@ -257,6 +257,50 @@ fn cmyk_session() -> Session { s } +/// `cmd` with `p` fails with "`keepModel` must be true or false". +fn refuses_keep_model(s: &mut Session, cmd: &str, p: &Value) { + let r = s.execute(cmd, p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{cmd} {p}: {r:?}"); +} + +/// How many steps there are to undo. +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// In a CMYK document, `paint.setFill`, `paint.setStroke`, `appearance.setItem`, `livePaint.fill`, +/// `livePaint.strokeEdge` and a `shapeBuilder.merge` fill refuse a `keepModel` given as text or as +/// another value that is not true or false. The objects, the defaults for new art and the undo +/// history stay as they were. Null is read as `keepModel` left out. +#[test] +fn the_paint_commands_refuse_a_keep_model_given_as_text() { + let mut s = cmyk_session(); + let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 40.0)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.paint.clone(), undo_len(s)); + let before = state(&s); + let stops = json!([{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]); + let calls = [ + ("paint.setFill", json!({"color": "#ff0000", "keepModel": "true"})), + ("paint.setStroke", json!({"color": "#ff0000", "keepModel": 1})), + ("paint.setFill", json!({"gradient": {"stops": stops}, "keepModel": "false"})), + ("appearance.setItem", json!({"index": 0, "color": "#ff0000", "keepModel": "true"})), + ("livePaint.fill", json!({"ids": [a.0, b.0], "point": [20, 30], "color": "#ff0000", "keepModel": [true]})), + ("shapeBuilder.merge", json!({"ids": [a.0, b.0], "points": [[20, 30], [80, 30]], "fill": {"color": "#ff0000", "keepModel": "true"}})), + ]; + for (cmd, p) in &calls { + refuses_keep_model(&mut s, cmd, p); + assert_eq!(state(&s), before, "{cmd} {p}"); + } + s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#ff0000", "keepModel": null})).unwrap(); + assert_eq!(node(&s, a).appearance.fill_paint().color(), Some(managed_cmyk(Color::rgb8(255, 0, 0)))); + let r = s.execute("livePaint.fill", &json!({"ids": [a.0, b.0], "point": [20, 30], "color": "#ff0000"})).unwrap(); + let g = NodeId(r["group"].as_u64().unwrap()); + let before = state(&s); + refuses_keep_model(&mut s, "livePaint.strokeEdge", &json!({"group": g.0, "point": [10, 30], "color": "#ff0000", "keepModel": "true"})); + assert_eq!(state(&s), before, "livePaint.strokeEdge"); +} + /// RGB colors given to the type commands in a CMYK document separate through the color settings, /// as `paint.setFill` stores them: `text.create {color}`, `text.setStyle {fill}` and /// `text.setRangeStyle {fill, stroke}`. CMYK colors are stored as given. @@ -280,6 +324,30 @@ fn rgb_type_colors_given_to_a_cmyk_document_separate_through_the_color_settings( assert_eq!(runs(&s)[0].1, Some(Color::rgb8(83, 170, 223)), "keepModel keeps RGB"); } +/// In a CMYK document, `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle` +/// refuse a `keepModel` given as text or as another value that is not true or false: no type is made, +/// and the type, the path and the undo history stay as they were. Null is read as `keepModel` left out. +#[test] +fn the_type_commands_refuse_a_keep_model_given_as_text() { + let mut s = cmyk_session(); + let t = id_of(s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hello"})).unwrap()); + let path = rect(&mut s, 200.0); + let before = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let calls = [ + ("text.create", json!({"x": 10, "y": 90, "text": "Hi", "color": "#ff0000", "keepModel": "true"})), + ("text.createInPath", json!({"path": path.0, "mode": "area", "text": "Hi", "color": "#ff0000", "keepModel": 1})), + ("text.setStyle", json!({"ids": [t.0], "fill": "#ff0000", "keepModel": "false"})), + ("text.setRangeStyle", json!({"id": t.0, "start": 0, "end": 2, "fill": "#ff0000", "keepModel": [true]})), + ]; + for (cmd, p) in &calls { + refuses_keep_model(&mut s, cmd, p); + assert_eq!((s.doc().unwrap().doc.clone(), undo_len(&s)), before, "{cmd} {p}"); + } + s.execute("text.setRangeStyle", &json!({"id": t.0, "start": 0, "end": 2, "fill": "#ff0000", "keepModel": null})).unwrap(); + let NodeKind::Text(o) = node(&s, t).kind else { panic!("not type") }; + assert_eq!(o.runs[0].style.fill.color(), Some(managed_cmyk(Color::rgb8(255, 0, 0)))); +} + /// `shapeBuilder.merge {fill: "#rrggbb"}` in a CMYK document fills the merged shape as /// `fill: {color}` does. #[test] @@ -339,10 +407,10 @@ fn rgb_gradient_and_mesh_colors_given_to_a_cmyk_document_separate_through_the_co assert_eq!(colors(&s)[1], blue); // New stops recolor the three points from their CMYK values. s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#ff0000"}]})).unwrap(); - let stored = vectorcraft_color::Gradient { - kind: Default::default(), - stops: vec![vectorcraft_color::GradientStop::new(0.0, managed_cmyk(Color::WHITE)), vectorcraft_color::GradientStop::new(1.0, red)], - }; + let stored = vectorcraft_color::Gradient::new( + Default::default(), + vec![vectorcraft_color::GradientStop::new(0.0, managed_cmyk(Color::WHITE)), vectorcraft_color::GradientStop::new(1.0, red)], + ); // The points mix in the model the stops share (#2829): CMYK here. assert_eq!(colors(&s), [0.0, 0.5, 1.0].map(|t| stored.sample_with(t, Color::lerp_keep_model).0), "{:?}", colors(&s)); @@ -375,6 +443,38 @@ fn rgb_gradient_and_mesh_colors_given_to_a_cmyk_document_separate_through_the_co assert!(mesh(&s).contains(&orange), "{:?}", mesh(&s)); } +/// In a CMYK document, `paint.editGradient`, `paint.freeform.addPoint` and `setPoint`, and +/// `object.mesh.setPointColor` and `addLine` refuse a `keepModel` given as text or as another value +/// that is not true or false. The gradient, the mesh and the undo history stay as they were. Null is +/// read as `keepModel` left out. +#[test] +fn the_gradient_and_mesh_commands_refuse_a_keep_model_given_as_text() { + let mut s = cmyk_session(); + let a = rect(&mut s, 10.0); + let (cyan, magenta) = (json!({"c": 100, "m": 0, "y": 0, "k": 0}), json!({"c": 0, "m": 100, "y": 0, "k": 0})); + let points = json!([{"at": [20, 20], "color": cyan}, {"at": [50, 50], "color": magenta}]); + s.execute("paint.setFill", &json!({"gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap(); + let m = rect(&mut s, 300.0); + s.execute("object.mesh.create", &json!({"ids": [m.0], "rows": 1, "cols": 1})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let before = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let stops = json!([{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#53aadf"}]); + let calls = [ + ("paint.editGradient", json!({"stops": stops, "keepModel": "true"})), + ("paint.freeform.addPoint", json!({"at": [40, 20], "color": "#ff0000", "keepModel": "true"})), + ("paint.freeform.setPoint", json!({"index": 1, "color": "#ff0000", "keepModel": 1})), + ("object.mesh.setPointColor", json!({"id": m.0, "index": 1, "color": "#ff0000", "keepModel": "false"})), + ("object.mesh.addLine", json!({"id": m.0, "x": 320, "y": 30, "color": "#ff0000", "keepModel": [true]})), + ]; + for (cmd, p) in &calls { + refuses_keep_model(&mut s, cmd, p); + assert_eq!((s.doc().unwrap().doc.clone(), undo_len(&s)), before, "{cmd} {p}"); + } + s.execute("object.mesh.setPointColor", &json!({"id": m.0, "index": 1, "color": "#ff0000", "keepModel": null})).unwrap(); + let NodeKind::Mesh(g) = node(&s, m).kind else { panic!("not a mesh") }; + assert_eq!(g.points[1].color, managed_cmyk(Color::rgb8(255, 0, 0))); +} + #[test] fn the_options_are_a_preference() { let mut s = Session::new(); @@ -409,3 +509,162 @@ fn type_takes_the_stroke_options_it_has() { assert_eq!((st.stroke.color().unwrap().to_hex(), st.stroke_width, st.stroke_cap), ("#0000ff".into(), 5.0, LineCap::Round)); assert!(st.stroke_dash.is_none(), "the dash pattern isn't applied"); } + +/// Unsupported object transparency must fail atomically; opting out still samples the range. +#[test] +fn eyedropper_text_range_rejects_object_transparency_without_changes() { + for transparency in [json!({"opacity": 50}), json!({"blend": "multiply"})] { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = rect(&mut s, 300.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("transparency.set", &transparency).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + s.tool_text("MAMA NA KAZI", view).unwrap(); + s.set_tool_option("select", &json!({"start": 0, "end": 4})); + s.select_tool("eyedropper", view).unwrap(); + s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + let paint = s.paint.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let selection = s.tool_options(); + let err = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err(); + assert!(err.to_string().contains("object transparency"), "{err}"); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.paint, paint); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(s.tool_options(), selection); + // Disabling transparency still copies the paint to just the highlighted characters. + s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap(); + let dst = s.doc().unwrap().selection.objects[0]; + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert_eq!(t.runs[0].text, "MAMA"); + assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00"); + assert_eq!(t.runs[1].text, " NA KAZI"); + let NodeKind::Text(before) = &doc.node(dst).unwrap().kind else { panic!() }; + assert_eq!(t.runs[1].style, before.runs.last().unwrap().style); + } +} + +/// An opaque sample cannot replace only a range's inherited object transparency. +#[test] +fn eyedropper_text_range_rejects_target_transparency_without_changes() { + for transparency in [json!({"opacity": 50}), json!({"blend": "multiply"})] { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = rect(&mut s, 300.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + s.tool_text("MAMA NA KAZI", view).unwrap(); + let dst = s.doc().unwrap().selection.objects[0]; + s.execute("transparency.set", &transparency).unwrap(); + s.set_tool_option("select", &json!({"start": 0, "end": 4})); + s.select_tool("eyedropper", view).unwrap(); + s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let paint = s.paint.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let selection = s.tool_options(); + let err = s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap_err(); + assert!(err.to_string().contains("object transparency"), "{err}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.paint, paint); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); + assert_eq!(s.tool_options(), selection); + // An explicit opt-out samples the range while retaining the object's transparency. + s.execute("appearance.copyFrom", &json!({"source": src.0, "apply": {"appearance": {"transparency": false}}})).unwrap(); + let n = node(&s, dst); + assert_eq!((n.opacity, n.blend), (before.node(dst).unwrap().opacity, before.node(dst).unwrap().blend)); + let NodeKind::Text(t) = n.kind else { panic!("not text") }; + assert_eq!(t.runs[0].text, "MAMA"); + assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00"); + let NodeKind::Text(old) = &before.node(dst).unwrap().kind else { panic!("not text") }; + assert_eq!(t.runs[1].text, " NA KAZI"); + assert_eq!(t.runs[1].style, old.runs.last().unwrap().style); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } +} + +/// Choosing the Eyedropper from highlighted type must preserve the character target. +#[test] +fn eyedropper_styles_only_highlighted_characters() { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = id_of(s.execute("text.create", &json!({"x": 300, "y": 100, "text": "Source"})).unwrap()); + s.execute("text.setStyle", &json!({"font": "Inter", "size": 30, "fill": "#00ff00"})).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + let dst = s.doc().unwrap().selection.objects[0]; + s.tool_text("MAMA NA KAZI", view).unwrap(); + s.set_tool_option("select", &json!({"start": 0, "end": 4})); + let before = match node(&s, dst).kind { + NodeKind::Text(t) => t.first_style(), + _ => panic!(), + }; + s.select_tool("eyedropper", view).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert_eq!(t.runs.len(), 2, "only MAMA takes the sampled style"); + assert_eq!(t.runs[0].text, "MAMA"); + assert_eq!(t.runs[0].style.size, 30.0); + assert_eq!(t.runs[0].style.fill.color().unwrap().to_hex(), "#00ff00"); + assert_eq!(t.runs[1].text, " NA KAZI"); + assert_eq!(t.runs[1].style, before); + s.execute("edit.undo", &json!({})).unwrap(); + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert_eq!(t.plain_text(), "MAMA NA KAZI", "undo keeps the separate typing step"); + assert_eq!(t.runs[0].style, before); + s.execute("edit.redo", &json!({})).unwrap(); + s.select_tool("type", view).unwrap(); + assert_eq!(s.tool_options()["editing"], dst.0); + assert_eq!((s.tool_options()["start"].as_u64(), s.tool_options()["end"].as_u64()), (Some(0), Some(4))); +} + +/// Color-only sampling preserves multibyte ranges and clears stale targets on deselection. +#[test] +fn eyedropper_shift_color_preserves_other_characters_and_clears_on_deselect() { + let mut s = session(); + s.prefs.placeholder_text = false; + let src = rect(&mut s, 300.0); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#b00000"})).unwrap(); + let view = ViewInfo::default(); + s.select_tool("type", view).unwrap(); + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap(); + } + let dst = s.doc().unwrap().selection.objects[0]; + s.tool_text("Aé中Z", view).unwrap(); + s.set_tool_option("select", &json!({"start": 1, "end": 6})); + let NodeKind::Text(before) = node(&s, dst).kind else { panic!() }; + click(&mut s, 325.0, 35.0, Mods { shift: true, ..Default::default() }); + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert_eq!(t.runs.iter().map(|r| r.text.as_str()).collect::>(), ["A", "é中", "Z"]); + assert_eq!(t.runs[0].style, before.first_style()); + assert_eq!(t.runs[2].style, before.first_style()); + assert_eq!(t.runs[1].style.fill.color().unwrap().to_hex(), "#00ff00"); + assert_eq!(t.runs[1].style.size, before.first_style().size); + assert!(s.overlays(view).iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { .. })), "the highlighted range remains visible"); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.tool_options().get("editing").is_none_or(Value::is_null)); + s.execute("select.set", &json!({"ids": [dst.0]})).unwrap(); + s.execute("appearance.copyFrom", &json!({"source": src.0})).unwrap(); + let NodeKind::Text(t) = node(&s, dst).kind else { panic!() }; + assert!( + t.runs.iter().all(|r| r.style.fill.color().unwrap().to_hex() == "#00ff00"), + "after deselecting, ordinary object sampling styles the whole object" + ); +} diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index 79479d047..01837b0c9 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -148,6 +148,212 @@ fn double_clicking_outside_the_draw_inside_object_returns_to_draw_normal() { assert_eq!(double(&mut s, 150.0, 150.0), (DrawMode::Normal, None)); } +/// #3705: in Draw Inside, Paste, Paste in Place and Paste on All Artboards put the copies inside +/// the object drawn into, as drawn art goes, each paste one undo step; the first one makes the +/// clip group and the next ones add to it, with Paste Remembers Layers on and with the object +/// isolated by a double-click too. Paste in Front and Paste in Back keep their places. +/// Draw Inside a compound path: the clip group's mask is a copy whose subpaths have ids of their +/// own, so no two objects share an id. An artboard copied without art, pasted on all artboards in +/// Draw Inside, makes no clip group. +#[test] +fn draw_inside_a_compound_path_gives_its_mask_ids_of_its_own() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300, "artboards": 2})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.rectangle", &json!({"x": 130, "y": 130, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let c = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [c]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 30, "height": 30})).unwrap(); + let d = &s.doc().unwrap().doc; + let mut ids = Vec::new(); + d.walk(|n| ids.push(n.id.0)); + let all = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), all, "an id is used twice"); + assert_eq!(d.node(d.parent_of(NodeId(c)).unwrap()).unwrap().kind_label(), "Clip Group"); + // An artboard alone on the clipboard: Paste on All Artboards adds nothing inside the object. + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300, "artboards": 2})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("artboard.copy", &json!({"index": 1, "art": false})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + let _ = s.execute("edit.pasteOnAllArtboards", &json!({})); + let d = &s.doc().unwrap().doc; + assert!(d.parent_of(NodeId(a)).and_then(|p| d.node(p)).is_some_and(|p| p.is_layer()), "no clip group around the object"); +} + +#[test] +fn pasting_in_draw_inside_puts_the_copies_inside_the_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300, "artboards": 2})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + let parent = |s: &Session, id: u64| s.doc().unwrap().doc.parent_of(NodeId(id)); + let pasted = |r: Value| r["ids"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect::>(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let ids = pasted(s.execute("edit.paste", &json!({"center": [150, 150]})).unwrap()); + let g = parent(&s, a).unwrap(); + assert_eq!(s.doc().unwrap().doc.node(g).unwrap().kind_label(), "Clip Group"); + assert_eq!(parent(&s, ids[0]), Some(g), "the copy is inside the object"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*s.doc().unwrap().doc, *before, "one undo step takes the copy and the clip group away"); + s.execute("edit.redo", &json!({})).unwrap(); + // Paste in Place and Paste on All Artboards (one copy per artboard) go into the same group, + // on top of what is in it. + let in_place = pasted(s.execute("edit.pasteInPlace", &json!({})).unwrap()); + let all = pasted(s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap()); + assert_eq!(all.len(), 2); + let children: Vec = s.doc().unwrap().doc.node(g).unwrap().children().unwrap().iter().map(|n| n.id.0).collect(); + assert_eq!(children.len(), 6, "{children:?}"); + assert_eq!(children[1..], [a, ids[0], in_place[0], all[0], all[1]]); + // Paste Remembers Layers doesn't take the copy out of the object. + s.execute("layer.pasteRemembersLayers", &json!({"on": true})).unwrap(); + let remembered = pasted(s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(parent(&s, remembered[0]), Some(g)); + // Paste in Front and Paste in Back ignore the drawing mode: next to the selection. + s.execute("layer.pasteRemembersLayers", &json!({"on": false})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let front = pasted(s.execute("edit.pasteInFront", &json!({})).unwrap()); + assert_eq!(parent(&s, front[0]), Some(g)); + let d = &s.doc().unwrap().doc; + let at = |id: u64| d.position(NodeId(id)).unwrap().1; + assert_eq!(at(front[0]), at(a) + 1, "just above the selected object"); + // The object isolated (a double-click with the Selection tool) before anything went inside. + let mut s = session(); + let b = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.pointer(&vectorcraft_tools::PointerEvent::new(vectorcraft_tools::PointerKind::DoubleClick, 150.0, 150.0), ViewInfo::default()).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(NodeId(b))); + let ids = pasted(s.execute("edit.paste", &json!({})).unwrap()); + let g = parent(&s, b).unwrap(); + assert_eq!((s.doc().unwrap().doc.node(g).unwrap().kind_label(), parent(&s, ids[0])), ("Clip Group", Some(g))); +} + +/// #3705: in Draw Inside, with the object isolated by a double-click with the Selection tool, what +/// is drawn or pasted inside it stays in isolation mode: isolation moves to the clip group Draw +/// Inside makes around the object, so a click picks the pasted copy and it draws undimmed. It used +/// to stay on the bare path, the new art locked and dimmed beside it, and double-clicks never +/// reached the clip group. Undoing the step that made the clip group leaves isolation mode, as the +/// group is gone. +#[test] +fn drawing_inside_an_isolated_object_keeps_the_new_art_in_isolation_mode() { + use vectorcraft_geom::Affine; + use vectorcraft_render::{RenderOptions, Renderer}; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let red = s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [red]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "inside"})).unwrap(); + let ev = |s: &mut Session, kinds: &[PointerKind], x: f64, y: f64| { + for k in kinds { + s.pointer(&PointerEvent::new(*k, x, y), ViewInfo::default()).unwrap(); + } + }; + s.select_tool("selection", ViewInfo::default()).unwrap(); + ev(&mut s, &[PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick], 150.0, 150.0); + assert_eq!(s.doc().unwrap().isolation, Some(NodeId(a)), "the double-click isolates the object"); + // An ellipse dragged out with the Ellipse tool (several previews) goes inside the object, and + // isolation mode with it. + s.select_tool("ellipse", ViewInfo::default()).unwrap(); + ev(&mut s, &[PointerKind::Down], 110.0, 110.0); + for d in [5.0, 10.0, 15.0, 20.0] { + ev(&mut s, &[PointerKind::Drag], 110.0 + d, 110.0 + d); + } + ev(&mut s, &[PointerKind::Up], 130.0, 130.0); + let d = &s.doc().unwrap().doc; + let g = d.parent_of(NodeId(a)).unwrap(); + assert_eq!(d.node(g).unwrap().kind_label(), "Clip Group"); + assert_eq!(s.doc().unwrap().isolation, Some(g), "isolation mode follows the object into its clip group"); + // A paste goes into it too, in isolation mode, and a click with the Selection tool picks it. + let pasted = s.execute("edit.paste", &json!({"center": [170, 170]})).unwrap()["ids"][0].as_u64().unwrap(); + let st = s.doc().unwrap(); + assert_eq!((st.doc.parent_of(NodeId(pasted)), st.isolation), (Some(g), Some(g))); + // Isolation mode draws the art around what is isolated dimmed: the red copy is not. + let opts = RenderOptions { background: Some([255, 255, 255, 255]), isolated: st.isolation, ..Default::default() }; + let p = Renderer::new().render(&st.doc, 400, 300, Affine::IDENTITY, &opts).pixel(170, 170); + assert_eq!([p[0], p[1], p[2]], [255, 0, 0], "the pasted copy draws undimmed"); + s.execute("select.none", &json!({})).unwrap(); + s.select_tool("selection", ViewInfo::default()).unwrap(); + ev(&mut s, &[PointerKind::Down, PointerKind::Up], 170.0, 170.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(pasted)], "a click picks the pasted copy"); + // Undoing the paste keeps isolation mode; undoing the ellipse, which made the clip group, ends it. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().isolation, Some(g)); + s.execute("edit.undo", &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.node(g).is_none() && st.isolation.is_none(), "{:?}", st.isolation); +} + +/// In Draw Behind, Paste in Place and Paste on All Artboards put the copies just below the +/// selection, as Paste in Back does; Paste puts them on top, as in Draw Normal. +#[test] +fn paste_in_place_in_draw_behind_goes_behind_the_selection() { + let mut s = session(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + s.execute("shape.ellipse", &json!({"x": 20, "y": 20, "width": 50, "height": 50})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("view.drawMode", &json!({"mode": "behind"})).unwrap(); + let order = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id.0).collect::>(); + let first = |r: Value| r["ids"][0].as_u64().unwrap(); + let placed = first(s.execute("edit.pasteInPlace", &json!({})).unwrap()); + let o = order(&s); + let at = |id: u64| o.iter().position(|x| *x == id).unwrap(); + assert_eq!(at(placed) + 1, at(a), "just below the selected rectangle: {o:?}"); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let all = first(s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap()); + let o = order(&s); + let at = |id: u64| o.iter().position(|x| *x == id).unwrap(); + assert_eq!(at(all) + 1, at(a), "{o:?}"); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let pasted = first(s.execute("edit.paste", &json!({})).unwrap()); + assert_eq!(order(&s).last(), Some(&pasted), "Paste: on top"); +} + +/// #3705: Shift+D (`view.drawMode {}`) goes Draw Normal → Draw Behind → Draw Inside → Draw Normal +/// with one path selected, and from Draw Behind back to Draw Normal when Draw Inside can't draw +/// into the selection: one group (such as the clip group Draw Inside made), one image, several +/// objects or none. It used to stop at Draw Behind with one object selected that isn't a path, +/// compound path or type. +#[test] +fn shift_d_goes_past_draw_behind_whatever_is_selected() { + let mut s = session(); + let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + let cycle = |s: &mut Session| (0..3).map(|_| s.execute("view.drawMode", &json!({})).map(|r| r["mode"].clone()).unwrap()).collect::>(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + assert_eq!(cycle(&mut s), [json!("behind"), json!("inside"), json!("normal")]); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + assert_eq!(cycle(&mut s), [json!("behind"), json!("normal"), json!("behind")]); + s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + assert_eq!(cycle(&mut s), [json!("behind"), json!("normal"), json!("behind")], "one group selected"); + assert!(s.draw_inside.is_none()); + s.execute("view.drawMode", &json!({"mode": "normal"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + assert_eq!(cycle(&mut s), [json!("behind"), json!("normal"), json!("behind")]); + // Asked for by name, Draw Inside is still refused without a path, compound path or type. + s.execute("select.set", &json!({"ids": [g]})).unwrap(); + assert!(s.execute("view.drawMode", &json!({"mode": "inside"})).is_err()); +} + #[test] fn export_for_screens_writes_every_combination() { let mut s = Session::new(); diff --git a/crates/engine/src/tests_fileinfo.rs b/crates/engine/src/tests_fileinfo.rs index 7b6e63485..09d1efd17 100644 --- a/crates/engine/src/tests_fileinfo.rs +++ b/crates/engine/src/tests_fileinfo.rs @@ -139,6 +139,25 @@ fn an_exported_pdf_opens_with_its_file_info() { assert_eq!(back["keywords"], json!(["fair", "Poster", "print"])); assert!(back["created"].as_str().is_some_and(|c| c.ends_with('Z')), "the file's creation date: {}", back["created"]); assert_eq!(back["title"], "poster.pdf", "the title stays the file name"); + assert_eq!(back["copyrightStatus"], "copyrighted"); + assert_eq!(back["copyrightNotice"], "(c) 2026 Ada"); + assert_eq!(back["copyrightUrl"], "https://example.com/rights"); +} + +#[test] +fn a_pdf_compatible_ai_carries_the_copyright() { + let mut s = session(); + let rights = json!({"copyrightStatus": "publicDomain", "copyrightNotice": "No rights reserved", "copyrightUrl": "https://example.com/cc0"}); + s.execute("file.info", &rights).unwrap(); + let path = std::env::temp_dir().join(format!("vc-fileinfo-rights-{}.ai", std::process::id())); + let saved = s.execute("document.save", &json!({"path": path.to_string_lossy()})); + let ai = std::fs::read(&path); + let _ = std::fs::remove_file(&path); + saved.unwrap(); + let f = vectorcraft_pdf::file_info(&ai.unwrap(), None).expect("a PDF-compatible .ai"); + assert_eq!(f.copyrighted, Some(false)); + assert_eq!(f.copyright_notice.as_deref(), Some("No rights reserved")); + assert_eq!(f.copyright_url.as_deref(), Some("https://example.com/cc0")); } /// Unix seconds of 2020-01-01 00:00:00 UTC. @@ -291,6 +310,18 @@ fn an_untitled_document_saved_to_a_file_exports_titled_after_it() { let dir = std::env::temp_dir().join(format!("vc-pdftitle-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("Document C.vectorcraft"); + // Unsaved and untitled: viewers keep showing the file name, not "Untitled-1". + let mut untitled = Session::new(); + untitled.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); + for pdf in pdf_texts(&mut untitled) { + assert!(!pdf.contains("/DisplayDocTitle true"), "{pdf}"); + } + // An explicitly empty document title is also not a title to display. + std::sync::Arc::make_mut(&mut untitled.doc_mut().unwrap().doc).title.clear(); + for pdf in pdf_texts(&mut untitled) { + assert!(!pdf.contains("/DisplayDocTitle true"), "{pdf}"); + } + // Saved as a file, then exported: Untitled-1 was only the name New gave it. let mut s = Session::new(); s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap(); @@ -298,6 +329,9 @@ fn an_untitled_document_saved_to_a_file_exports_titled_after_it() { for pdf in pdf_texts(&mut s) { assert!(pdf.contains("/Title(Document C)") && xmp_title(&pdf).contains("Document C"), "{}", xmp_title(&pdf)); } + // Non-object parameters still preserve that the file-stem title was not user supplied. + let pdf = String::from_utf8_lossy(&b64(&s.execute("document.exportPdf", &Value::Null).unwrap())).into_owned(); + assert!(!pdf.contains("/DisplayDocTitle true"), "{pdf}"); // Opened from that file, in another session. let mut s = Session::new(); s.execute("document.open", &json!({"path": path.to_str().unwrap()})).unwrap(); @@ -311,6 +345,7 @@ fn an_untitled_document_saved_to_a_file_exports_titled_after_it() { s.execute("document.save", &json!({"path": dir.join("Document D.vectorcraft").to_str().unwrap()})).unwrap(); for pdf in pdf_texts(&mut s) { assert!(pdf.contains("/Title(Poster)") && xmp_title(&pdf).contains("Poster")); + assert!(pdf.contains("/ViewerPreferences<>"), "{pdf}"); } let _ = std::fs::remove_dir_all(dir); } diff --git a/crates/engine/src/tests_flatten.rs b/crates/engine/src/tests_flatten.rs index fdfaf58e4..69882a32b 100644 --- a/crates/engine/src/tests_flatten.rs +++ b/crates/engine/src/tests_flatten.rs @@ -7,6 +7,7 @@ use vectorcraft_doc::{AppearanceItem, Node, NodeKind}; use vectorcraft_geom::{BezPath, Point, Rect, Shape}; use vectorcraft_render::{Rendered, Renderer}; +use super::tests_expand::{RANDOM_CASES, random_transforms}; use super::tests_menucmds::{place_undecodable_png, warns_undecoded}; use super::*; @@ -410,6 +411,27 @@ fn outlined_type_keeps_its_gradient_on_the_outlines_its_transform_effect_moves() assert!(diffs.iter().all(|&d| d < 0.0002), "shares of the pixels that changed: {diffs:?}"); } +/// Random in a Transform effect takes its seed from the object's id. Each of [`random_transforms`]' +/// objects at 50% opacity draws as the canvas drew it with its geometry effects baked, as Flatten +/// Transparency bakes them first, and after Flatten Transparency, within the seams between its +/// regions (the same objects without Random change 1.4% to 2.3% of the pixels). +#[test] +fn flattened_art_keeps_the_random_transforms_the_canvas_drew() { + let _settings = settings_lock(); + let mut diffs = vec![]; + for case in RANDOM_CASES { + let mut s = session(); + let id = random_transforms(&mut s, case); + run(&mut s, "transparency.set", json!({"ids": [id.0], "opacity": 50})); + let before = picture(&s); + let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap(); + let baked = Renderer::new().render_region(&baked, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + flatten(&mut s, json!({"ids": [id.0]})); + diffs.push((case, changed(&before, &baked), changed(&before, &picture(&s)))); + } + assert!(diffs.iter().all(|&(_, baked, flat)| baked == 0.0 && flat < 0.03), "shares of the pixels that changed: {diffs:?}"); +} + #[test] fn preserve_alpha_composites_over_nothing() { let _settings = settings_lock(); diff --git a/crates/engine/src/tests_freeform.rs b/crates/engine/src/tests_freeform.rs index 26ec4203f..e64a9a805 100644 --- a/crates/engine/src/tests_freeform.rs +++ b/crates/engine/src/tests_freeform.rs @@ -238,6 +238,27 @@ fn lines_join_split_and_follow_the_selection() { assert_eq!(g["points"][i]["at"], json!([190.0, 190.0])); } +/// Split Line refuses a `t` that is not a number, and the point commands and the query a `stroke` +/// that is not true or false, given as text or as another value of the wrong kind; the gradient and +/// the undo history stay as they were. Numbers, false and null still work. +#[test] +fn freeform_commands_refuse_a_number_or_boolean_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let (mut s, id) = freeform_rect(); + run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]})); + let calls = [ + not_a_number("paint.freeform.splitLine", json!({"line": 0, "segment": 0, "t": "0.25"}), "t"), + not_a_switch("paint.freeform.addPoint", json!({"at": [150, 150], "stroke": "false"}), "stroke"), + not_a_switch("paint.freeform.get", json!({"stroke": [false]}), "stroke"), + ]; + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + run(&mut s, "paint.freeform.splitLine", json!({"line": 0, "segment": 0, "t": null})); + let g = run(&mut s, "paint.freeform.get", json!({"stroke": false})); + assert_eq!(g["lines"][0].as_array().map(Vec::len), Some(3)); + assert_eq!(points(&s, id).lines[0].len(), 3); +} + #[test] fn point_commands_need_a_freeform_gradient() { let mut s = session(); @@ -262,6 +283,27 @@ fn point_commands_need_a_freeform_gradient() { assert!(s.execute("paint.freeform.addPoint", &json!({"at": [1, 1]})).is_err()); } +/// The point and line commands refuse a point, line or segment of 2^32 + k, where k names one that +/// exists, and the points and the selected point stay as they were. A 32-bit build read the number +/// as k. +#[test] +fn point_commands_refuse_an_index_past_u32() { + let (mut s, id) = freeform_rect(); + run(&mut s, "paint.freeform.addLine", json!({"points": [0, 1]})); + let (before, selected) = (points(&s, id), s.selected_freeform_point()); + let past = |k: u64| (1u64 << 32) + k; + for (cmd, p) in [ + ("paint.freeform.setPoint", json!({"index": past(1), "color": "#000000"})), + ("paint.freeform.deletePoint", json!({"index": past(1)})), + ("paint.freeform.selectPoint", json!({"index": past(1)})), + ("paint.freeform.splitLine", json!({"line": past(0), "segment": 0})), + ("paint.freeform.splitLine", json!({"line": 0, "segment": past(0)})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + assert_eq!((points(&s, id), s.selected_freeform_point()), (before.clone(), selected), "{cmd} {p}"); + } +} + #[test] fn selection_follows_the_object_and_the_proxy() { let (mut s, id) = freeform_rect(); diff --git a/crates/engine/src/tests_globaledit.rs b/crates/engine/src/tests_globaledit.rs new file mode 100644 index 000000000..c928d1712 --- /dev/null +++ b/crates/engine/src/tests_globaledit.rs @@ -0,0 +1,445 @@ +//! Select › Start Global Edit (`select.globalEdit`, `select.globalEditOptions`): which objects +//! count as similar, and edits made to one of them reaching the others in proportion, as one undo +//! step. Behaviour: . + +use serde_json::{Value, json}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::{Affine, PathData, Rect}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).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 node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + node(s, id).geometric_bounds().unwrap() +} + +fn sel(s: &mut Session, ids: &[NodeId]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::>()})).unwrap(); +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +fn start(s: &mut Session, key: NodeId, p: Value) -> Vec { + sel(s, &[key]); + let r = s.execute("select.globalEdit", &p).unwrap(); + assert_eq!(r["active"], true); + ids(&r) +} + +fn fill(s: &Session, id: NodeId) -> String { + node(s, id).appearance.fill_paint().color().unwrap().to_hex() +} + +fn stroke(s: &Session, id: NodeId) -> (String, f64) { + let n = node(s, id); + (n.appearance.stroke_paint().color().unwrap().to_hex(), n.appearance.stroke_width()) +} + +fn members(s: &Session) -> Option> { + s.doc().unwrap().global_edit.as_ref().map(|g| g.members.iter().map(|m| m.0).collect()) +} + +fn close(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() < 1e-6) +} + +/// `b`'s outline is `a`'s placed by `xf`, anchor for anchor. +fn placed(s: &Session, a: NodeId, b: NodeId, xf: Affine) -> bool { + let (pa, pb): (PathData, PathData) = (node(s, a).path_data().unwrap().transformed(xf), node(s, b).path_data().unwrap().clone()); + pa.subpaths.len() == pb.subpaths.len() + && pa.subpaths.iter().zip(&pb.subpaths).all(|(x, y)| { + x.closed == y.closed + && x.anchors.len() == y.anchors.len() + && x.anchors + .iter() + .zip(&y.anchors) + .all(|(m, n)| m.p.distance(n.p) < 1e-6 && m.h_in.distance(n.h_in) < 1e-6 && m.h_out.distance(n.h_out) < 1e-6) + }) +} + +/// A 100 pt square, a copy of it, a 20 pt square (the same shape, smaller), a 100 × 50 rectangle +/// and a 100 pt circle (other shapes). +fn squares(s: &mut Session) -> [NodeId; 5] { + [rect(s, 0.0, 0.0, 100.0, 100.0), rect(s, 300.0, 0.0, 100.0, 100.0), rect(s, 500.0, 0.0, 20.0, 20.0), rect(s, 0.0, 300.0, 100.0, 50.0), { + let r = s.execute("shape.ellipse", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) + }] +} + +#[test] +fn start_global_edit_takes_the_objects_of_the_same_shape_at_any_place_and_size() { + let mut s = session(); + let [a, b, c, wide, circle] = squares(&mut s); + let members = start(&mut s, a, json!({})); + assert_eq!(members, [a, b, c], "the copy and the smaller square; not the rectangle {wide} nor the circle {circle}"); + // The selected object stays the selection; the others are highlighted on the canvas. + assert_eq!(s.doc().unwrap().selection.objects, [a]); + assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2); + let r = s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(r["active"], false, "the second run is Stop Global Edit"); + assert!(s.doc().unwrap().global_edit.is_none() && crate::cmd::globaledit::highlights(&s).is_empty()); +} + +#[test] +fn size_and_appearance_options_narrow_the_match() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "the smaller square has another size"); + s.execute("select.globalEdit", &json!({})).unwrap(); + sel(&mut s, &[b]); + s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap(); + assert_eq!(start(&mut s, a, json!({"appearance": true})), [a, c], "the copy has another fill now"); + s.execute("select.globalEdit", &json!({})).unwrap(); + // The options are kept for the session, and report how many objects they take. + sel(&mut s, &[a]); + let r = s.execute("select.globalEditOptions", &json!({"size": true, "appearance": true})).unwrap(); + assert_eq!((r["size"].clone(), r["appearance"].clone(), r["count"].clone()), (json!(true), json!(true), json!(1))); + let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string(); + assert!(e.contains("no similar objects"), "{e}"); + s.execute("select.globalEditOptions", &json!({"size": false, "appearance": false})).unwrap(); + assert_eq!(start(&mut s, a, json!({})), [a, b, c]); +} + +#[test] +fn an_edit_reaches_every_member_in_proportion_as_one_undo_step() { + let mut s = session(); + let [a, b, c, wide, circle] = squares(&mut s); + start(&mut s, a, json!({})); + let before = (*s.doc().unwrap().doc).clone(); + let steps = s.doc().unwrap().history.undo.len(); + let (wide_was, circle_was) = (node(&s, wide), node(&s, circle)); + // Height 100 → 50 from the top left (the Transform panel): the 20 pt square goes to 10 pt. + s.execute("object.setBounds", &json!({"height": 50, "reference": 0})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 50.0)), "{:?}", bounds(&s, a)); + assert!(close(bounds(&s, b), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, b)); + assert!(close(bounds(&s, c), Rect::new(500.0, 0.0, 520.0, 10.0)), "{:?}", bounds(&s, c)); + assert_eq!((node(&s, wide), node(&s, circle)), (wide_was, circle_was), "objects of other shapes are left alone"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step"); + crate::tests_large_edits::undo_gives_back(&mut s, &before); +} + +#[test] +fn an_anchor_moved_on_one_member_moves_on_the_others() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + start(&mut s, a, json!({})); + s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap(); + s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap(); + assert!(placed(&s, a, b, Affine::translate((300.0, 0.0)))); + assert!(placed(&s, a, c, Affine::translate((500.0, 0.0)) * Affine::scale(0.2))); + assert!(!placed(&s, a, c, Affine::translate((500.0, 0.0))), "the smaller square's anchor moves a fifth as far"); +} + +#[test] +fn a_drag_moves_the_members_with_each_preview_and_commits_one_step() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + start(&mut s, a, json!({})); + let before = (*s.doc().unwrap().doc).clone(); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); + s.preview("object.move", &json!({"dx": 40, "dy": 0})).unwrap(); + assert!(close(bounds(&s, b), Rect::new(340.0, 0.0, 440.0, 100.0)), "the second preview replaces the first: {:?}", bounds(&s, b)); + s.commit_interaction().unwrap(); + assert!(close(bounds(&s, c), Rect::new(508.0, 0.0, 528.0, 20.0)), "{:?}", bounds(&s, c)); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Move"); + crate::tests_large_edits::undo_gives_back(&mut s, &before); +} + +#[test] +fn any_selected_member_leads_and_fills_reach_the_others_only_when_they_change() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + sel(&mut s, &[c]); + s.execute("paint.setFill", &json!({"color": "#123456"})).unwrap(); + start(&mut s, a, json!({})); + let c_fill = fill(&s, c); + // Moving the copy (selected during Global Edit) moves the others in proportion and keeps + // their own fills. + sel(&mut s, &[b]); + s.execute("object.move", &json!({"dx": 50, "dy": 0})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0)), "{:?}", bounds(&s, a)); + assert!(close(bounds(&s, c), Rect::new(510.0, 0.0, 530.0, 20.0)), "{:?}", bounds(&s, c)); + assert_eq!(fill(&s, c), c_fill); + // A new fill reaches all of them, and the stroke weight stays the model's, not scaled. + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + for id in [a, b, c] { + assert_eq!(fill(&s, id), "#ff0000"); + assert_eq!(node(&s, id).appearance.stroke_width(), 4.0); + } + // Selection changes alone edit nothing; after Stop Global Edit an edit stays on its object. + s.execute("select.globalEdit", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + assert!(close(bounds(&s, a), Rect::new(50.0, 0.0, 150.0, 100.0))); +} + +#[test] +fn locked_and_hidden_objects_are_left_out() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + let locked = rect(&mut s, 0.0, 450.0, 100.0, 100.0); + let hidden = rect(&mut s, 150.0, 450.0, 100.0, 100.0); + s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap(); + s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap(); + assert_eq!(start(&mut s, a, json!({})), [a, b, c], "locked and hidden squares are not found"); + // A member locked after the start is not edited either. + s.execute("object.lock", &json!({"ids": [b.0]})).unwrap(); + let b_was = node(&s, b); + sel(&mut s, &[a]); + s.execute("object.move", &json!({"dx": 0, "dy": 10})).unwrap(); + assert_eq!(node(&s, b), b_was); + assert!(close(bounds(&s, c), Rect::new(500.0, 2.0, 520.0, 22.0)), "{:?}", bounds(&s, c)); + assert_eq!(bounds(&s, locked), Rect::new(0.0, 450.0, 100.0, 550.0)); + assert_eq!(bounds(&s, hidden), Rect::new(150.0, 450.0, 250.0, 550.0)); +} + +#[test] +fn artboard_options_limit_where_similar_objects_are_found() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap(); + let key = rect(&mut s, 10.0, 10.0, 50.0, 50.0); + let same_board = rect(&mut s, 200.0, 10.0, 50.0, 50.0); + let second = rect(&mut s, 1100.0, 10.0, 50.0, 50.0); + let canvas = rect(&mut s, 2000.0, 2000.0, 50.0, 50.0); + assert_eq!(start(&mut s, key, json!({})), [key, same_board, second, canvas], "all artboards and the canvas by default"); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(start(&mut s, key, json!({"includeCanvas": false})), [key, same_board, second]); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(start(&mut s, key, json!({"artboards": "2"})), [key, second, canvas]); + s.execute("select.globalEdit", &json!({})).unwrap(); + assert_eq!(start(&mut s, key, json!({"artboards": "2", "includeCanvas": false})), [key, second]); + s.execute("select.globalEdit", &json!({})).unwrap(); + let e = s.execute("select.globalEdit", &json!({"artboards": "3"})).unwrap_err().to_string(); + assert!(e.contains("1 to 2"), "{e}"); +} + +#[test] +fn compound_paths_match_whole_and_keep_their_own_member_ids() { + let mut s = session(); + let compound = |s: &mut Session, x: f64, k: f64| { + let (p, q) = (rect(s, x, 0.0, 100.0 * k, 100.0 * k), rect(s, x + 25.0 * k, 25.0 * k, 50.0 * k, 50.0 * k)); + sel(s, &[p, q]); + NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()) + }; + let (one, two) = (compound(&mut s, 0.0, 1.0), compound(&mut s, 300.0, 0.5)); + let lone = rect(&mut s, 600.0, 0.0, 100.0, 100.0); + assert_eq!(start(&mut s, one, json!({})), [one, two], "not the square {lone} alone"); + let kids = |s: &Session, id: NodeId| node(s, id).children().unwrap().iter().map(|c| c.id).collect::>(); + let two_kids = kids(&s, two); + s.execute("object.setBounds", &json!({"width": 200, "reference": 0})).unwrap(); + assert!(close(bounds(&s, two), Rect::new(300.0, 0.0, 400.0, 50.0)), "{:?}", bounds(&s, two)); + assert_eq!(kids(&s, two), two_kids); + assert!(matches!(node(&s, two).kind, NodeKind::Compound { .. })); +} + +#[test] +fn symbol_instances_of_one_symbol_match() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + let made = s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap(); + let first = NodeId(made["id"].as_u64().unwrap()); + let placed = s.execute("symbol.place", &json!({"name": "Tile", "x": 300, "y": 300})).unwrap(); + let second = NodeId(placed["id"].as_u64().unwrap()); + let other = rect(&mut s, 500.0, 500.0, 40.0, 40.0); + assert_eq!(start(&mut s, first, json!({})), [first, second], "not the plain square {other}"); + let xf = |s: &Session, id: NodeId| match node(s, id).kind { + NodeKind::SymbolInstance { xf, .. } => xf, + _ => panic!("not an instance"), + }; + let was = xf(&s, second); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + let (now, want) = (xf(&s, second).as_coeffs(), (Affine::translate((10.0, 0.0)) * was).as_coeffs()); + assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}"); +} + +#[test] +fn global_edit_needs_one_supported_object() { + let mut s = session(); + let [a, b, ..] = squares(&mut s); + sel(&mut s, &[a, b]); + assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "a selection of two"); + let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap()); + sel(&mut s, &[t]); + assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type"); + let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0); + // A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the + // message says so. + let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0); + sel(&mut s, &[turned]); + s.execute("object.rotate", &json!({"angle": 90})).unwrap(); + sel(&mut s, &[lone]); + let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string(); + assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}"); + assert!(s.doc().unwrap().global_edit.is_none()); + // A group of two squares is refused, though another such group exists. + let group = |s: &mut Session, x: f64| { + let (p, q) = (rect(s, x, 100.0, 10.0, 10.0), rect(s, x + 20.0, 100.0, 10.0, 10.0)); + sel(s, &[p, q]); + NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()) + }; + let (g, _) = (group(&mut s, 600.0), group(&mut s, 700.0)); + sel(&mut s, &[g]); + let e = s.execute("select.globalEdit", &json!({})).unwrap_err(); + assert!(matches!(e, EngineError::Disabled(..)) && e.to_string().contains("paths, compound paths and symbol instances"), "{e}"); +} + +#[test] +fn global_edit_is_not_saved() { + let mut s = session(); + let [a, ..] = squares(&mut s); + start(&mut s, a, json!({})); + let saved = s.execute("document.serialize", &json!({})).unwrap().to_string(); + assert!(!saved.to_ascii_lowercase().contains("globaledit")); +} + +#[test] +fn a_stroke_weight_change_leaves_each_members_own_fill_and_stroke_colour() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + sel(&mut s, &[b]); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + start(&mut s, a, json!({})); + let (a_fill, c_fill) = (fill(&s, a), fill(&s, c)); + s.execute("stroke.set", &json!({"weight": 5})).unwrap(); + assert_eq!(fill(&s, b), "#0000ff", "the copy keeps its own fill"); + assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 5.0), "and its own stroke colour, with the new weight"); + assert_eq!(stroke(&s, c).1, 5.0, "the weight is the model's, not scaled"); + assert_eq!((fill(&s, a), fill(&s, c)), (a_fill, c_fill)); +} + +#[test] +fn a_fill_colour_change_reaches_the_fills_and_not_the_strokes() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + sel(&mut s, &[b]); + s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 3})).unwrap(); + start(&mut s, a, json!({})); + let (a_stroke, c_stroke) = (stroke(&s, a), stroke(&s, c)); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + for id in [a, b, c] { + assert_eq!(fill(&s, id), "#ff0000"); + } + assert_eq!(stroke(&s, b), ("#00ff00".to_string(), 3.0), "the copy keeps its own stroke"); + assert_eq!((stroke(&s, a), stroke(&s, c)), (a_stroke, c_stroke)); +} + +#[test] +fn a_fill_or_stroke_added_or_removed_reaches_the_members_that_keep_their_others() { + let mut s = session(); + let [a, b, c, ..] = squares(&mut s); + sel(&mut s, &[b]); + s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap(); + // The small square has two fills: not the model's fills and strokes in order. + sel(&mut s, &[c]); + s.execute("appearance.addFill", &json!({})).unwrap(); + start(&mut s, a, json!({})); + let kinds = |s: &Session, id| node(s, id).appearance.items.iter().map(|i| i.kind_name()).collect::>(); + s.execute("appearance.addStroke", &json!({})).unwrap(); + assert_eq!(kinds(&s, a), ["fill", "stroke", "stroke"]); + assert_eq!(kinds(&s, b), kinds(&s, a), "the copy gains the stroke"); + assert_eq!(fill(&s, b), "#0000ff", "and keeps its own fill"); + assert_eq!(kinds(&s, c), kinds(&s, a), "a member with other fills and strokes takes the model's list"); + s.execute("appearance.removeItem", &json!({"index": 0})).unwrap(); + assert_eq!(kinds(&s, a), ["stroke", "stroke"]); + assert_eq!(kinds(&s, b), kinds(&s, a), "the copy loses its fill"); +} + +#[test] +fn undo_and_redo_drop_the_members_the_document_lacks() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0); + assert_eq!(start(&mut s, a, json!({})), [a, b, c]); + // Undoing the making of the small square takes it out of the Global Edit, so a square made + // under its freed id is not edited with the others. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(members(&s), Some(vec![a, b])); + let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0); + assert_eq!(d, c, "the id is used again"); + let d_was = node(&s, d); + sel(&mut s, &[a]); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left"); + assert_eq!(node(&s, d), d_was); + // Redoing the deletion of a member takes it out too; with one left, Global Edit ends. + sel(&mut s, &[b]); + s.execute("edit.clear", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(members(&s), Some(vec![a, b]), "every member is there again"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(members(&s), None); +} + +/// Two 100 pt squares and a 20 pt one made after `begin`, all three in a Global Edit; `put_back` +/// takes the document back to before the small one. The small square leaves the Global Edit, and +/// a square made under its freed id is not edited with the others. +fn put_back_drops_the_member(begin: impl FnOnce(&mut Session) -> T, put_back: impl FnOnce(&mut Session, T)) { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 0.0, 100.0, 100.0); + let t = begin(&mut s); + let c = rect(&mut s, 500.0, 0.0, 20.0, 20.0); + assert_eq!(start(&mut s, a, json!({})), [a, b, c]); + put_back(&mut s, t); + assert_eq!(members(&s), Some(vec![a, b])); + let d = rect(&mut s, 500.0, 0.0, 20.0, 20.0); + assert_eq!(d, c, "the id is used again"); + let d_was = node(&s, d); + sel(&mut s, &[a]); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert_eq!(fill(&s, b), "#ff0000", "Global Edit goes on with the members left"); + assert_eq!(node(&s, d), d_was); +} + +#[test] +fn a_cancelled_drag_scrub_or_undo_scope_drops_the_members_it_takes_away() { + put_back_drops_the_member(|s| s.begin_interaction("Draw").unwrap(), |s, ()| s.cancel_interaction().unwrap()); + put_back_drops_the_member(Session::begin_undo_group, |s, ()| s.end_undo_group(true)); + put_back_drops_the_member( + |s| { + s.begin_undo_group(); + s.begin_undo_scope().unwrap() + }, + |s, scope| { + s.end_undo_scope(scope, true); + s.end_undo_group(false); + }, + ); +} + +#[test] +fn an_edit_that_takes_a_member_past_the_canvas_is_refused_whole() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 2.0, 2.0); + let big = rect(&mut s, 0.0, 0.0, 2000.0, 2000.0); + assert_eq!(start(&mut s, a, json!({})), [a, big]); + let before = (node(&s, a), node(&s, big)); + let steps = s.doc().unwrap().history.undo.len(); + // 4000 pt keeps the small square in range; the square 1000 times its size moves 1000 times as + // far, past `MAX_COORD`. + let e = s.execute("object.move", &json!({"dx": 4000, "dy": 0})).unwrap_err().to_string(); + assert!(e.contains("out of range"), "{e}"); + assert_eq!((node(&s, a), node(&s, big)), before, "nothing moved"); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + s.execute("object.move", &json!({"dx": 3000, "dy": 0})).unwrap(); + assert!(close(bounds(&s, big), Rect::new(3.0e6, 0.0, 3.0e6 + 2000.0, 2000.0)), "{:?}", bounds(&s, big)); +} diff --git a/crates/engine/src/tests_gradient.rs b/crates/engine/src/tests_gradient.rs index 6db651f41..c2e1c0e6a 100644 --- a/crates/engine/src/tests_gradient.rs +++ b/crates/engine/src/tests_gradient.rs @@ -33,14 +33,14 @@ fn fill_gradient(s: &Session, id: NodeId) -> GradientPaint { /// A three-stop freeform gradient with a 0.3 midpoint, partial opacity and a placed vector. fn three_stops() -> GradientPaint { let stop = |offset, color, opacity, midpoint| GradientStop { opacity, midpoint, ..GradientStop::new(offset, color) }; - let mut g = GradientPaint::new(Gradient { - kind: GradientKind::Freeform, - stops: vec![ + let mut g = GradientPaint::new(Gradient::new( + GradientKind::Freeform, + vec![ stop(0.0, Color::rgb(1.0, 0.0, 0.0), 1.0, 0.3), stop(0.4, Color::cmyk(0.1, 0.2, 0.3, 0.4), 0.5, 0.5), stop(1.0, Color::gray(0.25), 0.75, 0.5), ], - }); + )); g.geom = Some(GradientGeom { start: Point::new(10.0, 20.0), end: Point::new(110.0, 70.0), aspect: 0.5, focal: None }); g.angle = g.geom.unwrap().angle_deg(); g @@ -96,6 +96,33 @@ fn bad_gradients_are_rejected_without_editing() { assert_eq!(s.doc().unwrap().history.undo.len(), undo); } +/// The gradient commands refuse a `reverse` or `stroke` that is not true or false, and an `angle` +/// or a stop's `tint` that is not a number, given as text or as another value of the wrong kind: +/// the object and the undo history stay as they were. True, false, numbers and null still work. +#[test] +fn gradient_commands_refuse_a_number_or_boolean_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + let red = s.execute("swatch.new", &json!({"color": "#ff0000", "global": true})).unwrap()["name"].clone(); + let stops = |tint: Value| json!([{"offset": 0, "color": "#ff0000", "swatch": red, "tint": tint}, {"offset": 1, "color": "#000000"}]); + let calls = [ + not_a_switch("paint.editGradient", json!({"reverse": "true"}), "reverse"), + not_a_switch("paint.editGradient", json!({"angle": 45, "stroke": "false"}), "stroke"), + not_a_switch("paint.setGradientGeom", json!({"aspect": 50, "stroke": 0}), "stroke"), + not_a_number("paint.editGradient", json!({"angle": "45"}), "angle"), + ("paint.setFill".to_string(), json!({"gradient": {"angle": "45"}}), "`angle` must be a number, not \"45\"".to_string()), + ("paint.editGradient".to_string(), json!({"stops": stops(json!("50"))}), "stop 0: `tint` must be a number".to_string()), + ("paint.setFill".to_string(), json!({"gradient": {"stops": stops(json!([50]))}}), "stop 0: `tint` must be a number".to_string()), + ]; + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + s.execute("paint.editGradient", &json!({"angle": 30, "reverse": false, "stroke": null, "stops": stops(json!(50))})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!((g.angle, g.gradient.stops[0].tint), (30.0, 0.5)); +} + #[test] fn stop_opacity_and_offsets_are_normalised() { let mut s = session(); @@ -288,6 +315,8 @@ fn select_stop_is_validated_and_shared() { assert_eq!(s.execute("gradient.selectStop", &json!({"index": 1})).unwrap(), json!({"index": 1})); assert_eq!(s.selected_stop(), Some(1)); assert!(s.execute("gradient.selectStop", &json!({"index": 2})).is_err(), "two stops"); + // A 32-bit build read 2^32 + 1 as stop 1. + assert!(s.execute("gradient.selectStop", &json!({"index": (1u64 << 32) + 1})).is_err(), "past u32"); assert!(s.execute("gradient.selectStop", &json!({"index": "a"})).is_err()); assert!(s.execute("gradient.selectStop", &json!({})).is_err()); s.execute("gradient.selectStop", &json!({"index": null})).unwrap(); @@ -374,3 +403,124 @@ fn swatches_and_new_art_fit_a_placed_gradient_to_themselves() { let tb = node(&s, t).geometric_bounds().unwrap(); assert!(doc.start.distance(tb.center()) < 1e-9 && (doc.aspect - 0.5).abs() < 1e-9, "{doc:?} on {tb:?}"); } + +/// Dither (Illustrator 30's Gradient panel checkbox): set and cleared through the gradient +/// commands, refused on strokes (it applies to fills), kept by swatches, the params and a native +/// save and load. +#[test] +fn dither_is_a_fill_gradient_option_that_saves_and_loads() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); + assert!(!fill_gradient(&s, id).gradient.dither, "off by default"); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("paint.editGradient", &json!({"dither": true})).unwrap(); + assert!(fill_gradient(&s, id).gradient.dither); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The params carry it, so a swatch and a copied paint keep it. + let g = fill_gradient(&s, id); + assert_eq!(vectorcraft_tools::params::gradient_params(&g)["dither"], json!(true)); + let mut q = gradient_param(&g); + q["name"] = json!("Smooth"); + s.execute("swatch.new", &q).unwrap(); + let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch("Smooth").map(|w| w.paint.clone()) else { panic!("a gradient swatch") }; + assert!(sw.gradient.dither); + // Saved and opened again. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") }; + assert!(saved.gradient.dither, "the native file keeps dither"); + // Written only when on, so files without it (older ones) open with it off. + assert_eq!(serde_json::to_value(&saved.gradient).unwrap()["dither"], json!(true)); + assert!(serde_json::to_value(Gradient::default()).unwrap().get("dither").is_none()); + let old: Gradient = serde_json::from_value(json!({"kind": "Linear", "stops": []})).unwrap(); + assert!(!old.dither); + s.execute("paint.editGradient", &json!({"dither": false})).unwrap(); + assert!(!fill_gradient(&s, id).gradient.dither); + // Strokes: refused, nothing changed. + s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap(); + let before = node(&s, id).appearance.clone(); + assert!(s.execute("paint.editGradient", &json!({"dither": true, "stroke": true})).is_err()); + assert!(s.execute("paint.setStroke", &json!({"gradient": {"dither": true}})).is_err()); + assert!(s.execute("paint.setStroke", &json!({"swatch": null, "gradient": {"dither": true}})).is_err(), "a null swatch names none"); + assert!(s.execute("paint.editGradient", &json!({"dither": "yes"})).is_err()); + assert!(s.execute("paint.setFill", &json!({"gradient": {"dither": 1}})).is_err()); + assert_eq!(node(&s, id).appearance, before); +} + +/// A stroke ignores dither, so one that holds it anyway (swapped from a fill) outlines to a fill +/// without it: Outline Stroke keeps the look. +#[test] +fn an_outlined_stroke_leaves_dither_behind() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.execute("paint.setStroke", &json!({"gradient": {}})).unwrap(); + s.execute("paint.setFill", &json!({"gradient": {"dither": true}})).unwrap(); + s.execute("paint.swap", &json!({})).unwrap(); + let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") }; + assert!(st.gradient.dither, "the swapped stroke keeps the option"); + s.execute("object.path.outlineStroke", &json!({})).unwrap(); + let mut fills = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let Paint::Gradient(g) = n.appearance.fill_paint() { + fills.push(g.gradient.dither); + } + }); + assert_eq!(fills, [false, false], "the fill and the outline, neither dithered"); +} + +/// The Gradient panel's Method (Illustrator 30): `method` set and cleared through the gradient +/// commands, on fills and strokes, kept by the params, swatches and a native save and load; +/// anything but classic or perceptual is refused without editing. +#[test] +fn method_is_a_gradient_option_that_saves_and_loads() { + use vectorcraft_color::GradientMethod; + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let stops = json!([{"offset": 0, "color": "#0000ff"}, {"offset": 1, "color": "#ffff00"}]); + s.execute("paint.setFill", &json!({"gradient": {"stops": stops}})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::Classic, "classic by default"); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("paint.editGradient", &json!({"method": "perceptual"})).unwrap(); + let g = fill_gradient(&s, id); + assert_eq!(g.gradient.method, GradientMethod::Perceptual); + assert_eq!(g.gradient.stops.len(), 2, "the stops stay as they were"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + // The params carry it, so a swatch and a copied paint keep it. + assert_eq!(vectorcraft_tools::params::gradient_params(&g)["method"], json!("perceptual")); + let mut q = gradient_param(&g); + q["name"] = json!("Even"); + s.execute("swatch.new", &q).unwrap(); + let Some(Paint::Gradient(sw)) = s.doc().unwrap().doc.swatch("Even").map(|w| w.paint.clone()) else { panic!("a gradient swatch") }; + assert_eq!(sw.gradient.method, GradientMethod::Perceptual); + // Saved and opened again. + let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + let Paint::Gradient(saved) = back.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") }; + assert_eq!(saved.gradient.method, GradientMethod::Perceptual, "the native file keeps the method"); + // Strokes take it too. + s.execute("paint.setStroke", &json!({"gradient": {"method": "perceptual"}})).unwrap(); + let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") }; + assert_eq!(st.gradient.method, GradientMethod::Perceptual); + s.execute("paint.editGradient", &json!({"method": "classic", "stroke": true})).unwrap(); + let Paint::Gradient(st) = node(&s, id).appearance.stroke_paint() else { panic!("a gradient stroke") }; + assert_eq!(st.gradient.method, GradientMethod::Classic); + // Bad values: refused, nothing changed. + let before = node(&s, id).appearance.clone(); + for bad in [json!("smooth"), json!(1), json!(true)] { + let e = s.execute("paint.editGradient", &json!({"method": bad})).unwrap_err().to_string(); + assert!(e.contains("`method` must be classic or perceptual"), "{e}"); + assert!(s.execute("paint.setFill", &json!({"gradient": {"method": bad}})).is_err()); + } + assert_eq!(node(&s, id).appearance, before); + s.execute("paint.editGradient", &json!({"method": "Classic", "stroke": false})).unwrap(); + assert_eq!(fill_gradient(&s, id).gradient.method, GradientMethod::Classic); + assert!(vectorcraft_tools::params::gradient_params(&fill_gradient(&s, id)).get("method").is_none()); + // With dither: one edit sets both, and the params carry both back. + s.execute("paint.editGradient", &json!({"method": "perceptual", "dither": true, "stroke": false})).unwrap(); + let g = fill_gradient(&s, id); + assert!(g.gradient.dither && g.gradient.method == GradientMethod::Perceptual); + let p = vectorcraft_tools::params::gradient_params(&g); + assert_eq!((&p["method"], &p["dither"]), (&json!("perceptual"), &json!(true))); + s.execute("paint.setFill", &json!({"gradient": p})).unwrap(); + let back = fill_gradient(&s, id); + assert!(back.gradient.dither && back.gradient.method == GradientMethod::Perceptual); +} diff --git a/crates/engine/src/tests_gradpanel.rs b/crates/engine/src/tests_gradpanel.rs index ed2737223..b01592ab2 100644 --- a/crates/engine/src/tests_gradpanel.rs +++ b/crates/engine/src/tests_gradpanel.rs @@ -38,6 +38,8 @@ fn sample_color_with_a_stop_recolours_that_stop_in_one_undo_step() { s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 1})).unwrap(); assert_eq!(stop_hexes(&s, id, false), ["#ffffff", "#ff0000"]); assert!(s.execute("paint.sampleColor", &json!({"color": "#ff0000", "stop": 2})).unwrap_err().to_string().contains("no stop 2")); + // A 32-bit build read 2^32 + 1 as stop 1. + assert!(s.execute("paint.sampleColor", &json!({"color": "#00ff00", "stop": (1u64 << 32) + 1})).is_err()); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(stop_hexes(&s, id, false), ["#ffffff", "#000000"]); // The stroke's gradient through `stroke`, the fill left alone. diff --git a/crates/engine/src/tests_inline.rs b/crates/engine/src/tests_inline.rs index 71affad36..6ed8533b7 100644 --- a/crates/engine/src/tests_inline.rs +++ b/crates/engine/src/tests_inline.rs @@ -167,6 +167,23 @@ fn inline_graphics_follow_their_symbol() { let _ = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); } +/// A list paragraph that starts with an inline graphic draws its bullet before the graphic, and +/// live SVG text writes the bullet. +#[test] +fn a_list_paragraph_starting_with_an_inline_graphic_keeps_its_bullet() { + let mut s = session(); + circle_symbol(&mut s, "Dot", 10.0); + let id = text(&mut s, "ab"); + s.execute("text.insertInline", &json!({"id": id.0, "at": 0, "symbol": "Dot", "scale": 1})).unwrap(); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet"})).unwrap(); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &obj(&s, id)); + assert_eq!(lay.markers.len(), 1); + let bullet = &lay.glyphs[lay.markers[0].glyphs.start]; + assert!(bullet.origin.x < lay.inlines[0].bounds.x0, "the bullet comes before the graphic"); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string(); + assert!(svg.contains('\u{2022}'), "{svg}"); +} + #[test] fn saved_documents_keep_inline_graphics() { let mut s = session(); diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index 2de23ea0c..04d2bb71d 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -343,6 +343,136 @@ fn a_failed_batch_gives_back_the_highlighted_rows_and_the_current_layer() { assert_eq!(documents(&r), documents(&s)); } +/// Canceling undo groups open in two documents undoes the edits of both and drops the entries of +/// both from the journal, whichever document comes first in the list. +#[test] +fn canceling_undo_groups_in_two_documents_drops_the_edits_of_both_from_the_journal() { + let mut s = Session::new(); + s.execute("file.new", &json!({"created": null})).unwrap(); + s.execute("file.new", &json!({"created": null})).unwrap(); + let objects = |s: &Session| s.documents().iter().map(|d| d.doc.node_count()).collect::>(); + let before = objects(&s); + // The first document's group begins first, and the second document's group begins after it. + let journal = s.journal.len(); + assert!(s.set_active(0)); + s.begin_undo_group(); + s.execute("shape.rectangle", &rect_step(10)["params"]).unwrap(); + assert!(s.set_active(1)); + s.begin_undo_group(); + s.execute("shape.rectangle", &rect_step(50)["params"]).unwrap(); + s.end_undo_group(true); + assert_eq!(objects(&s), before, "both documents' edits are undone"); + assert_eq!(s.journal.len(), journal, "the journal holds neither group's edits"); + assert_eq!(documents(&replayed(&s)), documents(&s)); + + // The commands do the same: neither group's begin nor its edit stays in the journal. + let journal = s.journal.len(); + for (id, p) in [ + ("document.activate", json!({"index": 0})), + ("edit.undoGroup.begin", json!({})), + ("shape.rectangle", rect_step(10)["params"].clone()), + ("document.activate", json!({"index": 1})), + ("edit.undoGroup.begin", json!({})), + ("shape.rectangle", rect_step(50)["params"].clone()), + ("edit.undoGroup.end", json!({"cancel": true})), + ] { + s.execute(id, &p).unwrap(); + } + assert_eq!(objects(&s), before); + let ids = s.journal.since(journal).iter().map(|(id, _)| id.as_str()).collect::>(); + assert!(!ids.iter().any(|id| matches!(*id, "edit.undoGroup.begin" | "shape.rectangle")), "{ids:?}"); + assert_eq!(ids.last(), Some(&"edit.undoGroup.end")); +} + +/// A cancel drops only the canceled groups' own entries from the journal: an edit made in another +/// document while a group was open stays in that document and in the journal. +#[test] +fn canceling_undo_groups_keeps_the_journal_entries_made_outside_them() { + let mut s = Session::new(); + s.execute("file.new", &json!({"created": null})).unwrap(); + s.execute("file.new", &json!({"created": null})).unwrap(); + let objects = |s: &Session| s.documents().iter().map(|d| d.doc.node_count()).collect::>(); + let before = objects(&s); + // The second document's rectangle at 30 is drawn between the two groups and belongs to neither. + for (id, p) in [ + ("document.activate", json!({"index": 0})), + ("edit.undoGroup.begin", json!({})), + ("shape.rectangle", rect_step(10)["params"].clone()), + ("document.activate", json!({"index": 1})), + ("shape.rectangle", rect_step(30)["params"].clone()), + ("edit.undoGroup.begin", json!({})), + ("shape.rectangle", rect_step(50)["params"].clone()), + ("edit.undoGroup.end", json!({"cancel": true})), + ] { + s.execute(id, &p).unwrap(); + } + assert_eq!(objects(&s), vec![before[0], before[1] + 1], "only the groups' edits are undone"); + let ids = s.journal.iter().map(|(id, _)| id.as_str()).collect::>(); + assert_eq!(ids.iter().filter(|id| **id == "shape.rectangle").count(), 1, "{ids:?}"); + let r = replayed(&s); + assert_eq!(documents(&r), documents(&s)); + assert_eq!(r.active_index(), s.active_index()); + + // With one group open, a rectangle drawn in the other document stays in the journal too. A live + // drag committed in the group goes with the group, and one committed in the other document stays. + let before = objects(&s); + let opacity = |s: &mut Session, opacity: i32| { + s.begin_interaction("Opacity").unwrap(); + s.preview("transparency.set", &json!({ "opacity": opacity })).unwrap(); + s.commit_interaction().unwrap(); + }; + for (id, p) in + [("document.activate", json!({"index": 0})), ("edit.undoGroup.begin", json!({})), ("shape.rectangle", rect_step(10)["params"].clone())] + { + s.execute(id, &p).unwrap(); + } + opacity(&mut s, 50); + for (id, p) in [("document.activate", json!({"index": 1})), ("shape.rectangle", rect_step(70)["params"].clone())] { + s.execute(id, &p).unwrap(); + } + opacity(&mut s, 40); + for (id, p) in [("document.activate", json!({"index": 0})), ("edit.undoGroup.end", json!({"cancel": true}))] { + s.execute(id, &p).unwrap(); + } + assert_eq!(objects(&s), vec![before[0], before[1] + 1]); + let opacities = s.journal.iter().filter(|(id, _)| id == "transparency.set").map(|(_, p)| p["opacity"].clone()).collect::>(); + assert_eq!(opacities, vec![json!(40)]); + let r = replayed(&s); + assert_eq!(documents(&r), documents(&s)); + assert_eq!(r.active_index(), s.active_index()); + + // A batch that edits the group's document and then switches documents goes with the group, and + // only its switch stays, so the rectangle drawn after it replays in the second document. + let before = objects(&s); + let steps = json!([rect_step(90), {"command": "document.activate", "params": {"index": 1}}]); + for (id, p) in [ + ("document.activate", json!({"index": 0})), + ("edit.undoGroup.begin", json!({})), + ("command.batch", json!({ "commands": steps })), + ("shape.rectangle", rect_step(110)["params"].clone()), + ("edit.undoGroup.end", json!({"cancel": true})), + ] { + s.execute(id, &p).unwrap(); + } + assert_eq!(objects(&s), vec![before[0], before[1] + 1]); + assert!(!s.journal.iter().any(|(id, _)| id == "command.batch")); + let r = replayed(&s); + assert_eq!(documents(&r), documents(&s)); + assert_eq!(r.active_index(), s.active_index()); + + // So does a batch that opens the group itself before it switches documents. + let before = objects(&s); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + let steps = json!([{"command": "edit.undoGroup.begin"}, rect_step(130), {"command": "document.activate", "params": {"index": 1}}]); + s.execute("command.batch", &json!({ "commands": steps })).unwrap(); + s.end_undo_group(true); + assert_eq!(objects(&s), before); + assert!(!s.journal.iter().any(|(id, _)| id == "command.batch")); + let r = replayed(&s); + assert_eq!(documents(&r), documents(&s)); + assert_eq!(r.active_index(), s.active_index()); +} + /// The edits made in an undo group (a scrubbed numeric field, #400) are one undo step, journaled /// one by one; a cancelled group undoes them and leaves the journal as it was. #[test] @@ -390,6 +520,80 @@ fn an_undo_group_is_one_undo_step() { assert_eq!(undo_len(&s), undo + 2); } +/// An undo scope ([`Session::begin_undo_scope`]) in a document without an open undo group opens +/// one, and [`Session::end_undo_scope`] closes that group only, whichever document is active, and +/// leaves another document's group open; canceled, it undoes only that group's edits and cuts the +/// journal back to where the group began. +#[test] +fn an_undo_scope_closes_only_its_own_documents_group() { + let mut s = Session::new(); + let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50}); + let objects = |d: &DocState| d.doc.layers[0].children().map_or(0, |c| c.len()); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.begin_undo_group(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let journal = s.journal.len(); + let scope = s.begin_undo_scope().unwrap(); + assert!(s.doc().unwrap().undo_group.is_some(), "the scope opens a group"); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.end_undo_scope(scope, true); + assert_eq!(objects(s.doc().unwrap()), 0, "canceled: the document's edits are undone"); + assert_eq!(s.journal.len(), journal, "and dropped from the journal"); + assert!(s.documents()[0].undo_group.is_some(), "the other document's group stays open"); + assert_eq!(objects(&s.documents()[0]), 1, "with its edit"); + + let undo = s.doc().unwrap().history.undo.len(); + let scope = s.begin_undo_scope().unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.set_active(0); + s.end_undo_scope(scope, false); + let second = &s.documents()[1]; + assert_eq!(second.history.undo.len(), undo + 1, "closed: one undo step"); + assert!(second.undo_group.is_none(), "closed while another document is active"); + assert!(s.documents()[0].undo_group.is_some(), "the active document's group stays open"); +} + +/// An undo scope begun while the document's undo group is open joins the group: ended, its edits +/// are part of the group's step and the group stays open; canceled, the document goes back to +/// where it stood when the scope began, with the edits made in the group before it, the journal +/// drops the scope's entries, and a step that the scope's first edit recorded goes too. +#[test] +fn an_undo_scope_in_an_open_group_takes_back_only_its_own_edits() { + let mut s = Session::new(); + let rect = json!({"x": 10, "y": 10, "width": 100, "height": 50}); + let objects = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len()); + let undo_len = |s: &Session| s.doc().unwrap().history.undo.len(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let undo = undo_len(&s); + s.begin_undo_group(); + let journal = s.journal.len(); + let scope = s.begin_undo_scope().unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.end_undo_scope(scope, true); + assert_eq!((objects(&s), undo_len(&s), s.journal.len()), (0, undo, journal), "the group had no step: none is left"); + assert!(s.doc().unwrap().undo_group.is_some(), "the group stays open"); + + s.execute("shape.rectangle", &rect).unwrap(); + let journal = s.journal.len(); + let scope = s.begin_undo_scope().unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.end_undo_scope(scope, true); + assert_eq!((objects(&s), undo_len(&s), s.journal.len()), (1, undo + 1, journal), "the group's own edit stays"); + + let scope = s.begin_undo_scope().unwrap(); + s.execute("shape.rectangle", &rect).unwrap(); + s.end_undo_scope(scope, false); + assert_eq!((objects(&s), undo_len(&s)), (2, undo + 1), "ended: the edit joins the group's step"); + assert!(s.doc().unwrap().undo_group.is_some(), "the group stays open"); + s.end_undo_group(false); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s), 0, "one undo takes back the group's edits"); +} + /// A move drag is named for what its last preview did: Alt pressed or released during the drag /// turns it into a copy or back (the Selection and Direct Selection tools). #[test] diff --git a/crates/engine/src/tests_keyguide.rs b/crates/engine/src/tests_keyguide.rs index 5c908709e..9e45ae0f5 100644 --- a/crates/engine/src/tests_keyguide.rs +++ b/crates/engine/src/tests_keyguide.rs @@ -1,4 +1,5 @@ -//! #2143: a ruler guide selected with objects can be the key object Align lines them up on. +//! #2143: a ruler guide selected with objects can be the key object Align lines them up on, and +//! Distribute spaces ruler guides selected without objects. use serde_json::{Value, json}; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -152,9 +153,9 @@ fn selected_guides_align_to_a_clicked_key_object() { assert_eq!(guides(&s), [140.0, 110.0], "horizontal alignment moves only the vertical guide"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(guides(&s), [120.0, 110.0], "one undo step"); - // Aligning to the artboard leaves the guides alone. + // Aligning to the artboard moves the vertical guide to the artboard's left edge. s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap(); - assert_eq!(guides(&s), [120.0, 110.0]); + assert_eq!(guides(&s), [0.0, 110.0]); // Deselecting the guides lets the lone key go. s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap(); assert_eq!(s.doc().unwrap().selection.objects, vec![a]); @@ -183,3 +184,220 @@ fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide() s.execute("object.align", &json!({"horizontal": "center"})).unwrap(); assert_eq!(s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::>(), [700.0, 700.0]); } + +/// Objects 40 pt wide at `xs` (y 100) and a vertical guide at `pos` that is their key. +fn key_guide_across(xs: &[f64], pos: f64) -> (Session, Vec) { + let mut s = session(); + let ids: Vec = xs.iter().map(|&x| rect(&mut s, x, 100.0)).collect(); + s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap(); + s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + s.execute("select.key", &json!({"guide": 0})).unwrap(); + (s, ids) +} + +fn lefts(s: &Session, ids: &[NodeId]) -> Vec { + ids.iter().map(|id| bounds(s, *id).x0).collect() +} + +#[test] +fn distribute_with_a_spacing_spaces_the_objects_from_a_key_guide() { + let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0); + // Two objects are before the guide and one after it: the guide is spaced from as a key object + // with no size would be. + for (h, want) in [("left", [330.0, 340.0, 360.0]), ("center", [310.0, 320.0, 340.0]), ("right", [290.0, 300.0, 320.0])] { + s.execute("object.distribute", &json!({"horizontal": h, "spacing": 10})).unwrap(); + assert_eq!(lefts(&s, &ids), want, "{h}"); + assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0, "{h}: the guide stays put"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(lefts(&s, &ids), [100.0, 300.0, 500.0], "{h}: one undo step"); + } + // `to: "key"` is what the Align panel sends; 0 puts every line on the guide. + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 0, "to": "key"})).unwrap(); + assert_eq!(lefts(&s, &ids), [350.0; 3]); + // A guide before or after every object. + for (pos, want) in [(50.0, [60.0, 70.0, 80.0]), (600.0, [570.0, 580.0, 590.0])] { + let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], pos); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap(); + assert_eq!(lefts(&s, &ids), want, "guide at {pos}"); + } + // Two objects are enough, and a negative spacing reverses the order. + let (mut s, ids) = key_guide_across(&[100.0, 500.0], 300.0); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": -20})).unwrap(); + assert_eq!(lefts(&s, &ids), [320.0, 280.0]); +} + +#[test] +fn distribute_from_a_horizontal_key_guide_and_along_a_guide() { + let mut s = session(); + let ids: Vec = [100.0, 200.0, 400.0].iter().map(|&y| rect(&mut s, 50.0, y)).collect(); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + s.execute("select.key", &json!({"guide": 0})).unwrap(); + let tops = |s: &Session| ids.iter().map(|id| bounds(s, *id).y0).collect::>(); + s.execute("object.distribute", &json!({"vertical": "bottom", "spacing": 5})).unwrap(); + // Bottoms 120 and 220 are above the guide, 420 below it. + assert_eq!(tops(&s), [270.0, 275.0, 285.0]); + // A horizontal guide gives no line across a horizontal distribution: the objects stay where + // they are, as Align leaves them, and no undo step is added. + let undo = s.doc().unwrap().history.undo.len(); + let xs: Vec = ids.iter().map(|id| bounds(&s, *id).x0).collect(); + s.execute("object.distribute", &json!({"horizontal": "center", "spacing": 5})).unwrap(); + assert_eq!(ids.iter().map(|id| bounds(&s, *id).x0).collect::>(), xs); + assert_eq!(s.doc().unwrap().history.undo.len(), undo); +} + +#[test] +fn distribute_without_a_spacing_spaces_the_key_guide_too() { + // Without a spacing, the key guide is spaced with the objects as an item with no size: lines + // 100, 150, 350 (the guide) and 400 go to 100, 200, 300 and 400. + let (mut s, ids) = key_guide_across(&[100.0, 150.0, 400.0], 350.0); + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 200.0, 400.0], vec![300.0])); + // `to: "selection"` wins over the key guide, so a spacing isn't used either. + let (mut s, ids) = key_guide_across(&[100.0, 150.0, 400.0], 350.0); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10, "to": "selection"})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 200.0, 400.0], vec![300.0])); +} + +#[test] +fn distribute_spacing_spaces_the_objects_from_a_key_guide() { + // Left edges 100, 300 and 500 of 40 pt objects with a vertical key guide at 350, 10 pt apart: + // the object before the guide ends 10 pt before it, the one after it starts 10 pt after it. + let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![250.0, 300.0, 360.0], vec![350.0])); + // A guide along the axis leaves everything where it is. + s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 10})).unwrap(); + assert_eq!(lefts(&s, &ids), [250.0, 300.0, 360.0]); + assert_eq!(bounds(&s, ids[0]).y0, 100.0); +} + +#[test] +fn other_selected_guides_are_spaced_from_a_key_guide() { + let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap(); + s.execute("guide.select", &json!({"indexes": [1, 2], "toggle": true})).unwrap(); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap(); + // The vertical guide at 600 is the last line, 10 pt after the last object's; the horizontal + // guide stays. + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![330.0, 340.0, 360.0], vec![350.0, 370.0, 50.0])); +} + +/// Guides at `vertical` x positions and `horizontal` y positions, all selected without objects. +fn guides_alone(vertical: &[f64], horizontal: &[f64]) -> Session { + let mut s = session(); + for (v, pos) in vertical.iter().map(|p| (true, p)).chain(horizontal.iter().map(|p| (false, p))) { + s.execute("guide.add", &json!({"vertical": v, "pos": pos})).unwrap(); + } + let all: Vec = (0..vertical.len() + horizontal.len()).collect(); + s.execute("guide.select", &json!({"indexes": all})).unwrap(); + s +} + +/// Selects the objects `ids` and the ruler guides `guides`, and nothing else. +fn select_with_guides(s: &mut Session, ids: &[NodeId], guides: &[usize]) { + s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + let now = s.doc().unwrap().selection.guides.clone(); + let flip: Vec = (0..s.doc().unwrap().doc.guides.len()).filter(|i| now.contains(i) != guides.contains(i)).collect(); + s.execute("guide.select", &json!({"indexes": flip, "toggle": true})).unwrap(); + assert_eq!((s.doc().unwrap().selection.objects.len(), s.doc().unwrap().selection.guides.len()), (ids.len(), guides.len())); +} + +fn positions(s: &Session) -> Vec { + s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect() +} + +#[test] +fn guides_selected_alone_align_to_artboard() { + let mut s = guides_alone(&[100.0, 400.0], &[50.0, 300.0]); + s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(positions(&s), [0.0, 0.0, 50.0, 300.0]); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.align", &json!({"horizontal": "right", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(&positions(&s)[..2], [800.0, 800.0]); + s.execute("object.align", &json!({"vertical": "top", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(positions(&s), [800.0, 800.0, 0.0, 0.0]); +} + +#[test] +fn distribute_spaces_guides_selected_without_objects() { + let mut s = guides_alone(&[100.0, 150.0, 500.0], &[50.0, 60.0, 400.0]); + // A guide has no width: the three options of an axis give the same result, and only the + // guides across the axis move. + for h in ["left", "center", "right"] { + s.execute("object.distribute", &json!({"horizontal": h})).unwrap(); + assert_eq!(positions(&s), [100.0, 300.0, 500.0, 50.0, 60.0, 400.0], "{h}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(positions(&s), [100.0, 150.0, 500.0, 50.0, 60.0, 400.0], "{h}: one undo step"); + } + s.execute("object.distribute", &json!({"vertical": "center"})).unwrap(); + assert_eq!(positions(&s), [100.0, 150.0, 500.0, 50.0, 225.0, 400.0]); + // Align to Artboard: the outermost guides go to its edges (the document is 800 by 600). + s.execute("object.distribute", &json!({"horizontal": "center", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(&positions(&s)[..3], [0.0, 400.0, 800.0]); + // Distribute Spacing: equal gaps, or `spacing` apart from the first. + let mut s = guides_alone(&[100.0, 150.0, 500.0], &[]); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!(positions(&s), [100.0, 300.0, 500.0]); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap(); + assert_eq!(positions(&s), [100.0, 110.0, 120.0]); + s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 10})).unwrap(); + assert_eq!(positions(&s), [100.0, 110.0, 120.0], "no horizontal guide to space"); +} + +#[test] +fn distributing_guides_needs_two_items() { + // Two guides are the ends already; one guide can't be distributed. + let mut s = guides_alone(&[100.0, 500.0], &[]); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("object.distribute", &json!({"horizontal": "center"})).unwrap(); + assert_eq!((positions(&s), s.doc().unwrap().history.undo.len()), (vec![100.0, 500.0], undo)); + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + assert!(s.execute("object.distribute", &json!({"horizontal": "center"})).is_err()); + // One object and one guide are enough for Distribute Spacing. + let a = rect(&mut s, 300.0, 100.0); + select_with_guides(&mut s, &[a], &[0]); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap(); + assert_eq!((positions(&s), bounds(&s, a).x0), (vec![100.0, 500.0], 110.0)); +} + +#[test] +fn guides_selected_with_objects_are_distributed_with_them() { + // 40 pt objects at 100 and 300, vertical guides at 150 and 400, and a horizontal guide. + let mut s = guides_alone(&[150.0, 400.0], &[50.0]); + let ids: Vec = [100.0, 300.0].iter().map(|&x| rect(&mut s, x, 100.0)).collect(); + select_with_guides(&mut s, &ids, &[0, 1, 2]); + // Lines 100, 150 (a guide), 300 and 400 (a guide): the guide between the objects goes to + // 200, and the outermost items stay. The horizontal guide stays too. + s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 300.0], vec![200.0, 400.0, 50.0])); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(positions(&s), [150.0, 400.0, 50.0], "one undo step"); + // Distribute Spacing: a guide has no width. From 100 to 360 there are 260 pt, 80 of them + // objects: three gaps of 60 pt. + s.execute("guide.move", &json!({"index": 1, "pos": 360})).unwrap(); + select_with_guides(&mut s, &ids, &[0, 1, 2]); + s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 260.0], vec![200.0, 360.0, 50.0])); + // Align to Artboard: the outermost items go to its edges (the document is 800 wide). + s.execute("object.distribute", &json!({"horizontal": "left", "to": "artboard", "artboard": 0})).unwrap(); + assert_eq!(lefts(&s, &ids)[0], 0.0); + assert_eq!(positions(&s)[1], 800.0); +} + +#[test] +fn selected_guides_are_spaced_from_a_key_object() { + // Lines 100, 150 (a guide) and 300 (the key object), 10 pt apart outward from the key. + let mut s = guides_alone(&[150.0], &[]); + let ids: Vec = [100.0, 300.0].iter().map(|&x| rect(&mut s, x, 100.0)).collect(); + select_with_guides(&mut s, &ids, &[0]); + s.execute("select.key", &json!({"id": ids[1].0})).unwrap(); + s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![280.0, 300.0], vec![290.0])); + // Distribute Spacing: the object ends 10 pt before the guide, the guide 10 pt before the key. + s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap(); + assert_eq!((lefts(&s, &ids), positions(&s)), (vec![240.0, 300.0], vec![290.0])); +} diff --git a/crates/engine/src/tests_layerclip.rs b/crates/engine/src/tests_layerclip.rs index fd79992bd..21c694d3e 100644 --- a/crates/engine/src/tests_layerclip.rs +++ b/crates/engine/src/tests_layerclip.rs @@ -120,3 +120,17 @@ fn the_top_object_must_be_able_to_clip() { let err = s.execute("layer.clippingMask.toggle", &json!({"id": sub.0})); assert!(err.is_err(), "an empty sublayer has nothing to clip by"); } + +#[test] +fn send_to_back_in_a_clipping_layer_keeps_the_clipping_path_at_the_bottom() { + // #3188, for a clipping layer: the art went under the clipping path and became the clip. + let mut s = session(); + let (art, clip) = art_and_clip(&mut s); + assert!(toggle(&mut s)); + let top = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + select(&mut s, &[top]); + s.execute("object.arrange.sendToBack", &json!({})).unwrap(); + let ids: Vec = layer(&s).children().unwrap().iter().map(|n| n.id).collect(); + assert_eq!(ids, [clip, top, art]); + assert!(opaque(&render(&s), 50, 50), "the art inside the clip still shows"); +} diff --git a/crates/engine/src/tests_layers.rs b/crates/engine/src/tests_layers.rs index 1fc789c80..d78645e82 100644 --- a/crates/engine/src/tests_layers.rs +++ b/crates/engine/src/tests_layers.rs @@ -349,6 +349,8 @@ fn layer_options_validate_and_template_locks_and_dims() { p["id"] = json!(layer.0); assert!(s.execute("layer.setProps", &p).is_err(), "{bad}"); } + // A 32-bit build read color 2^32 + 4 as preset 4. + assert!(s.execute("layer.setProps", &json!({"id": layer.0, "color": (1u64 << 32) + 4})).is_err()); let color = |s: &Session| match node(s, layer).kind { NodeKind::Layer { color, .. } => color, _ => panic!("not a layer"), diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index cdd0837bd..381ea16ae 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -3,7 +3,7 @@ use serde_json::{Value, json}; -use super::tests_menucmds::place_undecodable_png; +use super::tests_menucmds::{place_undecodable_png, place_undecodable_png_sized}; use super::*; use crate::tests_links::Folder; @@ -204,8 +204,8 @@ const RADIAL_AND_LINEAR: &str = r##""##; /// A radial gradient is written with its highlight, `0 0 0 0 Bh` at the center (§3.5.5, §3.5.6). -/// #2687's file had none, and the reference app drew the circle in its outer color. The linear one -/// has none. Both open again with their type, stops and place. +/// #2687's file had none. The linear one has none. Both open again with their type, stops and +/// place. #[test] fn a_radial_gradient_is_written_with_its_highlight() { let mut s = Session::new(); @@ -244,6 +244,34 @@ fn a_radial_gradient_is_written_with_its_highlight() { } } +/// #2687's radial gradient lists its stops from white at ramp point 0 to #1b2a6b at 100, as +/// §3.5.2's radial definition lists them (10, then 100); the linear one lists them from 100 to 0 +/// (§1.4.3). The reference app read the radial gradient's two stops listed from 100 both at ramp +/// point 100 and drew the circle in #1b2a6b (reported on #2807). +#[test] +fn a_radial_gradient_lists_its_stops_from_the_first() { + let mut s = Session::new(); + let svg = vectorcraft_format::base64_encode(RADIAL_AND_LINEAR.as_bytes()); + s.execute("document.open", &json!({"name": "t_radial.svg", "dataBase64": svg})).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + let text = String::from_utf8(vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()).unwrap(); + // Each `%_Bs` line of a definition as its RGB color (0-255) and ramp point. + let listed = |head: &str| -> Vec<([u8; 3], String)> { + let def = text.split(head).nth(1).and_then(|t| t.split("BD\n").next()).unwrap_or_else(|| panic!("no {head:?} in\n{text}")); + def.lines() + .map(|l| { + let n: Vec<&str> = l.split_whitespace().collect(); + let [_, _, _, _, r, g, b, "2", "50", ramp, "%_Bs"] = n.as_slice() else { panic!("{l}") }; + let c = [r, g, b].map(|v| (v.parse::().unwrap() * 255.0).round() as u8); + (c, (*ramp).to_string()) + }) + .collect() + }; + let (white, navy) = ([255, 255, 255], [27, 42, 107]); + assert_eq!(listed("(Gradient) 1 2 Bd\n[\n"), [(white, "0".to_string()), (navy, "100".to_string())], "{text}"); + assert_eq!(listed("(Gradient 2) 0 2 Bd\n[\n"), [(navy, "100".to_string()), (white, "0".to_string())], "{text}"); +} + /// A `w`×`h` PNG of one colour (`rgba`), 72 ppi. fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec { let mut out = vec![]; @@ -334,6 +362,29 @@ fn a_flattened_image_that_cant_be_decoded_is_noted_once() { assert_eq!(undecoded_notes(&r), 1, "{r}"); } +/// Images that can't be decoded and are written as they are get the same note as one that is +/// flattened. All of them give one note, and the note names no image's key. +#[test] +fn images_written_or_flattened_that_cant_be_decoded_are_noted_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + place_undecodable_png(&mut s); + place_undecodable_png_sized(&mut s, 48, 24); + let keys: Vec = s.doc().unwrap().doc.images.keys().cloned().collect(); + assert_eq!(keys.len(), 2); + let decode_notes = |r: &Value| warnings(r).iter().filter(|w| w.contains("decoded")).count(); + let named = |r: &Value| warnings(r).iter().any(|w| keys.iter().any(|k| w.contains(k.as_str()))); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + assert_eq!((undecoded_notes(&r), decode_notes(&r), named(&r)), (1, 1, false), "{r}"); + // A third image, at 50% opacity, is flattened. The engine's note and the writer's note come out + // as one warning. + let cut = place_undecodable_png_sized(&mut s, 32, 16); + s.execute("transparency.set", &json!({"ids": [cut.0], "opacity": 50})).unwrap(); + let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{r}"); + assert_eq!((undecoded_notes(&r), decode_notes(&r), named(&r)), (1, 1, false), "{r}"); +} + #[test] fn an_image_that_cant_be_decoded_with_an_expanded_effect_is_noted() { let mut s = Session::new(); @@ -448,3 +499,147 @@ fn a_range_saves_those_artboards_and_save_writes_them_again() { let r = s.execute("document.save", &json!({})).unwrap(); assert_eq!(r["files"].as_array().map(Vec::len), Some(2), "{r}"); } + +/// A 32×32 PNG checkerboard of 16-pixel squares: `a` top left and bottom right, `b` elsewhere. +fn checker(a: [u8; 4], b: [u8; 4]) -> Vec { + let img = image::RgbaImage::from_fn(32, 32, |x, y| image::Rgba(if (x < 16) == (y < 16) { a } else { b })); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// A blue square (40–168) under a square at `opacity` filled with a pattern whose 32 pt tile is the +/// image `tile`, grouped. +fn pattern_over_blue(tile: &[u8], opacity: f32) -> Session { + let href = format!("data:image/png;base64,{}", vectorcraft_format::base64_encode(tile)); + let svg = format!( + r##""## + ); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "p.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + s +} + +/// #3666: a pattern fill whose tile is an image, under opacity, is flattened with the art under it +/// like a transparent image, instead of being written opaque over white. +#[test] +fn a_transparent_image_pattern_is_flattened_with_the_art_under_it() { + for (tile, what) in + [(checker([0, 200, 0, 255], [255, 230, 0, 255]), "opaque tile"), (checker([0, 200, 0, 255], [255, 255, 255, 0]), "see-through tile")] + { + let mut s = pattern_over_blue(&tile, 0.6); + let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)]; + let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + assert!(warnings(&r).iter().any(|w| w.contains("flattened with the art under them")), "{what}: {:?}", warnings(&r)); + let r = s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + assert!(!r.to_string().contains("left out"), "{what}: {:?}", warnings(&r)); + let d = &s.doc().unwrap().doc; + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(d, x, y)).collect(); + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "{what}: before {before:?} after {after:?}"); + } + } +} + +/// #3666: an opaque pattern fill whose tile is an opaque image is still written as its tiles by the +/// writer, not flattened. +#[test] +fn an_opaque_image_pattern_is_written_as_its_tiles() { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 230, 0, 255]), 1.0); + let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)]; + let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("flattened with the art under them")), "{w:?}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "before {before:?} after {after:?}"); + } +} + +/// A square (40–168) with two pattern fills of 32 pt image tiles: a red and blue checkerboard, +/// with a green checkerboard with see-through squares over it. +fn two_pattern_fills() -> Session { + let see_through = vectorcraft_format::base64_encode(&checker([0, 200, 0, 255], [255, 255, 255, 0])); + let opaque = vectorcraft_format::base64_encode(&checker([200, 0, 0, 255], [0, 0, 200, 255])); + let svg = format!( + r##""## + ); + let mut s = Session::new(); + s.execute("document.open", &json!({"name": "p.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())})).unwrap(); + // Move the opaque pattern fill under the see-through one, on the second rectangle. + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let ids: Vec = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + let under = d.node(ids[0]).unwrap().appearance.items.clone(); + assert!(!under.is_empty()); + let n = d.node_mut(ids[1]).unwrap(); + let mut items = under; + items.append(&mut n.appearance.items); + n.appearance.items = items; + let _ = d.remove(ids[0]); + s +} + +/// #3666: on a shape with two pattern fills, only the one that paints a transparent image is +/// flattened; the opaque image pattern under it is still written as its tiles. +#[test] +fn only_the_transparent_image_pattern_fill_of_a_shape_is_flattened() { + let mut s = two_pattern_fills(); + let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)]; + let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + assert_eq!(before[1], [0, 0, 200], "{before:?}"); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("flattened with the art under them")), "{w:?}"); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect(); + for (a, b) in before.iter().zip(&after) { + assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "before {before:?} after {after:?}"); + } +} + +/// #3666: with `omit`, a pattern fill whose tile image is see-through is not expanded, so its tiles +/// aren't left out: the writer still draws them. +#[test] +fn omit_keeps_a_transparent_image_pattern() { + let mut s = pattern_over_blue(&checker([0, 200, 0, 255], [255, 255, 255, 0]), 1.0); + let p = json!({"format": "ai", "aiVersion": "7", "transparentImages": "omit", "hiddenObjects": "layer"}); + let r = s.execute("file.saveCopy", &p).unwrap(); + let w = warnings(&r); + assert!(w.iter().any(|w| w.contains("pattern fills are written as their tiles")), "{w:?}"); + assert!(!w.iter().any(|w| w.contains("are left out")), "{w:?}"); + s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap(); + assert!(images(&s.doc().unwrap().doc).0 > 0); +} + +/// #3666: expanding the transparent image patterns of a shape with two pattern fills expands only +/// the fill that paints a transparent image; the opaque image pattern stays a pattern fill. +#[test] +fn only_the_transparent_pattern_fill_of_a_leaf_is_expanded() { + let mut s = two_pattern_fills(); + let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let keys = crate::cmd::flatten::see_through_images(d); + let layer = d.layers[0].id; + assert_eq!(crate::cmd::expand::expand_transparent_image_patterns(d, layer, &keys).unwrap(), 1); + let mut patterns = vec![]; + let mut images = 0; + d.walk(|n| { + for i in &n.appearance.items { + if let vectorcraft_doc::appearance::AppearanceItem::Fill(f) = i + && let vectorcraft_color::Paint::Pattern { pattern, .. } = &f.paint + { + patterns.push(pattern.clone()); + } + } + images += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))); + }); + assert_eq!(patterns.len(), 1, "{patterns:?}"); + assert!(images > 0); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 15c8d9be8..31398944c 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -143,6 +143,66 @@ fn text_styles_come_from_the_selected_type_and_apply_in_one_step() { assert_eq!(r["style"], format!("{name} 2")); } +/// A library with the character and paragraph styles of 31 pt type, an undo group opened in the +/// first document with a rectangle drawn in it, and a second document, active, with type +/// selected → (session, library, character style, paragraph style). +fn group_open_in_another_document() -> (Session, String, String, String) { + let mut s = session(); + let lib = run(&mut s, "library.create", json!({}))["id"].as_str().unwrap().to_string(); + let t = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Heading", "size": 31}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [t]})); + let char_style = run(&mut s, "library.add", json!({"library": lib, "kind": "charStyle"}))["name"].as_str().unwrap().to_string(); + let para_style = run(&mut s, "library.add", json!({"library": lib, "kind": "paraStyle"}))["name"].as_str().unwrap().to_string(); + run(&mut s, "edit.undoGroup.begin", json!({})); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 20})); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let u = run(&mut s, "text.create", json!({"x": 10, "y": 50, "text": "Body", "size": 9}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [u]})); + (s, lib, char_style, para_style) +} + +/// A library style used in one document does not close an undo group that is open in another +/// document, so that document's next edit still joins the group's undo step. +#[test] +fn a_library_style_leaves_another_documents_undo_group_open() { + let (mut s, lib, name, _) = group_open_in_another_document(); + run(&mut s, "library.use", json!({"library": lib, "kind": "charStyle", "item": name})); + assert!(s.documents()[0].undo_group.is_some(), "the first document's undo group stays open"); + s.set_active(0); + let undo = s.doc().unwrap().history.undo.len(); + run(&mut s, "shape.rectangle", json!({"x": 30, "y": 0, "width": 20, "height": 20})); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "the edit joins the group's step"); +} + +/// A library style that fails to apply takes back only the edits it made in its own document: an +/// undo group open in another document stays open, and the edits made in it stay. +#[test] +fn a_library_style_that_fails_takes_back_only_its_own_edits() { + let (mut s, lib, _, name) = group_open_in_another_document(); + run(&mut s, "select.none", json!({})); + assert!(s.execute("library.use", &json!({"library": lib, "kind": "paraStyle", "item": name})).is_err(), "no type selected"); + assert!(s.doc().unwrap().doc.para_styles.iter().all(|p| p.name != name), "the style it added is taken back"); + let first = &s.documents()[0]; + assert_eq!(first.doc.layers[0].children().map_or(0, |c| c.len()), 2, "the type and the rectangle drawn in the group stay"); + assert!(first.undo_group.is_some(), "the first document's undo group stays open"); +} + +/// A library style that fails to apply inside an undo group open in its own document takes back +/// the style it added, and the group stays open with the edits made in it before. +#[test] +fn a_library_style_that_fails_in_an_open_group_takes_back_its_own_edits() { + let (mut s, lib, _, name) = group_open_in_another_document(); + s.set_active(0); + run(&mut s, "select.none", json!({})); + let undo = s.doc().unwrap().history.undo.len(); + assert!(s.execute("library.use", &json!({"library": lib, "kind": "paraStyle", "item": name})).is_err(), "no type selected"); + let d = s.doc().unwrap(); + assert!(d.doc.para_styles.iter().all(|p| p.name != name), "the style it added is taken back"); + assert_eq!(d.doc.layers[0].children().map_or(0, |c| c.len()), 2, "the type and the rectangle drawn in the group stay"); + assert_eq!(d.history.undo.len(), undo, "still the group's one step"); + assert!(d.undo_group.is_some(), "the group stays open"); +} + /// A text style that a library file names with spaces around it comes into the document with the /// name trimmed, as New Character Style and New Paragraph Style trim a name, and applies. #[test] diff --git a/crates/engine/src/tests_link_paths.rs b/crates/engine/src/tests_link_paths.rs index 8cbac1ea7..b851d94ac 100644 --- a/crates/engine/src/tests_link_paths.rs +++ b/crates/engine/src/tests_link_paths.rs @@ -136,6 +136,46 @@ fn a_document_on_a_mapped_drive_is_on_the_share_behind_it() { } } +/// The final path Windows gives a folder on a drive: `Z:` and `V:` are mapped to `\\server\share`, +/// `Y:` is mapped to `\\server\other` and `W:` is mapped to `\\far\jobs`; `C:` and `D:` are volumes +/// of this computer. +fn mapped(folder: &str) -> Option { + let root = match folder.get(..1)?.to_ascii_uppercase().as_str() { + "Z" | "V" => r"\\?\UNC\server\share", + "Y" => r"\\?\UNC\server\other", + "W" => r"\\?\UNC\far\jobs", + "C" => r"\\?\C:", + "D" => r"\\?\D:", + _ => return None, + }; + let rest = folder.get(2..)?.trim_start_matches(['\\', '/']).replace('/', "\\"); + Some(if rest.is_empty() { root.into() } else { format!(r"{root}\{rest}").into() }) +} + +#[test] +fn a_path_on_a_mapped_drive_is_on_the_share_behind_it() { + use crate::cmd::fileio::drive_share_of; + // Only the drive's root folder is looked up, so a path in a folder that doesn't exist still gets + // its path on the share. + let mut asked = vec![]; + let mut ask = |root: &str| { + asked.push(root.to_string()); + mapped(root) + }; + assert_eq!(drive_share_of(r"Y:\art\gone\x.png", &mut ask).as_deref(), Some(r"\\server\other\art\gone\x.png")); + assert_eq!(drive_share_of(r"Y:\x.png", &mut ask).as_deref(), Some(r"\\server\other\x.png")); + assert_eq!(drive_share_of(r"\\?\Y:\x.png", &mut ask).as_deref(), Some(r"\\server\other\x.png")); + assert_eq!(drive_share_of("y:/art/x.png", &mut ask).as_deref(), Some(r"\\server\other\art\x.png")); + assert_eq!(asked, [r"Y:\", r"Y:\", r"Y:\", "y:/"]); + // A volume of this computer, and a drive that can't be opened, give no path on a share. + assert_eq!(drive_share_of(r"D:\art\x.png", mapped), None); + assert_eq!(drive_share_of(r"Q:\art\x.png", mapped), None); + // UNC, root-relative, relative and drive-relative paths, and a drive's root, are not looked up. + for path in [r"\\server\other\x.png", r"\\?\UNC\server\other\x.png", "/Users/a/x.png", "x.png", "Y:x.png", r"Y:\"] { + assert_eq!(drive_share_of(path, |r: &str| panic!("{path}: looked up {r}")), None, "{path}"); + } +} + #[cfg(windows)] #[test] fn links_on_a_mapped_drives_share_are_followed() { @@ -155,16 +195,69 @@ fn links_on_a_mapped_drives_share_are_followed() { assert_eq!(document_path_with(r"D:\art\logo.png", doc, unasked).as_deref(), Ok(r"D:\art\logo.png")); } +#[cfg(windows)] +#[test] +fn links_on_a_drive_mapped_to_another_share_are_not_followed() { + use crate::cmd::fileio::{document_path_on, drive_share_of, share_of}; + let share = |d: &str| share_of(d, mapped); + let drive = |p: &str| drive_share_of(p, mapped); + let refused = Err(craft_docpath::Refused::Network); + // From a document on this computer, a drive mapped to any share is another computer. + let local = Some(r"C:\art\doc.vectorcraft"); + for link in [r"Y:\img\logo.png", r"Z:\logo.png", r"\\?\Y:\logo.png", "file:///Y:/logo.png", r"Y:\gone\logo.png"] { + assert_eq!(document_path_on(link, local, share, drive), refused, "{link}"); + } + // Links on a mapped drive are refused from a document on a third share, given by its drive or + // by its share, and from a document without a path. + for doc in [Some(r"W:\jobs\doc.vectorcraft"), Some(r"\\far\jobs\doc.vectorcraft"), None] { + assert_eq!(document_path_on(r"Y:\img\logo.png", doc, share, drive), refused, "{doc:?}"); + assert_eq!(document_path_on(r"Z:\img\logo.png", doc, share, drive), refused, "{doc:?}"); + } +} + +#[cfg(windows)] +#[test] +fn links_on_the_documents_share_or_this_computer_are_followed() { + use crate::cmd::fileio::{document_path_on, drive_share_of, share_of}; + let share = |d: &str| share_of(d, mapped); + let drive = |p: &str| drive_share_of(p, mapped); + // Links on the document's share are followed: through another drive mapped to it, by the share's + // own path, and from the document given by its share. + let doc = Some(r"Z:\jobs\doc.vectorcraft"); + assert_eq!(document_path_on(r"V:\img\logo.png", doc, share, drive).as_deref(), Ok(r"V:\img\logo.png")); + let by_share = Some(r"\\server\share\jobs\doc.vectorcraft"); + assert_eq!(document_path_on(r"Z:\img\logo.png", by_share, share, drive).as_deref(), Ok(r"Z:\img\logo.png")); + assert_eq!(document_path_on(r"\\server\share\logo.png", doc, share, drive).as_deref(), Ok(r"\\server\share\logo.png")); + // A document written `\\?\Z:\…` is on the same share. + let verbatim = Some(r"\\?\Z:\jobs\doc.vectorcraft"); + assert_eq!(document_path_on(r"V:\img\logo.png", verbatim, share, drive).as_deref(), Ok(r"V:\img\logo.png")); + assert_eq!(document_path_on(r"\\server\share\logo.png", verbatim, share, drive).as_deref(), Ok(r"\\server\share\logo.png")); + assert_eq!(document_path_on(r"Y:\img\logo.png", verbatim, share, drive), Err(craft_docpath::Refused::Network)); + // Links on this computer and on a drive that can't be opened are followed, from a document + // anywhere. + for doc in [Some(r"C:\art\doc.vectorcraft"), doc, Some(r"\\far\jobs\doc.vectorcraft"), None] { + assert_eq!(document_path_on(r"D:\art\logo.png", doc, share, drive).as_deref(), Ok(r"D:\art\logo.png"), "{doc:?}"); + assert_eq!(document_path_on(r"Q:\art\logo.png", doc, share, drive).as_deref(), Ok(r"Q:\art\logo.png"), "{doc:?}"); + } + // A path on the document's own drive is not looked up. + let unasked = |p: &str| -> Option { panic!("looked up {p}") }; + assert_eq!(document_path_on(r"img\logo.png", doc, share, unasked).as_deref(), Ok(r"Z:\jobs\img\logo.png")); + assert_eq!(document_path_on(r"z:\logo.png", doc, share, unasked).as_deref(), Ok(r"z:\logo.png")); + assert_eq!(document_path_on(r"C:\x\logo.png", Some(r"C:\art\doc.vectorcraft"), share, unasked).as_deref(), Ok(r"C:\x\logo.png")); +} + #[test] fn a_document_off_a_mapped_drive_keeps_its_path() { - use crate::cmd::fileio::share_path; + use crate::cmd::fileio::{drive_share_path, share_path}; let dir = Folder::new("docpath-share"); // A folder on this computer's own disk, and one that doesn't exist. assert_eq!(share_path(&dir.file("doc.vectorcraft")), None); assert_eq!(share_path(&dir.file("gone/doc.vectorcraft")), None); - // Elsewhere than Windows, no document is looked up. + assert_eq!(drive_share_path(&dir.file("gone/x.png")), None); + // Elsewhere than Windows, no document or link is looked up. if cfg!(not(windows)) { assert_eq!(share_path(r"Z:\jobs\doc.vectorcraft"), None); + assert_eq!(drive_share_path(r"Y:\img\logo.png"), None); } } diff --git a/crates/engine/src/tests_linkfiles.rs b/crates/engine/src/tests_linkfiles.rs index 1c4cc5306..4fd26220f 100644 --- a/crates/engine/src/tests_linkfiles.rs +++ b/crates/engine/src/tests_linkfiles.rs @@ -306,7 +306,7 @@ fn a_copy_stays_inside_the_write_root() { let allowed = root.join("allowed"); write(&allowed.join("photo.png"), b"kept as it is"); std::os::unix::fs::symlink(allowed.join("photo.png"), root.join("doc/photo.png")).unwrap(); - let roots = crate::file_access::AutomationRoots::new(Some(&root), Some(&allowed)).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&root], &[&allowed]).unwrap(); let r = crate::file_access::confine(roots.as_ref(), || s.execute("links.relink", &json!({"ids": [id], "path": found, "copy": true}))); assert!(matches!(&r, Err(EngineError::Other(m)) if m.contains("outside the write root")), "{r:?}"); assert!(!root.join("doc/photo 2.png").exists()); @@ -332,7 +332,7 @@ fn an_svg_reads_its_links_within_the_read_root() { }; let (inside, outside) = (import("in"), import("out")); assert_eq!(inside, outside, "both are read with no roots in force"); - let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&root.join("in")], &[]).unwrap(); crate::file_access::confine(roots.as_ref(), || { assert_eq!(import("in"), inside, "inside the read root: read as before"); assert_ne!(import("out"), outside, "outside the read root: not read"); diff --git a/crates/engine/src/tests_links.rs b/crates/engine/src/tests_links.rs index 080814ef5..95ab45349 100644 --- a/crates/engine/src/tests_links.rs +++ b/crates/engine/src/tests_links.rs @@ -293,6 +293,60 @@ fn a_modified_link_follows_the_update_links_preference() { assert!(s.doc().unwrap().history.undo.is_empty(), "opening is not an undo step"); } +/// Preferences › File Handling › Use Low Resolution Proxy for Linked EPS (#394): an unchanged linked +/// `.eps` keeps its saved preview on open; Update Links still reads a modified file when set to +/// Automatically. +#[test] +fn low_res_proxy_eps_keeps_unchanged_preview_and_automatic_update_reads_modified_eps() { + let dir = Folder::new("eps-proxy"); + let pic = dir.file("photo.eps"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap(); + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "askWhenModified"})).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "unchanged EPS stays on its preview"); + assert!(s.doc().unwrap().doc.images[&image(&s, id).key].is_proxy()); + write(&pic, &png(400, 400, BLUE)); + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!(r["updatedLinks"][0]["ids"].clone(), json!([id.0]), "modified EPS updates on open: {r}"); + assert!(!s.doc().unwrap().doc.images[&image(&s, id).key].is_proxy()); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); +} + +/// With Low Resolution Proxy on, export reads the linked EPS file's full pixels, not the preview. +#[test] +fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() { + let dir = Folder::new("eps-proxy-export"); + let pic = dir.file("photo.eps"); + write(&pic, &png(600, 300, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + s.execute("prefs.set", &json!({"key": "lowResProxyEps", "value": true})).unwrap(); + let r = open(&mut s, &doc_path); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}"); + let key = image(&s, id).key; + assert!(s.doc().unwrap().doc.images[&key].is_proxy()); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + assert!(warnings.is_empty(), "{warnings:?}"); + assert!(!full.images[&key].is_proxy()); + let dim = + image::ImageReader::new(std::io::Cursor::new(full.images[&key].bytes.as_ref())).with_guessed_format().unwrap().into_dimensions().unwrap(); + assert_eq!(dim, (600, 300)); + let png_out = s.execute("document.export", &json!({"format": "png"})).unwrap(); + let bytes = vectorcraft_format::base64_decode(png_out["dataBase64"].as_str().unwrap()).unwrap(); + let rendered = image::load_from_memory(&bytes).unwrap(); + assert!(rendered.width() >= 400 && rendered.height() >= 300); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "canvas still shows the preview"); +} + /// The half of opening that the desktop app runs on a thread of its own (`read_for_open`) reads /// the linked files and lays out the type. The half left for the UI thread (`open_read`) reads no /// file: with a linked file moved away in between, it opens the document that `document.open` opens. @@ -326,7 +380,7 @@ fn a_file_read_for_opening_has_its_links_read_and_its_type_laid_out() { let expected = open(&mut at_once, &doc_path); let rows = if update == "automatically" { &expected["updatedLinks"] } else { &expected["modifiedLinks"] }; assert!(expected["missingLinks"][0]["name"] == "gone.png" && rows[0]["name"] == "changed.png", "{update}: {expected}"); - let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, update == "automatically").unwrap(); + let file = cmd::fileio::read_for_open(&doc_path, &bytes, Some(doc_path.clone()), &Value::Null, update == "automatically", false).unwrap(); let doc = file.document(); let NodeKind::Image(im) = &doc.node(photo).unwrap().kind else { panic!("not an image") }; assert!(!doc.images[&im.key].is_proxy(), "{update}: the TIFF is read, not the preview saved for it"); @@ -408,6 +462,7 @@ fn relative_paths_are_written_against_the_saved_file() { xf: Default::default(), link: Some(LinkInfo::new(format!("{root}{sep}art{sep}a.png"))), placement: Default::default(), + pixels: None, }; d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap(); let rel = |dest: String| { @@ -447,7 +502,8 @@ fn a_placed_document_brings_its_links_with_the_files_pixels() { } /// The placed (linked) files of a `.ai` file come in with the preview the file keeps of them: one -/// not found shows it, one found is read at once into the same bounds, which isn't a change. +/// not found shows it, one found is read at once into the same bounds, which isn't a change. A copy +/// saved without PDF compatibility, placed without Link, reads the found file too. #[test] fn links_of_an_imported_file_are_read_where_they_are_found() { use vectorcraft_testkit::ai; @@ -459,7 +515,8 @@ fn links_of_an_imported_file_are_read_where_they_are_found() { "%AI5_BeginPlace\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n({pic})`\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 \x7f\x10\x10\x60\x7f\x10\x10\x60\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n" ); let doc_path = dir.file("poster.ai"); - write(&doc_path, &ai::ai(ai::editing_data(200.0, 100.0, &ai::layer("Art", &place)).as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f")); + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &place)); + write(&doc_path, &ai::ai(data.as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f")); let placed = |s: &Session| { let mut found = vec![]; s.doc().unwrap().doc.visit_images(|n, im| found.push((n.geometric_bounds().unwrap(), im.clone()))); @@ -481,4 +538,11 @@ fn links_of_an_imported_file_are_read_where_they_are_found() { let (b, im) = placed(&s); assert!(in_place(b) && (im.width, im.height) == (600, 300) && im.link.as_ref().is_some_and(|l| l.hash.is_some()), "{b:?} {im:?}"); assert!(!s.doc().unwrap().doc.images[&im.key].is_proxy()); + + let alone = dir.file("alone.ai"); + write(&alone, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET")); + let mut s = session(); + s.execute("file.place", &json!({"path": alone, "link": false})).unwrap(); + let (_, im) = placed(&s); + assert!((im.width, im.height) == (600, 300) && !s.doc().unwrap().doc.images[&im.key].is_proxy(), "{im:?}"); } diff --git a/crates/engine/src/tests_live.rs b/crates/engine/src/tests_live.rs index 59bfc4e70..146148dc9 100644 --- a/crates/engine/src/tests_live.rs +++ b/crates/engine/src/tests_live.rs @@ -80,6 +80,13 @@ fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) { assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); } +/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false. +fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) { + let r = s.execute(cmd, p); + let want = format!("`{key}` must be true or false"); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); +} + // ---------- Blend ---------- #[test] @@ -171,6 +178,38 @@ fn blend_options_refuse_a_number_given_as_text() { assert_eq!(s.prefs.blend_options, defaults, "the options new blends start with stayed"); } +/// Make and Blend Options refuse a `smooth` given as text or as another value that is not true or +/// false, also beside Specified Steps: Make makes no blend, and the selected blend and the options new +/// blends start with keep their spacing. Null is read as `smooth` left out, and true still gives +/// Smooth Color. +#[test] +fn blend_make_and_options_refuse_a_smooth_given_as_text() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 100.0, 0.0, 10.0, 10.0)); + sel(&mut s, &[a, b]); + let n = undo_len(&s); + let calls = [json!({"smooth": "true"}), json!({"smooth": 1}), json!({"steps": 4, "smooth": "false"}), json!({"smooth": [true]})]; + for p in &calls { + refuses_non_boolean(&mut s, "object.blend.make", p, "smooth"); + } + assert_eq!(undo_len(&s), n, "nothing changed"); + assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not blended"); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "smooth": null})).unwrap()); + let n = undo_len(&s); + for p in &calls { + refuses_non_boolean(&mut s, "object.blend.options", p, "smooth"); + } + assert_eq!((undo_len(&s), steps_of(&s, g).len()), (n, 5), "the blend kept its options"); + s.execute("object.blend.options", &json!({"smooth": true})).unwrap(); + assert!(matches!(&node(&s, g).kind, NodeKind::Blend { spec, .. } if spec.spacing == BlendSpacing::SmoothColor)); + s.execute("select.none", &json!({})).unwrap(); + let defaults = s.prefs.blend_options; + for p in &calls { + refuses_non_boolean(&mut s, "object.blend.options", p, "smooth"); + } + assert_eq!(s.prefs.blend_options, defaults, "the options new blends start with stayed"); +} + #[test] fn blend_mid_step_interpolates_geometry_and_colour() { let mut s = session(); @@ -534,6 +573,24 @@ fn envelope_options_and_edit_contents() { assert_eq!(s.doc().unwrap().selection.objects, vec![e]); } +/// Edit Contents refuses an `editing` given as text or as another value that is not true or false, +/// and the envelope, the selection and the undo history stay as they were; a null `editing` toggles, +/// as a missing one does. +#[test] +fn envelope_edit_contents_refuses_editing_given_as_text() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap()); + let before = (node(&s, e), undo_len(&s), s.doc().unwrap().selection.objects.clone()); + for p in [json!({"editing": "true"}), json!({"editing": 1}), json!({"editing": "false"}), json!({"editing": [true]})] { + refuses_non_boolean(&mut s, "object.envelope.editContents", &p, "editing"); + assert_eq!((node(&s, e), undo_len(&s), s.doc().unwrap().selection.objects.clone()), before, "{p}"); + } + let r = s.execute("object.envelope.editContents", &json!({"editing": null})).unwrap(); + assert_eq!((r["editing"].clone(), s.doc().unwrap().selection.objects.clone()), (json!(true), vec![a])); +} + /// The warp's `bend`, and `h` and `v` by those keys or by their long names, given as text. fn warp_numbers_as_text() -> [(&'static str, Value); 5] { [ @@ -601,6 +658,37 @@ fn envelope_reset_with_mesh_refuses_a_number_given_as_text() { assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed"); } +/// Make with Warp and Reset with Warp refuse a `horizontal` given as text or as another value that +/// is not true or false, also beside an `orientation`, and Reset with Mesh refuses such a +/// `maintainShape`: no envelope is made, and a warp envelope keeps its warp. Null is read as the key +/// left out. +#[test] +fn envelope_make_and_reset_refuse_a_boolean_given_as_text() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let n = undo_len(&s); + let warps = [json!({"horizontal": "false"}), json!({"style": "arch", "horizontal": 0}), json!({"orientation": "vertical", "horizontal": "true"})]; + for p in &warps { + refuses_non_boolean(&mut s, "object.envelope.makeWithWarp", p, "horizontal"); + } + assert_eq!(undo_len(&s), n, "nothing changed"); + assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped"); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50, "horizontal": null})).unwrap()); + let (before, n) = (node(&s, e), undo_len(&s)); + for p in &warps { + refuses_non_boolean(&mut s, "object.envelope.resetWithWarp", p, "horizontal"); + } + for p in [json!({"maintainShape": "false"}), json!({"rows": 2, "cols": 2, "maintainShape": 0})] { + refuses_non_boolean(&mut s, "object.envelope.resetWithMesh", &p, "maintainShape"); + } + assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed"); + s.execute("object.envelope.resetWithWarp", &json!({"horizontal": false})).unwrap(); + assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Warp { horizontal: false, .. }))); + s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 2, "maintainShape": null})).unwrap(); + assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } if matches!(**kind, EnvelopeKind::Mesh { rows: 2, cols: 2, .. }))); +} + /// Envelope Options refuses a fidelity, bend or distortion given as text, for the selected /// envelope and for the options new envelopes get, and changes neither. #[test] @@ -623,6 +711,40 @@ fn envelope_options_refuse_a_number_given_as_text() { assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed"); } +/// Envelope Options refuses Anti-Alias, the three Distort options or a warp's `horizontal` given as +/// text or as another value that is not true or false, for a selected warp envelope, a selected mesh +/// envelope and the options new envelopes get, and changes none of them; null keeps the current +/// value, as a missing key does. +#[test] +fn envelope_options_refuse_a_boolean_given_as_text() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let warp = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 50})).unwrap()); + let b = rect(&mut s, 400.0, 100.0, 100.0, 100.0); + sel(&mut s, &[b]); + let mesh = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap()); + let calls = [ + ("antiAlias", json!({"antiAlias": "false"})), + ("distortAppearance", json!({"fidelity": 80, "distortAppearance": 1})), + ("distortLinearGradients", json!({"distortLinearGradients": "true"})), + ("distortPatternFills", json!({"distortPatternFills": [true]})), + ("horizontal", json!({"horizontal": "false"})), + ]; + for ids in [vec![warp], vec![mesh], vec![]] { + sel(&mut s, &ids); + let before = (node(&s, warp), node(&s, mesh), undo_len(&s)); + for (key, p) in &calls { + refuses_non_boolean(&mut s, "object.envelope.options", p, key); + } + assert_eq!((node(&s, warp), node(&s, mesh), undo_len(&s)), before, "nothing changed with {ids:?} selected"); + } + assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed"); + sel(&mut s, &[warp]); + s.execute("object.envelope.options", &json!({"antiAlias": false, "distortAppearance": null, "horizontal": null})).unwrap(); + assert!(matches!(&node(&s, warp).kind, NodeKind::Envelope { options, .. } if !options.anti_alias && options.distort_appearance)); +} + #[test] fn envelope_renders_distorted() { let mut s = session(); @@ -981,7 +1103,7 @@ fn two_colour_image(s: &mut Session, clear_right: bool, linked: bool) -> NodeId let id = d.alloc_id(); let xf = vectorcraft_geom::Affine::translate((100.0, 50.0)) * vectorcraft_geom::Affine::scale(2.0); let link = linked.then(|| vectorcraft_doc::LinkInfo { path: "/tmp/pic.png".into(), ..Default::default() }); - let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 8, height: 4, xf, link, placement: Default::default() }; + let im = vectorcraft_doc::ImageObject { key: "pic".into(), width: 8, height: 4, xf, link, placement: Default::default(), pixels: None }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(id) diff --git a/crates/engine/src/tests_livecorners.rs b/crates/engine/src/tests_livecorners.rs index 14c7a2631..a1658be2e 100644 --- a/crates/engine/src/tests_livecorners.rs +++ b/crates/engine/src/tests_livecorners.rs @@ -158,7 +158,7 @@ fn corners_of_a_non_square_rectangle_are_alike_circles() { let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); let NodeKind::Path { path, live: Some(live), .. } = &mut d.node_mut(id).unwrap().kind else { panic!("not live") }; let xf = Affine::translate((10.0, 10.0)) * Affine::scale_non_uniform(2.0, 1.0); - **live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), xf }; + **live = LiveShape::Rectangle { w: 50.0, h: 60.0, radii: [10.0; 4], kinds: Default::default(), radii_relative: false, xf }; *path = live.to_path(); } assert_eq!(spans(&s, id)[0], (20.0, 10.0)); @@ -331,6 +331,49 @@ fn an_ellipse_takes_pie_angles_and_inverts() { assert!(s.execute("object.setLiveShape", &json!({"pieStart": "half"})).is_err()); } +/// Invert Pie and Make Sides Equal given as text or as another value that is not true or false are +/// an error naming the key: the ellipse's pie, the polygon and the undo history stay as they were. +/// True makes the change, and false and null leave the shape as it is. +#[test] +fn invert_pie_and_make_sides_equal_refuse_a_boolean_given_as_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap(); + let id = |v: Value| NodeId(v["id"].as_u64().unwrap()); + let ellipse = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()); + run(&mut s, json!({"id": ellipse.0, "pieStart": 0, "pieEnd": 90})); + let polygon = id(s.execute("shape.polygon", &json!({"cx": 200, "cy": 200, "radius": 50, "sides": 6})).unwrap()); + s.execute("object.scale", &json!({"sx": 200, "sy": 100})).unwrap(); + let shapes = |s: &Session| { + let d = &s.doc().unwrap().doc; + [ellipse, polygon].map(|i| match &d.node(i).unwrap().kind { + NodeKind::Path { live: Some(l), .. } => (**l).clone(), + k => panic!("{k:?}"), + }) + }; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let before = (undo(&s), shapes(&s)); + for (key, p) in [ + ("invertPie", json!({"id": ellipse.0, "invertPie": "true"})), + ("invertPie", json!({"id": ellipse.0, "pieEnd": 180, "invertPie": 1})), + ("makeSidesEqual", json!({"id": polygon.0, "makeSidesEqual": "true"})), + ("makeSidesEqual", json!({"id": polygon.0, "sides": 5, "makeSidesEqual": [true]})), + ] { + let r = s.execute("object.setLiveShape", &p); + let want = format!("`{key}` must be true or false"); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{p}: {r:?}"); + assert_eq!((undo(&s), shapes(&s)), before, "{p}"); + } + for p in [json!({"id": ellipse.0, "invertPie": false}), json!({"id": polygon.0, "makeSidesEqual": null})] { + run(&mut s, p); + } + assert_eq!((undo(&s), shapes(&s)), before); + run(&mut s, json!({"id": ellipse.0, "invertPie": true})); + run(&mut s, json!({"id": polygon.0, "makeSidesEqual": true})); + let [LiveShape::Ellipse { pie, .. }, l] = shapes(&s) else { panic!("an ellipse and a polygon") }; + assert_eq!(pie, (90.0, 360.0)); + assert!(l.polygon_sides_equal()); +} + /// The Selection tool's pie widgets: dragging the End widget from 0° to straight up sets Pie End /// Angle to 90 in one undo step; on an inverted pie a drag moves only the dragged angle; a /// double-click on a widget makes the ellipse whole again in one step. @@ -419,3 +462,26 @@ fn a_polygon_takes_its_angle_radius_side_length_and_equal_sides() { assert!(s.execute("object.setLiveShape", &bad).is_err(), "{bad}"); } } + +#[test] +fn relative_corner_rounding_keeps_fraction_on_resize() { + let (mut s, id) = session(); + run(&mut s, json!({"relativeRadii": true})); + let n = s.doc().unwrap().doc.node(id).unwrap(); + let c = LiveCorners::of(n).unwrap(); + let max = c.corner(0).unwrap().max_radius(); + let want = 0.25; + run(&mut s, json!({"radius": want * max})); + let n = s.doc().unwrap().doc.node(id).unwrap(); + let LiveShape::Rectangle { radii, radii_relative, .. } = n.live_shape().unwrap() else { panic!() }; + assert!(radii_relative); + assert!((radii[0] - want).abs() < 0.02, "stored fraction: {}", radii[0]); + let drawn = LiveCorners::of(n).unwrap().drawn_radius(0); + s.execute("object.scale", &json!({"sx": 200, "sy": 200, "corners": true})).unwrap(); + let n = s.doc().unwrap().doc.node(id).unwrap(); + let LiveShape::Rectangle { radii, radii_relative, .. } = n.live_shape().unwrap() else { panic!() }; + assert!(radii_relative); + assert!((radii[0] - want).abs() < 0.02, "fraction unchanged after scale: {}", radii[0]); + let c = LiveCorners::of(n).unwrap(); + assert!((c.drawn_radius(0) / drawn - 2.0).abs() < 0.05, "drawn radius doubled with the shape"); +} diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 1b888a2a7..115665ac6 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -41,8 +41,14 @@ fn sel(s: &mut Session, ids: &[NodeId]) { /// A placed 64 × 32 px PNG whose header reads (so it places at its size) but whose pixel data is /// cut short: the renderer can't decode it and draws nothing. pub(crate) fn place_undecodable_png(s: &mut Session) -> NodeId { + place_undecodable_png_sized(s, 64, 32) +} + +/// Places a PNG like [`place_undecodable_png`] does, at `w` × `h` px. PNGs of different sizes are +/// different images, each stored under its own key. +pub(crate) fn place_undecodable_png_sized(s: &mut Session, w: u32, h: u32) -> NodeId { let mut png = Vec::new(); - image::RgbaImage::from_pixel(64, 32, image::Rgba([200, 30, 30, 255])) + image::RgbaImage::from_pixel(w, h, image::Rgba([200, 30, 30, 255])) .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) .unwrap(); let idat = png.windows(4).position(|w| w == b"IDAT").unwrap(); @@ -90,6 +96,13 @@ fn refuses_non_number(s: &mut Session, cmd: &str, p: &Value, key: &str) { assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); } +/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false. +fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) { + let r = s.execute(cmd, p); + let want = format!("`{key}` must be true or false"); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); +} + // ---------- Object → Lock / Hide ---------- #[test] @@ -429,6 +442,67 @@ fn transform_each_refuses_a_number_given_as_text() { assert_eq!(bounds(&s, a), Rect::new(35.0, 25.0, 85.0, 75.0)); } +/// Transform Each refuses Reflect X, Reflect Y, Random, Copy, Scale Strokes & Effects, Scale +/// Corners, Transform Objects or Transform Patterns given as text or as another value that is not +/// true or false, and the object stays as it was and is not copied; true, false and null still work. +#[test] +fn transform_each_refuses_a_boolean_given_as_text() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().unwrap().len(); + let before = (undo_len(&s), bounds(&s, a), count(&s)); + for (key, p) in [ + ("reflectX", json!({"reflectX": "true"})), + ("reflectY", json!({"moveH": 10, "reflectY": 1})), + ("random", json!({"moveH": 10, "random": "false"})), + ("copy", json!({"copy": "true"})), + ("copy", json!({"moveH": 10, "copy": [true]})), + ("strokes", json!({"scaleH": 50, "scaleV": 50, "strokes": "false"})), + ("corners", json!({"scaleH": 50, "corners": 1})), + ("objects", json!({"moveH": 10, "objects": "false"})), + ("patterns", json!({"moveH": 10, "patterns": [true]})), + ] { + refuses_non_boolean(&mut s, "object.transformEach", &p, key); + assert_eq!((undo_len(&s), bounds(&s, a), count(&s)), before, "{p}"); + } + let p = json!({"moveH": 10, "reflectX": true, "random": false, "copy": null, "objects": null, "patterns": false}); + s.execute("object.transformEach", &p).unwrap(); + assert_eq!((bounds(&s, a), count(&s)), (Rect::new(10.0, 0.0, 110.0, 100.0), before.2)); +} + +/// With Scale Strokes & Effects on, Transform Each asked to scale a stroked rectangle with +/// `strokes` given as text, such as "false", fails: the saved document, the undo history and what +/// Transform Again repeats stay as they were, and Transform Again still repeats the Transform Each +/// before it. `strokes: false` scales the rectangle and keeps its stroke weight; `strokes: true` +/// and a null (the preference) scale both. +#[test] +fn transform_each_refuses_strokes_given_as_text_and_keeps_transform_again() { + let mut s = session(); + s.prefs.scale_strokes = true; + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + sel(&mut s, &[a]); + s.execute("stroke.set", &json!({"weight": 4})).unwrap(); + s.execute("object.transformEach", &json!({"moveH": 10})).unwrap(); + let saved = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false); + let repeat = |s: &Session| s.doc().unwrap().last_transform.clone(); + let before = (saved(&s), history(&s), repeat(&s)); + for strokes in [json!("false"), json!("true"), json!(0)] { + let p = json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}); + refuses_non_boolean(&mut s, "object.transformEach", &p, "strokes"); + assert!((saved(&s), history(&s), repeat(&s)) == before, "{p}"); + } + let weight = |s: &Session| node(s, a).appearance.stroke_width(); + s.execute("object.transformAgain", &json!({})).unwrap(); + assert_eq!((bounds(&s, a), weight(&s)), (Rect::new(20.0, 0.0, 120.0, 100.0), 4.0), "the move before it repeats"); + for (strokes, w) in [(json!(false), 4.0), (json!(true), 8.0), (Value::Null, 8.0)] { + let p = json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}); + s.execute("object.transformEach", &p).unwrap(); + assert_eq!((bounds(&s, a), weight(&s)), (Rect::new(-30.0, -50.0, 170.0, 150.0), w), "{p}"); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + /// #1910: Transform Each's Reflect X flips left-right; Reflect Y flips top-bottom (same as /// Effect › Distort › Transform and Illustrator's Transform Each checkboxes). #[test] @@ -673,6 +747,24 @@ fn crop_image_refuses_a_rect_given_as_text() { assert!(close(b.x0, 0.0) && close(b.y0, 0.0), "{b:?}"); } +/// Crop Image refuses a `trim` given as text or as another value that is not true or false, and the +/// image stays as it was; a null `trim` crops to `rect` as a missing one does. +#[test] +fn crop_image_refuses_a_trim_given_as_text() { + let mut s = session(); + let a = rect(&mut s, -20.0, -20.0, 100.0, 100.0); + sel(&mut s, &[a]); + let img = id_of(&s.execute("object.rasterize", &json!({"ppi": 72})).unwrap()); + let before = (undo_len(&s), node(&s, img)); + for p in [json!({"trim": "true"}), json!({"trim": "false", "rect": [10, 10, 50, 40]}), json!({"trim": 1})] { + refuses_non_boolean(&mut s, "object.cropImage", &p, "trim"); + assert_eq!((undo_len(&s), node(&s, img)), before, "{p}"); + } + s.execute("object.cropImage", &json!({"trim": null, "rect": [10, 10, 50, 40]})).unwrap(); + let b = bounds(&s, img); + assert!(close(b.x0, 10.0) && close(b.y0, 10.0) && close(b.width(), 50.0) && close(b.height(), 40.0), "{b:?}"); +} + #[test] fn crop_image_disabled_without_image() { let mut s = session(); @@ -745,6 +837,32 @@ fn trim_marks_refuse_a_number_given_as_text() { s.execute("object.createTrimMarks", &json!({"offset": 10, "length": 20, "bleed": null})).unwrap(); } +/// Create Trim Marks refuses an `allArtboards` that is not true or false and a `style` that is not +/// text, around the selection and around the artboards, and makes no marks; null takes the default. +#[test] +fn trim_marks_refuse_a_boolean_or_style_of_the_wrong_kind() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 200.0, 100.0); + let style = "`style` must be roman or japanese"; + let calls = [ + (json!({"allArtboards": "true"}), "`allArtboards` must be true or false"), + (json!({"allArtboards": 0}), "`allArtboards` must be true or false"), + (json!({"style": 5}), style), + (json!({"allArtboards": true, "style": true}), style), + (json!({"style": ["roman"]}), style), + ]; + for ids in [vec![a], vec![]] { + sel(&mut s, &ids); + let before = (undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()); + for (p, msg) in &calls { + let r = s.execute("object.createTrimMarks", p); + assert!(matches!(&r, Err(EngineError::BadParams { msg: m, .. }) if m == msg), "{p}: {r:?}"); + assert_eq!((undo_len(&s), s.doc().unwrap().doc.layers[0].children().unwrap().len()), before, "{p}"); + } + } + s.execute("object.createTrimMarks", &json!({"allArtboards": null, "style": null})).unwrap(); +} + // ---------- Convert to Shape ---------- #[test] @@ -896,6 +1014,30 @@ fn rearrange_refuses_a_spacing_given_as_text() { assert_eq!(boards(&s)[1], Rect::new(0.0, 650.0, 400.0, 950.0)); } +/// Rearrange All Artboards refuses `byColumn`, `moveArtwork` or `patterns` given as text or as +/// another value that is not true or false, and the artboards and the art stay where they were; +/// `moveArtwork: false` leaves the art in place. +#[test] +fn rearrange_refuses_a_boolean_given_as_text() { + let mut s = session(); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 300})).unwrap(); + let b = rect(&mut s, 1050.0, 25.0, 10.0, 10.0); + let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let before = (boards(&s), undo_len(&s), bounds(&s, b)); + for (key, p) in [ + ("byColumn", json!({"byColumn": "true"})), + ("byColumn", json!({"layout": "row", "byColumn": 1})), + ("moveArtwork", json!({"moveArtwork": "false"})), + ("moveArtwork", json!({"columns": 1, "moveArtwork": 0})), + ("patterns", json!({"patterns": "true"})), + ] { + refuses_non_boolean(&mut s, "artboard.rearrange", &p, key); + assert_eq!((boards(&s), undo_len(&s), bounds(&s, b)), before, "{p}"); + } + s.execute("artboard.rearrange", &json!({"columns": 1, "spacing": 50, "byColumn": null, "moveArtwork": false})).unwrap(); + assert_eq!((boards(&s)[1], bounds(&s, b)), (Rect::new(0.0, 650.0, 400.0, 950.0), before.2)); +} + /// Fit to Artwork Bounds fits each artboard it is given to the art on it, in one undo step: an /// object partly on an artboard counts for each artboard it overlaps, art flush with an edge from /// outside or on no artboard doesn't count, and an artboard with no art stays as it is. @@ -1044,6 +1186,18 @@ fn find_and_replace_options() { assert!(s.execute("edit.findReplace", &json!({"find": ""})).is_err()); } +/// Find and Replace with no ids leaves the type on a locked artboard as it is. +#[test] +fn find_and_replace_leaves_type_on_a_locked_artboard() { + let mut s = session(); + let a = text(&mut s, "cat"); + let b = id_of(&s.execute("text.create", &json!({"x": 2050, "y": 50, "text": "cat"})).unwrap()); + s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 400, "height": 400})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog"})).unwrap(); + assert_eq!((r["count"].as_u64(), plain(&s, a), plain(&s, b)), (Some(1), "dog".to_string(), "cat".to_string())); +} + /// Find and Replace that replaces each match with the text it is (`CAT` found in `cat`, ignoring /// case, and replaced by `cat`) records no undo step: the step to redo stays and the document stays /// unmodified. It still replies with the count. A replacement that changes the text is one undo step. @@ -1217,6 +1371,27 @@ fn select_point_and_area_text() { assert_eq!(s.execute("select.object.brushStrokes", &json!({})).unwrap()["count"], 0); } +/// Select › Object › Bristle Brush Strokes goes by the brush's type: a bristle brush counts +/// whatever it is named, and a brush of another type with "Bristle" in its name does not. +#[test] +fn bristle_brush_strokes_select_by_brush_type() { + let mut s = session(); + let round = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let soft = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + let nib = rect(&mut s, 40.0, 0.0, 10.0, 10.0); + let _plain = rect(&mut s, 60.0, 0.0, 10.0, 10.0); + s.execute("brush.apply", &json!({"name": "Bristle Round", "ids": [round.0]})).unwrap(); + s.execute("brush.apply", &json!({"name": "Bristle Flat Fan", "ids": [soft.0]})).unwrap(); + s.execute("brush.options", &json!({"name": "Bristle Flat Fan", "newName": "Soft"})).unwrap(); + s.execute("brush.new", &json!({"type": "calligraphic", "name": "Bristle-like"})).unwrap(); + s.execute("brush.apply", &json!({"name": "Bristle-like", "ids": [nib.0]})).unwrap(); + let brush = |s: &Session, id: NodeId| s.doc().unwrap().doc.node(id).and_then(|n| n.appearance.stroke()).and_then(|st| st.brush.clone()); + assert_eq!(brush(&s, soft).as_deref(), Some("Soft")); + assert_eq!(brush(&s, nib).as_deref(), Some("Bristle-like")); + assert_eq!(s.execute("select.object.bristleBrushStrokes", &json!({})).unwrap()["count"], 2); + assert_eq!(selected(&s), vec![round, soft]); +} + /// Select › Object and Select › Same leave out hidden and locked objects, and the objects in hidden /// or locked groups and sublayers, as Select All does. #[test] @@ -1482,6 +1657,17 @@ fn select_all_on_artboard_takes_lines_on_its_right_and_bottom_edges() { assert_eq!(on(1), vec![right, shared]); } +/// All on Active Artboard refuses artboard 2^32 and selects nothing. A 32-bit build read it as +/// artboard 0. +#[test] +fn select_all_on_artboard_refuses_an_artboard_past_u32() { + let mut s = session(); + rect(&mut s, 10.0, 10.0, 20.0, 20.0); + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("select.allOnArtboard", &json!({"artboard": 1u64 << 32})).is_err()); + assert!(selected(&s).is_empty()); +} + #[test] fn save_and_recall_selection() { let mut s = session(); @@ -2056,8 +2242,12 @@ fn guide_path(s: &mut Session, x: f64) -> NodeId { fn exit_isolation_leaves_a_locked_guide_path_deselected() { let mut s = session(); let g = guide_path(&mut s, 0.0); + // A locked guide path can't be isolated; one isolated before Lock Guides went on stays isolated. s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); + assert!(s.execute("object.isolate", &json!({"id": g.0})).is_err()); + s.execute("view.guides.lock", &json!({"locked": false})).unwrap(); s.execute("object.isolate", &json!({"id": g.0})).unwrap(); + s.execute("view.guides.lock", &json!({"locked": true})).unwrap(); s.execute("object.exitIsolation", &json!({})).unwrap(); assert!(selected(&s).is_empty()); let _ = s.execute("object.move", &json!({"dx": 10, "dy": 0})); @@ -2662,6 +2852,18 @@ fn close_all_documents() { assert!(s.execute("file.closeAll", &json!({})).is_err()); } +/// Close and Activate refuse a document index of 2^32, and both documents stay open with the second +/// one active. A 32-bit build read the index as 0. +#[test] +fn close_and_activate_refuse_a_document_index_past_u32() { + let mut s = session(); + s.execute("file.new", &json!({})).unwrap(); + for cmd in ["document.activate", "file.close"] { + assert!(s.execute(cmd, &json!({"index": 1u64 << 32})).is_err(), "{cmd}"); + assert_eq!((s.documents().len(), s.active_index()), (2, Some(1)), "{cmd}"); + } +} + #[test] fn document_color_mode_converts_colours() { let mut s = session(); diff --git a/crates/engine/src/tests_newart.rs b/crates/engine/src/tests_newart.rs index 5be2e2069..cc1ced4f7 100644 --- a/crates/engine/src/tests_newart.rs +++ b/crates/engine/src/tests_newart.rs @@ -141,3 +141,17 @@ fn the_option_round_trips_with_the_preferences() { let old: Prefs = serde_json::from_value(json!({"keyboardIncrement": 2})).unwrap(); assert!(old.new_art_basic); } + +/// Line Segment Tool Options' Fill Line: `shape.line {fill: true}` gives the line the current +/// fill; without it a new line has none. +#[test] +fn fill_line_gives_a_new_line_the_current_fill() { + let mut s = session(); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + let plain = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 200, "y2": 80}))["id"].as_u64().unwrap()); + let filled = NodeId(run(&mut s, "shape.line", json!({"x1": 10, "y1": 20, "x2": 200, "y2": 80, "fill": true}))["id"].as_u64().unwrap()); + assert_eq!(node(&s, plain).appearance.fill_paint(), Paint::None); + assert_eq!(node(&s, filled).appearance.fill_paint(), s.paint.fill); + assert_ne!(s.paint.fill, Paint::None); + assert!(s.execute("shape.line", &json!({"x1": 0, "y1": 0, "x2": 1, "y2": 1, "fill": "yes"})).is_err()); +} diff --git a/crates/engine/src/tests_newdoc.rs b/crates/engine/src/tests_newdoc.rs index bada2ebba..a2f840c67 100644 --- a/crates/engine/src/tests_newdoc.rs +++ b/crates/engine/src/tests_newdoc.rs @@ -174,6 +174,20 @@ fn categories_list_generated_presets_with_neutral_names() { assert!(s.execute("file.newPresets", &json!({"category": "Furniture"})).is_err()); } +/// Branding has the Japanese business card, 91 × 55 mm, after the 85 × 55 mm one (#3828). +#[test] +fn branding_has_the_japanese_business_card() { + let mut s = Session::new(); + let branding = presets(&mut s, "Branding"); + let names: Vec<&str> = branding.iter().map(|p| p["name"].as_str().unwrap()).collect(); + let at = names.iter().position(|n| *n == "Business Card 91 × 55 mm").unwrap_or_else(|| panic!("{names:?}")); + assert_eq!(names[at - 1], "Business Card 85 × 55 mm"); + s.execute("file.new", &json!({"preset": "Business Card 91 × 55 mm"})).unwrap(); + let b = boards(&s)[0]; + let mm = 72.0 / 25.4; + assert!((b.width() - 91.0 * mm).abs() < 1e-6 && (b.height() - 55.0 * mm).abs() < 1e-6, "{b:?}"); +} + #[test] fn recent_remembers_the_last_sizes() { let mut s = Session::new(); diff --git a/crates/engine/src/tests_objects_on_path.rs b/crates/engine/src/tests_objects_on_path.rs index 5f2faa67d..4cd740dab 100644 --- a/crates/engine/src/tests_objects_on_path.rs +++ b/crates/engine/src/tests_objects_on_path.rs @@ -373,6 +373,50 @@ fn what_cannot_be_attached_is_refused_with_a_reason() { assert!(s.execute("object.objectsOnPath.options", &json!({"ids": [other.0]})).is_err()); } +#[test] +fn paths_and_objects_that_are_part_of_live_art_or_a_clipping_path_stay_where_they_are() { + let mut s = session(); + let ([left, middle, _], path) = squares_and_line(&mut s); + let err = |s: &mut Session, p: Value| s.execute("object.objectsOnPath.attach", &p).unwrap_err().to_string(); + let parent = |s: &Session, id: NodeId| s.doc().unwrap().doc.parent_of(id); + // A compound path's member as the path; as an object, the whole compound attaches. + let member = rect(&mut s, 20.0, 150.0, 40.0, 40.0); + let hole = rect(&mut s, 30.0, 160.0, 10.0, 10.0); + select(&mut s, &[member, hole]); + let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap()); + let why = err(&mut s, json!({"path": member.0, "ids": [left.0]})); + assert!(why.contains("a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects"), "{why}"); + assert_eq!(parent(&s, member), Some(compound)); + // A blend's key path, as the path and as an object. + let key = line(&mut s, (50.0, 200.0), (350.0, 200.0)); + let other_key = line(&mut s, (50.0, 250.0), (350.0, 250.0)); + select(&mut s, &[key, other_key]); + let blend = id_of(&s.execute("object.blend.make", &json!({"steps": 2})).unwrap()); + assert!(err(&mut s, json!({"path": key.0, "ids": [left.0]})).contains("can't take objects")); + let why = err(&mut s, json!({"path": path.0, "ids": [key.0]})); + assert!(why.contains("objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path"), "{why}"); + assert_eq!((parent(&s, key), parent(&s, other_key)), (Some(blend), Some(blend))); + // A repeat's source art as an object. + let source = rect(&mut s, 300.0, 340.0, 10.0, 10.0); + select(&mut s, &[source]); + let repeat = id_of(&s.execute("object.repeat.grid", &json!({})).unwrap()); + assert!(err(&mut s, json!({"path": path.0, "ids": [source.0]})).contains("can't be attached to a path")); + assert_eq!(parent(&s, source), Some(repeat)); + // A clipping path, as the path and as an object. + let art = rect(&mut s, 200.0, 100.0, 60.0, 60.0); + let mask = rect(&mut s, 210.0, 110.0, 40.0, 40.0); + select(&mut s, &[art, mask]); + let clip = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + assert!(err(&mut s, json!({"path": mask.0, "ids": [left.0]})).contains("a clipping path can't take objects")); + assert!(err(&mut s, json!({"path": path.0, "ids": [mask.0]})).contains("a clipping path can't be attached to a path")); + assert_eq!(s.doc().unwrap().doc.node(clip).unwrap().children().unwrap().first().map(|c| c.id), Some(mask)); + // The art the clipping path clips, and a compound path's member as an object, still attach. + let g = attach(&mut s, path, &[left, middle, member, art], json!({})); + let rec = s.doc().unwrap().doc.node(g).unwrap().extras().objects_on_path.as_deref().cloned().unwrap(); + assert!(rec.members.iter().any(|m| m.id == compound), "the compound attaches whole"); + assert_eq!(parent(&s, member), Some(compound)); +} + #[test] fn options_and_expand_take_ids_without_a_selection() { let mut s = session(); diff --git a/crates/engine/src/tests_objexpand.rs b/crates/engine/src/tests_objexpand.rs index ea92bca1f..48435adbb 100644 --- a/crates/engine/src/tests_objexpand.rs +++ b/crates/engine/src/tests_objexpand.rs @@ -197,6 +197,8 @@ fn the_mesh_option_makes_a_mesh_that_paints_the_gradient() { for gradient in [ json!({"kind": "linear", "angle": 30, "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 0.4, "color": "#00ff00", "midpoint": 0.3}, {"offset": 1, "color": "#0000ff"}]}), json!({"kind": "radial", "stops": [{"offset": 0, "color": "#ffff00"}, {"offset": 0.5, "color": "#ff00ff"}, {"offset": 1, "color": "#004080"}]}), + // Perceptual: the mesh follows the OKLab curve between the stops, not a straight RGB mix. + json!({"kind": "linear", "method": "perceptual", "stops": [{"offset": 0, "color": "#2050f0"}, {"offset": 1, "color": "#f5d020"}]}), ] { let mut s = session(); gradient_rect(&mut s, Rect::new(30.0, 40.0, 170.0, 160.0), gradient.clone(), false); diff --git a/crates/engine/src/tests_outlineobject.rs b/crates/engine/src/tests_outlineobject.rs index 51feaf949..c1ff06b53 100644 --- a/crates/engine/src/tests_outlineobject.rs +++ b/crates/engine/src/tests_outlineobject.rs @@ -48,19 +48,17 @@ fn outline_object_is_applied_in_one_undo_step_and_takes_no_parameters() { } #[test] -fn type_with_outline_object_stays_editable_type_and_keeps_its_bounds() { +fn type_with_outline_object_stays_editable_type_and_keeps_its_geometric_bounds() { let mut s = session(); let id = text(&mut s); - let before = [false, true].map(|preview| { - s.prefs.use_preview_bounds = preview; - s.transform_bounds(&[id]) - }); + let before = s.transform_bounds(&[id]); apply(&mut s, id); - let after = [false, true].map(|preview| { - s.prefs.use_preview_bounds = preview; - s.transform_bounds(&[id]) - }); - assert_eq!(after, before); + assert_eq!(s.transform_bounds(&[id]), before); + // With Use Preview Bounds on, the type is measured by its glyph outlines. + s.prefs.use_preview_bounds = true; + let glyphs = vectorcraft_render::effects::reshape(None, &node(&s, id), None).unwrap().geometric_bounds(); + assert_eq!(s.transform_bounds(&[id]), glyphs); + s.prefs.use_preview_bounds = false; // A native save keeps the effect. let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); let ids: Vec = back.node(id).unwrap().appearance.effects.iter().map(|e| e.id.clone()).collect(); @@ -68,10 +66,10 @@ fn type_with_outline_object_stays_editable_type_and_keeps_its_bounds() { // The type is still type, drawn as its outlines, and a text edit changes them. let n = node(&s, id); assert!(matches!(n.kind, NodeKind::Text(_))); - let drawn = vectorcraft_render::effects::reshape(&n, None).unwrap(); + let drawn = vectorcraft_render::effects::reshape(None, &n, None).unwrap(); assert_eq!(count_text(&drawn), 0); s.execute("text.setText", &json!({"id": id.0, "text": "OUTLINED TYPE"})).unwrap(); - let longer = vectorcraft_render::effects::reshape(&node(&s, id), None).unwrap(); + let longer = vectorcraft_render::effects::reshape(None, &node(&s, id), None).unwrap(); assert!(longer.geometric_bounds().unwrap().width() > drawn.geometric_bounds().unwrap().width() + 50.0); } diff --git a/crates/engine/src/tests_package.rs b/crates/engine/src/tests_package.rs index f6b5b6c84..e1eacfe0a 100644 --- a/crates/engine/src/tests_package.rs +++ b/crates/engine/src/tests_package.rs @@ -124,6 +124,80 @@ fn without_a_folder_the_zip_has_the_same_files() { assert_eq!(f["files"], r["files"]); } +/// The reply of a prepared package's [`cmd::package::Package::write`] and the files it gave the +/// writer, by path. +fn write_prepared(package: cmd::package::Package) -> (Value, BTreeMap>) { + let mut files = BTreeMap::new(); + let mut write = |path: &str, bytes: &[u8]| { + files.insert(path.to_string(), bytes.to_vec()); + Ok(()) + }; + let r = package.write(&mut write).unwrap_or_else(|e| panic!("{e}")); + (r, files) +} + +#[test] +fn a_prepared_package_writes_what_file_package_writes() { + let dir = Folder::new("package-prepared"); + let mut s = poster(&dir); + run(&mut s, "text.create", json!({"x": 10, "y": 80, "text": "Gone", "font": "No Such Font Family"})); + save(&mut s, &dir.file("poster.vectorcraft")); + std::fs::remove_file(dir.file("art/blue.png")).unwrap(); + open(&mut s, &dir.file("poster.vectorcraft")); + let p = json!({"folder": dir.file("out")}); + let want = run(&mut s, "file.package", p.clone()); + assert_eq!((want["missingLinks"].clone(), want["skippedFonts"][0]["font"].clone()), (json!(["blue.png"]), json!("No Such Font Family Regular"))); + let root = std::path::Path::new(&dir.file("out")).join("poster Folder"); + let on_disk: BTreeMap> = want["files"] + .as_array() + .unwrap() + .iter() + .map(|f| root.join(f.as_str().unwrap())) + .map(|f| (f.to_string_lossy().into_owned(), std::fs::read(&f).unwrap())) + .collect(); + assert_eq!(on_disk.len(), 4, "{want}"); + std::fs::remove_dir_all(&root).unwrap(); + // A prepared package is journaled as the command, and nothing is written until `Package::write` runs. + let journaled = s.journal.len(); + let package = s.prepare_package(&p).unwrap(); + assert_eq!((s.journal.len(), s.journal.last().map(|(id, _)| id.as_str())), (journaled + 1, Some("file.package"))); + assert!(!root.exists()); + let (got, files) = write_prepared(package); + assert_eq!(got, want); + assert_eq!(files.keys().collect::>(), on_disk.keys().collect::>()); + for (path, bytes) in &on_disk { + assert!(files[path] == *bytes, "{path} differs"); + } + // Without a folder, the package is the same zip and no file is written. + let want = run(&mut s, "file.package", json!({})); + let (got, files) = write_prepared(s.prepare_package(&json!({})).unwrap()); + assert!(files.is_empty(), "{:?}", files.keys()); + let unzipped = |r: &Value| unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + assert_eq!(unzipped(&got), unzipped(&want)); + for key in ["files", "links", "fonts", "missingLinks", "skippedFonts", "warnings", "name", "bytes"] { + assert_eq!(got[key], want[key], "{key}"); + } + // The checks come first, as the command's do. + let e = s.prepare_package(&json!({"name": "../up"})).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(e.contains("can't name a folder"), "{e}"); +} + +#[test] +fn a_prepared_package_holds_the_document_as_it_was() { + let dir = Folder::new("package-snapshot"); + let mut s = poster(&dir); + let nodes = s.doc().unwrap().doc.node_count(); + let package = s.prepare_package(&json!({})).unwrap(); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.clear", json!({})); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + let (r, _) = write_prepared(package); + let zip = unzip(&vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()); + let doc = vectorcraft_format::load(&zip["poster Folder/poster.vectorcraft"]).unwrap(); + assert_eq!(doc.node_count(), nodes); + assert_eq!(r["links"], 2, "{r}"); +} + #[test] fn options_leave_out_links_fonts_and_the_report() { let dir = Folder::new("package-options"); @@ -219,6 +293,7 @@ fn the_document_info_report_has_every_category() { "DOCUMENT", "OBJECTS", "GRAPHIC STYLES", + "BRUSHES", "SPOT COLORS", "PATTERN OBJECTS", "GRADIENT SWATCHES", @@ -227,6 +302,7 @@ fn the_document_info_report_has_every_category() { "FONT DETAILS", "LINKED IMAGES", "EMBEDDED IMAGES", + "REPEAT OBJECTS", ] { assert!(text.contains(&format!("\n{h}\n")), "{h}: {text}"); } @@ -238,8 +314,13 @@ fn the_document_info_report_has_every_category() { assert_eq!((i["sections"].as_array().unwrap().len(), i["sections"][0]["id"].as_str()), (1, Some("linkedImages"))); assert_eq!(i["sections"][0]["rows"].as_array().unwrap().len(), 2); let all = run(&mut s, "document.info", json!({})); - assert_eq!(all["sections"].as_array().unwrap().len(), 11); - assert!(s.execute("document.info", &json!({"category": "brushes"})).is_err()); + assert_eq!(all["sections"].as_array().unwrap().len(), 13); + let i = run(&mut s, "document.info", json!({"category": "brushes"})); + assert_eq!((i["sections"].as_array().unwrap().len(), i["sections"][0]["id"].as_str()), (1, Some("brushes"))); + let i = run(&mut s, "document.info", json!({"category": "repeats"})); + assert_eq!((i["sections"].as_array().unwrap().len(), i["sections"][0]["id"].as_str()), (1, Some("repeats"))); + assert!(text.contains("\nREPEAT OBJECTS\nNone\n"), "{text}"); + assert!(s.execute("document.info", &json!({"category": "strokes"})).is_err()); assert!(s.execute("document.info", &json!({"format": "xml"})).is_err()); // The bundled fonts allow embedding. let face = vectorcraft_text::FontDb::global().face("Source Sans 3", "Regular").unwrap(); @@ -321,6 +402,169 @@ fn zipped_package_recursively_relinks_without_absolute_creator_paths() { verify_portable_nested_package(&dir.0.join("extracted/poster Folder")); } +#[test] +fn package_copies_a_linked_pdf_page_as_it_is() { + let dir = Folder::new("package-placed-pdf"); + let mut d = vectorcraft_doc::Document::new(100.0, 50.0); + let (layer, id) = (d.layers[0].id, d.alloc_id()); + let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(RED[0], RED[1], RED[2])); + let look = vectorcraft_doc::Appearance::basic(red, vectorcraft_color::Paint::None, 0.0); + let page = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 100.0, 50.0)); + d.insert(Some(layer), 0, vectorcraft_doc::Node::path(id, page, look)).unwrap(); + let pdf = cmd::fileio::encode_all(&d, "pdf", &json!({"preserveEditing": false})).unwrap().files.remove(0).1; + let src = dir.file("art/flyer.pdf"); + write(&src, &pdf); + let mut s = session(); + let placed = run(&mut s, "file.place", json!({"path": src})); + assert_eq!(placed["linked"], true, "{placed}"); + save(&mut s, &dir.file("poster.vectorcraft")); + let r = run(&mut s, "file.package", json!({"folder": dir.file("out")})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone(), r["warnings"].clone()), (Some(1), json!([]), json!([])), "{r}"); + let root = dir.0.join("out/poster Folder"); + assert_eq!(std::fs::read(root.join("Links/flyer.pdf")).unwrap(), pdf, "copied as it is"); + // The packaged document shows the copy. + std::fs::remove_dir_all(dir.0.join("art")).unwrap(); + let opened = open(&mut s, &root.join("poster.vectorcraft").to_string_lossy()); + assert_eq!(opened["missingLinks"], json!([]), "{opened}"); + assert!(s.doc().unwrap().doc.has_placed()); +} + +#[test] +fn package_copies_the_fonts_of_the_type_on_a_linked_pdf_page() { + let dir = Folder::new("package-placed-pdf-type"); + let mut src = session(); + run(&mut src, "text.create", json!({"x": 10, "y": 40, "text": "Flyer"})); + let pdf = run(&mut src, "document.serialize", json!({"format": "pdf", "preserveEditing": false})); + let path = dir.file("art/flyer.pdf"); + write(&path, &vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap()); + for (link, copy_links, out) in [(false, true, "embedded"), (true, true, "linked"), (true, false, "fonts only")] { + let mut s = session(); + let placed = run(&mut s, "file.place", json!({"path": path, "link": link})); + assert_eq!(placed["linked"], link, "{placed}"); + save(&mut s, &dir.file("poster.vectorcraft")); + let r = run(&mut s, "file.package", json!({"folder": dir.file(out), "copyLinks": copy_links})); + assert_eq!((r["fonts"].as_u64(), r["skippedFonts"].clone()), (Some(1), json!([])), "{out}: {r}"); + assert!(dir.0.join(out).join("poster Folder/Fonts/SourceSans3-Regular.ttf").is_file(), "{out}"); + } +} + +#[test] +fn package_copies_the_fonts_of_each_page_of_a_pdf_that_two_documents_place() { + let dir = Folder::new("package-placed-pdf-pages"); + let mut src = session(); + run(&mut src, "text.create", json!({"x": 10, "y": 40, "text": "One"})); + run(&mut src, "artboard.new", json!({"x": 500, "y": 0, "width": 400, "height": 300})); + run(&mut src, "text.create", json!({"x": 510, "y": 40, "text": "Two", "font": "SourceSans3-Semibold"})); + let pdf = run(&mut src, "document.serialize", json!({"format": "pdf", "preserveEditing": false})); + let path = dir.file("art/flyer.pdf"); + write(&path, &vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap()); + // The poster places page 1, and a document it places shows page 2. + let mut part = session(); + run(&mut part, "file.place", json!({"path": path, "page": 2})); + let part_path = dir.file("art/part.vectorcraft"); + save(&mut part, &part_path); + let mut s = session(); + run(&mut s, "file.place", json!({"path": path, "page": 1})); + run(&mut s, "file.place", json!({"path": part_path})); + save(&mut s, &dir.file("poster.vectorcraft")); + let r = run(&mut s, "file.package", json!({})); + assert_eq!((r["links"].as_u64(), r["fonts"].as_u64(), r["skippedFonts"].clone()), (Some(2), Some(2), json!([])), "{r}"); +} + +/// With Copy Fonts off, Package reads no placed PDF page for its type: neither a page of a PDF it +/// copies nor a page of one another document placed and it copied already. +#[test] +fn package_reads_no_placed_pdf_page_without_copy_fonts() { + use cmd::package::PAGES_READ; + let dir = Folder::new("package-placed-pdf-no-fonts"); + let mut src = session(); + run(&mut src, "text.create", json!({"x": 10, "y": 40, "text": "One"})); + run(&mut src, "artboard.new", json!({"x": 500, "y": 0, "width": 400, "height": 300})); + run(&mut src, "text.create", json!({"x": 510, "y": 40, "text": "Two"})); + let pdf = run(&mut src, "document.serialize", json!({"format": "pdf", "preserveEditing": false})); + let path = dir.file("art/flyer.pdf"); + write(&path, &vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap()); + let mut part = session(); + run(&mut part, "file.place", json!({"path": path, "page": 2})); + let part_path = dir.file("art/part.vectorcraft"); + save(&mut part, &part_path); + let mut s = session(); + run(&mut s, "file.place", json!({"path": path, "page": 1})); + run(&mut s, "file.place", json!({"path": part_path})); + save(&mut s, &dir.file("poster.vectorcraft")); + for (copy_links, copy_fonts, fonts, reads, out) in [(true, false, 0, 0, "links"), (false, false, 0, 0, "neither"), (true, true, 1, 2, "both")] { + let before = PAGES_READ.with(std::cell::Cell::get); + let r = run(&mut s, "file.package", json!({"folder": dir.file(out), "copyLinks": copy_links, "copyFonts": copy_fonts})); + let read = PAGES_READ.with(std::cell::Cell::get) - before; + assert_eq!((r["fonts"].as_u64(), read), (Some(fonts), reads), "{out}: {r}"); + } +} + +/// `dir/art/badge.ai`, saved without its PDF part, its art placing each of `pics` (600 × 300 px, +/// with a 2×1 preview) → its path. +fn ai_placing(dir: &Folder, pics: &[&str]) -> String { + use vectorcraft_testkit::ai; + let place = |pic: &str| { + let pic = pic.replace('\\', "/"); + format!( + "%AI5_BeginPlace\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n({pic})`\n%AI26_BeginPlacedObjectPreview\n2 1 8 32 17 \x7f\x10\x10\x60\x7f\x10\x10\x60\n%AI26_EndPlacedObjectPreview\n~\n%AI5_EndPlace\n" + ) + }; + let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", &pics.iter().map(|p| place(p)).collect::())); + let src = dir.file("art/badge.ai"); + write(&src, &ai::ai(data.as_bytes(), "BT /F1 12 Tf 10 50 Td (Saved without its PDF part) Tj ET")); + src +} + +#[test] +fn package_copies_the_files_a_linked_ai_files_editing_data_places_beside_it() { + let dir = Folder::new("package-placed-ai"); + let pic = dir.file("art/photo.png"); + write(&pic, &png(600, 300, RED)); + let src = ai_placing(&dir, &[&pic]); + let mut s = session(); + let placed = run(&mut s, "file.place", json!({"path": src})); + assert_eq!(placed["linked"], true, "{placed}"); + save(&mut s, &dir.file("poster.vectorcraft")); + let r = run(&mut s, "file.package", json!({"folder": dir.file("out")})); + assert_eq!((r["links"].as_u64(), r["missingLinks"].clone(), r["warnings"].clone()), (Some(2), json!([]), json!([])), "{r}"); + let root = dir.0.join("out/poster Folder"); + assert_eq!(std::fs::read(root.join("Links/photo.png")).unwrap(), png(600, 300, RED)); + // The copy of the .ai finds the picture beside it, not its preview. + std::fs::remove_dir_all(dir.0.join("art")).unwrap(); + let copy = root.join("Links/badge.ai").to_string_lossy().into_owned(); + let shows_the_file = || { + let bytes = std::fs::read(©).unwrap(); + let a = cmd::place::document::document_art("badge.ai", &bytes, Some(©), 1, false, vectorcraft_doc::PageBox::Crop, "t").unwrap(); + a.doc.images.values().any(|b| !b.is_proxy()) + }; + assert!(shows_the_file()); + std::fs::remove_file(root.join("Links/photo.png")).unwrap(); + assert!(!shows_the_file(), "the preview only"); +} + +#[test] +fn package_warns_when_a_file_a_linked_ai_file_places_is_copied_under_another_name() { + let dir = Folder::new("package-placed-ai-names"); + let (one, two) = (dir.file("art/a/photo.png"), dir.file("art/b/photo.png")); + write(&one, &png(600, 300, RED)); + write(&two, &png(600, 300, BLUE)); + let src = ai_placing(&dir, &[&one, &two]); + let mut s = session(); + run(&mut s, "file.place", json!({"path": src})); + save(&mut s, &dir.file("poster.vectorcraft")); + let r = run(&mut s, "file.package", json!({"folder": dir.file("out")})); + let warnings = r["warnings"].as_array().unwrap(); + assert_eq!(r["links"].as_u64(), Some(3), "{r}"); + assert!(warnings.len() == 1 && warnings[0].as_str().unwrap().contains("looks for it as photo.png and shows the file of that name"), "{r}"); + // Moved, the copy of the .ai shows Links/photo.png for both, as the warning says. + std::fs::remove_dir_all(dir.0.join("art")).unwrap(); + let copy = dir.0.join("out/poster Folder/Links/badge.ai").to_string_lossy().into_owned(); + let bytes = std::fs::read(©).unwrap(); + let a = cmd::place::document::document_art("badge.ai", &bytes, Some(©), 1, false, vectorcraft_doc::PageBox::Crop, "t").unwrap(); + assert_eq!(a.doc.images.values().filter(|b| !b.is_proxy()).count(), 1); +} + #[test] fn package_keeps_distinct_linked_documents_with_colliding_file_names() { let dir = Folder::new("package-collisions"); @@ -449,6 +693,21 @@ fn packaging_nested_files_preserves_embedded_preview_pdf_and_compression() { assert_eq!(images[0].as_ref().unwrap().relative.as_deref(), Some("photo.png")); } +/// Without Relink, Package copies a placed document as it is and reads no preview or PDF from it. +#[test] +fn package_reads_no_preview_or_pdf_of_a_placed_document_without_relink() { + use cmd::package::PARTS_READ; + let dir = Folder::new("package-native-no-relink"); + let mut parent = poster_with_placed_document(&dir); + let original = std::fs::read(dir.file("art/part.vectorcraft")).unwrap(); + for (relink, reads, out) in [(false, 0, "as is"), (true, 1, "relinked")] { + let before = PARTS_READ.with(std::cell::Cell::get); + let r = run(&mut parent, "file.package", json!({"folder": dir.file(out), "relink": relink})); + assert_eq!((r["links"].as_u64(), PARTS_READ.with(std::cell::Cell::get) - before), (Some(2), reads), "{out}: {r}"); + } + assert_eq!(std::fs::read(dir.0.join("as is/poster Folder/Links/part.vectorcraft")).unwrap(), original); +} + /// The report keeps the packaged document's sections as they were, then gives each placed /// document's own, headed by its file, instead of nesting them inside the parent's list. #[test] diff --git a/crates/engine/src/tests_paintproxy.rs b/crates/engine/src/tests_paintproxy.rs index 2e2c0d5c8..8893ff18c 100644 --- a/crates/engine/src/tests_paintproxy.rs +++ b/crates/engine/src/tests_paintproxy.rs @@ -227,6 +227,78 @@ fn focus_false_keeps_the_active_proxy() { assert_eq!(node(&s, id).appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000"); } +/// Toggle Fill/Stroke Focus, Invert, Complement, Apply Last Color, Apply Last Gradient, +/// `paint.setFill` and `paint.setStroke` refuse a `fill`, `stroke`, `focus` or `none` given as text or +/// as another value that is not true or false. The object, the defaults for new art, the proxy in +/// front and the undo history stay as they were, with the object selected and with nothing selected. +/// Null is read as the key left out. +#[test] +fn proxy_commands_refuse_a_boolean_given_as_text() { + let mut s = session(); + let id = rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + let calls = [ + ("paint.toggleActive", json!({"fill": "false"}), "fill"), + ("paint.invert", json!({"stroke": "true"}), "stroke"), + ("paint.complement", json!({"stroke": 1}), "stroke"), + ("paint.lastColor", json!({"stroke": "true"}), "stroke"), + ("paint.lastGradient", json!({"stroke": [true]}), "stroke"), + ("paint.setStroke", json!({"color": "#0000ff", "focus": "false"}), "focus"), + ("paint.setFill", json!({"none": "true"}), "none"), + ("paint.setFill", json!({"color": "#0000ff", "none": "false"}), "none"), + ]; + let state = |s: &Session| (node(s, id), s.paint.clone(), s.fill_active, history(s)); + for selected in [true, false] { + if !selected { + s.execute("select.none", &json!({})).unwrap(); + } + let before = state(&s); + for (cmd, p, key) in &calls { + let r = s.execute(cmd, p); + let want = format!("`{key}` must be true or false"); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); + assert_eq!(state(&s), before, "{cmd} {p}, selected: {selected}"); + } + } + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let r = s.execute("paint.toggleActive", &json!({"fill": null})).unwrap(); + assert_eq!(r["fillActive"], json!(false), "a null `fill` swaps the proxy in front"); + s.execute("paint.setFill", &json!({"color": "#0000ff", "none": null, "focus": null})).unwrap(); + assert_eq!((node(&s, id).appearance.fill_paint().color().unwrap().to_hex(), s.fill_active), ("#0000ff".to_string(), true)); +} + +/// `appearance.setItem`, `livePaint.fill`, `livePaint.strokeEdge`, a `shapeBuilder.merge` `fill` +/// object, `swatch.new` and `swatch.edit`'s `paint` refuse a `none` given as text or as another value +/// that is not true or false. The document, the defaults for new art and the undo history stay as +/// they were. Null is read as `none` left out. +#[test] +fn the_commands_that_take_a_paint_refuse_a_none_given_as_text() { + let mut s = session(); + let id = rect(&mut s); + let name = s.execute("swatch.new", &json!({"color": "#00ff00"})).unwrap()["name"].clone(); + let refused = |r: &Result| matches!(r, Err(EngineError::BadParams { msg, .. }) if msg == "`none` must be true or false"); + let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.paint.clone(), history(s)); + let before = state(&s); + let calls = [ + ("appearance.setItem", json!({"index": 0, "ids": [id.0], "none": "true"})), + ("livePaint.fill", json!({"ids": [id.0], "point": [20, 30], "none": 1})), + ("shapeBuilder.merge", json!({"ids": [id.0], "points": [[20, 30]], "fill": {"none": "true"}})), + ("swatch.new", json!({"none": [true]})), + ("swatch.edit", json!({"name": name, "paint": {"none": "false"}})), + ]; + for (cmd, p) in &calls { + let r = s.execute(cmd, p); + assert!(refused(&r), "{cmd} {p}: {r:?}"); + assert_eq!(state(&s), before, "{cmd} {p}"); + } + let r = s.execute("livePaint.fill", &json!({"ids": [id.0], "point": [20, 30], "color": "#ff0000", "none": null})).unwrap(); + let g = NodeId(r["group"].as_u64().unwrap()); + let before = state(&s); + let r = s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [10, 30], "none": "true"})); + assert!(refused(&r), "livePaint.strokeEdge: {r:?}"); + assert_eq!(state(&s), before, "livePaint.strokeEdge"); +} + #[test] fn a_mixed_selection_shows_a_question_mark_proxy() { let mut s = session(); diff --git a/crates/engine/src/tests_panelcmds.rs b/crates/engine/src/tests_panelcmds.rs index 8b451d865..0dcb818d8 100644 --- a/crates/engine/src/tests_panelcmds.rs +++ b/crates/engine/src/tests_panelcmds.rs @@ -1,7 +1,7 @@ //! Panel-support commands (`cmd/{panelcmds,gradient,appearance,stroke,swatch,style}.rs`). use serde_json::json; -use vectorcraft_color::{GradientKind, Paint}; +use vectorcraft_color::{Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, NodeKind}; use super::*; @@ -81,6 +81,31 @@ fn appearance_duplicate_and_move() { assert!(s.execute("appearance.duplicateItem", &json!({"index": 99})).is_err()); } +/// The appearance and effect commands given an item index of 2^32 + k, where k names one of the +/// object's items, leave the object's appearance and the undo history as they were, as for any index +/// past the last item: `appearance.removeItem` removes nothing, and the others fail. A 32-bit build +/// read the index as k. +#[test] +fn appearance_commands_leave_an_item_past_u32_alone() { + let mut s = session(); + let id = rect(&mut s); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let before = node(&s, id).appearance; + let undo = s.doc().unwrap().history.undo.len(); + let past = |k: u64| (1u64 << 32) + k; + for (cmd, p, fails) in [ + ("appearance.removeItem", json!({"index": past(0)}), false), + ("appearance.removeItem", json!({"indices": [past(0)]}), false), + ("appearance.duplicateItem", json!({"index": past(0)}), true), + ("appearance.moveItem", json!({"from": past(1), "to": 0}), true), + ("effect.apply", json!({"effect": "distort.roughen", "item": past(1)}), true), + ] { + assert_eq!(s.execute(cmd, &p).is_err(), fails, "{cmd} {p}"); + assert_eq!(node(&s, id).appearance, before, "{cmd} {p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{cmd} {p}"); + } +} + #[test] fn artboards_reorder_and_duplicate() { let mut s = session(); @@ -94,6 +119,78 @@ fn artboards_reorder_and_duplicate() { assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second"); } +/// An artboard's background color (Properties › Artboards › Fill): `artboard.setProps {color}` sets +/// it and `{color: null}` clears it, each in one undo step. A change without `color` leaves it, a +/// duplicate keeps it, `inspect_document` reports it, `artboard.new {color}` starts with it, an +/// `artboards` list gives each listed artboard its own color in one undo step, and a bad color is +/// refused. +#[test] +fn artboard_color_is_set_and_cleared_in_one_undo_step() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let color = |s: &Session, i: usize| s.doc().unwrap().doc.artboards[i].color; + let blue = Color::from_hex("#336699"); + let black = Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)); + s.execute("artboard.setProps", &json!({"index": 1, "color": "#336699"})).unwrap(); + assert_eq!((color(&s, 0), color(&s, 1)), (None, blue), "only that artboard"); + s.execute("artboard.setProps", &json!({"index": 1, "name": "Dark"})).unwrap(); + assert_eq!(color(&s, 1), blue, "a change without a color leaves it"); + s.execute("artboard.setProps", &json!({"index": 1, "color": {"c": 0, "m": 0, "y": 0, "k": 100}})).unwrap(); + assert_eq!(color(&s, 1), black); + s.execute("artboard.setProps", &json!({"index": 1, "color": null})).unwrap(); + assert_eq!(color(&s, 1), None); + for was in [black, blue, blue, None] { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(color(&s, 1), was); + } + s.execute("edit.redo", &json!({})).unwrap(); + let doc = crate::inspect::document(&s); + assert_eq!(doc["artboards"][1]["color"], "#336699"); + assert!(doc["artboards"][0].get("color").is_none(), "{doc}"); + let copy = s.execute("artboard.duplicate", &json!({"index": 1})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(color(&s, copy), blue, "a duplicate keeps the color"); + let made = s.execute("artboard.new", &json!({"color": "#336699"})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(color(&s, made), blue, "a new artboard takes `color` too"); + let undo = s.doc().unwrap().history.undo.len(); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "color": "#336699"}, {"index": 1, "color": null}]})).unwrap(); + assert_eq!((color(&s, 0), color(&s, 1)), (blue, None), "each listed artboard takes its own color"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "in one undo step"); + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "color": null}, {"index": 1, "color": "nope"}]})).is_err()); + assert!(s.execute("artboard.setProps", &json!({"index": 1, "color": "nope"})).is_err()); + assert!(s.execute("artboard.new", &json!({"color": "nope"})).is_err()); + assert_eq!(*s.doc().unwrap().doc, *before, "a bad color changes nothing"); +} + +/// The native format writes an artboard's background color and reads it back. An artboard without +/// one is written as before, with no `color` key. +#[test] +fn artboard_color_round_trips_through_the_native_format() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "color": {"c": 10, "m": 20, "y": 30, "k": 40}})).unwrap(); + let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false); + let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let keys = |i: usize| saved["document"]["artboards"][i].as_object().unwrap().keys().cloned().collect::>(); + assert_eq!(keys(0), ["id", "name", "rect", "show_center_mark", "show_cross_hairs"]); + assert!(keys(1).contains(&"color".to_string()), "{:?}", keys(1)); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(back.artboards[0].color, None); + assert_eq!(back.artboards[1].color, Some(Color::cmyk(0.1, 0.2, 0.3, 0.4))); + assert_eq!(back.artboards, s.doc().unwrap().doc.artboards); +} + +/// An artboard moved to place 2^32 goes last. A 32-bit build read the place as 0 and moved it to the +/// front. +#[test] +fn artboard_reorder_past_u32_goes_last() { + let mut s = session(); + s.execute("artboard.new", &json!({"name": "Second"})).unwrap(); + s.execute("artboard.new", &json!({"name": "Third"})).unwrap(); + s.execute("artboard.reorder", &json!({"index": 1, "to": 1u64 << 32})).unwrap(); + assert_eq!(s.doc().unwrap().doc.artboards.last().map(|a| a.name.as_str()), Some("Second")); +} + /// The artboards' left edges and names. fn boards(s: &Session) -> Vec<(f64, String)> { s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.name.clone())).collect() @@ -168,6 +265,52 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() { assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied"); } +/// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an +/// option given as text or as another value that is not true or false, such as "true", and +/// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The +/// artboards, the art, the undo history and the clipboard stay as they were; a null reads as the +/// option left out. +#[test] +fn artboard_commands_refuse_a_boolean_or_number_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let mut s = session(); + let r = rect(&mut s); + s.execute("artboard.new", &json!({})).unwrap(); + let mut calls: Vec<_> = [ + ("artboard.setProps", "scaleArt", json!({"index": 0, "width": 400, "scaleArt": "true"})), + ("artboard.setProps", "strokes", json!({"index": 0, "width": 400, "scaleArt": true, "strokes": "false"})), + ("artboard.setProps", "corners", json!({"index": 0, "width": 400, "scaleArt": true, "corners": 1})), + ("artboard.setProps", "patterns", json!({"index": 0, "x": 50, "moveArt": true, "patterns": "true"})), + ("artboard.setProps", "moveArt", json!({"index": 0, "x": 50, "moveArt": "true"})), + ("artboard.setProps", "lockedAndHidden", json!({"index": 0, "x": 50, "moveArt": true, "lockedAndHidden": [true]})), + ("artboard.setProps", "locked", json!({"artboards": [{"index": 1, "locked": "true"}]})), + ("artboard.move", "moveArt", json!({"index": 0, "dx": 10, "moveArt": "true"})), + ("artboard.move", "copy", json!({"index": 0, "dx": 10, "copy": 1})), + ("artboard.move", "patterns", json!({"index": 0, "dx": 10, "moveArt": true, "patterns": "false"})), + ("artboard.duplicate", "patterns", json!({"index": 0, "patterns": "true"})), + ("artboard.duplicate", "art", json!({"index": 0, "art": "false"})), + ("artboard.copy", "art", json!({"index": 0, "art": "true"})), + ("artboard.cut", "art", json!({"index": 1, "art": 0})), + ] + .into_iter() + .map(|(cmd, key, p)| not_a_switch(cmd, p, key)) + .collect(); + for (cmd, key, p) in [ + ("artboard.setProps", "x", json!({"index": 0, "x": "50"})), + ("artboard.setProps", "height", json!({"index": 1, "height": [300]})), + ("artboard.move", "dx", json!({"index": 0, "dx": "10"})), + ("artboard.move", "dy", json!({"index": 0, "dx": 10, "dy": "5"})), + ] { + calls.push(not_a_number(cmd, p, key)); + } + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied"); + let p = json!({"index": 0, "dx": 10, "dy": null, "moveArt": true, "copy": null, "patterns": false}); + s.execute("artboard.move", &p).unwrap(); + assert_eq!(node(&s, r).geometric_bounds().map(|b| b.x0), Some(20.0)); +} + /// Artboards 1 and 2, each with a square on it, and an artboard 3 below artboard 1 with a square on /// it, with the Artboard tool selecting artboards 1 and 2 → the squares' ids, artboard by artboard. fn two_selected_artboards(s: &mut Session) -> [u64; 3] { @@ -209,6 +352,28 @@ fn duplicate_artboards_copies_every_selected_artboard_in_one_step() { } } +/// #2103: with the Artboard tool, an arrow key moves the selected artboards and the art on them by +/// the keyboard increment (Shift: ten times as far) in one undo step each; the selected art +/// doesn't move on its own. +#[test] +fn artboard_tool_arrow_keys_move_the_selected_artboards_with_their_art() { + use vectorcraft_tools::{Mods, ToolKey}; + let mut s = session(); + let [a, b, c] = two_selected_artboards(&mut s); + s.execute("prefs.set", &json!({"key": "keyboardIncrement", "value": 4})).unwrap(); + let v = ViewInfo::default(); + assert!(s.tool_claims_key(ToolKey::Right, v)); + let undo = s.doc().unwrap().history.undo.len(); + s.tool_key(ToolKey::Right, Mods::default(), v).unwrap(); + s.tool_key(ToolKey::Right, Mods { shift: true, ..Mods::default() }, v).unwrap(); + let xs = |s: &Session| boards(s).into_iter().map(|(x, _)| x).collect::>(); + assert_eq!(xs(&s), [44.0, 1044.0, 0.0]); + assert_eq!([left(&s, a), left(&s, b), left(&s, c)], [54.0, 1054.0, 10.0], "the art moves with its artboard"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 2, "one step per press"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((xs(&s), left(&s, a)), (vec![4.0, 1004.0, 0.0], 14.0)); +} + /// With the Artboard tool, Copy takes every selected artboard and the art on each (the public Cut, /// copy, and paste artboards page). Paste adds a copy of each with its art in one undo step, /// together right of the last artboard; Paste in Place puts them where they were. The tool then @@ -363,6 +528,535 @@ fn copied_artboards_take_a_free_id_and_keep_their_own_guides() { ); } +/// `artboard.setProps {locked}` locks an artboard in one undo step and deselects the art on it. +/// Select All, `select.set` and Select › Inverse then pass over that art, a command that takes the +/// selection leaves it where it is, and the document summary reports `locked: true`. Unlocking +/// gives the art back. The art of pattern editing mode is on no artboard. +#[test] +fn a_locked_artboard_protects_the_art_on_it() { + let mut s = session(); + let a = rect(&mut s); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 200, "height": 200})).unwrap(); + let b = NodeId(s.execute("shape.rectangle", &json!({"x": 1010, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()); + let selected = |s: &Session| s.doc().unwrap().selection.objects.clone(); + let locked = |s: &Session| s.doc().unwrap().doc.artboards[1].locked; + s.execute("select.all", &json!({})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + assert_eq!(selected(&s), [a], "locking deselects b"); + let doc = crate::inspect::document(&s); + assert_eq!(doc["artboards"][1]["locked"], true); + assert!(doc["artboards"][0].get("locked").is_none(), "{doc}"); + for (cmd, p) in [("select.all", json!({})), ("select.set", json!({"ids": [a.0, b.0]}))] { + s.execute(cmd, &p).unwrap(); + assert_eq!(selected(&s), [a], "{cmd}"); + } + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.inverse", &json!({})).unwrap(); + assert_eq!(selected(&s), [a]); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + assert_eq!((left(&s, a.0), left(&s, b.0)), (15.0, 1010.0), "b stays"); + // Art drawn on it stays selected until the selection leaves it; art moved onto it is + // deselected by the edit. + let drawn = NodeId(s.execute("shape.rectangle", &json!({"x": 1100, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()); + assert_eq!(selected(&s), [drawn], "drawn on it"); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap(); + assert!(selected(&s).is_empty(), "moved onto it"); + s.execute("select.set", &json!({"ids": [drawn.0]})).unwrap(); + assert!(selected(&s).is_empty(), "drawn on it, then left out of the selection"); + s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(selected(&s), [a, b, drawn]); + for was in [true, true, true, true, false] { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(locked(&s), was); + } + // The art of pattern editing mode is on no artboard: Select All selects it on a locked one. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "P", "edit": false})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + s.execute("object.pattern.edit", &json!({"name": "P"})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let tile = selected(&s); + assert!(!tile.is_empty() && !tile.contains(&a), "{tile:?}"); +} + +/// New art on a locked artboard stays selected and editable until the selection leaves it: a +/// second Pen click goes on the path the first one started, and pasted art moves. Once another +/// object is clicked, a click, a marquee, `select.set` and Select All pass over that art. Undo, in +/// a batch too, and a failed batch bring back the selection the step had, and the new art with +/// it. Isolation mode starts with nothing selected, so the art is protected in it, and leaving it +/// doesn't select that art. Art drawn on an unlocked artboard, or +/// new art that leaves the locked artboard, is deselected when it is moved onto one. Selection +/// changes leave the document as it is, and once one has left the new art out, an unlocked +/// artboard over it moves without it. A member of a new group can be selected, and ungrouping it +/// keeps the members selected. +#[test] +fn new_art_on_a_locked_artboard_stays_editable_until_the_selection_leaves_it() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut s = session(); + let a = rect(&mut s); + s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 400, "height": 400})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + // An unlocked artboard over part of the locked one. + s.execute("artboard.new", &json!({"x": 1100, "y": 200, "width": 200, "height": 200})).unwrap(); + let selected = |s: &Session| s.doc().unwrap().selection.objects.iter().map(|id| id.0).collect::>(); + let v = ViewInfo { zoom: 1.0, smart_guides: false, snap_to_grid: false, snap_to_pixel: false, snap_to_point: false, ..Default::default() }; + let click = |s: &mut Session, x, y| { + for kind in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(kind, x, y), v).unwrap(); + } + }; + // Art drawn on the unlocked artboard and moved onto the locked one isn't new art there. + let off = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(selected(&s), [off]); + s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap(); + assert!(selected(&s).is_empty(), "moved onto it"); + s.select_tool("pen", v).unwrap(); + click(&mut s, 1050.0, 50.0); + click(&mut s, 1150.0, 50.0); + let [path] = selected(&s)[..] else { panic!("one path selected: {:?}", selected(&s)) }; + assert_eq!(node(&s, NodeId(path)).path_data().unwrap().anchor_count(), 2, "the second click goes on the same path"); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("edit.copy", &json!({})).unwrap(); + let pasted = ids(&s.execute("edit.paste", &json!({"center": [1200, 300]})).unwrap()["ids"]); + assert_eq!(selected(&s), pasted); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert_eq!((selected(&s), left(&s, pasted[0])), (pasted.clone(), 1160.0), "pasted art moves"); + // Moved off the locked artboard and back, it is no longer new art. + s.execute("object.move", &json!({"dx": -1000, "dy": 0})).unwrap(); + assert_eq!(selected(&s), pasted); + s.execute("object.move", &json!({"dx": 1000, "dy": 0})).unwrap(); + assert!(selected(&s).is_empty(), "moved off and back"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(selected(&s), pasted, "undone twice"); + s.select_tool("selection", v).unwrap(); + let doc = s.doc().unwrap().doc.clone(); + click(&mut s, 10.0, 30.0); + assert_eq!(selected(&s), [a.0], "a click on other art"); + assert!(Arc::ptr_eq(&doc, &s.doc().unwrap().doc), "a selection change leaves the document as it is"); + let moved = s.execute("artboard.move", &json!({"index": 2, "dx": 0, "dy": 1000, "moveArt": true})).unwrap(); + assert!(!ids(&moved["moved"]).contains(&pasted[0]), "the artboard over it moves without it"); + s.execute("edit.undo", &json!({})).unwrap(); + click(&mut s, 1160.0, 300.0); + assert!(selected(&s).is_empty(), "a click on the pasted art's edge"); + s.pointer(&PointerEvent::new(PointerKind::Down, 1010.0, 10.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 1390.0, 390.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 1390.0, 390.0), v).unwrap(); + assert!(selected(&s).is_empty(), "a marquee"); + s.execute("select.set", &json!({"ids": [path, pasted[0]]})).unwrap(); + assert!(selected(&s).is_empty(), "select.set"); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(selected(&s), [a.0], "Select All"); + // Undoing the move brings back the pasted art selected, as it was before the move. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(selected(&s), pasted); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert_eq!(left(&s, pasted[0]), 1160.0); + let editable = |s: &Session| s.doc().unwrap().doc.is_editable(NodeId(pasted[0])); + // A batch that deselects it and fails brings it back selected and editable, and so does + // undoing a batch that deselects it and draws. + let failed = json!({"commands": [{"command": "select.none"}, {"command": "no.such.command"}]}); + assert!(s.execute("command.batch", &failed).is_err()); + assert_eq!((selected(&s), editable(&s)), (pasted.clone(), true), "after a failed batch"); + let drawn = + json!({"commands": [{"command": "select.none"}, {"command": "shape.rectangle", "params": {"x": 300, "y": 300, "width": 20, "height": 20}}]}); + s.execute("command.batch", &drawn).unwrap(); + assert!(!editable(&s), "deselected by the batch"); + // Undo inside a later batch gives the document the new art of the selection it brings back: + // the unlocked artboard over that art then moves it along. + let up = json!({"command": "artboard.move", "params": {"index": 2, "dx": 0, "dy": 1000, "moveArt": true}}); + let r = s.execute("command.batch", &json!({"commands": [{"command": "edit.undo"}, up.clone()]})).unwrap(); + assert!(ids(&r["results"][1]["moved"]).contains(&pasted[0]), "undone inside a batch: {r}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((selected(&s), editable(&s)), (pasted.clone(), true), "the batch undone"); + // Isolation mode inside a batch protects it before the next step. + let isolated = json!({"commands": [{"command": "object.isolate", "params": {"id": pasted[0]}}, up]}); + let r = s.execute("command.batch", &isolated).unwrap(); + assert!(!ids(&r["results"][1]["moved"]).contains(&pasted[0]), "isolated inside a batch: {r}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.exitIsolation", &json!({})).unwrap(); + // Isolation mode starts with nothing selected, so leaving it doesn't select the art again, + // and the art, protected again, isn't isolated, as locked art isn't. + assert!(selected(&s).is_empty(), "after isolation mode"); + assert!(!editable(&s), "after isolation mode"); + assert!(s.execute("object.isolate", &json!({"id": pasted[0]})).is_err(), "isolating it again"); + // A member of a new group can be selected while the group is new art. + let copy = ids(&s.execute("edit.paste", &json!({"center": [1200, 150]})).unwrap()["ids"]); + s.execute("object.group", &json!({})).unwrap(); + let group = selected(&s); + s.execute("select.set", &json!({"ids": copy})).unwrap(); + assert_eq!(selected(&s), copy, "a member of a new group"); + s.execute("select.set", &json!({"ids": group})).unwrap(); + assert_eq!(selected(&s), group, "from the member back to the group"); + s.execute("object.ungroup", &json!({})).unwrap(); + assert_eq!(selected(&s), copy, "ungrouped"); + s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap(); + assert_eq!(selected(&s), copy, "the ungrouped art moves"); +} + +/// When an artboard moves, scales or is cut with its art, the art inside it that is also on a +/// locked artboard stays where it is, unless prefs moveLockedWithArtboard is on, as locked art +/// does; a copy of the artboard copies that art. +#[test] +fn art_also_on_a_locked_artboard_stays_when_another_artboard_moves() { + let mut s = session(); + // A locked artboard over the right edge of artboard 1, and `held` inside artboard 1 and on it. + s.execute("artboard.new", &json!({"x": 700, "y": 0, "width": 300, "height": 300})).unwrap(); + let free = rect(&mut s); + let held = NodeId(s.execute("shape.rectangle", &json!({"x": 710, "y": 10, "width": 50, "height": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let undo = |s: &mut Session| s.execute("edit.undo", &json!({})).unwrap(); + let moved = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 1000, "moveArt": true})).unwrap(); + assert_eq!(ids(&moved["moved"]), [free.0]); + undo(&mut s); + let scaled = s.execute("artboard.setProps", &json!({"index": 0, "width": 1600, "scaleArt": true})).unwrap(); + assert_eq!(ids(&scaled["scaled"]), [free.0]); + undo(&mut s); + s.execute("artboard.cut", &json!({"index": 0, "art": true})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(held).is_some() && d.node(free).is_none(), "the cut leaves it"); + undo(&mut s); + assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 2, "a copy takes it"); + undo(&mut s); + s.prefs.move_locked_with_artboard = true; + let moved = s.execute("artboard.move", &json!({"index": 0, "dx": 0, "dy": 1000, "moveArt": true})).unwrap(); + assert_eq!(ids(&moved["moved"]), [free.0, held.0], "with the preference on, it moves"); +} + +/// A locked artboard keeps its place and size: `artboard.setProps` refuses a new rectangle but +/// still renames it, `artboard.move`, `artboard.delete`, `artboard.cut` and `artboard.fitToArt` +/// refuse it, and Rearrange leaves it and the art on it where they are. A copy of it starts +/// unlocked, and unlocked it moves again. +#[test] +fn a_locked_artboard_keeps_its_place_and_size() { + let mut s = session(); + // Three artboards in a row, 800 pt wide: at 0, 820 and 1640. + for _ in 0..2 { + s.execute("artboard.new", &json!({})).unwrap(); + } + // On the second and third artboards, its center on the third. + let shape = |s: &mut Session, x: u32, y: u32| { + s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 80, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let c = shape(&mut s, 1610, 10); + // In a sublayer, one like it and one on the third artboard alone. + s.execute("layer.newSublayer", &json!({"name": "Sub"})).unwrap(); + let (c2, d2) = (shape(&mut s, 1610, 100), shape(&mut s, 2000, 100)); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let rects = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::>(); + let was = rects(&s); + let refused = |r: Result| matches!(r, Err(EngineError::Other(m)) if m == "the artboard is locked"); + assert!(refused(s.execute("artboard.setProps", &json!({"index": 1, "width": 300})))); + assert!(refused(s.execute("artboard.move", &json!({"index": 1, "dx": 5, "dy": 0})))); + assert!(refused(s.execute("artboard.delete", &json!({"index": 1})))); + assert!(refused(s.execute("artboard.cut", &json!({"index": 1})))); + assert!(refused(s.execute("artboard.fitToArt", &json!({"index": 1})))); + assert_eq!(rects(&s), was); + s.execute("artboard.setProps", &json!({"index": 1, "name": "Cover", "x": 820, "y": 0, "width": 800, "height": 600})).unwrap(); + assert_eq!(boards(&s)[1], (820.0, "Cover".into()), "Artboard Options renames it"); + // Its slot in the row is at 850; the third artboard's is at 1700. + s.execute("artboard.rearrange", &json!({"layout": "row", "spacing": 50})).unwrap(); + assert_eq!(rects(&s)[1], was[1]); + assert_eq!(rects(&s)[2].x0, 1700.0, "the third artboard moves"); + assert_eq!((left(&s, c), left(&s, c2)), (1610.0, 1610.0), "the art on the locked artboard stays, in a sublayer too"); + assert_eq!(left(&s, d2), 2060.0, "the sublayer's other art moves with the third artboard"); + let copy = s.execute("artboard.duplicate", &json!({"index": 1, "art": false})).unwrap()["index"].as_u64().unwrap() as usize; + assert!(!s.doc().unwrap().doc.artboards[copy].locked, "a copy starts unlocked"); + s.execute("artboard.setProps", &json!({"index": 1, "locked": false, "x": 5})).unwrap(); + assert_eq!(rects(&s)[1].x0, 5.0, "unlocking it lets the same change move it"); +} + +/// A locked artboard keeps its exact rectangle when `artboard.setProps` passes values within +/// 1e-6 pt of it, as Artboard Options does when it renames the artboard: repeated calls don't +/// move or resize it by small steps. +#[test] +fn a_locked_artboard_keeps_its_exact_rectangle() { + let mut s = session(); + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + let rect = |s: &Session| s.doc().unwrap().doc.artboards[0].rect; + let was = rect(&s); + for i in 0..10 { + let r = rect(&s); + let p = + json!({"index": 0, "name": format!("Cover {i}"), "x": r.x0 + 5e-7, "y": r.y0 - 9e-7, "width": r.width() + 4e-7, "height": r.height()}); + s.execute("artboard.setProps", &p).unwrap(); + } + assert_eq!(rect(&s), was); + assert_eq!(boards(&s)[0].1, "Cover 9", "the name still changes"); +} + +/// `artboard.setProps {artboards}` takes `locked` from each entry, and a list that gives a locked +/// artboard a new rectangle is refused and changes no artboard. +#[test] +fn a_list_of_artboards_takes_each_entrys_lock() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "locked": true}, {"index": 1, "name": "Back"}]})).unwrap(); + let locks = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.locked).collect::>(); + let widths = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect.width()).collect::>(); + assert_eq!(locks(&s), [true, false]); + let was = widths(&s); + let r = s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "width": 300}, {"index": 0, "width": 300}]})); + assert!(matches!(r, Err(EngineError::Other(m)) if m == "the artboard is locked")); + assert_eq!(widths(&s), was, "neither artboard changes"); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 1, "width": 300}, {"index": 0, "width": 300, "locked": false}]})).unwrap(); + assert_eq!((locks(&s), widths(&s)), (vec![false, false], vec![300.0, 300.0])); +} + +/// The native format writes an artboard's lock and reads it back. An unlocked artboard is written +/// as before, with no `locked` key: unlocked again, the document saves to the bytes it had before. +#[test] +fn artboard_lock_round_trips_through_the_native_format() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false); + let before = save(&s); + s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap(); + let bytes = save(&s); + let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let keys = saved["document"]["artboards"][0].as_object().unwrap().keys().cloned().collect::>(); + assert_eq!(keys, ["id", "name", "rect", "show_center_mark", "show_cross_hairs"]); + assert_eq!(saved["document"]["artboards"][1]["locked"], true); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert!(back.artboards[1].locked && !back.artboards[0].locked); + assert_eq!(back.artboards, s.doc().unwrap().doc.artboards); + s.execute("artboard.setProps", &json!({"index": 1, "locked": false})).unwrap(); + assert_eq!(save(&s), before); +} + +/// Artboard Options' Show Center Mark and Show Cross Hairs: `artboard.setProps {showCenterMark, +/// showCrossHairs}` sets and clears each on that artboard only, in one undo step, also for a +/// locked artboard and in an `artboards` list. A change without them leaves them, a duplicate +/// keeps them, `artboard.new` takes them, `inspect_document` reports them, and a value that isn't +/// true or false is refused. +#[test] +fn artboard_display_marks_are_set_and_cleared_in_one_undo_step() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let marks = |s: &Session, i: usize| { + let a = &s.doc().unwrap().doc.artboards[i]; + (a.show_center_mark, a.show_cross_hairs) + }; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + let u0 = undo(&s); + s.execute("artboard.setProps", &json!({"index": 1, "showCenterMark": true})).unwrap(); + assert_eq!((marks(&s, 0), marks(&s, 1)), ((false, false), (true, false)), "only that artboard"); + s.execute("artboard.setProps", &json!({"index": 1, "showCrossHairs": true, "showCenterMark": false})).unwrap(); + assert_eq!(marks(&s, 1), (false, true)); + s.execute("artboard.setProps", &json!({"index": 1, "name": "Marked"})).unwrap(); + assert_eq!(marks(&s, 1), (false, true), "a change without them leaves them"); + assert_eq!(undo(&s), u0 + 3, "one undo step each"); + for was in [(false, true), (true, false), (false, false)] { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(marks(&s, 1), was); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(marks(&s, 1), (true, false)); + // A locked artboard keeps its rectangle, not its display marks. + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "showCrossHairs": true})).unwrap(); + assert_eq!(marks(&s, 0), (false, true)); + let u = undo(&s); + s.execute( + "artboard.setProps", + &json!({"artboards": [{"index": 0, "showCenterMark": true}, {"index": 1, "showCenterMark": false, "showCrossHairs": true}]}), + ) + .unwrap(); + assert_eq!((marks(&s, 0), marks(&s, 1)), ((true, true), (false, true)), "each listed artboard takes its own"); + assert_eq!(undo(&s), u + 1, "in one undo step"); + let doc = crate::inspect::document(&s); + assert_eq!((doc["artboards"][0]["showCenterMark"].clone(), doc["artboards"][1]["showCrossHairs"].clone()), (json!(true), json!(true))); + assert!(doc["artboards"][1].get("showCenterMark").is_none(), "{doc}"); + let copy = s.execute("artboard.duplicate", &json!({"index": 1})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(marks(&s, copy), (false, true), "a duplicate keeps them"); + let made = s.execute("artboard.new", &json!({"showCenterMark": true})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(marks(&s, made), (true, false), "a new artboard takes them too"); + let plain = s.execute("artboard.new", &json!({})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(marks(&s, plain), (false, false)); + let before = s.doc().unwrap().doc.clone(); + assert!(s.execute("artboard.setProps", &json!({"index": 1, "showCenterMark": "yes"})).is_err()); + assert!( + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showCenterMark": false}, {"index": 1, "showCrossHairs": 1}]})).is_err() + ); + assert!(s.execute("artboard.new", &json!({"showCrossHairs": "yes"})).is_err()); + assert_eq!(*s.doc().unwrap().doc, *before, "a bad value changes nothing"); +} + +/// The native format keeps Show Center Mark and Show Cross Hairs. A document that never shows them +/// is written byte for byte as before they could be set, and a file written without the two keys +/// opens with both off. +#[test] +fn artboard_display_marks_round_trip_through_the_native_format() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false); + let before = save(&s); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showCenterMark": true}, {"index": 1, "showCrossHairs": true}]})).unwrap(); + let bytes = save(&s); + let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let ab = |i: usize| saved["document"]["artboards"][i].clone(); + assert_eq!((ab(0)["show_center_mark"].clone(), ab(0)["show_cross_hairs"].clone()), (json!(true), json!(false))); + assert_eq!((ab(1)["show_center_mark"].clone(), ab(1)["show_cross_hairs"].clone()), (json!(false), json!(true))); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(back.artboards, s.doc().unwrap().doc.artboards); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showCenterMark": false}, {"index": 1, "showCrossHairs": false}]})).unwrap(); + assert_eq!(save(&s), before, "off again, the file is as it was"); + // A file whose artboards have no such keys (older writers) opens with both off. + let mut old: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + for a in old["document"]["artboards"].as_array_mut().unwrap() { + let a = a.as_object_mut().unwrap(); + a.remove("show_center_mark"); + a.remove("show_cross_hairs"); + } + let back = vectorcraft_format::load(&serde_json::to_vec(&old).unwrap()).unwrap(); + assert!(back.artboards.iter().all(|a| !a.show_center_mark && !a.show_cross_hairs)); +} + +/// Show Center Mark, Show Cross Hairs, Show Video Safe Areas and the Video Ruler Pixel Aspect Ratio +/// are screen-only: SVG and PDF exports (and print, which prints the PDF) come out the same with +/// them on as with them off. +#[test] +fn artboard_display_marks_stay_out_of_exports() { + let mut s = session(); + rect(&mut s); + let doc = |s: &Session| (*s.doc().unwrap().doc).clone(); + let off = doc(&s); + s.execute( + "artboard.setProps", + &json!({"index": 0, "showCenterMark": true, "showCrossHairs": true, "showVideoSafeAreas": true, "videoRulerPixelAspectRatio": 2}), + ) + .unwrap(); + let on = doc(&s); + assert_ne!(on.artboards, off.artboards); + assert_eq!(vectorcraft_svg::export(&on, &Default::default()), vectorcraft_svg::export(&off, &Default::default())); + assert_eq!(crate::export_pdf(&on, &Default::default()).unwrap(), crate::export_pdf(&off, &Default::default()).unwrap()); +} + +/// Artboard Options' video display options: `artboard.setProps {showVideoSafeAreas, +/// videoRulerPixelAspectRatio}` sets each on that artboard only, in one undo step, also for a +/// locked artboard and in an `artboards` list. A change without them leaves them, a duplicate +/// keeps them, `artboard.new` takes them, `inspect_document` reports them, and a value that isn't +/// true or false, or a ratio outside 0.1–10, is refused and changes nothing. +#[test] +fn artboard_video_display_options_are_set_in_one_undo_step() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let video = |s: &Session, i: usize| { + let a = &s.doc().unwrap().doc.artboards[i]; + (a.show_video_safe_areas, a.video_ruler_pixel_aspect_ratio) + }; + let undo = |s: &Session| s.doc().unwrap().history.undo.len(); + assert_eq!((video(&s, 0), video(&s, 1)), ((false, 1.0), (false, 1.0)), "off and square by default"); + let u0 = undo(&s); + s.execute("artboard.setProps", &json!({"index": 1, "showVideoSafeAreas": true})).unwrap(); + assert_eq!((video(&s, 0), video(&s, 1)), ((false, 1.0), (true, 1.0)), "only that artboard"); + s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 0.9})).unwrap(); + assert_eq!(video(&s, 1), (true, 0.9)); + s.execute("artboard.setProps", &json!({"index": 1, "name": "Video"})).unwrap(); + assert_eq!(video(&s, 1), (true, 0.9), "a change without them leaves them"); + assert_eq!(undo(&s), u0 + 3, "one undo step each"); + for was in [(true, 0.9), (true, 1.0), (false, 1.0)] { + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(video(&s, 1), was); + } + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(video(&s, 1), (true, 1.0)); + // A locked artboard keeps its rectangle, not its video display options. + s.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "videoRulerPixelAspectRatio": 2})).unwrap(); + assert_eq!(video(&s, 0), (false, 2.0)); + let u = undo(&s); + s.execute( + "artboard.setProps", + &json!({"artboards": [{"index": 0, "showVideoSafeAreas": true}, {"index": 1, "showVideoSafeAreas": false, "videoRulerPixelAspectRatio": 1.2}]}), + ) + .unwrap(); + assert_eq!((video(&s, 0), video(&s, 1)), ((true, 2.0), (false, 1.2)), "each listed artboard takes its own"); + assert_eq!(undo(&s), u + 1, "in one undo step"); + let doc = crate::inspect::document(&s); + assert_eq!(doc["artboards"][0]["showVideoSafeAreas"], json!(true)); + assert_eq!( + (doc["artboards"][0]["videoRulerPixelAspectRatio"].clone(), doc["artboards"][1]["videoRulerPixelAspectRatio"].clone()), + (json!(2.0), json!(1.2)) + ); + assert!(doc["artboards"][1].get("showVideoSafeAreas").is_none(), "{doc}"); + s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 1})).unwrap(); + assert!(crate::inspect::document(&s)["artboards"][1].get("videoRulerPixelAspectRatio").is_none(), "square pixels aren't reported"); + let copy = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap()["index"].as_u64().unwrap() as usize; + assert_eq!(video(&s, copy), (true, 2.0), "a duplicate keeps them"); + let made = s.execute("artboard.new", &json!({"showVideoSafeAreas": true, "videoRulerPixelAspectRatio": 0.1})).unwrap()["index"].as_u64().unwrap() + as usize; + assert_eq!(video(&s, made), (true, 0.1), "a new artboard takes them too"); + let before = s.doc().unwrap().doc.clone(); + for bad in [json!("yes"), json!(1)] { + assert!(s.execute("artboard.setProps", &json!({"index": 1, "showVideoSafeAreas": bad})).is_err()); + assert!(s.execute("artboard.new", &json!({"showVideoSafeAreas": bad})).is_err()); + } + for bad in [json!(0.09), json!(10.5), json!(0), json!(-1), json!("2"), json!(null)] { + assert!(s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": bad})).is_err(), "{bad}"); + assert!(s.execute("artboard.new", &json!({"videoRulerPixelAspectRatio": bad})).is_err(), "{bad}"); + } + assert!( + s.execute( + "artboard.setProps", + &json!({"artboards": [{"index": 0, "showVideoSafeAreas": false}, {"index": 1, "videoRulerPixelAspectRatio": 11}]}) + ) + .is_err() + ); + assert_eq!(*s.doc().unwrap().doc, *before, "a bad value changes nothing"); + s.execute("artboard.setProps", &json!({"index": 1, "videoRulerPixelAspectRatio": 10})).unwrap(); + assert_eq!(video(&s, 1), (false, 10.0), "10 is in range"); +} + +/// The native format keeps Show Video Safe Areas and the Video Ruler Pixel Aspect Ratio. A +/// document whose artboards hide the safe areas and have square pixels is written byte for byte as +/// before they could be set, a file written without the two keys opens with the safe areas off +/// and square pixels, and a file's ratio outside 0.1–10 opens brought into that range. +#[test] +fn artboard_video_display_options_round_trip_through_the_native_format() { + let mut s = session(); + s.execute("artboard.new", &json!({})).unwrap(); + let save = |s: &Session| vectorcraft_format::save(&s.doc().unwrap().doc, false); + let before = save(&s); + s.execute( + "artboard.setProps", + &json!({"artboards": [{"index": 0, "showVideoSafeAreas": true}, {"index": 1, "videoRulerPixelAspectRatio": 0.9}]}), + ) + .unwrap(); + let bytes = save(&s); + let saved: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + let ab = |i: usize| saved["document"]["artboards"][i].clone(); + assert_eq!(ab(0)["show_video_safe_areas"], json!(true)); + assert!(ab(0).get("video_ruler_pixel_aspect_ratio").is_none(), "square pixels aren't written: {}", ab(0)); + assert_eq!(ab(1)["video_ruler_pixel_aspect_ratio"], json!(0.9)); + assert!(ab(1).get("show_video_safe_areas").is_none(), "hidden safe areas aren't written: {}", ab(1)); + let back = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(back.artboards, s.doc().unwrap().doc.artboards); + assert_eq!((back.artboards[0].show_video_safe_areas, back.artboards[1].video_ruler_pixel_aspect_ratio), (true, 0.9)); + s.execute("artboard.setProps", &json!({"artboards": [{"index": 0, "showVideoSafeAreas": false}, {"index": 1, "videoRulerPixelAspectRatio": 1}]})) + .unwrap(); + assert_eq!(save(&s), before, "off and square again, the file is as it was"); + // A file whose artboards have no such keys (older writers) opens with them off and square. + let mut old: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + for a in old["document"]["artboards"].as_array_mut().unwrap() { + let a = a.as_object_mut().unwrap(); + a.remove("show_video_safe_areas"); + a.remove("video_ruler_pixel_aspect_ratio"); + } + let back = vectorcraft_format::load(&serde_json::to_vec(&old).unwrap()).unwrap(); + assert!(back.artboards.iter().all(|a| !a.show_video_safe_areas && a.video_ruler_pixel_aspect_ratio == 1.0)); + for (written, opens) in [(json!(0), 0.1), (json!(-3), 0.1), (json!(1e9), 10.0), (json!(2.5), 2.5)] { + let mut file: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + file["document"]["artboards"][1]["video_ruler_pixel_aspect_ratio"] = written.clone(); + let back = vectorcraft_format::load(&serde_json::to_vec(&file).unwrap()).unwrap(); + assert_eq!(back.artboards[1].video_ruler_pixel_aspect_ratio, opens, "{written}"); + } +} + #[test] fn swatch_groups_duplicate_sort() { let mut s = session(); diff --git a/crates/engine/src/tests_params.rs b/crates/engine/src/tests_params.rs index dd3664dbf..a1f8e00cc 100644 --- a/crates/engine/src/tests_params.rs +++ b/crates/engine/src/tests_params.rs @@ -56,7 +56,7 @@ fn an_unknown_key_is_named_with_the_nearest_accepted_one() { assert_eq!(refusal("select.all", json!({"zzz": 1})), "unknown parameter `zzz`; the command takes no parameters"); assert_eq!( refusal("shape.line", json!({"x1": 0, "y1": 0, "x2": 5, "y2": 5, "colour": "red"})), - "unknown parameter `colour`; the command takes: x1, y1, x2, y2" + "unknown parameter `colour`; the command takes: x1, y1, x2, y2, fill" ); for (id, ok) in [ ("file.new", json!({"width": 300, "title": "a", "artboardLayout": {"anything": 1}})), @@ -139,6 +139,46 @@ fn effect_params_are_the_effects_own() { assert!(s.check_params("effect.setParams", &json!({"index": 7, "params": {"blurr": 3}})).is_ok()); } +/// The Transform effect (`distort.transform`) is held to its params description: `effect.apply` +/// and `effect.setParams` refuse each of its on/off options given as text, such as `reflectX: +/// "false"`, and each of its numbers given as text, such as `copies: "0"`, and leave the effect +/// as it was; true, false and numbers pass. The loop takes the keys from the effect's defaults, so +/// an option the effect gains is checked too. +#[test] +fn transform_effect_refuses_a_boolean_or_number_given_as_text() { + let info = vectorcraft_render::effects::effect_info("distort.transform").unwrap(); + let one = |key: &str, v: serde_json::Value| serde_json::Value::Object([(key.to_string(), v)].into_iter().collect()); + let apply = |params| json!({"effect": "distort.transform", "params": params}); + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + s.execute("effect.apply", &apply(json!({"moveH": 20}))).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.history.undo.len(), st.doc.layers[0].children().unwrap()[0].appearance.effects[0].params.clone()) + }; + let before = state(&s); + let mut switches = vec![]; + for (key, default) in info.defaults.as_object().unwrap() { + let why = match default { + serde_json::Value::Bool(_) => format!("`{key}` must be true or false"), + serde_json::Value::Number(_) => format!("`{key}` must be a number"), + _ => continue, + }; + if default.is_boolean() { + switches.push(key.as_str()); + } + let text = json!(default.to_string()); + assert_eq!(refusal("effect.apply", apply(one(key, text.clone()))), why); + let e = s.execute("effect.setParams", &json!({"index": 0, "params": one(key, text)})).unwrap_err().to_string(); + assert!(e.contains(&why), "{e}"); + assert!(check_params("effect.apply", &apply(one(key, default.clone()))).is_ok(), "{key}"); + } + assert!(["reflectX", "reflectY", "random"].iter().all(|k| switches.contains(k)), "{switches:?}"); + assert_eq!(state(&s), before); + s.execute("effect.setParams", &json!({"index": 0, "params": {"reflectX": true, "random": false, "copies": 2}})).unwrap(); +} + /// What a command notes for its journal entry is a key its description names, so the entry /// passes the check a caller's params get. #[test] diff --git a/crates/engine/src/tests_pathops.rs b/crates/engine/src/tests_pathops.rs index 384fa6593..1cda89bad 100644 --- a/crates/engine/src/tests_pathops.rs +++ b/crates/engine/src/tests_pathops.rs @@ -205,6 +205,44 @@ fn a_group_selected_alone_is_combined_member_by_member() { assert_eq!((bounds.x0, bounds.x1), (0.0, 10.0), "the back member less the front one"); } +/// #2178: Unite on separated shapes is one compound path; Release releases each subpath (Illustrator parity). +#[test] +fn pathfinder_unite_non_overlapping_shapes_become_compound_path_releasable() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let b = rect(&mut s, 30.0, 0.0, 20.0, 20.0); + let layer = s.doc().unwrap().doc.parent_of(a).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + s.execute("object.pathfinder.unite", &json!({})).unwrap(); + let result = s.doc().unwrap().selection.objects[0]; + let united = node(&s, result); + assert!(matches!(united.kind, NodeKind::Compound { .. })); + assert_eq!(united.children().unwrap().len(), 2); + s.execute("object.compoundPath.release", &json!({})).unwrap(); + let d = &s.doc().unwrap().doc; + assert!(d.node(result).is_none()); + assert_eq!(s.doc().unwrap().selection.len(), 2); + for id in &s.doc().unwrap().selection.objects { + assert_eq!(d.parent_of(*id), Some(layer)); + } +} + +/// #2178: Unite keeps disjoint geometry in one compound path (not a group of separate path objects). +#[test] +fn pathfinder_unite_disjoint_islands_stay_one_compound_path() { + let mut s = session(); + let outer = rect(&mut s, 0.0, 0.0, 40.0, 40.0); + let hole = rect(&mut s, 10.0, 10.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [outer.0, hole.0]})).unwrap(); + let ring = NodeId(s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + let separate = rect(&mut s, 60.0, 0.0, 20.0, 20.0); + s.execute("select.set", &json!({"ids": [ring.0, separate.0]})).unwrap(); + s.execute("object.pathfinder.unite", &json!({})).unwrap(); + let result = s.doc().unwrap().selection.objects[0]; + assert!(matches!(node(&s, result).kind, NodeKind::Compound { .. })); + assert_eq!(path_of(&s, result).0.subpaths.len(), 3); +} + #[test] fn unite_with_group() { let mut s = session(); @@ -483,6 +521,8 @@ fn simplify_level_moves_the_anchor_count_monotonically() { fn auto_simplify_keeps_the_shape_within_a_400th_of_its_size() { let (mut s, id) = dense_wave(100, 15.0, 8.0, 0.2); let original = path_of(&s, id).0.clone(); + // The level the Simplify slider opens at for this selection is the one auto uses. + assert_eq!(crate::auto_simplify_level(&s).unwrap(), 70.0); let out = s.execute("object.path.simplify", &json!({"auto": true})).unwrap(); // 100 pt across: 0.25 pt at most, level 70 (0.25 pt). assert_eq!(out["level"], json!(70.0)); @@ -812,6 +852,59 @@ fn create_outlines_matches_text_bounds() { assert!(s.doc().unwrap().doc.node(t).is_some()); } +/// Colour bitmap glyphs (emoji) become embedded images where they sat; glyphs showing one image +/// share its pixels. +#[test] +fn create_outlines_turns_bitmap_glyphs_into_images() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut s = session(); + let r = s + .execute( + "text.create", + &json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F{COLOUR_CHAR}"), "size": 32, "font": colour_bitmap_family(false)}), + ) + .unwrap(); + let t = NodeId(r["id"].as_u64().unwrap()); + let tb = bounds(&s, t); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let g = node(&s, r[0]); + let ch = g.children().unwrap(); + let images: Vec<_> = ch.iter().filter_map(|c| if let NodeKind::Image(im) = &c.kind { Some(im) } else { None }).collect(); + assert_eq!(images.len(), 2, "{:?}", ch.iter().map(|c| c.kind_label()).collect::>()); + assert!(matches!(ch[1].kind, NodeKind::Path { .. } | NodeKind::Compound { .. }), "the outline between them"); + assert_eq!(images[0].key, images[1].key, "one image"); + let d = &s.doc().unwrap().doc; + assert!(d.images.contains_key(&images[0].key)); + // The first one em (32 pt) square, from 2 px (4 pt) right of the origin and 12 px (24 pt) + // above the baseline; inside the type's bounds. + let b = ch[0].geometric_bounds().unwrap(); + assert!((b.x0 - 14.0).abs() < 1e-6 && (b.y0 - 26.0).abs() < 1e-6 && (b.width() - 32.0).abs() < 1e-6, "{b:?}"); + assert!(b.x0 >= tb.x0 && b.y0 >= tb.y0 - 1e-6 && b.y1 <= tb.y1 + 1e-6, "{b:?} in {tb:?}"); +} + +/// A glyph image whose key another image of the document holds gets a key of its own. +#[test] +fn create_outlines_keeps_an_image_already_under_a_glyph_images_key() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font, solid_png}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut s = session(); + let glyph = vectorcraft_doc::ImageBlob::png(solid_png(COLOUR_PPEM.into(), COLOUR_LARGE)); + let taken = glyph.content_key(); + let other = vectorcraft_doc::ImageBlob::png(solid_png(4, [0, 255, 0, 255])); + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).images.insert(taken.clone(), other.clone()); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": COLOUR_CHAR.to_string(), "size": 32, "font": colour_bitmap_family(false)})).unwrap(); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let g = node(&s, r[0]); + let Some(NodeKind::Image(im)) = g.children().unwrap().first().map(|c| &c.kind) else { panic!("an image") }; + let d = &s.doc().unwrap().doc; + assert_ne!(im.key, taken); + assert_eq!(d.images[&im.key].bytes, glyph.bytes, "the glyph's own pixels"); + assert_eq!(d.images[&taken].bytes, other.bytes, "the other image is kept"); +} + #[test] fn set_style_size_changes_bounds() { let mut s = session(); @@ -982,3 +1075,26 @@ fn offset_path_that_leaves_nothing_says_so() { let e = s.execute("object.path.offsetPath", &json!({"offset": -20})).unwrap_err().to_string(); assert!(e.contains("leaves nothing") && !e.contains("select paths"), "{e}"); } + +/// COLR glyphs become groups of their layers: real paths with their colours and gradients, the +/// composite's blend mode kept, every node with an id of the document's. +#[test] +fn create_outlines_turns_colr_glyphs_into_layer_groups() { + let _fonts = crate::adding_fonts(); + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font}; + vectorcraft_text::FontDb::global().add_font(colr_font(true).unwrap()); + let mut s = session(); + s.execute("text.create", &json!({"x": 10, "y": 50, "text": format!("{COLOUR_CHAR}F"), "size": 32, "font": colr_family(true)})).unwrap(); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + let g = node(&s, r[0]); + let ch = g.children().unwrap(); + assert!(matches!(ch[0].kind, NodeKind::Group { .. }), "the glyph's group: {:?}", ch[0].kind_label()); + assert!(matches!(ch[1].kind, NodeKind::Path { .. } | NodeKind::Compound { .. }), "the `F` an outline"); + let mut nodes = vec![]; + ch[0].walk(&mut |n| nodes.push((n.id, n.blend, n.appearance.fill_paint()))); + assert!(nodes.iter().any(|(_, _, p)| matches!(p, vectorcraft_color::Paint::Gradient(_))), "its gradients"); + assert!(nodes.iter().any(|(_, b, _)| *b == vectorcraft_color::BlendMode::Multiply), "its multiply"); + let d = &s.doc().unwrap().doc; + let mut seen = std::collections::HashSet::new(); + assert!(nodes.iter().all(|(id, ..)| d.node(*id).is_some() && seen.insert(*id)), "ids of the document's, each once"); +} diff --git a/crates/engine/src/tests_pattern.rs b/crates/engine/src/tests_pattern.rs index 1d33e9d56..787c6ceb2 100644 --- a/crates/engine/src/tests_pattern.rs +++ b/crates/engine/src/tests_pattern.rs @@ -419,15 +419,63 @@ fn svg_export_writes_pattern_def() { assert!(svg[start..end].contains(" + + + + + + + +"##; + let d = vectorcraft_svg::import(svg).unwrap(); + let rep = vectorcraft_pdf::export_with_report(&d, &Default::default()).unwrap(); assert!(rep.warnings.iter().all(|w| !w.contains("pattern")), "{:?}", rep.warnings); - assert!(rep.bytes.len() > 500); + assert!(rep.bytes.len() < 8_000, "{} bytes", rep.bytes.len()); + let plain = vectorcraft_pdf::export(&d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); + assert!(String::from_utf8_lossy(&plain).contains("/PatternType 1")); + let back = vectorcraft_pdf::import(&rep.bytes).unwrap(); + assert_eq!(back.patterns.len(), 1); + assert_eq!(back.patterns[0].art.len(), 2, "the hatch's two stripes"); + let mut leaves = vec![]; + back.walk(|n| { + if !n.is_container() { + leaves.push(n.appearance.fill_paint()); + } + }); + assert!(leaves.len() <= 2, "{leaves:?}"); + assert!(leaves.iter().any(|p| matches!(p, Paint::Pattern { pattern, .. } if *pattern == back.patterns[0].name)), "{leaves:?}"); +} + +/// A pattern fill nested more than four patterns deep is written as its tiles, and the export says so. +#[test] +fn pdf_export_writes_patterns_nested_too_deep_as_tiles_with_a_warning() { + use std::sync::Arc; + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_doc::{Appearance, Document}; + let square = |paint: Paint| { + Node::path(NodeId(0), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(paint, Paint::None, 0.0)) + }; + let mut d = Document::new(100.0, 100.0); + d.patterns.push(PatternDef::new("P0", vec![Arc::new(square(Paint::solid(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0))))])); + for i in 1..=4 { + d.patterns.push(PatternDef::new(&format!("P{i}"), vec![Arc::new(square(pattern_paint(&format!("P{}", i - 1))))])); + } + // P3 nests four tiling patterns (P3 to P0); P4 one more, which is written as its tiles. + for (top, tiles) in [("P3", false), ("P4", true)] { + let mut d = d.clone(); + let mut n = square(pattern_paint(top)); + n.id = d.alloc_id(); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let rep = vectorcraft_pdf::export_with_report(&d, &Default::default()).unwrap(); + assert_eq!(rep.warnings.iter().any(|w| w.contains("are written as their tiles")), tiles, "{top}: {:?}", rep.warnings); + } } #[test] @@ -504,7 +552,7 @@ fn radial_repeat_wraps_selection() { let RepeatKind::Radial { instances, radius, center, .. } = spec.kind else { panic!() }; assert_eq!((instances, radius), (8, 50.0)); assert!(close(center, Point::new(200.0, 150.0))); - assert_eq!(spec.transforms().len(), 8); + assert_eq!(spec.transforms(None).len(), 8); assert_eq!(n.kind_label(), "Radial Repeat"); } @@ -544,7 +592,7 @@ fn radial_instance_transforms_rotate_around_center() { sel(&mut s, &[r]); let v = s.execute("object.repeat.radial", &json!({"instances": 4, "radius": 50})).unwrap(); let NodeKind::Repeat(spec) = node(&s, id_of(&v)).kind else { panic!() }; - let t = spec.transforms(); + let t = spec.transforms(None); let c = Point::new(200.0, 100.0); assert!(close(t[0] * c, Point::new(200.0, 100.0))); assert!(close(t[1] * c, Point::new(250.0, 150.0))); @@ -583,7 +631,7 @@ fn object_expand_expands_repeats() { sel(&mut s, &[r]); let id = id_of(&s.execute(cmd, &opts).unwrap()); let NodeKind::Repeat(spec) = node(&s, id).kind else { panic!("{cmd}") }; - let n = spec.expand().len(); + let n = spec.expand(None).len(); assert_eq!(s.execute("object.expand.info", &json!({})).unwrap()["object"], json!(true), "{cmd}"); let undo = s.doc().unwrap().history.undo.len(); s.execute("object.expand", &json!({"fill": false, "stroke": false})).unwrap(); @@ -632,7 +680,7 @@ fn grid_repeat_and_options() { let RepeatKind::Grid { rows, cols, flip_cols, grid_type, .. } = spec.kind else { panic!() }; assert_eq!((rows, cols, flip_cols), (2, 4, true)); assert_eq!(grid_type, TileType::BrickByRow { offset: 0.5 }); - assert_eq!(spec.expand().len(), 8); + assert_eq!(spec.expand(None).len(), 8); } #[test] @@ -788,7 +836,7 @@ fn mirror_axis_drag_previews_once_and_undo_redo_preserve_source() { let after = node(&s, id); let (NodeKind::Repeat(a), NodeKind::Repeat(b)) = (&before.kind, &after.kind) else { panic!() }; assert_eq!(a.source, b.source, "moving the mirror axis leaves the source artwork in place"); - assert_ne!(a.transforms(), b.transforms(), "the reflected copy updates live"); + assert_ne!(a.transforms(None), b.transforms(None), "the reflected copy updates live"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(node(&s, id), before); s.execute("edit.redo", &json!({})).unwrap(); diff --git a/crates/engine/src/tests_pdffidelity.rs b/crates/engine/src/tests_pdffidelity.rs index 21acfe4a7..35fe314a2 100644 --- a/crates/engine/src/tests_pdffidelity.rs +++ b/crates/engine/src/tests_pdffidelity.rs @@ -1,5 +1,5 @@ -//! PDF vector fidelity: pattern strokes are written as their tiles clipped to the stroke's -//! outline, freeform gradients as images of their colour field clipped to what they paint, and +//! PDF vector fidelity: pattern fills and strokes are written as tiling patterns (strokes clipped +//! to the stroke's outline), freeform gradients as images of their colour field clipped to what they paint, and //! both read back looking like the canvas (within 2% at 150 ppi), with nothing to report. use serde_json::{Value, json}; @@ -55,7 +55,7 @@ fn assert_faithful(what: &str, s: &Session, image: bool, rough: &Document) { } #[test] -fn pattern_strokes_are_tiles_clipped_to_the_stroke() { +fn pattern_strokes_are_tiling_patterns_clipped_to_the_stroke() { let mut s = Session::new(); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 6, "height": 6}))["id"].clone(); @@ -73,6 +73,23 @@ fn pattern_strokes_are_tiles_clipped_to_the_stroke() { assert_faithful("pattern stroke", &s, false, &repainted(&s, &frame, None, Some(grey))); } +/// A brick tiling whose art reaches over the next tiles repeats as one period of the tiling (#3849). +#[test] +fn brick_tilings_with_overlapping_tiles_are_tiling_patterns() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let dot = run(&mut s, "shape.ellipse", json!({"x": 0, "y": 0, "width": 12, "height": 12}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [dot]})); + run(&mut s, "paint.setFill", json!({"color": "#d02020"})); + run(&mut s, "object.pattern.make", json!({"name": "Bricks", "tileType": "brickByRow", "width": 10, "height": 10})); + run(&mut s, "object.pattern.done", json!({})); + let frame = run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 140, "height": 120}))["id"].clone(); + run(&mut s, "select.set", json!({"ids": [frame]})); + run(&mut s, "paint.setFill", json!({"swatch": "Bricks"})); + let grey = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + assert_faithful("brick fill", &s, false, &repainted(&s, &frame, Some(grey), None)); +} + #[test] fn freeform_gradients_are_images_of_their_colour_field() { let mut s = Session::new(); diff --git a/crates/engine/src/tests_pdfraster.rs b/crates/engine/src/tests_pdfraster.rs index a3de3debf..896db4eef 100644 --- a/crates/engine/src/tests_pdfraster.rs +++ b/crates/engine/src/tests_pdfraster.rs @@ -103,7 +103,15 @@ fn groups_layers_type_images_and_symbols_keep_their_raster_effects() { let xf = Affine::translate((40.0, 50.0)) * Affine::scale(2.5); let im = Node::new( d.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key: "pic".into(), width: 40, height: 30, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key: "pic".into(), + width: 40, + height: 30, + xf, + link: None, + placement: Default::default(), + pixels: None, + })), ); add(&mut d, with(im, shadow())); assert_written("image", &d); diff --git a/crates/engine/src/tests_persp_text.rs b/crates/engine/src/tests_persp_text.rs index 88c9ac1fe..846306fc2 100644 --- a/crates/engine/src/tests_persp_text.rs +++ b/crates/engine/src/tests_persp_text.rs @@ -26,7 +26,7 @@ fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node { /// The art the renderer and exporters draw for `id` (its outlines, projected). fn drawn_bounds(s: &Session, id: NodeId) -> Rect { let n = node(s, id); - vectorcraft_render::effects::reshape(&n, None).unwrap().geometric_bounds().unwrap() + vectorcraft_render::effects::reshape(None, &n, None).unwrap().geometric_bounds().unwrap() } #[test] @@ -39,7 +39,7 @@ fn type_in_perspective_is_projected_and_stays_type() { let h = n.projection().expect("drawn through a projection"); // Really foreshortened: the right plane recedes to the right, so the letters' far end is // shorter than their near end. - let out = vectorcraft_render::effects::reshape(&n, None).unwrap(); + let out = vectorcraft_render::effects::reshape(None, &n, None).unwrap(); let mut pts = vec![]; out.walk(&mut |c| { if let Some(p) = c.path_data() { diff --git a/crates/engine/src/tests_perspgrid.rs b/crates/engine/src/tests_perspgrid.rs index 21397ccbd..69c9c9556 100644 --- a/crates/engine/src/tests_perspgrid.rs +++ b/crates/engine/src/tests_perspgrid.rs @@ -28,6 +28,18 @@ fn near(a: f64, b: f64) -> bool { (a - b).abs() < 1e-6 } +/// `cmd` with `p` fails with the error for `key`, which `p` gives as something other than true or false. +fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) { + let r = s.execute(cmd, p); + let want = format!("`{key}` must be true or false"); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); +} + +/// Values for an on/off param that are not true, false or null. +fn not_booleans() -> [Value; 4] { + [json!("true"), json!(1), json!("false"), json!([true])] +} + // ---------- M8.8: Define Grid ---------- #[test] @@ -209,6 +221,24 @@ fn presets_export_import_and_live_in_the_preferences() { assert!(serde_json::from_value::(json!({})).unwrap().perspective_presets.is_empty()); } +/// Import Perspective Grid Presets refuses a `replace` given as text or as another value that is not +/// true or false: the saved presets stay as they were. Null is read as the key left out, so the +/// imported presets whose names are taken get a number. +#[test] +fn presets_import_refuses_replace_given_as_text() { + let mut s = session(); + run(&mut s, "perspective.presets.save", json!({"name": "Street", "gridline": 1})); + let text = run(&mut s, "perspective.presets.export", json!({}))["data"].as_str().unwrap().to_string(); + let before = s.prefs.perspective_presets.clone(); + for v in not_booleans() { + refuses_non_boolean(&mut s, "perspective.presets.import", &json!({"data": text, "replace": v}), "replace"); + assert_eq!(s.prefs.perspective_presets, before, "{v}"); + } + assert_eq!(run(&mut s, "perspective.presets.import", json!({"data": text, "replace": null}))["imported"], json!(["Street 2"])); + assert_eq!(run(&mut s, "perspective.presets.import", json!({"data": text, "replace": true}))["imported"], json!(["Street"])); + assert_eq!(s.prefs.perspective_presets.len(), 2); +} + // ---------- M8.10: view options ---------- use vectorcraft_geom::Point; @@ -246,6 +276,33 @@ fn view_options_toggle_without_undo_steps_and_survive_presets() { assert_eq!(PerspectiveGrid::from_doc(&back), Some(g)); } +/// Lock Grid, Lock Station Point, Snap to Grid and Show Rulers refuse an `on` given as text or as +/// another value that is not true or false: the grid and the undo history stay as they were. Null is +/// read as the key left out, so it toggles the option. +#[test] +fn view_options_refuse_on_given_as_text() { + let mut s = session(); + run(&mut s, "perspective.grid.preset", json!({"kind": 2})); + run(&mut s, "perspective.grid.lock", json!({"on": true})); + let before = (grid(&s), undo_len(&s)); + for id in ["perspective.grid.lock", "perspective.grid.lockStation", "perspective.grid.snap", "perspective.grid.rulers"] { + for v in not_booleans() { + refuses_non_boolean(&mut s, id, &json!({"on": v}), "on"); + assert_eq!((grid(&s), undo_len(&s)), before, "{id} {v}"); + } + } + for (id, on) in [ + ("perspective.grid.lock", false), + ("perspective.grid.lockStation", true), + ("perspective.grid.snap", false), + ("perspective.grid.rulers", true), + ] { + assert_eq!(run(&mut s, id, json!({"on": null})), json!({"on": on}), "{id} toggles"); + assert_eq!(run(&mut s, id, json!({"on": on})), json!({"on": on}), "{id} is set"); + } + assert_eq!(undo_len(&s), before.1); +} + #[test] fn lock_grid_keeps_the_widgets_still_but_planes_switch() { let mut s = session(); diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests_place.rs index a41452867..bb7ac1999 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests_place.rs @@ -221,6 +221,194 @@ fn replace_needs_exactly_one_selected_object() { assert!(r.unwrap_err().to_string().contains("select the one object")); } +/// `p` with the members of `extra` added. +fn with(mut p: Value, extra: Value) -> Value { + if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) { + o.extend(e); + } + p +} + +/// A file read apart from the session ([`Session::prepare_place`], then [`cmd::place::PlaceJob::read`] +/// on another thread and [`Session::place_read`]), as File › Place reads one off the UI thread, is +/// placed as `file.place` places it: the same reply, document, selection and undo step, and the +/// same journal entry. +#[test] +fn a_file_read_apart_from_the_session_places_as_file_place_does() { + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); + let (pdf, native, eps) = (serialize(&mut src, "pdf"), serialize(&mut src, "vectorcraft"), eps_of(&mut src)); + // SVG is serialized as text. + let svg = src.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().as_bytes().to_vec(); + let dxf = serialize(&mut src, "dxf"); + let dir = std::env::temp_dir().join(format!("vc-place-apart-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let saved = |name: &str, bytes: &[u8]| { + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().into_owned() + }; + let (pdf_path, native_path) = (saved("page.pdf", &pdf), saved("art.vectorcraft", &native)); + let words = super::tests_textimport::docx(r#"Placed words"#, super::tests_textimport::STYLES); + let cases = [ + with(file("red.png", &png(30, 20, [255, 0, 0, 255], Some(144.0))), json!({"at": [150, 100]})), + with(file("notes.txt", b"Two lines\nof text"), json!({"text": {}})), + file("note.rtf", br"{\rtf1\ansi {\b Bold} words\par}"), + file("words.docx", &words), + with(file("art.svg", &svg), json!({"rect": [10, 10, 80, 40]})), + file("art.eps", &eps), + file("plan.dxf", &dxf), + json!({"path": pdf_path}), + json!({"path": pdf_path, "link": false, "crop": "bounding"}), + json!({"path": native_path}), + with(file("page.pdf", &pdf), json!({"template": true})), + with(file("blue.png", &png(8, 8, [0, 0, 255, 255], None)), json!({"replace": true})), + ]; + for p in cases { + let name = p["name"].as_str().or(p["path"].as_str()).map(cmd::fileio::file_name).unwrap_or_default(); + let (mut a, mut b) = (session(), session()); + for s in [&mut a, &mut b] { + let id = s.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + } + let at_once = a.execute("file.place", &p).map_err(|e| e.to_string()); + assert!(at_once.is_ok(), "{name}: {at_once:?}"); + let job = b.prepare_place(&p).unwrap(); + assert_eq!(job.name(), name); + let read = std::thread::spawn(move || job.read()).join().unwrap().unwrap(); + assert_eq!(b.place_read(read).map_err(|e| e.to_string()), at_once, "{name}: the same reply"); + let (sa, sb) = (a.doc().unwrap(), b.doc().unwrap()); + // `file.new` stamps each document with the second it was made in, which can differ by one. + let undated = |d: &vectorcraft_doc::Document| { + let mut d = d.clone(); + d.metadata.created = None; + d + }; + assert!(undated(&sa.doc) == undated(&sb.doc), "{name}: the same document"); + assert_eq!(sa.selection.objects, sb.selection.objects, "{name}"); + assert_eq!(sa.history.undo.len(), sb.history.undo.len(), "{name}: one undo step"); + assert_eq!(a.journal.last(), b.journal.last(), "{name}"); + assert_eq!(b.journal.last().map(|(id, _)| id.as_str()), Some("file.place"), "{name}: journaled as file.place"); + } + let _ = std::fs::remove_dir_all(&dir); + // With no document open, the error the commands give. + let mut none = Session::new(); + let p = file("red.png", &png(2, 2, [0; 4], None)); + let err = |r: Result| r.err().map(|e| e.to_string()); + assert_eq!(none.prepare_place(&p).err().map(|e| e.to_string()), err(none.execute("file.place", &p))); + let q = json!({"files": [p]}); + assert_eq!(none.prepare_place_queue(&q).err().map(|e| e.to_string()), err(none.execute("file.place.queue", &q))); +} + +/// Replace swaps the object selected when Place started, even when another one is selected by the +/// time the file has been read, and the journal replays the same; when that object was deleted or +/// changed meanwhile, nothing is placed. +#[test] +fn a_file_read_apart_from_the_session_replaces_what_was_selected_when_place_started() { + let two = || { + let mut s = session(); + let first = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + let other = s.execute("shape.rectangle", &json!({"x": 200, "y": 100, "width": 20, "height": 20})).unwrap()["id"].clone(); + (s, first, other) + }; + let (mut s, first, other) = two(); + let p = with(file("blue.png", &png(8, 8, [0, 0, 255, 255], None)), json!({"replace": true})); + s.execute("select.set", &json!({"ids": [first]})).unwrap(); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("select.set", &json!({"ids": [other]})).unwrap(); + let r = s.place_read(read).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.node(NodeId(first.as_u64().unwrap())).is_none(), "the object selected when Place started is replaced"); + assert!(st.doc.node(NodeId(other.as_u64().unwrap())).is_some()); + assert_eq!(st.selection.objects, [NodeId(r["ids"][0].as_u64().unwrap())]); + // The journal's last two entries, run where the other object is selected, replace the same one. + let last: Vec<_> = s.journal.iter().rev().take(2).rev().cloned().collect(); + assert_eq!(last.iter().map(|(id, _)| id.as_str()).collect::>(), ["select.set", "file.place"]); + let (mut replay, _, _) = two(); + replay.execute("select.set", &json!({"ids": [other]})).unwrap(); + for (id, p) in &last { + replay.execute(id, p).unwrap(); + } + let undated = |s: &Session| { + let mut d = (*s.doc().unwrap().doc).clone(); + d.metadata.created = None; + d + }; + assert!(undated(&replay) == undated(&s), "the replay replaces the same object"); + // The object to replace is deleted before the file has been read. + let read = s.prepare_place(&p).unwrap().read().unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let (doc, undo, journal) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len(), s.journal.len()); + let err = s.place_read(read).unwrap_err(); + assert!(err.to_string().contains("the object to replace was changed or deleted"), "{err}"); + let st = s.doc().unwrap(); + assert!(*st.doc == *doc && st.history.undo.len() == undo && s.journal.len() == journal, "nothing placed or journaled"); + // The object to replace is moved, or an undo removes it and the next object made takes its id, + // before the file has been read. + for undone in [false, true] { + let mut s = session(); + let id = s.execute("shape.rectangle", &json!({"x": 300, "y": 10, "width": 20, "height": 20})).unwrap()["id"].clone(); + let read = s.prepare_place(&p).unwrap().read().unwrap(); + if undone { + s.execute("edit.undo", &json!({})).unwrap(); + let next = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 10, "height": 10})).unwrap()["id"].clone(); + assert_eq!(next, id, "the next object takes the id"); + } else { + s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + } + let (doc, undo, journal) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len(), s.journal.len()); + let err = s.place_read(read).unwrap_err(); + assert!(err.to_string().contains("the object to replace was changed or deleted"), "undone {undone}: {err}"); + let st = s.doc().unwrap(); + assert!(*st.doc == *doc && st.history.undo.len() == undo && s.journal.len() == journal, "undone {undone}: nothing placed or journaled"); + } +} + +/// A linked image keeps the preview its file was read with ([`cmd::place::PlaceJob::read`] makes +/// it), so placing the read file does not decode the image again; an embedded image has none. +#[test] +fn a_linked_image_keeps_the_preview_made_when_its_file_was_read() { + let blob = vectorcraft_doc::ImageBlob::new("image/png", png(600, 300, [9, 9, 9, 255], None)).with_proxy(); + let made = blob.proxy.clone().unwrap(); + let mut d = vectorcraft_doc::Document::new(100.0, 100.0); + cmd::links::store_image(&mut d, "linked", blob.clone(), true); + assert!(d.images["linked"].proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &made)), "the preview it was read with"); + cmd::links::store_image(&mut d, "embedded", blob, false); + assert!(d.images["embedded"].proxy.is_none()); +} + +/// Type and a DXF drawing fitted to the artboard, read apart from the session while the artboard +/// under `at` changes size, are placed against the artboard as it is when they are placed: as +/// `file.place` places them after the change, which is what a replay of the journal does. +#[test] +fn a_file_read_apart_from_the_session_is_placed_against_the_artboard_as_it_is_then() { + let mut src = Session::new(); + src.execute("file.new", &json!({"width": 100, "height": 50})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 5, "y": 5, "width": 20, "height": 10})).unwrap(); + let dxf = serialize(&mut src, "dxf"); + let text = with(file("notes.txt", b"Words to place"), json!({"text": {}, "at": [100, 150]})); + for p in [text, with(file("plan.dxf", &dxf), json!({"dxf": {"fit": true}, "at": [100, 150]}))] { + let (mut a, mut b) = (session(), session()); + let read = b.prepare_place(&p).unwrap().read().unwrap(); + for s in [&mut a, &mut b] { + s.execute("artboard.setProps", &json!({"index": 0, "width": 200})).unwrap(); + } + let at_once = a.execute("file.place", &p).unwrap(); + assert_eq!(b.place_read(read).unwrap(), at_once, "{}", at_once["name"]); + let undated = |s: &Session| { + let mut d = (*s.doc().unwrap().doc).clone(); + d.metadata.created = None; + d + }; + assert!(undated(&a) == undated(&b), "{}", at_once["name"]); + if at_once["format"] == "txt" { + // The 200 × 300 pt artboard less a 36 pt margin on each side. + assert!(close(at_once["width"].as_f64().unwrap(), 128.0) && close(at_once["height"].as_f64().unwrap(), 228.0), "{at_once}"); + } + } +} + #[test] fn template_puts_the_art_on_a_locked_template_layer_below_the_current_one() { let mut s = session(); @@ -481,6 +669,61 @@ fn a_queue_of_3_with_click_drag_and_esc_places_2() { assert_eq!(s.doc().unwrap().history.undo.len(), 2, "one undo step per placement"); } +/// With [`Session::place_cursor_requests`] set, a click of the place cursor asks the frontend to +/// place its file ([`UiRequest::Place`]) and places nothing itself; the params it asks with place +/// the file as the click places it without the flag. +#[test] +fn a_click_of_the_place_cursor_asks_the_frontend_to_place_the_file_when_asked_to() { + let view = ViewInfo::default(); + let files: Vec = ["a.png", "b.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect(); + let (mut at_once, mut s) = (session(), session()); + s.place_cursor_requests = true; + for s in [&mut at_once, &mut s] { + s.execute("file.place.queue", &json!({"files": files})).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 10.0, 10.0), view).unwrap(); + } + at_once.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), view).unwrap(); + let reqs = s.pointer(&PointerEvent::new(PointerKind::Up, 10.0, 10.0), view).unwrap(); + let [UiRequest::Place(p)] = &reqs[..] else { panic!("{reqs:?}") }; + assert_eq!(p["rect"], json!([10.0, 10.0, 40.0, 20.0])); + assert!(top_children(&s).is_empty(), "the frontend places it"); + assert_eq!(s.tool_options()["names"], json!(["b.png"]), "the cursor goes on to the next file"); + s.execute("file.place", p).unwrap(); + // Left out: the second each document was made in. + let doc = |s: &Session| { + let mut d = (*s.doc().unwrap().doc).clone(); + d.metadata.created = None; + d + }; + assert!(doc(&s) == doc(&at_once)); + assert_eq!(s.journal.last(), at_once.journal.last()); +} + +/// Files read for the place cursor apart from the session ([`Session::prepare_place_queue`], then +/// [`cmd::place::QueueJob::read`] on another thread and [`Session::place_queue_read`]) load it as +/// `file.place.queue` loads it: the same reply, with the files that can't be read skipped, and the +/// same tool and queue. +#[test] +fn files_read_for_the_place_cursor_apart_from_the_session_load_it_as_file_place_queue_does() { + let mut files: Vec = ["a.png", "b.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect(); + files.push(file("notes.xyz", b"hello")); + let p = json!({"files": files, "thumbnail": 8, "link": false}); + let (mut a, mut b) = (session(), session()); + let at_once = a.execute("file.place.queue", &p).unwrap(); + assert_eq!((at_once["count"].as_u64(), at_once["skipped"][0]["name"].as_str()), (Some(2), Some("notes.xyz"))); + let job = b.prepare_place_queue(&p).unwrap(); + assert_eq!(job.names(), ["a.png", "b.png", "notes.xyz"]); + let read = std::thread::spawn(move || job.read()).join().unwrap().unwrap(); + assert_eq!(b.place_queue_read(read).unwrap(), at_once); + assert_eq!((b.tool_id(), b.tool_options()), (a.tool_id(), a.tool_options())); + // None of the files can be read: an error, and the tool stays. + let mut c = session(); + let none = json!({"files": [file("notes.xyz", b"hello")]}); + let read = c.prepare_place_queue(&none).unwrap().read().unwrap(); + assert!(c.place_queue_read(read).unwrap_err().to_string().contains("not a format")); + assert_eq!(c.tool_id(), "selection"); +} + #[test] fn bad_place_params_are_errors() { let mut s = session(); diff --git a/crates/engine/src/tests_placed_document.rs b/crates/engine/src/tests_placed_document.rs index dd5a45121..dd9e47462 100644 --- a/crates/engine/src/tests_placed_document.rs +++ b/crates/engine/src/tests_placed_document.rs @@ -1,6 +1,7 @@ //! Placed documents: a VectorCraft document placed linked (`file.place`'s default for one read //! from a file), drawn and output as vectors, saved with its preview, checked, updated and -//! relinked when the file changes, and turned into an editable copy (Break Link, Expand). +//! relinked when the file changes, and turned into an editable copy (Break Link, Expand). A PDF +//! or `.ai` page places the same way. use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; @@ -437,6 +438,171 @@ fn relink_points_a_placed_document_at_another_file() { assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); } +/// A file made from scratch at `path` in `format` (`pdf`, or `ai`: a PDF-compatible .ai): one +/// 100×50 page per color of `pages`, filled with it (`extra` more rectangles on each change the +/// file's size). +fn pdf_source(path: &str, format: &str, pages: &[[u8; 3]], extra: usize) { + let mut d = Document::new(100.0, 50.0); + let layer = d.layers[0].id; + for (i, rgb) in pages.iter().enumerate() { + let x = i as f64 * 150.0; + let page = Rect::new(x, 0.0, x + 100.0, 50.0); + if i > 0 { + let mut b = d.artboards[0].clone(); + (b.id, b.rect) = (i as u32 + 1, page); + d.artboards.push(b); + } + for _ in 0..=extra { + let id = d.alloc_id(); + d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(page), look(*rgb))).unwrap(); + } + } + let p = if format == "ai" { json!({"pdfCompatible": true}) } else { json!({"range": "all", "preserveEditing": false}) }; + write(path, &cmd::fileio::encode_all(&d, format, &p).unwrap().files[0].1); +} + +#[test] +fn a_pdf_page_placed_without_link_is_embedded() { + let dir = Folder::new("placed-pdf-embedded"); + let src = dir.file("flyer.pdf"); + pdf_source(&src, "pdf", &[RED], 0); + let mut s = session(); + let r = s.execute("file.place", &json!({"path": src, "link": false, "at": [200, 150]})).unwrap(); + assert_eq!(r["linked"], false, "{r}"); + let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + assert_eq!(n.kind_label(), "Clip Group"); + assert!(!s.doc().unwrap().doc.has_placed()); + // Text asked for as outlines is embedded with Link too. + let r = s.execute("file.place", &json!({"path": src, "textAs": "outlines"})).unwrap(); + assert_eq!(r["linked"], false, "{r}"); + assert!(!s.doc().unwrap().doc.has_placed()); +} + +#[test] +fn with_link_a_pdf_page_places_linked() { + let dir = Folder::new("placed-pdf-linked"); + let src = dir.file("flyer.pdf"); + pdf_source(&src, "pdf", &[RED], 0); + let mut s = session(); + let id = place(&mut s, &src); + let p = placed(&s, id); + assert_eq!((p.link.path.as_str(), p.link.page, p.bounding), (src.as_str(), Some(1), false), "{p:?}"); + assert!((p.width - 100.0).abs() < 1e-6 && (p.height - 50.0).abs() < 1e-6, "{p:?}"); + assert_eq!(bounds(&s, id), Rect::new(150.0, 125.0, 250.0, 175.0)); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + let row = &s.execute("links.list", &json!({})).unwrap()["links"][0]; + assert_eq!((row["format"].as_str(), row["document"].as_bool(), row["status"].as_str()), (Some("PDF"), Some(true), Some("ok")), "{row}"); + assert_eq!(s.doc().unwrap().doc.images[&p.key].mime, "application/pdf"); + // Break Link gives the group placing it without Link gives. + let r = s.execute("links.embed", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(r["embedded"], json!([id.0]), "{r}"); + assert_eq!(s.doc().unwrap().doc.node(id).unwrap().kind_label(), "Clip Group"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); +} + +#[test] +fn a_changed_pdf_is_modified_and_update_links_reads_it_again() { + let dir = Folder::new("placed-pdf-update"); + let src = dir.file("flyer.pdf"); + pdf_source(&src, "pdf", &[RED], 0); + let mut s = session(); + let id = place(&mut s, &src); + pdf_source(&src, "pdf", &[BLUE], 1); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["links"][0]["status"], "modified"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "shows the page as it read it"); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u["updated"], json!([id.0]), "{u}"); + assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0); + // Relinked to another PDF, it shows that file's page. + let other = dir.file("other.pdf"); + pdf_source(&other, "pdf", &[RED], 2); + let r = s.execute("links.relink", &json!({"ids": [id.0], "path": other})).unwrap(); + assert_eq!(r["relinked"], json!([id.0]), "{r}"); + assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); + // A file it can't show is refused. + let notes = dir.file("notes.txt"); + write(¬es, b"not a page"); + let e = s.execute("links.relink", &json!({"ids": [id.0], "path": notes})).unwrap_err().to_string(); + assert!(e.contains("isn't a VectorCraft document, a PDF or a PDF-compatible .ai file"), "{e}"); + assert_eq!(placed(&s, id).link.path, other); +} + +#[test] +fn a_linked_pdf_page_outputs_as_vectors_after_the_document_reopens() { + let dir = Folder::new("placed-pdf-outputs"); + let src = dir.file("flyer.pdf"); + pdf_source(&src, "pdf", &[RED], 3); + let mut s = session(); + let id = place(&mut s, &src); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + open(&mut s, &doc_path); + let key = placed(&s, id).key; + assert!(s.doc().unwrap().doc.images[&key].is_proxy(), "opened with the preview only"); + // Output reads the file again: the page's paths, not the preview. + let pdf = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap(); + assert_eq!(pdf["warnings"], json!([]), "{}", pdf["warnings"]); + let (images, paths) = pdf_contents(&decode(&pdf)); + assert_eq!(images, 0, "no image in the PDF"); + assert!(paths > 0); + let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap(); + assert!(!svg["text"].as_str().unwrap().contains(">(), [("distort.roughen", true), ("plugin.test.engine.fx", false)]); + assert_eq!(effects[0].params, vectorcraft_render::effects::merged_params("distort.roughen", &json!({"size": 3})), "filled in"); + assert_eq!(effects[1].params, json!({"n": 2})); + // Handed back unchanged (an echo), nothing changes, not even the records' parameters. + install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.same", "filter", "{}"), tpl::ECHO, "", 1)); + let before = s.doc().unwrap().doc.clone(); + run(&mut s, "plugin.run", json!({"id": "test.engine.same", "ids": [id.0]})); + assert_eq!(s.doc().unwrap().doc, before); + // Refused: what effect.apply refuses, a live effect that isn't installed, parameters its + // manifest refuses, a texture to read from a file, two 3D effects. + for (name, fx, why) in [ + ("test.engine.unknown", json!([{"id": "distort.nothing"}]), "no such effect"), + ("test.engine.key", json!([{"id": "distort.roughen", "params": {"bogus": 1}}]), "bogus"), + ("test.engine.gone", json!([{"id": "plugin.test.engine.gone"}]), "no such effect"), + ("test.engine.badn", json!([{"id": "plugin.test.engine.fx", "params": {"n": "two"}}]), "`n`"), + ("test.engine.file", json!([{"id": "distort.glass", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"), + ("test.engine.file2", json!([{"id": "texture.texturizer", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"), + ("test.engine.two3d", json!([{"id": "threeD.extrude"}, {"id": "threeD.extrude"}]), "one 3D effect"), + ] { + let e = filter(&mut s, name, fx).unwrap_err().to_string(); + assert!(e.contains("objects[0].effects") && e.contains(why), "{name}: {e}"); + assert_eq!(s.doc().unwrap().doc, before, "{name}"); + } +} + +/// `CRAFT_PLUGIN_FRAME_WASM=` saves a test filter to try in the app +/// (`rough-frame-test-filter.wasm`): it adds a frame around 60,40–340,260 with a 6 pt stroke with +/// round joins and caps, roughened by the Roughen effect. +#[test] +fn rough_frame_test_filter() { + let Some(dir) = std::env::var_os("CRAFT_PLUGIN_FRAME_WASM") else { return }; + let frame = PathData::single(SubPath::polyline( + &[Point::new(60.0, 40.0), Point::new(340.0, 40.0), Point::new(340.0, 260.0), Point::new(60.0, 260.0)], + true, + )); + let ink = json!({"type": "solid", "color": {"model": "rgb", "r": 0.15, "g": 0.2, "b": 0.45}}); + let out = json!({"objects": [{"name": "Frame", "path": frame, "fills": [], + "strokes": [{"paint": ink, "width": 6, "join": "round", "cap": "round"}], + "effects": [{"id": "distort.roughen", "params": {"size": 2, "detail": 8, "points": "smooth"}}]}]}); + let manifest = r#"{"id":"org.example.frame","name":"Rough Frame (test plug-in)","version":"1","kind":"filter", + "description":"Adds a frame whose stroke is roughened by the Roughen effect.","params":{}}"#; + let src = tpl::returning(manifest, &out.to_string()); + std::fs::write(std::path::Path::new(&dir).join("rough-frame-test-filter.wasm"), wat::parse_str(src).unwrap()).unwrap(); +} + +/// A filter keeps data with the document: it writes it with `documentData` in the same undo step as +/// its objects (`null` removes it) and reads what it last wrote in `_context.documentData`, which +/// no other filter is given. It is the document's foreign data, so the native file keeps it with +/// no section of its own. More than the limit is refused. +#[test] +fn filters_keep_their_data_with_the_document() { + let mut s = session(); + let id = rect(&mut s, 10.0, "#ff0000"); + let pid = "test.engine.data"; + let returning = |out: Value| tpl::returning(&tpl::manifest(pid, "filter", "{}"), &out.to_string()); + let keep = |data: Value| json!({"objects": [{"id": id.0}], "documentData": data}); + let data = |s: &Session| s.doc().unwrap().doc.plugin_data(pid).cloned(); + let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]})); + install_wat(&mut s, &returning(keep(json!({"pen": 2})))); + filter(&mut s, pid).unwrap(); + assert_eq!(data(&s), Some(json!({"pen": 2}))); + run(&mut s, "edit.undo", json!({})); + assert_eq!(data(&s), None, "one undo step with the objects"); + run(&mut s, "edit.redo", json!({})); + // Read back: these name the object only when they are given what they look for. + let named = |name: &str| json!({"objects": [{"id": id.0, "name": name}]}).to_string(); + let when_given = + |pid: &str, needle: &str, name: &str| tpl::when_given(&tpl::manifest(pid, "filter", "{}"), tpl::Given::Params, needle, &named(name)); + install_wat(&mut s, &when_given(pid, r#""documentData":{"pen":2}"#, "Pen 2")); + filter(&mut s, pid).unwrap(); + assert_eq!(node(&s, id).name.as_deref(), Some("Pen 2")); + // Another filter is given nothing: what a filter keeps is its own. + let other = "test.engine.other"; + install_wat(&mut s, &when_given(other, r#""documentData":null"#, "Nothing kept")); + filter(&mut s, other).unwrap(); + assert_eq!(node(&s, id).name.as_deref(), Some("Nothing kept")); + // Saved with the document, among its foreign data. + let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(saved.plugin_data(pid), Some(&json!({"pen": 2}))); + assert_eq!(saved.unknown.get(vectorcraft_doc::plugin_data::PLUGIN_DATA), Some(&json!({pid: {"pen": 2}}))); + // Too much is refused, and the document stays as it was; null removes the data. + let before = s.doc().unwrap().doc.clone(); + let big = "x".repeat(vectorcraft_plugins::objects::MAX_DOCUMENT_DATA); + install_wat(&mut s, &returning(keep(json!(big)))); + let e = filter(&mut s, pid).unwrap_err().to_string(); + assert!(e.contains("documentData"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + install_wat(&mut s, &returning(keep(Value::Null))); + filter(&mut s, pid).unwrap(); + assert_eq!(data(&s), None); +} + #[test] fn plugin_management_commands() { let mut s = session(); @@ -326,3 +441,142 @@ fn installed_plugins_are_kept_in_the_store() { third.attach_plugin_store(folder.0.clone()); assert!(vectorcraft_plugins::registry::get(id).is_none()); } + +/// A heavy test filter `id` that counts `steps` down, then adds a triangle named "Plug-in". +fn heavy(id: &str, steps: u64) -> Vec { + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#); + let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]}); + wat::parse_str(tpl::spinning(&manifest, steps, &output.to_string())).unwrap() +} + +/// A filter that asks for a heavy budget installs only once the user agrees (`allowHeavy`); +/// `plugin.inspect` tells whoever installs it so beforehand, and installs nothing. +#[test] +fn a_heavy_filter_installs_once_the_user_agrees() { + let mut s = session(); + let id = "test.engine.heavy"; + let data = vectorcraft_format::base64_encode(&heavy(id, 1)); + // With the file's name, as the app passes it. + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data, "name": "route.wasm"})); + assert_eq!((inspected["id"].as_str(), inspected["budget"].as_str()), (Some(id), Some("heavy"))); + assert_eq!(inspected["source"], "route.wasm"); + assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing"); + let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string(); + assert!(e.contains("heavy budget") && e.contains("5 minutes") && e.contains("allowHeavy"), "{e}"); + assert!(vectorcraft_plugins::registry::get(id).is_none()); + let info = run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true})); + assert_eq!(info["budget"], "heavy"); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL); + let listed = run(&mut s, "plugin.list", json!({})); + assert!(listed["plugins"].as_array().unwrap().iter().any(|p| p["id"] == id && p["budget"] == "heavy")); + // A light plug-in's entry says nothing of budgets. + assert!(install(&mut s, DESATURATE).get("budget").is_none()); +} + +/// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next +/// start without asking, with its budget. +#[test] +fn a_stored_heavy_filter_keeps_its_budget() { + let folder = crate::tests_links::Folder::new("plugin-store-heavy"); + let id = "test.engine.storedheavy"; + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true})); + vectorcraft_plugins::registry::remove(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + let plugin = vectorcraft_plugins::registry::get(id).expect("installed again from the store"); + assert_eq!(plugin.limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL); + run(&mut next, "plugin.remove", json!({"id": id})); +} + +/// The app runs a heavy filter away from the session ([`cmd::plugin::FilterRun`]) and hands its +/// output in: `plugin.run` with the same params applies it, as one undo step, without running the +/// plug-in again. Only once, and only for the same run: objects that changed since, or the plug-in +/// installed again, run the plug-in instead. +#[test] +fn a_filter_run_made_elsewhere_is_applied_by_plugin_run() { + use cmd::plugin::FilterRun; + let mut s = session(); + let id = "test.engine.handin"; + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true})); + let p = json!({"id": id, "ids": []}); + let named = |s: &Session, ids: &Value| s.doc().unwrap().doc.node(NodeId(ids[0].as_u64().unwrap())).unwrap().name.clone(); + // What it computed, as the app hands it in; here marked, so the test sees which output applied. + let r = FilterRun::prepare(&s, &p).unwrap(); + assert!(r.current(&s)); + let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Plug-in", "Handed in"); + r.hand_in(&mut s, output.clone().into_bytes()); + let undo = s.doc().unwrap().history.undo.len(); + let made = run(&mut s, "plugin.run", p.clone())["ids"].clone(); + assert_eq!(named(&s, &made).as_deref(), Some("Handed in")); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + // Once only: the next run runs the plug-in. + let made = run(&mut s, "plugin.run", p.clone())["ids"].clone(); + assert_eq!(named(&s, &made).as_deref(), Some("Plug-in")); + // Made for objects that changed since, it isn't theirs. + let obj = rect(&mut s, 10.0, "#ff0000"); + let on = json!({"id": id, "ids": [obj.0]}); + let stale = FilterRun::prepare(&s, &on).unwrap(); + run(&mut s, "paint.setFill", json!({"ids": [obj.0], "color": "#00ff00"})); + assert!(!stale.current(&s), "the document changed"); + stale.hand_in(&mut s, output.clone().into_bytes()); + let made = run(&mut s, "plugin.run", on)["ids"].clone(); + assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made for objects that changed since"); + // A plug-in installed again is another one. + let again = FilterRun::prepare(&s, &p).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true})); + assert!(!again.current(&s)); + again.hand_in(&mut s, output.into_bytes()); + let made = run(&mut s, "plugin.run", p)["ids"].clone(); + assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made by the plug-in installed before"); +} + +/// A heavy filter run away from the session reads what it keeps with the document +/// (`_context.documentData`) and hands its `documentData` back with its output: `plugin.run` +/// applies both as one undo step. +#[test] +fn a_filter_run_made_elsewhere_keeps_its_document_data() { + use cmd::plugin::FilterRun; + let mut s = session(); + let id = "test.engine.handindata"; + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#); + let install = |s: &mut Session, src: String| { + let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap()); + run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true})); + }; + // It first keeps the pens it found with the document. + install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string())); + run(&mut s, "plugin.run", json!({"id": id, "ids": []})); + // Given those pens, it adds one, in the background; the output handed in is marked, so the + // test sees it is the one that applied. + let next = json!({"objects": [{"path": triangle(), "name": "Pen 3"}], "documentData": {"pens": 3}}).to_string(); + install(&mut s, tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":{"pens":2}"#, &next)); + let p = json!({"id": id, "ids": []}); + let r = FilterRun::prepare(&s, &p).unwrap(); + let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Pen 3", "Handed in"); + r.hand_in(&mut s, output.into_bytes()); + let undo = s.doc().unwrap().history.undo.len(); + let made = run(&mut s, "plugin.run", p)["ids"].clone(); + let st = s.doc().unwrap(); + assert_eq!(st.doc.node(NodeId(made[0].as_u64().unwrap())).unwrap().name.as_deref(), Some("Handed in")); + assert_eq!(st.doc.plugin_data(id), Some(&json!({"pens": 3}))); + assert_eq!(st.history.undo.len(), undo + 1, "one undo step"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects"); +} + +/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between +/// two slices of its budget and changes nothing. +#[test] +fn a_cancelled_filter_run_changes_nothing() { + let mut s = session(); + let id = "test.engine.cancelled"; + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1 << 40)), "allowHeavy": true})); + let before = s.doc().unwrap().doc.clone(); + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let r = vectorcraft_doc::cancel::scope(&token, None, || s.execute("plugin.run", &json!({"id": id}))); + assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}"); + assert_eq!(s.doc().unwrap().doc, before); +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 77aa6e454..a47bf56c2 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -1,6 +1,8 @@ //! Preferences: `prefs.get` / `prefs.set` / `prefs.reset` / `prefs.list`. use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_tools::{PointerEvent, PointerKind}; use super::*; use crate::cmd::prefscmds::{PREF_CATEGORIES, PREF_GROUPS, PREF_SPECS, validate}; @@ -461,6 +463,24 @@ fn use_precise_cursors_swaps_the_tools_own_pointers_for_a_crosshair() { } } +/// With Alt held, the Pencil and the Paintbrush show their closing cursors, and Use Precise Cursors turns those into +/// a crosshair too. +#[test] +fn alt_shows_the_pencil_and_paintbrush_closing_cursors() { + use vectorcraft_tools::{Cursor, Mods}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let at = vectorcraft_geom::Point::new(300.0, 300.0); + let alt = Mods { alt: true, ..Default::default() }; + for precise in [false, true] { + s.execute("prefs.set", &json!({"key": "usePreciseCursors", "value": precise})).unwrap(); + for (tool, c) in [("pencil", Cursor::PencilClose), ("paintbrush", Cursor::PaintbrushClose)] { + s.select_tool(tool, ViewInfo::default()).unwrap(); + assert_eq!(s.cursor(at, alt, ViewInfo::default()), if precise { Cursor::Crosshair } else { c }, "{tool}, precise {precise}"); + } + } +} + /// Every state of the Pen's pointer, and the other drawing pointers, turn into a crosshair; the rest stay. #[test] fn precise_cursors_replace_every_pen_state() { @@ -596,6 +616,30 @@ fn spacing_guides_space_a_dragged_square_like_its_row() { assert_eq!(drag(&mut s), (161.0, 0), "off: where it was dragged"); } +/// Smart Guides › Invoke Distance Guides: with a square selected, Alt held over another square to +/// its right shows the gap between them in the document's General unit; with the preference or +/// View › Smart Guides off, nothing. +#[test] +fn invoke_distance_guides_measures_the_selection_to_the_object_under_alt() { + use vectorcraft_tools::{Mods, Overlay, PointerKind::Move}; + let mut s = new_doc(); + let a = square(&mut s, 100.0, 100.0); + square(&mut s, 250.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let shown = |s: &mut Session, v: ViewInfo| { + s.pointer(&PointerEvent::new(Move, 275.0, 125.0).with_mods(Mods { alt: true, ..Mods::default() }), v).unwrap(); + s.overlays(v).into_iter().filter_map(|o| if let Overlay::Distance { text, .. } = o { Some(text) } else { None }).collect::>() + }; + assert_eq!(shown(&mut s, v), ["100.00 pt"], "the 100 pt gap; the tops and bottoms are level"); + s.execute("document.setUnits", &json!({"units": "Millimeters"})).unwrap(); + assert_eq!(shown(&mut s, v), ["35.28 mm"]); + assert!(shown(&mut s, ViewInfo { smart_guides: false, ..v }).is_empty(), "View › Smart Guides off"); + set_pref(&mut s, "distanceGuides", json!(false)); + assert!(shown(&mut s, v).is_empty(), "Invoke Distance Guides off"); +} + /// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement /// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a /// square is dragged into line with another, and how far the pull reaches; a hidden guide still @@ -1040,6 +1084,38 @@ fn type_increments_step_the_type() { assert_eq!((style_at(&s, id, 0).size, style_at(&s, id, 6).size), (22.0, 25.0)); } +/// Auto leading steps from the leading its paragraph's Justification › Auto Leading sets, which +/// is what the layout shows; a run spanning paragraphs with different Auto Leading is split. +/// Fill with Placeholder Text counts lines at that leading too. +#[test] +fn auto_leading_steps_and_fills_from_the_paragraph_auto_leading() { + let mut s = new_doc(); + let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "One\nTwo", "size": 20})).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setFormat", &json!({"ids": [id.0], "autoLeading": 200, "start": 5, "end": 5})).unwrap(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("type.step", &json!({"attribute": "leading"})).unwrap(); + let lead = |s: &Session, byte| style_at(s, id, byte).leading.unwrap(); + assert!((lead(&s, 0) - (20.0 * 1.2 + 2.0)).abs() < 1e-9, "default Auto Leading 120%"); + assert!((lead(&s, 3) - (20.0 * 1.2 + 2.0)).abs() < 1e-9, "the line break is the first paragraph's"); + assert!((lead(&s, 5) - (20.0 * 2.0 + 2.0)).abs() < 1e-9, "Auto Leading 200%"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((style_at(&s, id, 0).leading, style_at(&s, id, 5).leading), (None, None), "one undo step"); + + let area = |s: &mut Session, x: f64| { + let r = s.execute("text.create", &json!({"x": x, "y": 10, "text": "x", "size": 10, "area": {"width": 100, "height": 200}})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) + }; + let (normal, airy) = (area(&mut s, 10.0), area(&mut s, 200.0)); + s.execute("text.setFormat", &json!({"ids": [airy.0], "autoLeading": 240})).unwrap(); + s.execute("type.fillPlaceholder", &json!({"ids": [normal.0, airy.0]})).unwrap(); + let len = |s: &Session, id: NodeId| match s.doc().unwrap().doc.node(id).map(|n| &n.kind) { + Some(vectorcraft_doc::NodeKind::Text(t)) => t.plain_text().len(), + _ => panic!("not text"), + }; + let (n, a) = (len(&s, normal), len(&s, airy)); + assert!(a * 3 < n * 2 && a * 3 > n, "half as many lines at twice the leading: {a} vs {n}"); +} + /// Cmd/Ctrl+Alt+Shift+. and , step the font size by five times the Size/Leading increment, on /// the selected type objects and on the Type tool's selected text. #[test] @@ -1352,6 +1428,17 @@ fn show_font_names_in_english_preference_is_stored() { assert!(s.prefs.font_names_in_english); } +/// Preferences › Devices › Enable Touch Workspace (#394): stored and restored through `prefs.set`. +#[test] +fn touch_workspace_preference_is_stored() { + let mut s = Session::new(); + assert!(s.prefs.touch_workspace, "on by default"); + set_pref(&mut s, "touchWorkspace", json!(false)); + assert!(!s.prefs.touch_workspace); + set_pref(&mut s, "touchWorkspace", json!(true)); + assert!(s.prefs.touch_workspace); +} + /// Preferences › Type › Highlight Alternate Glyphs (#394): off by default so OpenType /// substitutions are not pink on every canvas until the user turns the preference on. #[test] @@ -1364,6 +1451,141 @@ fn highlight_alternate_glyphs_preference_is_off_by_default() { assert!(!s.prefs.highlight_alternate_glyphs); } +/// Preferences › Performance › Real-time Drawing and Editing (#394): off, the document isn't +/// previewed while dragging but the pending shape still shows as a canvas overlay until commit. +#[test] +fn real_time_drawing_off_skips_tool_previews_until_commit() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + let paths = |s: &Session| { + s.doc() + .unwrap() + .doc + .layers + .first() + .and_then(|l| l.children()) + .map_or(0, |c| c.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).count()) + }; + let rev = s.doc().unwrap().revision; + assert_eq!(paths(&s), 0); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 150.0), v).unwrap(); + assert_eq!(s.doc().unwrap().revision, rev, "no preview steps"); + assert_eq!(paths(&s), 0, "the rectangle isn't in the document yet"); + assert!(s.overlays(v).iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Path { .. })), "the deferred rectangle shows as an outline"); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 150.0), v).unwrap(); + assert!(s.doc().unwrap().revision > rev, "commit applies the shape"); + assert_eq!(paths(&s), 1); + set_pref(&mut s, "realTimeDrawing", json!(true)); + let rev = s.doc().unwrap().revision; + s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 120.0), v).unwrap(); + assert!(s.doc().unwrap().revision > rev, "on again: previews run while dragging"); +} + +/// Real-time Drawing turned on mid-drag still applies the deferred preview on commit. +#[test] +fn real_time_drawing_turned_on_mid_drag_still_commits_deferred_preview() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + let paths = |s: &Session| { + s.doc() + .unwrap() + .doc + .layers + .first() + .and_then(|l| l.children()) + .map_or(0, |c| c.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).count()) + }; + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 150.0), v).unwrap(); + assert_eq!(paths(&s), 0); + set_pref(&mut s, "realTimeDrawing", json!(true)); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 150.0), v).unwrap(); + assert_eq!(paths(&s), 1, "the deferred rectangle is committed"); +} + +/// Repeated overlay redraws with Real-time Drawing off reuse the cached deferred feedback. +#[test] +fn deferred_preview_overlays_are_cached_across_redraws() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("rectangle", v).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + s.pointer(&PointerEvent::new(PointerKind::Down, 50.0, 50.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 150.0), v).unwrap(); + let rev = s.doc().unwrap().revision; + let count = |s: &mut Session| s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Path { .. })).count(); + let first = count(&mut s); + assert!(first > 0); + assert_eq!(s.doc().unwrap().revision, rev); + let second = count(&mut s); + assert_eq!(first, second); + assert_eq!(s.doc().unwrap().revision, rev, "the preview command is not run again"); +} + +/// A deferred commit that fails its preview cancels the interaction instead of leaving it open. +#[test] +fn deferred_commit_preview_failure_cancels_interaction() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + s.begin_interaction("Bad").unwrap(); + s.doc_mut().unwrap().interaction.as_mut().unwrap().preview = Some(("no.such.command".into(), json!({}))); + s.apply_actions(vec![vectorcraft_tools::Action::Commit]).unwrap(); + assert!(s.doc().unwrap().interaction.is_none()); +} + +/// A deferred commit applies preview warnings to the UI, as live previews do. +#[test] +fn deferred_commit_forwards_preview_warnings() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap(); + let text = s.execute("text.create", &json!({"x": 300, "y": 200, "text": "Hi"})).unwrap(); + s.execute("select.set", &json!({"ids": [rect["id"], text["id"]]})).unwrap(); + set_pref(&mut s, "realTimeDrawing", json!(false)); + s.begin_interaction("Bloat").unwrap(); + let p = json!({"tool": "bloat", "points": [[290.0, 200.0]], "width": 200.0, "height": 200.0}); + s.doc_mut().unwrap().interaction.as_mut().unwrap().preview = Some(("object.liquify".into(), p)); + let ui = s.apply_actions(vec![vectorcraft_tools::Action::Commit]).unwrap(); + assert!(ui.iter().any(|r| matches!(r, UiRequest::Status(m) if m.contains("type"))), "{ui:?}"); +} + +/// File Handling › Use Low Resolution Proxy for Linked EPS (#394): off by default; toggling it does +/// not replace full-resolution pixels already in the document. +#[test] +fn low_res_proxy_eps_preference_does_not_swap_full_resolution_pixels() { + use crate::tests_links::{Folder, RED, place, png, save, write}; + let dir = Folder::new("eps-proxy-pref"); + let pic = dir.file("photo.eps"); + write(&pic, &png(600, 300, RED)); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + assert!(!s.prefs.low_res_proxy_eps); + let id = place(&mut s, &pic); + let doc_path = dir.file("doc.vectorcraft"); + save(&mut s, &doc_path); + s.execute("links.update", &json!({})).unwrap(); + let key = { + let st = s.doc().unwrap(); + let NodeKind::Image(im) = &st.doc.node(id).unwrap().kind else { panic!("image") }; + im.key.clone() + }; + assert!(!s.doc().unwrap().doc.images[&key].is_proxy()); + set_pref(&mut s, "lowResProxyEps", json!(true)); + assert!(!s.doc().unwrap().doc.images[&key].is_proxy(), "full pixels stay in the document"); + set_pref(&mut s, "lowResProxyEps", json!(false)); + assert!(!s.prefs.low_res_proxy_eps); +} + /// Preferences › Hyphenation › Exceptions (#394): `prefs.set` pushes the list into the hyphenator /// and bumps open documents so type reflows. Parsing of the list is covered in `vectorcraft-text`. #[test] @@ -1435,3 +1657,65 @@ fn snap_to_point_cues_anchors_and_lands_pen_clicks_on_them() { s.execute("select.none", &json!({})).unwrap(); } } + +/// A millimetre document with a rectangle (100, 100)–(300, 250) stroked 1 pt and a vertical +/// ruler guide at no whole number of millimetres or points (62.31 mm): the rectangle and the +/// guide's x. +fn stroked_rect_and_guide() -> (Session, NodeId, f64) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600, "units": "Millimeters"})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 150})).unwrap()["id"].as_u64().unwrap()); + s.execute("stroke.set", &json!({"weight": 1})).unwrap(); + let g = 62.31 / 25.4 * 72.0; + s.execute("guide.add", &json!({"vertical": true, "pos": g})).unwrap(); + (s, id, g) +} + +/// #3701: at 3,891%, the Selection tool pressed on a rectangle's 1 pt stroke inside its left edge, +/// or on its fill 3 px inside it, and dragged until the edge is 1 px from a ruler guide lands the +/// edge on the guide exactly, with "align". The pressed spot used to be what snapped, so the edge +/// stopped that far beside the guide however far one zoomed in. +#[test] +fn a_move_held_by_a_paths_stroke_lands_the_path_on_a_ruler_guide() { + use vectorcraft_tools::Overlay; + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + let (mut s, id, g) = stroked_rect_and_guide(); + let v = ViewInfo { zoom: 38.91, ..ViewInfo::default() }; + s.select_tool("selection", v).unwrap(); + // 0.3 pt (12 px) into the stroke, and 3 px into the fill. + for x in [100.3, 100.0 + 3.0 / v.zoom] { + s.execute("select.none", &json!({})).unwrap(); + let to = x + g + 1.0 / v.zoom - 100.0; + gesture(&mut s, v, &[(Down, x, 175.0), (Drag, (x + to) / 2.0, 175.0), (Drag, to, 175.0)]); + let align = s.overlays(v).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align")); + gesture(&mut s, v, &[(Up, to, 175.0)]); + let edge = top_left(&s, id).0; + assert!((edge - g).abs() < 1e-9 && align, "pressed at {x}: the edge is {} pt off the guide (align: {align})", edge - g); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +/// #3701: at 28,757%, Direct Selection pressed 1.5 px beside a rectangle's straight left side and +/// dragged until the side is 1 px from a ruler guide lands both its anchors on the guide; so does +/// the same press and drag on the rectangle selected whole, which moves it. +#[test] +fn a_straight_segment_dragged_by_direct_selection_lands_on_a_ruler_guide() { + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + let (mut s, id, g) = stroked_rect_and_guide(); + let v = ViewInfo { zoom: 287.57, ..ViewInfo::default() }; + s.select_tool("directSelection", v).unwrap(); + for whole in [false, true] { + s.execute("select.set", &json!({"ids": if whole { vec![id.0] } else { vec![] }})).unwrap(); + let x = 100.0 + 1.5 / v.zoom; + let to = x + g + 1.0 / v.zoom - 100.0; + gesture(&mut s, v, &[(Down, x, 175.0), (Drag, (x + to) / 2.0, 175.0), (Drag, to, 175.0), (Up, to, 175.0)]); + let mut xs: Vec = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + xs.sort_by(f64::total_cmp); + assert!( + xs[..2].iter().all(|x| (x - g).abs() < 1e-9), + "whole: {whole}, the side's anchors are {:?} pt off the guide", + xs[..2].iter().map(|x| x - g).collect::>() + ); + s.execute("edit.undo", &json!({})).unwrap(); + } +} diff --git a/crates/engine/src/tests_previewbounds.rs b/crates/engine/src/tests_previewbounds.rs index 986b19a96..95440f2ae 100644 --- a/crates/engine/src/tests_previewbounds.rs +++ b/crates/engine/src/tests_previewbounds.rs @@ -1,10 +1,13 @@ //! Use Preview Bounds: the Transform panel values, the bounding box, Align and Distribute measure -//! visual bounds (strokes included) when it is on. +//! visual bounds (strokes and geometry effects included) when it is on. + +use std::sync::Arc; use serde_json::{Value, json}; -use vectorcraft_doc::NodeId; -use vectorcraft_geom::Rect; -use vectorcraft_tools::{PointerEvent, PointerKind}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId, NodeKind, Symbol}; +use vectorcraft_geom::{Affine, Point, Rect, shapes}; +use vectorcraft_tools::{Cursor, Mods, PointerEvent, PointerKind}; use super::*; @@ -75,6 +78,375 @@ fn distribute_spacing_measures_preview_bounds() { assert_eq!(geometric(&s, b).x0 - geometric(&s, a).x1, 10.0); } +/// A 100 pt square at (`x`, 100) without a stroke, with Offset Path 10. +fn offset_square(s: &mut Session, x: f64) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(s, "paint.setStroke", json!({"none": true})); + run(s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "ids": [id.0], "item": null})); + id +} + +/// Point type `text` (48 pt) at (`x`, 100) with Outline Object. +fn outlined_type(s: &mut Session, x: f64, text: &str) -> NodeId { + let id = NodeId(run(s, "text.create", json!({"x": x, "y": 100, "text": text, "size": 48}))["id"].as_u64().unwrap()); + run(s, "effect.apply", json!({"effect": "path.outlineObject", "ids": [id.0], "item": null})); + id +} + +/// The box of the glyph outlines that type `id` with Outline Object is drawn as. +fn glyphs(s: &Session, id: NodeId) -> Rect { + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + vectorcraft_render::effects::reshape(None, &n, None).unwrap().geometric_bounds().unwrap() +} + +fn near(a: Rect, b: Rect, tolerance: f64) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tolerance) +} + +#[test] +fn a_square_with_offset_path_is_measured_by_the_offset_shape() { + let mut s = session(); + let a = offset_square(&mut s, 100.0); + preview(&mut s, true); + assert!(near(s.transform_bounds(&[a]).unwrap(), Rect::new(90.0, 90.0, 210.0, 210.0), 1e-6), "{:?}", s.transform_bounds(&[a])); + // Align left with an unstroked rectangle at x 20: the offset shape's left edge lines up with it. + rect(&mut s, 20.0, 50.0, 0.0); + run(&mut s, "select.all", json!({})); + run(&mut s, "object.align", json!({"horizontal": "left"})); + assert!((geometric(&s, a).x0 - 30.0).abs() < 1e-6, "{:?}", geometric(&s, a)); + // Distribute Spacing 0: the offset shapes touch, so the squares are 20 pt apart. + let mut s = session(); + let (a, b) = (offset_square(&mut s, 0.0), offset_square(&mut s, 300.0)); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + run(&mut s, "object.distributeSpacing", json!({"axis": "horizontal", "spacing": 0})); + assert!((geometric(&s, b).x0 - geometric(&s, a).x1 - 20.0).abs() < 1e-6); +} + +#[test] +fn a_stroke_with_offset_path_of_its_own_is_measured_where_it_paints() { + // A 100 pt square with a 10 pt stroke whose own Offset Path 10 moves it 10 pt out: the stroke + // paints from 15 pt outside the square to 5 pt outside it. + let mut s = session(); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"weight": 10})); + let items = s.doc().unwrap().doc.node(id).unwrap().appearance.items.clone(); + let stroke = items.iter().position(|i| matches!(i, vectorcraft_doc::AppearanceItem::Stroke(_))).unwrap(); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "ids": [id.0], "item": stroke})); + preview(&mut s, true); + assert!(near(s.transform_bounds(&[id]).unwrap(), Rect::new(85.0, 85.0, 215.0, 215.0), 1e-6), "{:?}", s.transform_bounds(&[id])); +} + +#[test] +fn a_groups_own_stroke_with_offset_path_is_measured_where_it_paints() { + // A group of an unstroked 100 pt square, with a 10 pt stroke of its own whose Offset Path 10 + // moves it 10 pt out: the stroke paints from 15 pt outside the square to 5 pt outside it. + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})); + run(&mut s, "paint.setStroke", json!({"none": true})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addStroke", json!({"ids": [g.0]})); + run(&mut s, "paint.setStroke", json!({"ids": [g.0], "item": 0, "color": "#000000"})); + run(&mut s, "stroke.set", json!({"ids": [g.0], "item": 0, "weight": 10})); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "ids": [g.0], "item": 0})); + preview(&mut s, true); + assert!(near(s.transform_bounds(&[g]).unwrap(), Rect::new(85.0, 85.0, 215.0, 215.0), 1e-6), "{:?}", s.transform_bounds(&[g])); +} + +#[test] +fn a_groups_own_fill_is_measured_on_its_members_where_they_are() { + // A group with a fill of its own around a 100 pt square whose fill a Transform effect moves + // 100 pt right: the group's fill paints the square where it is, and the square's own fill + // paints 100 pt to the right of it. + let mut s = session(); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setStroke", json!({"none": true})); + let items = s.doc().unwrap().doc.node(id).unwrap().appearance.items.clone(); + let fill = items.iter().position(|i| i.is_fill()).unwrap(); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [id.0], "item": fill})); + let g = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "appearance.addFill", json!({"ids": [g.0]})); + run(&mut s, "paint.setFill", json!({"ids": [g.0], "item": 0, "color": "#000000"})); + preview(&mut s, true); + assert!(near(s.transform_bounds(&[g]).unwrap(), Rect::new(100.0, 100.0, 300.0, 200.0), 1e-6), "{:?}", s.transform_bounds(&[g])); +} + +#[test] +fn a_clip_group_is_measured_where_its_clipping_paths_stroke_paints() { + // A 200 pt square clipped by a 100 pt square at (100, 100) with Offset Path 20. The clipped art + // is clipped by the square without the offset, so the group is measured by that square. + let mut s = session(); + let content = NodeId(run(&mut s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 200, "height": 200}))["id"].as_u64().unwrap()); + let path = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [content.0, path.0]})); + let clip = NodeId(run(&mut s, "object.clippingMask.make", json!({}))["id"].as_u64().unwrap()); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 20}, "ids": [path.0], "item": null})); + preview(&mut s, true); + let square = Rect::new(100.0, 100.0, 200.0, 200.0); + assert!(near(s.transform_bounds(&[clip]).unwrap(), square, 1e-6), "{:?}", s.transform_bounds(&[clip])); + // A 4 pt stroke on the clipping path paints along the offset shape, over the clipped art, from + // 78 to 222. + run(&mut s, "paint.setStroke", json!({"ids": [path.0], "color": "#000000"})); + run(&mut s, "stroke.set", json!({"ids": [path.0], "weight": 4})); + let stroked = Rect::new(78.0, 78.0, 222.0, 222.0); + assert!(near(s.transform_bounds(&[clip]).unwrap(), stroked, 1e-6), "{:?}", s.transform_bounds(&[clip])); + // A fill of the clip group's own keeps that box. + run(&mut s, "appearance.addFill", json!({"ids": [clip.0]})); + run(&mut s, "paint.setFill", json!({"ids": [clip.0], "item": 0, "color": "#000000"})); + assert!(near(s.transform_bounds(&[clip]).unwrap(), stroked, 1e-6), "with a fill: {:?}", s.transform_bounds(&[clip])); + // Turned 30°, the box keeps that size. + run(&mut s, "select.set", json!({"ids": [clip.0]})); + run(&mut s, "object.rotate", json!({"angle": 30})); + let turned = s.transform_box(&[clip]).unwrap(); + assert!( + (turned.angle - 30.0).abs() < 1e-9 && (turned.rect.width() - 144.0).abs() < 1e-6 && (turned.rect.height() - 144.0).abs() < 1e-6, + "{turned:?}" + ); + // In a group, the clip group is measured the same way. + run(&mut s, "object.rotate", json!({"angle": -30})); + let group = NodeId(run(&mut s, "object.group", json!({}))["id"].as_u64().unwrap()); + assert!(near(s.transform_bounds(&[group]).unwrap(), stroked, 1e-6), "{:?}", s.transform_bounds(&[group])); + // Without Use Preview Bounds, the clipping path's geometry is measured. + preview(&mut s, false); + assert!(near(s.transform_bounds(&[clip]).unwrap(), square, 1e-6), "{:?}", s.transform_bounds(&[clip])); +} + +#[test] +fn only_fills_and_strokes_that_paint_are_measured() { + let item = |s: &Session, id: NodeId, fill: bool| { + let items = s.doc().unwrap().doc.node(id).unwrap().appearance.items.clone(); + items.iter().position(|i| i.is_fill() == fill).unwrap() + }; + // A 100 pt square without a stroke whose fill a Transform effect moves 100 pt right is measured + // where the fill paints, not where the path is. + let mut s = session(); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setStroke", json!({"none": true})); + let fill = item(&s, id, true); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 100}, "ids": [id.0], "item": fill})); + preview(&mut s, true); + assert!(near(s.transform_bounds(&[id]).unwrap(), Rect::new(200.0, 100.0, 300.0, 200.0), 1e-6), "{:?}", s.transform_bounds(&[id])); + // A hidden 10 pt stroke with Offset Path 10 of its own adds nothing to the box of the square, + // which Offset Path 5 of the object's own takes 5 pt out. + let mut s = session(); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"weight": 10})); + let stroke = item(&s, id, false); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 10}, "ids": [id.0], "item": stroke})); + run(&mut s, "appearance.setItem", json!({"index": stroke, "visible": false, "ids": [id.0]})); + preview(&mut s, true); + assert!(near(s.transform_bounds(&[id]).unwrap(), Rect::new(100.0, 100.0, 200.0, 200.0), 1e-6), "{:?}", s.transform_bounds(&[id])); + run(&mut s, "effect.apply", json!({"effect": "path.offsetPath", "params": {"offset": 5}, "ids": [id.0], "item": null})); + assert!(near(s.transform_bounds(&[id]).unwrap(), Rect::new(95.0, 95.0, 205.0, 205.0), 1e-6), "{:?}", s.transform_bounds(&[id])); +} + +#[test] +fn type_with_outline_object_aligns_by_its_glyphs() { + // "ace" has no ascender or descender; "Tg" has a capital and a descender. Aligned top, their + // glyph tops line up; aligned bottom, the bottom of the g lines up with the bottom of "ace". + let edge = |vertical: &str| { + let mut s = session(); + let a = outlined_type(&mut s, 20.0, "ace"); + let b = outlined_type(&mut s, 200.0, "Tg"); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + run(&mut s, "object.align", json!({"vertical": vertical})); + let (a, b) = (glyphs(&s, a), glyphs(&s, b)); + if vertical == "top" { (a.y0, b.y0) } else { (a.y1, b.y1) } + }; + for vertical in ["top", "bottom"] { + let (a, b) = edge(vertical); + assert!((a - b).abs() < 1e-6, "{vertical}: {a} and {b}"); + } +} + +#[test] +fn type_with_outline_object_measures_as_its_outlines() { + let mut s = session(); + let id = outlined_type(&mut s, 40.0, "Outline"); + preview(&mut s, true); + let measured = s.transform_bounds(&[id]).unwrap(); + assert!(near(measured, glyphs(&s, id), 1e-9), "{measured:?}"); + // The type's Create Outlines copy has the same visual box. + run(&mut s, "select.set", json!({"ids": [id.0]})); + let outlines: Vec = + run(&mut s, "type.createOutlines", json!({}))["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + let copy = s.doc().unwrap().doc.bounds_of(&outlines, true).unwrap(); + assert!(near(measured, copy, 0.01), "{measured:?} {copy:?}"); +} + +#[test] +fn set_bounds_sizes_the_glyphs_of_type_with_outline_object() { + let mut s = session(); + let id = outlined_type(&mut s, 40.0, "Outline"); + preview(&mut s, true); + let before = glyphs(&s, id); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 100, "reference": 0})); + let after = glyphs(&s, id); + assert!((after.width() - 100.0).abs() < 1e-6, "{after:?}"); + assert!((after.x0 - before.x0).abs() < 1e-6 && (after.y0 - before.y0).abs() < 1e-6, "{before:?} {after:?}"); + assert!(near(s.transform_bounds(&[id]).unwrap(), after, 1e-9)); +} + +#[test] +fn set_bounds_sizes_the_offset_shape_of_a_square_with_offset_path() { + // The offset shape is 120 pt wide. Set to 220 wide from its top-left corner, it is 220 wide and + // stays 120 high, with Scale Strokes & Effects off (the offset stays 10 pt) and on (the offset + // scales with the square). + for strokes in [false, true] { + let mut s = session(); + let id = offset_square(&mut s, 100.0); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 220, "reference": 0, "strokes": strokes})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!(near(v, Rect::new(90.0, 90.0, 310.0, 210.0), 1e-6), "strokes {strokes}: {v:?}"); + } + // Set to 10 x 10, smaller than the 20 pt the offset adds, with Scale Strokes & Effects on: the + // offset shrinks with the square, and the offset shape is 10 x 10. + let mut s = session(); + let id = offset_square(&mut s, 100.0); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 10, "height": 10, "reference": 0, "strokes": true})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!(near(v, Rect::new(90.0, 90.0, 100.0, 100.0), 1e-6), "{v:?}"); +} + +#[test] +fn set_bounds_sizes_art_that_a_transform_effect_turns() { + // A 100 x 50 rectangle that a Transform effect turns 90° is drawn 50 wide and 100 high. Set to + // 100 x 100, it is drawn 100 x 100: its width stays 100 and its height doubles. + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 0.0); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"rotate": 90}, "ids": [id.0], "item": null})); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 100, "height": 100})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 100.0).abs() < 1e-6 && (v.height() - 100.0).abs() < 1e-6, "{v:?}"); + let g = geometric(&s, id); + assert!((g.width() - 100.0).abs() < 1e-6 && (g.height() - 100.0).abs() < 1e-6, "{g:?}"); +} + +#[test] +fn a_symbol_instance_is_measured_with_the_geometry_effects_in_its_art() { + let mut s = session(); + offset_square(&mut s, 100.0); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Offset"}))["id"].as_u64().unwrap()); + preview(&mut s, true); + let v = s.transform_bounds(&[id]).unwrap(); + assert!(near(v, Rect::new(90.0, 90.0, 210.0, 210.0), 1e-6), "{v:?}"); +} + +#[test] +fn blends_envelopes_and_repeats_are_measured_with_the_geometry_effects_of_their_objects() { + // A grid repeat of a 100 pt square without a stroke, with Offset Path 10: each copy is drawn + // 10 pt out from its square. + let mut s = session(); + let a = offset_square(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let id = NodeId(run(&mut s, "object.repeat.grid", json!({}))["id"].as_u64().unwrap()); + preview(&mut s, true); + let (g, v) = (geometric(&s, id), s.transform_bounds(&[id]).unwrap()); + assert!(g.width() > 200.0 && near(v, g.inflate(10.0, 10.0), 1e-6), "{v:?} {g:?}"); + // A blend between two such squares, 100 pt apart. + let mut s = session(); + let (a, b) = (offset_square(&mut s, 0.0), offset_square(&mut s, 200.0)); + run(&mut s, "select.set", json!({"ids": [a.0, b.0]})); + let id = NodeId(run(&mut s, "object.blend.make", json!({"steps": 1}))["id"].as_u64().unwrap()); + preview(&mut s, true); + let v = s.transform_bounds(&[id]).unwrap(); + assert!(near(v, Rect::new(-10.0, 90.0, 310.0, 210.0), 1e-6), "{v:?}"); + // An envelope around one, with a warp that leaves it as it is. + let mut s = session(); + let a = offset_square(&mut s, 100.0); + run(&mut s, "select.set", json!({"ids": [a.0]})); + let id = NodeId(run(&mut s, "object.envelope.makeWithWarp", json!({"style": "arc", "bend": 0}))["id"].as_u64().unwrap()); + preview(&mut s, true); + let v = s.transform_bounds(&[id]).unwrap(); + assert!(near(v, Rect::new(90.0, 90.0, 210.0, 210.0), 1e-6), "{v:?}"); +} + +#[test] +fn an_object_has_the_art_of_at_most_4096_symbol_instances_drawn() { + // Symbol S0 is a filled 10 pt square with Offset Path 1, drawn from -1 to 11. Each of S1 to S12 + // holds two instances of the symbol before it, the second 20 × 2^(k - 1) pt to the right, so an + // instance of S12 shows 4,096 squares in a row, 20 pt apart, through 8,191 instances. The art of + // the first 4,096 is drawn: the instance itself and the 4,095 in the left half. The squares in + // the right half are measured without the offset. + let mut d = Document::new(400.0, 400.0); + let mut filled = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + filled.effects.push(Effect { id: "path.offsetPath".into(), params: json!({"offset": 1}), visible: true }); + let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), filled); + d.symbols.push(Symbol { name: "S0".into(), art: Arc::new(square) }); + for k in 1..=12 { + let mut instance = + |dx: f64| Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf: Affine::translate((dx, 0.0)) })); + let pair = vec![instance(0.0), instance(20.0 * f64::from(1 << (k - 1)))]; + let art = Node::group(d.alloc_id(), pair); + d.symbols.push(Symbol { name: format!("S{k}"), art: Arc::new(art) }); + } + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S12".into(), xf: Affine::IDENTITY }); + let (layer, id) = (d.layers[0].id, instance.id); + d.insert(Some(layer), 0, instance).unwrap(); + let mut s = Session::new(); + s.add_document(d, None); + preview(&mut s, true); + let v = s.transform_bounds(&[id]).unwrap(); + assert!(near(v, Rect::new(-1.0, -1.0, 81910.0, 11.0), 1e-6), "{v:?}"); +} + +#[test] +fn a_turned_type_objects_box_follows_its_glyphs() { + let mut s = session(); + let id = outlined_type(&mut s, 40.0, "Outline"); + preview(&mut s, true); + let before = s.transform_box(&[id]).unwrap(); + assert!(near(before.rect, glyphs(&s, id), 1e-9), "{before:?}"); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.rotate", json!({"angle": 30})); + let after = s.transform_box(&[id]).unwrap(); + assert!((after.angle - 30.0).abs() < 1e-9, "{after:?}"); + assert!( + (after.rect.width() - before.rect.width()).abs() < 1e-6 && (after.rect.height() - before.rect.height()).abs() < 1e-6, + "{before:?} {after:?}" + ); + assert!(after.center().distance(before.center()) < 1e-6, "{before:?} {after:?}"); +} + +#[test] +fn objects_selected_whole_with_anchors_turn_about_the_box_of_their_offset_shape() { + // A square with Offset Path 10 (drawn from 90 to 210) selected whole with the top-left anchor + // of an unstroked square at (300, 100); a half turn about the center of the box around both. + let center_after = |on: bool| { + let mut s = session(); + let a = offset_square(&mut s, 100.0); + let b = NodeId(run(&mut s, "shape.rectangle", json!({"x": 300, "y": 100, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setStroke", json!({"none": true})); + preview(&mut s, on); + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "select.anchors", json!({"id": b.0, "anchors": [[0, 0]], "mode": "add"})); + run(&mut s, "object.rotate", json!({"angle": 180})); + geometric(&s, a).center() + }; + // On, the box runs from (90, 90) to (300, 210); off, from (100, 100) to (300, 200). + assert!(center_after(true).distance(vectorcraft_geom::Point::new(240.0, 150.0)) < 1e-6, "{:?}", center_after(true)); + assert!(center_after(false).distance(vectorcraft_geom::Point::new(250.0, 150.0)) < 1e-6, "{:?}", center_after(false)); +} + +#[test] +fn without_preview_bounds_geometry_effects_are_not_measured() { + let mut s = session(); + let ids = [offset_square(&mut s, 100.0), outlined_type(&mut s, 40.0, "Outline")]; + let d = &s.doc().unwrap().doc; + for id in ids { + assert_eq!(s.transform_bounds(&[id]), d.bounds_of(&[id], false)); + assert_eq!(s.transform_box(&[id]), d.oriented_bounds(&[id], false)); + } +} + #[test] fn the_w_field_sets_the_visual_width() { let mut s = session(); @@ -357,3 +729,189 @@ fn the_bounding_box_handles_sit_on_the_preview_bounds() { assert!(drag(true).width() > 150.0, "a scale from the visual edge"); assert_eq!(drag(false).width(), 100.0, "off, the point is no handle"); } + +#[test] +fn the_bounding_box_handles_sit_on_the_offset_shape() { + // Drag from the right edge of the offset shape (10 pt outside the square) 100 pt to the right. + let drag = |on: bool| { + let mut s = session(); + let id = offset_square(&mut s, 100.0); + preview(&mut s, on); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + for (k, x) in [(PointerKind::Down, 210.0), (PointerKind::Drag, 310.0), (PointerKind::Up, 310.0)] { + s.pointer(&PointerEvent::new(k, x, 150.0), v).unwrap(); + } + geometric(&s, id) + }; + assert!(drag(true).width() > 150.0, "a scale from the edge of the offset shape"); + assert_eq!(drag(false).width(), 100.0, "off, the point is no handle"); +} + +/// The box `id` is drawn in (strokes and geometry effects included) and its geometric box after a +/// Selection tool drag from `from` to `to` with `mods`, with Smart Guides off. +fn drag_box(s: &mut Session, id: NodeId, from: (f64, f64), to: (f64, f64), mods: Mods) -> (Rect, Rect) { + let v = ViewInfo { smart_guides: false, ..ViewInfo::default() }; + s.select_tool("selection", v).unwrap(); + for (k, (x, y)) in [(PointerKind::Down, from), (PointerKind::Drag, to), (PointerKind::Up, to)] { + s.pointer(&PointerEvent { mods, ..PointerEvent::new(k, x, y) }, v).unwrap(); + } + let drawn = vectorcraft_render::effects::preview::bounds_of(&s.doc().unwrap().doc, &[id], true).unwrap(); + (drawn, geometric(s, id)) +} + +/// Drags of `make`'s object with Use Preview Bounds on, and Scale Strokes & Effects off and on: from +/// the handle at the first point to the second point, with the modifiers, giving the visual box. +type HandleDrags = [((f64, f64), (f64, f64), Mods, Rect); 4]; + +fn assert_handle_drags(make: fn(&mut Session) -> NodeId, drags: HandleDrags) { + for strokes in [false, true] { + for (from, to, mods, want) in drags { + let mut s = session(); + let id = make(&mut s); + preview(&mut s, true); + s.prefs.scale_strokes = strokes; + let (v, _) = drag_box(&mut s, id, from, to, mods); + assert!(near(v, want, 1e-6), "strokes {strokes}, {from:?} to {to:?}, {mods:?}: {v:?}"); + } + } +} + +const SHIFT: Mods = Mods { shift: true, alt: false, cmd: false, ctrl: false, space: false }; +const ALT: Mods = Mods { shift: false, alt: true, cmd: false, ctrl: false, space: false }; + +#[test] +fn a_handle_drag_keeps_the_opposite_visual_edge_of_a_stroked_path() { + // A 100 x 50 rectangle at (100, 100) with a 20 pt stroke, drawn from (90, 90) to (210, 160): the + // opposite edge of the visual box stays and the dragged edge goes to the pointer, with Shift in + // proportion, with Alt about the center. + let make = |s: &mut Session| rect(s, 100.0, 100.0, 20.0); + assert_handle_drags( + make, + [ + ((210.0, 125.0), (310.0, 125.0), Mods::default(), Rect::new(90.0, 90.0, 310.0, 160.0)), + ((210.0, 160.0), (260.0, 200.0), Mods::default(), Rect::new(90.0, 90.0, 260.0, 200.0)), + ((210.0, 160.0), (330.0, 170.0), SHIFT, Rect::new(90.0, 90.0, 330.0, 230.0)), + ((210.0, 125.0), (260.0, 125.0), ALT, Rect::new(40.0, 90.0, 260.0, 160.0)), + ], + ); + // Off, the handles sit on the path, and the path scales from its own opposite edge. + for strokes in [false, true] { + let mut s = session(); + let id = make(&mut s); + s.prefs.scale_strokes = strokes; + let (_, g) = drag_box(&mut s, id, (200.0, 125.0), (300.0, 125.0), Mods::default()); + assert_eq!(g, Rect::new(100.0, 100.0, 300.0, 150.0), "strokes {strokes}"); + let (_, g) = drag_box(&mut s, id, (300.0, 150.0), (350.0, 200.0), Mods::default()); + assert_eq!(g, Rect::new(100.0, 100.0, 350.0, 200.0), "strokes {strokes}"); + } +} + +#[test] +fn transform_again_repeats_the_transform_of_a_handle_drag_with_preview_bounds() { + // The drag takes the visual right edge of the stroked rectangle from 210 to 310, so the path, + // with its stroke kept, goes from 100..200 to 100..300: x becomes 2x - 100. + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 20.0); + preview(&mut s, true); + s.prefs.scale_strokes = false; + let (_, g) = drag_box(&mut s, id, (210.0, 125.0), (310.0, 125.0), Mods::default()); + assert!(near(g, Rect::new(100.0, 100.0, 300.0, 150.0), 1e-6), "{g:?}"); + run(&mut s, "object.transformAgain", json!({})); + assert!(near(geometric(&s, id), Rect::new(100.0, 100.0, 500.0, 150.0), 1e-6), "{:?}", geometric(&s, id)); +} + +#[test] +fn a_handle_drag_with_preview_bounds_scales_a_compound_paths_member_alone() { + // A ring: a 100 pt square at (100, 100) with a 20 pt square hole at (140, 140), the hole selected + // alone in the isolated compound path. + let mut s = session(); + let square = |s: &mut Session, at: f64, size: f64| { + NodeId(run(s, "shape.rectangle", json!({"x": at, "y": at, "width": size, "height": size}))["id"].as_u64().unwrap()) + }; + let (outer, hole) = (square(&mut s, 100.0, 100.0), square(&mut s, 140.0, 20.0)); + run(&mut s, "select.set", json!({"ids": [outer.0, hole.0]})); + let c = run(&mut s, "object.compoundPath.make", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "object.isolate", json!({"id": c})); + run(&mut s, "select.set", json!({"ids": [hole.0]})); + preview(&mut s, true); + let (b, around) = (s.transform_bounds(&[hole]).unwrap(), s.transform_bounds(&[outer])); + let (v, _) = drag_box(&mut s, hole, (b.x1, b.center().y), (b.x1 + 10.0, b.center().y), Mods::default()); + assert_eq!(s.transform_bounds(&[outer]), around, "the square around the hole stays"); + assert!(near(v, Rect::new(b.x0, b.y0, b.x1 + 10.0, b.y1), 1e-6), "the hole: {v:?} from {b:?}"); +} + +#[test] +fn a_handle_drag_with_preview_bounds_leaves_the_art_some_width() { + // Dragging the visual right edge of the rectangle with a kept 20 pt stroke (drawn from 90 to 210) + // to 95 asks for less width than the stroke adds: the path keeps 1e-4 of its width. + let mut s = session(); + let id = rect(&mut s, 100.0, 100.0, 20.0); + preview(&mut s, true); + s.prefs.scale_strokes = false; + let (_, g) = drag_box(&mut s, id, (210.0, 125.0), (95.0, 125.0), Mods::default()); + assert!((g.width() - 0.01).abs() < 1e-9, "{g:?}"); +} + +#[test] +fn a_handle_drag_keeps_the_opposite_edge_of_the_offset_shape() { + // A 100 pt square at (100, 100) with Offset Path 10, drawn from (90, 90) to (210, 210). + let make = |s: &mut Session| offset_square(s, 100.0); + assert_handle_drags( + make, + [ + ((210.0, 150.0), (310.0, 150.0), Mods::default(), Rect::new(90.0, 90.0, 310.0, 210.0)), + ((210.0, 210.0), (260.0, 250.0), Mods::default(), Rect::new(90.0, 90.0, 260.0, 250.0)), + ((210.0, 210.0), (330.0, 220.0), SHIFT, Rect::new(90.0, 90.0, 330.0, 330.0)), + ((210.0, 150.0), (260.0, 150.0), ALT, Rect::new(40.0, 90.0, 260.0, 210.0)), + ], + ); + // Off, the handles sit on the square, and the square scales from its own opposite edge. + for strokes in [false, true] { + let mut s = session(); + let id = make(&mut s); + s.prefs.scale_strokes = strokes; + let (_, g) = drag_box(&mut s, id, (200.0, 150.0), (300.0, 150.0), Mods::default()); + assert_eq!(g, Rect::new(100.0, 100.0, 300.0, 200.0), "strokes {strokes}"); + let (_, g) = drag_box(&mut s, id, (300.0, 200.0), (350.0, 250.0), Mods::default()); + assert_eq!(g, Rect::new(100.0, 100.0, 350.0, 250.0), "strokes {strokes}"); + } +} + +#[test] +fn hovering_over_an_unchanged_document_measures_nothing_again() { + // A selected square with Offset Path 10, drawn from 90 to 210, with Use Preview Bounds and Smart + // Guides on: the bounding box and the center Smart Guides label are measured once. + let mut s = session(); + let id = offset_square(&mut s, 100.0); + preview(&mut s, true); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let hover = |s: &mut Session, x: f64| { + s.pointer(&PointerEvent::new(PointerKind::Move, x, 150.0), v).unwrap(); + s.cursor(Point::new(x, 150.0), Mods::default(), v) + }; + let xs = [210.0, 150.0, 151.0, 180.0, 250.0]; + for x in xs { + hover(&mut s, x); + } + let measured = s.preview_memo.measurements(); + assert!(measured > 0, "the first hover measures"); + for x in xs { + hover(&mut s, x); + } + assert_eq!(s.preview_memo.measurements(), measured, "hovering again measures nothing"); + assert_eq!(hover(&mut s, 210.0), Cursor::ResizeH, "the right edge of the offset shape"); + assert_ne!(hover(&mut s, 230.0), Cursor::ResizeH); + + // Offset Path 30 draws the square from 70 to 230: the box is measured again. + run(&mut s, "effect.setParams", json!({"index": 0, "params": {"offset": 30}, "ids": [id.0], "item": null})); + assert_eq!(hover(&mut s, 230.0), Cursor::ResizeH, "the right edge of the new offset shape"); + let measured_again = s.preview_memo.measurements(); + assert!(measured_again > measured); + + // Moved 50 pt to the right, the square is drawn from 120 to 280. + run(&mut s, "object.move", json!({"dx": 50, "dy": 0})); + assert_eq!(hover(&mut s, 280.0), Cursor::ResizeH, "the right edge of the moved offset shape"); + assert!(s.preview_memo.measurements() > measured_again); +} diff --git a/crates/engine/src/tests_printtiling.rs b/crates/engine/src/tests_printtiling.rs index 122acb3ab..3df7c1298 100644 --- a/crates/engine/src/tests_printtiling.rs +++ b/crates/engine/src/tests_printtiling.rs @@ -144,6 +144,8 @@ fn tiles_start_at_the_tile_origin_and_cover_the_art() { // Bad input is refused. assert!(s.execute("print.tiling.set", &json!({})).is_err()); assert!(s.execute("print.tiling.set", &json!({"origin": [1, 2], "artboard": 7})).is_err()); + // A 32-bit build read 2^32 as artboard 0. + assert!(s.execute("print.tiling.set", &json!({"origin": [1, 2], "artboard": 1u64 << 32})).is_err()); assert!(s.execute("print.tiling.set", &json!({"origin": [1e9, 2]})).is_err()); } diff --git a/crates/engine/src/tests_rasterfilters.rs b/crates/engine/src/tests_rasterfilters.rs index fa08af258..45ead1054 100644 --- a/crates/engine/src/tests_rasterfilters.rs +++ b/crates/engine/src/tests_rasterfilters.rs @@ -31,12 +31,20 @@ const BRUSH_STROKES: [(&str, &str); 8] = [ ]; /// Effect › Artistic, at their defaults. -const ARTISTIC: [(&str, &str); 8] = [ +const ARTISTIC: [(&str, &str); 15] = [ + ("artistic.coloredPencil", "{}"), ("artistic.cutout", "{}"), + ("artistic.dryBrush", "{}"), ("artistic.filmGrain", "{}"), + ("artistic.fresco", "{}"), ("artistic.neonGlow", "{}"), + // Wide Blurry softens dabs; Simple leaves uniform fills unchanged on the striped art. + ("artistic.paintDaubs", r#"{"brushType": "wideBlurry"}"#), + ("artistic.paletteKnife", "{}"), ("artistic.plasticWrap", "{}"), ("artistic.posterEdges", "{}"), + ("artistic.underpainting", "{}"), + ("artistic.roughPastels", "{}"), ("artistic.smudgeStick", "{}"), ("artistic.sponge", "{}"), ("artistic.watercolor", "{}"), @@ -52,11 +60,12 @@ const PIXELATE: [(&str, &str); 4] = [("pixelate.colorHalftone", "{}"), ("pixelate.crystallize", "{}"), ("pixelate.mezzotint", "{}"), ("pixelate.pointillize", "{}")]; /// Effect › Sketch, at their defaults. -const SKETCH: [(&str, &str); 11] = [ +const SKETCH: [(&str, &str); 13] = [ ("sketch.basRelief", "{}"), ("sketch.chalkCharcoal", "{}"), ("sketch.charcoal", "{}"), ("sketch.chrome", "{}"), + ("sketch.conteCrayon", "{}"), ("sketch.graphicPen", "{}"), ("sketch.halftonePattern", "{}"), ("sketch.notePaper", "{}"), @@ -64,6 +73,7 @@ const SKETCH: [(&str, &str); 11] = [ ("sketch.plaster", "{}"), ("sketch.reticulation", "{}"), ("sketch.stamp", "{}"), + ("sketch.waterPaper", "{}"), ]; /// Effect › Texture, at their defaults. @@ -206,6 +216,7 @@ fn artistic_settings_survive_a_save_and_open() { json!({"levels": 3.0, "edgeSimplicity": 2.0, "edgeFidelity": 3.0}), [json!({"levels": 2.0}), json!({"edgeSimplicity": 10.0})], ), + ("artistic.fresco", json!({"brushSize": 4.0, "brushDetail": 6.0, "texture": 2.0}), [json!({"brushSize": 8.0}), json!({"texture": 3.0})]), ( "artistic.neonGlow", json!({"glowSize": -6.0, "glowBrightness": 30.0, "color": "#ff3300"}), @@ -221,6 +232,19 @@ fn artistic_settings_survive_a_save_and_open() { json!({"highlightStrength": 18.0, "detail": 10.0, "smoothness": 8.0}), [json!({"highlightStrength": 5.0}), json!({"highlightStrength": 0.0})], ), + ( + "artistic.roughPastels", + json!({ + "strokeLength": 8.0, + "strokeDetail": 12.0, + "texture": "sandstone", + "scaling": 100.0, + "relief": 8.0, + "lightDirection": "topLeft", + "invert": false + }), + [json!({"strokeLength": 20.0}), json!({"relief": 0.0})], + ), ] { let (mut s, g) = striped(); // Black and a grey beside the reds: Cutout puts the grey with black at 2 levels and apart @@ -334,6 +358,29 @@ fn glass_refuses_a_texture_that_is_not_an_image() { assert_eq!(glass_params(&s, g), before); } +/// Effect › Texture › Texturizer › Load Texture: an image is stored with the effect and drawn in relief. +#[test] +fn texturizer_loads_a_texture_and_draws_it() { + let png = rings_png(); + let data = vectorcraft_format::base64_encode(&png); + let (mut s, g) = striped(); + let file = json!({"name": "rings.png", "dataBase64": data}); + s.execute("effect.apply", &json!({"effect": "texture.texturizer", "ids": [g], "params": {"relief": 20, "textureImage": file}})).unwrap(); + let p = glass_params(&s, g); + assert_eq!(p["texture"], json!("loaded")); + let loaded = render(&s); + assert!(glass_params(&s, g)["textureImage"].as_str().is_some()); + for (_, builtin) in vectorcraft_render::effects::pixel::TEXTURES { + s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": builtin}})).unwrap(); + assert!(mean_diff(&loaded, &render(&s)) > 0.001, "{builtin}"); + } + s.execute("effect.setParams", &json!({"index": 0, "ids": [g], "params": {"texture": "loaded"}})).unwrap(); + assert_eq!(render(&s).pixels, loaded.pixels); + let doc = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap(); + assert_eq!(doc.node(NodeId(g)).unwrap().appearance.effects[0].params, glass_params(&s, g)); + assert_eq!(Renderer::new().render_region(&doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true).pixels, loaded.pixels); +} + #[test] fn spin_blur_reaches_past_the_corners() { let (mut s, g) = striped(); diff --git a/crates/engine/src/tests_recolor.rs b/crates/engine/src/tests_recolor.rs index 84d4cc670..bca05af5c 100644 --- a/crates/engine/src/tests_recolor.rs +++ b/crates/engine/src/tests_recolor.rs @@ -72,6 +72,8 @@ fn five_colours_reduce_to_two() { assert_eq!(run(&mut s, "recolor.colors", json!({}))["colors"].as_array().unwrap().len(), 5); // Without a count every colour is its own row. assert_eq!(run(&mut s, "recolor.reduce", json!({}))["map"].as_array().unwrap().len(), 5); + // A count larger than the number of colors also keeps every color in its own row. A 32-bit build read 2^32 + 2 as 2. + assert_eq!(run(&mut s, "recolor.reduce", json!({"colors": (1u64 << 32) + 2}))["map"].as_array().unwrap().len(), 5); } #[test] @@ -367,3 +369,54 @@ fn recolor_to_a_library_spot_colour_links_it_and_adds_the_swatch() { let r = run(&mut s, "recolor.apply", json!({"map": map, "method": "scaleTints", "limitTo": lib})); assert_eq!(r["swatches"], json!(["Leaf"])); } + +#[test] +fn model_param_chooses_the_model_of_the_new_colour() { + // RGB art in a CMYK document: by default a CMYK replacement comes out RGB (#2170). + let mut s = session(); + run(&mut s, "file.new", json!({"width": 400, "height": 200, "colorMode": "cmyk"})); + let art = NodeId(run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 15, "height": 15}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"ids": [art.0], "color": "#1a1a1a", "keepModel": true})); + assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Rgb); + select_all(&mut s); + let map = json!({"#1a1a1a": {"c": 0, "m": 0, "y": 0, "k": 100}}); + let r = run(&mut s, "recolor.apply", json!({"map": map})); + assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Rgb, "kept RGB"); + assert!(r["note"].as_str().is_some_and(|n| n.contains("model")), "warns the model was kept: {r}"); + run(&mut s, "edit.undo", json!({})); + let r = run(&mut s, "recolor.apply", json!({"map": map, "model": "new"})); + assert_eq!(fill(&s, art), Color::cmyk(0.0, 0.0, 0.0, 1.0)); + assert!(r.get("note").is_none(), "no note when the model follows the new colour: {r}"); + run(&mut s, "edit.undo", json!({})); + // "document" converts a new RGB colour into the document's CMYK. + run(&mut s, "recolor.apply", json!({"map": {"#1a1a1a": "#ff0000"}, "model": "document"})); + assert_eq!(fill(&s, art).model(), vectorcraft_color::cms::Model::Cmyk); + assert!(s.execute("recolor.apply", &json!({"map": map, "model": "nope"})).is_err()); +} + +#[test] +fn model_param_must_be_a_string_when_given() { + let mut s = session(); + let art = rect(&mut s, json!("#1a1a1a")); + select_all(&mut s); + for bad in [json!(42), json!(null), json!(true)] { + assert!(s.execute("recolor.apply", &json!({"map": {"#1a1a1a": "#ff0000"}, "model": bad})).is_err(), "{bad}"); + } + assert_eq!(fill(&s, art).to_hex(), "#1a1a1a", "nothing changed"); +} + +#[test] +fn document_model_converts_the_colours_of_a_group_too() { + let mut s = session(); + run(&mut s, "file.new", json!({"width": 400, "height": 200, "colorMode": "cmyk"})); + run(&mut s, "swatch.newGroup", json!({"name": "Theme", "colors": ["cmyk 0 0 0 50"]})); + let group = |s: &Session| -> Vec { + let g = s.doc().unwrap().doc.swatch_groups.iter().find(|g| g.name == "Theme").unwrap(); + g.swatches.iter().filter_map(|w| w.paint.color()).collect() + }; + // Group-only edit from the rows, and with explicit group colours. + run(&mut s, "recolor.apply", json!({"map": {"cmyk 0 0 0 50": "#ff0000"}, "group": "Theme", "model": "document"})); + assert!(group(&s).iter().all(|c| c.model() == vectorcraft_color::cms::Model::Cmyk), "{:?}", group(&s)); + run(&mut s, "recolor.apply", json!({"map": [], "group": "Theme", "groupColors": ["#00ff00"], "model": "document"})); + assert!(group(&s).iter().all(|c| c.model() == vectorcraft_color::cms::Model::Cmyk), "{:?}", group(&s)); +} diff --git a/crates/engine/src/tests_recovery.rs b/crates/engine/src/tests_recovery.rs index 3d732ab5c..e22d29d86 100644 --- a/crates/engine/src/tests_recovery.rs +++ b/crates/engine/src/tests_recovery.rs @@ -363,6 +363,33 @@ fn folder_areas_are_locked_by_running_apps() { let _ = std::fs::remove_dir_all(&dir); } +/// The copies are unsaved documents: on Unix the folders the store makes are owner-only, and a +/// store folder that already exists (one picked in Preferences) keeps its mode. +#[cfg(unix)] +#[test] +fn the_folder_store_makes_owner_only_folders() { + use std::os::unix::fs::PermissionsExt as _; + let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + let base = std::env::temp_dir().join(format!("vc-recovery-modes-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + // A store folder that doesn't exist yet is made with the first copy, owner-only like its area. + let new = base.join("new"); + let store = FolderStore::new(&new); + store.write("1-1/Untitled-1-1.vectorcraft", b"copy").unwrap(); + assert_eq!(mode(&new), 0o700); + assert_eq!(mode(&new.join("1-1")), 0o700); + assert!(matches!(store.lock("1-2"), Ok(cmd::recovery::Lock::Held(_)))); + assert_eq!(mode(&new.join("1-2")), 0o700); + // A folder that already exists keeps its mode; the area made inside it is owner-only. + let picked = base.join("picked"); + std::fs::create_dir_all(&picked).unwrap(); + std::fs::set_permissions(&picked, std::fs::Permissions::from_mode(0o755)).unwrap(); + FolderStore::new(&picked).write("2-1/Untitled-1-1.vectorcraft", b"copy").unwrap(); + assert_eq!(mode(&picked), 0o755); + assert_eq!(mode(&picked.join("2-1")), 0o700); + let _ = std::fs::remove_dir_all(&base); +} + #[test] fn saving_reverting_or_closing_removes_the_copy() { let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id())); diff --git a/crates/engine/src/tests_scripts.rs b/crates/engine/src/tests_scripts.rs index 36b163262..44341f2bc 100644 --- a/crates/engine/src/tests_scripts.rs +++ b/crates/engine/src/tests_scripts.rs @@ -113,7 +113,7 @@ fn links_out_of_the_folder_and_the_automation_read_root_keep_reads_inside() { assert_eq!(statuses(&r), ["notFollowed", "checked"]); assert_eq!(r["outside"], json!([])); // Under a read root, the script and its includes are read inside it, and a script outside isn't. - let roots = crate::file_access::AutomationRoots::new(Some(&dir.join("scripts")), None).unwrap().unwrap(); + let roots = crate::file_access::AutomationRoots::new(&[&dir.join("scripts")], &[]).unwrap().unwrap(); let mut s = Session::new(); let r = crate::file_access::confine(Some(&roots), || s.execute("script.check", &json!({"path": main, "includes": true}))).unwrap(); assert_eq!(statuses(&r), ["notFollowed", "checked"]); diff --git a/crates/engine/src/tests_select_ids_first.rs b/crates/engine/src/tests_select_ids_first.rs index fbeb2c867..03d4ebd0b 100644 --- a/crates/engine/src/tests_select_ids_first.rs +++ b/crates/engine/src/tests_select_ids_first.rs @@ -143,6 +143,24 @@ fn bad_ids_are_refused_and_change_nothing() { } } +#[test] +fn a_command_that_fails_after_selecting_its_ids_puts_the_selection_back() { + let (mut s, ids) = doc(); + let far = s.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [ids[3]]})).unwrap(); + let (before, layer) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().active_layer); + // The ids are fine, so they are selected; the shapes don't overlap, so Intersect then fails. + let e = s.execute("object.pathfinder.intersect", &json!({"ids": [ids[0], far]})).unwrap_err().to_string(); + assert!(e.contains("empty result"), "{e}"); + assert!(s.doc().unwrap().doc == before); + assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(ids[3])], "the selection stays"); + assert_eq!(s.doc().unwrap().active_layer, layer); + // Without ids the failing command leaves the selection it ran on, as before. + s.execute("select.set", &json!({"ids": [ids[0], far]})).unwrap(); + assert!(s.execute("object.pathfinder.intersect", &json!({})).is_err()); + assert_eq!(s.doc().unwrap().selection.objects, vec![NodeId(ids[0]), NodeId(far)]); +} + #[test] fn without_ids_the_selection_is_used_as_before() { let (mut s, ids) = doc(); diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests_shaper.rs index 244aba6a5..0de25e814 100644 --- a/crates/engine/src/tests_shaper.rs +++ b/crates/engine/src/tests_shaper.rs @@ -311,6 +311,23 @@ fn shaper_construction_taps_stay_in_the_isolated_group() { assert_eq!(s.doc().unwrap().isolation, Some(sources)); } +/// `object.isolate` on a Shaper Group whose source group is locked or hidden is refused, and +/// isolation mode stays off. +#[test] +fn shaper_isolate_refuses_a_locked_or_hidden_source_group() { + for (key, on) in [("locked", true), ("visible", false)] { + let mut s = session(); + rectangle(&mut s, 0.0, "#ff0000"); + rectangle(&mut s, 50.0, "#0000ff"); + let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]); + let sources = node(&s, g).children().unwrap()[0].id; + s.execute("object.setProps", &json!({"ids": [sources.0], key: on})).unwrap(); + let r = s.execute("object.isolate", &json!({"id": g.0})).map_err(|e| e.to_string()); + assert_eq!(r, Err("invalid parameters for `object.isolate`: the object is hidden or locked".into()), "{key}"); + assert_eq!(s.doc().unwrap().isolation, None, "{key}"); + } +} + #[test] fn shaper_lines_keep_free_angles_with_or_without_shift() { for shift in [false, true] { diff --git a/crates/engine/src/tests_simplify_part.rs b/crates/engine/src/tests_simplify_part.rs new file mode 100644 index 000000000..4f600f68a --- /dev/null +++ b/crates/engine/src/tests_simplify_part.rs @@ -0,0 +1,223 @@ +//! Simplify of the direct-selected part of a path: with anchors or segments of a path +//! direct-selected, `object.path.simplify` refits only each run of selected segments between its end +//! anchors, as the Simplify help page describes for part of a path picked with Direct Selection +//! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html). + +use std::collections::BTreeSet; + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; +use vectorcraft_geom::kurbo::ParamCurveNearest; +use vectorcraft_geom::{ParamCurve, PathData, SubPath}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn path_of(s: &Session, id: NodeId) -> PathData { + match &s.doc().unwrap().doc.node(id).unwrap().kind { + NodeKind::Path { path, .. } => path.clone(), + _ => panic!("not a path"), + } +} + +/// A path through `points`, closed or open, selected whole: its id. +fn polyline(s: &mut Session, points: &[(f64, f64)], closed: bool) -> NodeId { + let anchors: Vec = points.iter().map(|&(x, y)| json!({"x": x, "y": y})).collect(); + NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap()) +} + +/// A sine wave of 101 anchors, 3 pt apart, 40 pt high. +fn wave(s: &mut Session) -> NodeId { + let pts: Vec<(f64, f64)> = (0..=100).map(|i| (f64::from(i) * 3.0, 200.0 + 40.0 * (f64::from(i) * 3.0 / 50.0).sin())).collect(); + polyline(s, &pts, false) +} + +/// Direct-select anchors `ranges` (inclusive) of the first subpath of `id`. +fn select_anchors(s: &mut Session, id: NodeId, ranges: &[(usize, usize)]) { + let anchors: Vec = ranges.iter().flat_map(|&(a, b)| (a..=b).map(|i| json!([0, i]))).collect(); + s.execute("select.anchors", &json!({"id": id.0, "anchors": anchors})).unwrap(); +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// The farthest any of 11 points along each of `original`'s segments `segs` lies from `fitted`'s +/// segments `fit`. +fn worst_offset(original: &SubPath, segs: impl Iterator, fitted: &SubPath, fit: std::ops::Range) -> f64 { + let off = |p| fit.clone().map(|i| fitted.segment(i % fitted.segment_count()).nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); + segs.flat_map(|i| (0..=10).map(move |k| original.segment(i).eval(f64::from(k) / 10.0))).map(off).fold(0.0, f64::max) +} + +/// With anchors 20 to 60 of a 101-anchor wave direct-selected, Simplify refits only the 40 +/// segments between them: anchors 0–19 and 61–100 and the segments outside the run stay exactly as +/// they were, anchors 20 and 60 stay where they were with their handles on the side away from the +/// run, the run stays within the tolerance of the original, and it stays direct-selected. It is one +/// undo step, and undo brings back the path and the selection. +#[test] +fn simplify_refits_only_the_run_between_direct_selected_anchors() { + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id).subpaths[0].clone(); + select_anchors(&mut s, id, &[(20, 60)]); + let (doc, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), undo_len(&s)); + let out = s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + let p = path_of(&s, id); + let sp = &p.subpaths[0]; + let len = sp.anchors.len(); + assert_eq!(out["after"].as_u64(), Some(len as u64)); + assert!(len < 101 - 30, "{len}"); + let end = len - 41; + assert_eq!(sp.anchors[..20], original.anchors[..20], "the anchors before the run"); + assert_eq!(sp.anchors[end + 1..], original.anchors[61..], "the anchors after the run"); + assert_eq!((sp.anchors[20].p, sp.anchors[20].h_in), (original.anchors[20].p, original.anchors[20].h_in)); + assert_eq!((sp.anchors[end].p, sp.anchors[end].h_out), (original.anchors[60].p, original.anchors[60].h_out)); + for i in 0..20 { + assert_eq!(sp.segment(i), original.segment(i)); + assert_eq!(sp.segment(end + i), original.segment(60 + i)); + } + assert!(worst_offset(&original, 20..60, sp, 20..end) <= 1.0 + 1e-6); + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.anchors.get(&id), Some(&(20..=end).map(|i| (0, i)).collect()), "the run stays direct-selected"); + assert_eq!(undo_len(&s), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, doc); + assert_eq!(s.doc().unwrap().selection, selection); +} + +/// Two runs of one path simplify independently, in one undo step: the result is the first run as +/// Simplify makes it alone, the anchors between the runs as they were, and the second run as +/// Simplify makes it alone. +#[test] +fn separate_runs_on_one_path_simplify_independently_in_one_step() { + let alone = |ranges: &[(usize, usize)]| { + let mut s = session(); + let id = wave(&mut s); + select_anchors(&mut s, id, ranges); + s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + path_of(&s, id).subpaths[0].anchors.clone() + }; + let (first, second) = (alone(&[(10, 30)]), alone(&[(60, 80)])); + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id).subpaths[0].clone(); + select_anchors(&mut s, id, &[(10, 30), (60, 80)]); + let undo = undo_len(&s); + s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + assert_eq!(undo_len(&s), undo + 1, "one undo step"); + let both = path_of(&s, id).subpaths[0].anchors.clone(); + // Where old anchor 30 is in the first result, and old anchor 80 in the second. + let (a30, a80) = (first.len() - 71, second.len() - 21); + assert!(a30 < 30 && a80 < 80, "both runs lose anchors"); + let mid = a30 + 30; + assert_eq!(both[..=a30], first[..=a30]); + assert_eq!(both[a30 + 1..mid], original.anchors[31..60], "the anchors between the runs"); + assert_eq!(both[mid..], second[60..]); + let sel: BTreeSet<_> = (10..=a30).chain(mid..=mid + a80 - 60).map(|i| (0, i)).collect(); + assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&sel)); +} + +/// On a closed 80-gon, a run from anchor 70 through the start point to anchor 10 simplifies as one +/// run: anchor 0 goes, anchors 11–69 stay exactly as they were (the path now starts at anchor 10), +/// anchors 10 and 70 stay where they were with their outer handles, and the path stays closed. +#[test] +fn a_run_across_the_start_of_a_closed_path_simplifies_as_one() { + let mut s = session(); + let pts: Vec<(f64, f64)> = + (0..80).map(|i| (f64::from(i) * std::f64::consts::TAU / 80.0).sin_cos()).map(|(y, x)| (300.0 + 100.0 * x, 300.0 + 100.0 * y)).collect(); + let id = polyline(&mut s, &pts, true); + let original = path_of(&s, id).subpaths[0].clone(); + select_anchors(&mut s, id, &[(70, 79), (0, 10)]); + s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + let sp = path_of(&s, id).subpaths[0].clone(); + assert!(sp.closed); + assert!(sp.anchors.len() < 80 - 10, "{}", sp.anchors.len()); + assert!(!sp.anchors.iter().any(|a| a.p == original.anchors[0].p), "anchor 0 was inside the run"); + assert_eq!(sp.anchors[1..60], original.anchors[11..70]); + assert_eq!((sp.anchors[0].p, sp.anchors[0].h_out), (original.anchors[10].p, original.anchors[10].h_out)); + assert_eq!((sp.anchors[60].p, sp.anchors[60].h_in), (original.anchors[70].p, original.anchors[70].h_in)); + for i in 0..60 { + assert_eq!(sp.segment(i), original.segment(10 + i)); + } + let n = sp.anchors.len(); + assert!(worst_offset(&original, (70..80).chain(0..10), &sp, 60..n) <= 1.0 + 1e-6); + let sel: BTreeSet<_> = std::iter::once((0, 0)).chain((60..n).map(|i| (0, i))).collect(); + assert_eq!(s.doc().unwrap().selection.anchors.get(&id), Some(&sel)); +} + +/// Segments picked with the Direct Selection tool simplify the same way, and stay picked as +/// segments. +#[test] +fn picked_segments_simplify_and_stay_picked() { + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id).subpaths[0].clone(); + let segs: Vec = (20..60).map(|i| json!([0, i])).collect(); + s.execute("select.segments", &json!({"id": id.0, "segments": segs})).unwrap(); + s.execute("object.path.simplify", &json!({"tolerance": 1})).unwrap(); + let sp = path_of(&s, id).subpaths[0].clone(); + let end = sp.anchors.len() - 41; + assert_eq!(sp.anchors[..20], original.anchors[..20]); + assert_eq!(sp.anchors[end + 1..], original.anchors[61..]); + let sel = &s.doc().unwrap().selection; + assert_eq!(sel.segments.get(&id), Some(&(20..end).map(|i| (0, i)).collect())); + assert_eq!(sel.anchors.get(&id), Some(&(20..=end).map(|i| (0, i)).collect())); +} + +/// `wholePaths` simplifies a partly selected path whole, as Simplify with it selected whole does. +#[test] +fn whole_paths_ignores_the_direct_selection() { + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id); + select_anchors(&mut s, id, &[(20, 60)]); + s.execute("object.path.simplify", &json!({"tolerance": 1, "wholePaths": true})).unwrap(); + let opts = vectorcraft_pathops::SimplifyOptions { tolerance: 1.0, ..Default::default() }; + assert_eq!(path_of(&s, id), vectorcraft_pathops::simplify_with(&original, &opts)); + assert!(s.doc().unwrap().selection.anchors.is_empty()); +} + +/// The Simplify slider's preview, at a level or at Auto-Simplify's, simplifies only the run too, and +/// committing it is what the command does. +#[test] +fn slider_preview_and_auto_simplify_only_the_run() { + for p in [json!({"level": 40}), json!({"auto": true})] { + let mut s = session(); + let id = wave(&mut s); + let original = path_of(&s, id).subpaths[0].clone(); + select_anchors(&mut s, id, &[(20, 60)]); + let undo = undo_len(&s); + s.begin_interaction("Simplify").unwrap(); + s.preview("object.path.simplify", &json!({"level": 90})).unwrap(); + s.preview("object.path.simplify", &p).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(undo_len(&s), undo + 1, "{p}"); + let previewed = path_of(&s, id); + let sp = &previewed.subpaths[0]; + assert!(sp.anchors.len() < 101, "{p}"); + assert_eq!(sp.anchors[..20], original.anchors[..20], "{p}"); + assert_eq!(sp.anchors[sp.anchors.len() - 40..], original.anchors[61..], "{p}"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.path.simplify", &p).unwrap(); + assert_eq!(path_of(&s, id), previewed, "{p}"); + } +} + +/// A run Simplify can't make simpler (two sides of a rectangle, which meet at a corner) is left as +/// it is: no undo step, and the rectangle stays a live shape. +#[test] +fn a_run_that_cannot_be_simpler_changes_nothing() { + let mut s = session(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap()["id"].as_u64().unwrap()); + let before = s.doc().unwrap().doc.clone(); + select_anchors(&mut s, id, &[(0, 2)]); + let undo = undo_len(&s); + s.execute("object.path.simplify", &json!({"tolerance": 5})).unwrap(); + assert_eq!(undo_len(&s), undo); + assert_eq!(s.doc().unwrap().doc, before); +} diff --git a/crates/engine/src/tests_slices.rs b/crates/engine/src/tests_slices.rs index 24674a1ff..897d0569f 100644 --- a/crates/engine/src/tests_slices.rs +++ b/crates/engine/src/tests_slices.rs @@ -140,6 +140,9 @@ fn divide_two_by_three_and_by_size() { assert_eq!(hs, [25.0, 25.0, 10.0]); assert!(s.execute("object.slice.divide", &json!({"rows": 0})).is_err()); assert!(s.execute("object.slice.divide", &json!({"columns": 5000})).is_err()); + // A 32-bit build read 2^32 + 2 as 2, passed the check and went on to list 2^32 + 3 cuts. + let e = s.execute("object.slice.divide", &json!({"rows": (1u64 << 32) + 2})).unwrap_err().to_string(); + assert!(e.contains("divide into 1 to"), "{e}"); assert!(s.execute("object.slice.divide", &json!({})).is_err(), "nothing to divide by"); } diff --git a/crates/engine/src/tests_stylepanel.rs b/crates/engine/src/tests_stylepanel.rs index 07ffb03ff..81a165d1e 100644 --- a/crates/engine/src/tests_stylepanel.rs +++ b/crates/engine/src/tests_stylepanel.rs @@ -102,6 +102,16 @@ fn move_reorders_and_undoes_in_one_step() { assert!(s.execute("graphicStyle.move", &json!({"name": first})).is_err()); } +/// A graphic style moved to place 2^32 goes last. A 32-bit build read the place as 0 and moved it +/// to the front. +#[test] +fn move_past_u32_goes_last() { + let mut s = session(); + let second = names(&s)[1].clone(); + run(&mut s, "graphicStyle.move", json!({"name": second, "to": 1u64 << 32})); + assert_eq!(names(&s).last().unwrap(), &second); +} + #[test] fn override_character_color_clears_the_run_fill() { let mut s = session(); diff --git a/crates/engine/src/tests_styles.rs b/crates/engine/src/tests_styles.rs index 8c0ff4e76..1b464d564 100644 --- a/crates/engine/src/tests_styles.rs +++ b/crates/engine/src/tests_styles.rs @@ -250,6 +250,10 @@ fn select_same_appearance_attribute() { run(&mut s, "select.set", json!({"ids": [a.0]})); run(&mut s, "select.same.appearanceAttribute", json!({"item": 1})); assert_eq!(s.doc().unwrap().selection.objects, vec![a, b, c]); + // An item past the last one leaves the shared effect. A 32-bit build read 2^32 + 1 as item 1. + run(&mut s, "select.set", json!({"ids": [a.0]})); + run(&mut s, "select.same.appearanceAttribute", json!({"item": (1u64 << 32) + 1})); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); } #[test] diff --git a/crates/engine/src/tests_swatchcmds.rs b/crates/engine/src/tests_swatchcmds.rs index 459bf671e..d62543173 100644 --- a/crates/engine/src/tests_swatchcmds.rs +++ b/crates/engine/src/tests_swatchcmds.rs @@ -56,6 +56,17 @@ fn move_between_groups_is_one_undo_step() { assert_eq!(names(&s, None).last().map(String::as_str), Some("White")); } +/// A swatch or color group moved to place 2^32 goes last. A 32-bit build read the place as 0 and +/// moved it to the front. +#[test] +fn move_past_u32_goes_last() { + let mut s = session(); + run(&mut s, "swatch.move", json!({"name": "White", "to": 1u64 << 32})); + assert_eq!(names(&s, None).last().map(String::as_str), Some("White")); + run(&mut s, "swatch.move", json!({"name": "Grays", "to": 1u64 << 32})); + assert_eq!(doc(&s).swatch_groups.last().map(|g| g.name.as_str()), Some("Grays")); +} + #[test] fn move_reorders_groups_and_refuses_bad_moves() { let mut s = session(); @@ -267,6 +278,71 @@ fn a_cmyk_document_stores_rgb_colors_given_to_the_swatch_commands_as_cmyk() { assert_eq!(color(&s, &r["names"][0]), Some(green)); } +/// In a CMYK document `swatch.new` (with `color`, `colors` or `mode`), `swatch.newGroup`, +/// `swatch.edit` (its `keepModel` and its `paint`'s) and `swatch.editGroup` refuse a `keepModel` given +/// as text or as another value that is not true or false, and the swatches and the undo history stay +/// as they were. Null is read as `keepModel` left out. +#[test] +fn the_swatch_commands_refuse_a_keep_model_given_as_text() { + use vectorcraft_color::Color; + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100, "colorMode": "cmyk"})); + let cms = vectorcraft_color::cms::active(); + let managed = |c: Color| cms.convert(&c, vectorcraft_color::cms::Model::Cmyk, cms.settings().intent); + let name = run(&mut s, "swatch.new", json!({"color": "#000000", "keepModel": true}))["name"].clone(); + run(&mut s, "swatch.newGroup", json!({"name": "G", "colors": ["#00ff00"]})); + let before = (doc(&s).clone(), undo_len(&s)); + let calls = [ + ("swatch.new", json!({"color": "#ff0000", "keepModel": "true"})), + ("swatch.new", json!({"colors": ["#ff0000"], "keepModel": 1})), + ("swatch.new", json!({"color": "#ff0000", "mode": "rgb", "keepModel": "false"})), + ("swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "keepModel": "true"})), + ("swatch.edit", json!({"name": name, "color": "#ff0000", "keepModel": [true]})), + ("swatch.edit", json!({"name": name, "paint": {"color": "#ff0000", "keepModel": "true"}, "mode": "cmyk"})), + ("swatch.editGroup", json!({"group": "G", "colors": ["#ff0000"], "keepModel": "true"})), + ]; + for (cmd, p) in &calls { + let r = s.execute(cmd, p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == "`keepModel` must be true or false"), "{cmd} {p}: {r:?}"); + assert_eq!((doc(&s).clone(), undo_len(&s)), before, "{cmd} {p}"); + } + let r = run(&mut s, "swatch.new", json!({"color": "#ff0000", "keepModel": null})); + assert_eq!(doc(&s).swatch(r["name"].as_str().unwrap()).and_then(|w| w.paint.color()), Some(managed(Color::rgb(1.0, 0.0, 0.0)))); +} + +/// The swatch commands refuse an on/off option given as text or as another value that is not true +/// or false, and `swatch.move` a `to` that is not a number, naming the key; the swatches and the +/// undo history stay as they were. True, false, numbers and null still work. +#[test] +fn the_swatch_commands_refuse_a_boolean_or_number_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let mut s = session(); + run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})); + run(&mut s, "paint.setFill", json!({"color": "#3366cc"})); + let name = run(&mut s, "swatch.new", json!({"color": "#000000"}))["name"].clone(); + let mut calls: Vec<_> = [ + ("swatch.new", json!({"color": "#ff0000", "spot": "true"}), "spot"), + ("swatch.new", json!({"color": "#ff0000", "global": 1}), "global"), + ("swatch.delete", json!({"name": name, "unlink": "false"}), "unlink"), + ("swatch.newGroup", json!({"name": "K", "colors": ["#00ff00"], "includeTints": "true"}), "includeTints"), + ("swatch.newGroup", json!({"name": "K", "fromArtwork": "true"}), "fromArtwork"), + ("swatch.newGroup", json!({"name": "K", "fromArtwork": true, "toGlobal": "false"}), "toGlobal"), + ("swatch.edit", json!({"name": name, "spot": "true"}), "spot"), + ("swatch.edit", json!({"name": name, "global": [true]}), "global"), + ("swatch.addUsedColors", json!({"global": "true"}), "global"), + ("swatch.addUsedColors", json!({"selection": 1}), "selection"), + ] + .into_iter() + .map(|(cmd, p, key)| not_a_switch(cmd, p, key)) + .collect(); + calls.push(not_a_number("swatch.move", json!({"name": name, "to": "0"}), "to")); + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + run(&mut s, "swatch.edit", json!({"name": name, "global": true, "spot": null})); + run(&mut s, "swatch.move", json!({"name": name, "to": 0})); + assert!(doc(&s).swatch(name.as_str().unwrap()).is_some_and(|w| w.global && !w.spot)); +} + #[test] fn rgb_documents_keep_colours_as_given_and_edits_keep_the_source_model() { use vectorcraft_color::Color; diff --git a/crates/engine/src/tests_swatchlib.rs b/crates/engine/src/tests_swatchlib.rs index 0f25a04ec..7b17fa586 100644 --- a/crates/engine/src/tests_swatchlib.rs +++ b/crates/engine/src/tests_swatchlib.rs @@ -189,6 +189,38 @@ fn libraries_saved_in_the_user_folder_are_user_defined() { let _ = std::fs::remove_dir_all(dir); } +/// A library kind whose reader panics on a file that holds `trap`, as a parser bug would. +#[derive(Default)] +struct Trap(String); + +impl cmd::swatchlib::LibraryFile for Trap { + const EXTS: &'static [&'static str] = &["trap"]; + fn read(bytes: &[u8], stem: &str) -> std::result::Result { + if bytes == b"trap" { + panic!("a parser bug"); + } + Ok(Self(stem.to_string())) + } + fn name(&self) -> &str { + &self.0 + } +} + +/// When reading one file of the user library folder panics, `set_user_dir` (as the desktop app +/// calls it at launch) skips that file and lists the other one. +#[test] +fn a_user_library_file_whose_reading_panics_is_skipped() { + let dir = temp_dir("user-panic"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Calm.trap"), "calm").unwrap(); + std::fs::write(dir.join("Loud.trap"), "trap").unwrap(); + let mut libraries = cmd::swatchlib::Libraries::::default(); + libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let names: Vec<&str> = libraries.infos().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Calm"]); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn another_documents_swatches_load_as_a_library() { let mut s = session(); @@ -436,6 +468,52 @@ fn copying_a_color_book_from_a_temporary_path_uses_its_title() { let _ = std::fs::remove_dir_all(dir); } +/// Color books in the user library folder and opened by path show the file name as the tab title +/// (#3395), not only the title stored inside the book. +#[test] +fn color_book_tab_titles_use_the_file_name() { + fn acb_bytes(title: &str) -> Vec { + fn acb_str(out: &mut Vec, s: &str) { + let units: Vec = s.encode_utf16().collect(); + out.extend((units.len() as u32).to_be_bytes()); + units.iter().for_each(|u| out.extend(u.to_be_bytes())); + } + let mut bytes = b"8BCB".to_vec(); + bytes.extend(1u16.to_be_bytes()); + bytes.extend(3000u16.to_be_bytes()); + for s in [title, "PANTONE ", " C", ""] { + acb_str(&mut bytes, s); + } + bytes.extend(1u16.to_be_bytes()); + bytes.extend(7u16.to_be_bytes()); + bytes.extend(0u16.to_be_bytes()); + bytes.extend(0u16.to_be_bytes()); + acb_str(&mut bytes, "185"); + bytes.extend(b"TST001"); + bytes.extend([228, 0, 43]); + bytes.extend(b"spflspot"); + bytes + } + let dir = temp_dir("acb-name"); + let user = dir.join("user"); + let elsewhere = dir.join("elsewhere"); + std::fs::create_dir_all(&user).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + let on_disk = user.join("PANTONE Solid Coated.acb"); + std::fs::write(&on_disk, acb_bytes("Internal Book Title")).unwrap(); + let named = elsewhere.join("Brand Colors.acb"); + std::fs::write(&named, acb_bytes("Another Internal Title")).unwrap(); + + let mut s = session(); + s.swatch_libraries.set_user_dir(Some(user.to_string_lossy().to_string())); + assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": "user/PANTONE Solid Coated.acb" }))["name"], "PANTONE Solid Coated"); + let loaded = run(&mut s, "swatch.library.load", json!({ "path": named.to_string_lossy() }))["library"].clone(); + assert_eq!(run(&mut s, "swatch.library.get", json!({ "library": loaded }))["name"], "Brand Colors"); + let r = run(&mut s, "swatch.library.copyToUser", json!({ "library": loaded })); + assert_eq!((r["library"].as_str(), r["name"].as_str(), r["copied"].as_bool()), (Some("user/Brand Colors.acb"), Some("Brand Colors"), Some(true))); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn a_library_group_stays_one_group_when_a_swatch_has_its_name() { use vectorcraft_testkit::ase::{Block, ase}; diff --git a/crates/engine/src/tests_symbollib.rs b/crates/engine/src/tests_symbollib.rs new file mode 100644 index 000000000..eed7229ef --- /dev/null +++ b/crates/engine/src/tests_symbollib.rs @@ -0,0 +1,227 @@ +//! Symbol libraries: the built-in library, saving a document's symbols as a library (User +//! Defined, or a file), reopening it and adding its symbols to another document (nested symbols, +//! patterns and Symbol Options along, one undo step, deduped, renamed on a clash, placed), other +//! documents' symbols, and files that can't be read. + +use serde_json::{Value, json}; +use vectorcraft_doc::{NodeId, NodeKind, SymbolRegistration}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn doc(s: &Session) -> &vectorcraft_doc::Document { + &s.doc().unwrap().doc +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +fn symbols(s: &Session) -> Vec { + doc(s).symbols.iter().map(|x| x.name.clone()).collect() +} + +fn strs(v: &Value) -> Vec<&str> { + v.as_array().unwrap().iter().map(|n| n.as_str().unwrap()).collect() +} + +/// A folder of its own under the temp folder, emptied. +fn folder(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("vc-symbollib-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +/// A document with "Star" (a red star), "Badge" (a circle with a Star instance in it, registration +/// top left) and "Dots" (a square painted with pattern "Dots"). +fn kit() -> Session { + let mut s = session(); + run(&mut s, "shape.star", json!({"cx": 50, "cy": 50, "radius1": 20, "radius2": 8})); + run(&mut s, "paint.setFill", json!({"color": "#ff0000"})); + run(&mut s, "symbol.new", json!({"name": "Star"})); + let star = s.doc().unwrap().selection.objects.clone(); + let ring = run(&mut s, "shape.ellipse", json!({"x": 20, "y": 20, "width": 60, "height": 60}))["id"].as_u64().unwrap(); + let mut ids: Vec = star.iter().map(|i| i.0).collect(); + ids.push(ring); + run(&mut s, "symbol.new", json!({"name": "Badge", "ids": ids, "registration": 0})); + s.edit("Pattern", |d, _| { + let dot = vectorcraft_doc::Node::path( + d.alloc_id(), + vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 4.0, 4.0)), + vectorcraft_doc::Appearance::default_art(), + ); + let def = vectorcraft_doc::PatternDef { + tile: vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0), + ..vectorcraft_doc::PatternDef::new("Dots", vec![std::sync::Arc::new(dot)]) + }; + d.patterns.push(def); + d.swatches.push(vectorcraft_color::Swatch { + name: "Dots".into(), + paint: vectorcraft_color::Paint::Pattern { pattern: "Dots".into(), xf: Default::default() }, + global: false, + spot: false, + }); + Ok(()) + }) + .unwrap(); + run(&mut s, "shape.rectangle", json!({"x": 120, "y": 120, "width": 40, "height": 30})); + run(&mut s, "paint.setFill", json!({"swatch": "Dots"})); + run(&mut s, "symbol.new", json!({"name": "Dots"})); + s +} + +#[test] +fn the_builtin_library_is_original_art_generated_in_code() { + let mut s = Session::new(); + let list = run(&mut s, "symbol.libraries", json!({})); + let libs = list["libraries"].as_array().unwrap(); + assert_eq!(libs.len(), 1, "{libs:?}"); + assert_eq!((libs[0]["id"].as_str(), libs[0]["category"].as_str()), (Some("basic-shapes"), Some("builtIn"))); + let lib = run(&mut s, "symbol.library", json!({"library": "basic shapes"})); + let names: Vec<&str> = lib["symbols"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["Circle", "Square", "Rounded Square", "Triangle", "Diamond", "Hexagon", "Star", "Burst", "Arrow"]); + assert_eq!(libs[0]["count"].as_u64(), Some(names.len() as u64)); + let arrow = lib["symbols"].as_array().unwrap().iter().find(|x| x["name"] == "Arrow").unwrap(); + assert_eq!(arrow["size"], json!([48.0, 32.0])); + assert!(s.execute("symbol.library", &json!({"library": "nope"})).is_err()); +} + +#[test] +fn a_saved_library_reopens_and_its_symbols_join_another_document_in_one_step() { + let dir = folder("user"); + let mut s = kit(); + s.symbol_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + // Saving the Badge brings the Star it nests, not Dots. + let r = run(&mut s, "symbol.saveLibrary", json!({"user": true, "name": "Signs", "names": ["Badge"]})); + assert_eq!((r["library"].as_str(), r["count"].as_u64()), (Some("user/Signs.vectorcraft"), Some(2))); + let path = r["path"].as_str().unwrap().to_string(); + let list = run(&mut s, "symbol.libraries", json!({})); + let user: Vec<&Value> = list["libraries"].as_array().unwrap().iter().filter(|l| l["category"] == "user").collect(); + assert_eq!(user.len(), 1); + assert_eq!((user[0]["name"].as_str(), user[0]["count"].as_u64()), (Some("Signs"), Some(2))); + // The file is a document: its symbols, no art. + let bytes = std::fs::read(&path).unwrap(); + let saved = vectorcraft_format::load(&bytes).unwrap(); + assert_eq!(saved.symbols.iter().map(|x| x.name.as_str()).collect::>(), ["Star", "Badge"]); + assert!(saved.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty()))); + // Loading the file gives that User Defined library. + let r = run(&mut s, "symbol.loadLibrary", json!({ "path": path })); + assert_eq!((r["library"].as_str(), r["name"].as_str()), (Some("user/Signs.vectorcraft"), Some("Signs"))); + + // Into a new document: Badge and the Star in it, with Badge's registration, as one step. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + let before = undo_len(&s); + let r = run(&mut s, "symbol.addFromLibrary", json!({"library": "Signs", "name": "Badge"})); + assert_eq!((strs(&r["names"]), strs(&r["added"]), strs(&r["existing"])), (vec!["Badge"], vec!["Badge"], vec![])); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Add to Symbols"); + assert_eq!(symbols(&s), ["Star", "Badge"]); + assert_eq!(doc(&s).symbol_registration("Badge"), SymbolRegistration(0)); + let (_, lib) = cmd::symbollib::library(&s, "Signs").unwrap(); + assert_eq!(doc(&s).symbols, lib.doc.symbols); + assert_eq!(doc(&s).unknown.get("symbolSizes").and_then(|m| m.get("Badge")), lib.doc.unknown.get("symbolSizes").and_then(|m| m.get("Badge"))); + assert_eq!(run(&mut s, "symbol.list", json!({}))["current"], "Badge"); + // Undo takes it all back. + run(&mut s, "edit.undo", json!({})); + assert!(symbols(&s).is_empty()); + run(&mut s, "edit.redo", json!({})); + // Again: already there, so no step. + let before = undo_len(&s); + let r = run(&mut s, "symbol.addFromLibrary", json!({"library": "user/Signs.vectorcraft", "names": ["Badge", "Star", "Badge"]})); + assert_eq!((strs(&r["names"]), strs(&r["existing"])), (vec!["Badge", "Star"], vec!["Badge", "Star"])); + assert_eq!((undo_len(&s), symbols(&s).len()), (before, 2)); + assert_eq!(run(&mut s, "symbol.list", json!({}))["current"], "Badge"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn a_library_file_brings_patterns_renames_clashes_and_places_an_instance() { + let dir = folder("file"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Kit.vectorcraft"); + let mut s = kit(); + let r = run(&mut s, "symbol.saveLibrary", json!({"path": file.to_string_lossy()})); + assert_eq!(r["count"].as_u64(), Some(3)); + let lib = run(&mut s, "symbol.loadLibrary", json!({"path": file.to_string_lossy()})); + assert_eq!((lib["name"].as_str(), lib["count"].as_u64()), (Some("Kit"), Some(3))); + let id = lib["library"].as_str().unwrap().to_string(); + assert!(id.starts_with("loaded/")); + + // A document with another "Star" of its own. + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + run(&mut s, "symbol.new", json!({"name": "Star"})); + let mine = doc(&s).symbols[0].clone(); + let before = undo_len(&s); + let r = run(&mut s, "symbol.addFromLibrary", json!({"library": id, "names": ["Star", "Dots"], "place": true, "x": 30, "y": 40})); + assert_eq!(undo_len(&s), before + 1, "adding and placing are one step"); + assert_eq!(strs(&r["names"]), ["Star 2", "Dots"]); + assert_eq!(doc(&s).symbols[0], mine, "the document's own Star stays"); + // The pattern came along, with its swatch; the placed instance is the first one, selected. + assert!(doc(&s).pattern("Dots").is_some() && doc(&s).swatch("Dots").is_some()); + let placed = NodeId(r["placed"].as_u64().unwrap()); + assert_eq!(s.doc().unwrap().selection.objects, [placed]); + let NodeKind::SymbolInstance { symbol, .. } = &doc(&s).node(placed).unwrap().kind else { panic!("not an instance") }; + assert_eq!(symbol, "Star 2"); + let b = doc(&s).node(placed).unwrap().geometric_bounds().unwrap(); + assert!((b.center().x - 30.0).abs() < 0.01 && (b.center().y - 40.0).abs() < 0.01, "{b:?}"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(symbols(&s), ["Star"]); + assert!(doc(&s).node(placed).is_none() && doc(&s).pattern("Dots").is_none()); + // Placing a symbol the document has still places (one step), without adding it again. + run(&mut s, "edit.redo", json!({})); + let before = undo_len(&s); + let r = run(&mut s, "symbol.addFromLibrary", json!({"library": id, "name": "Dots", "place": true})); + assert_eq!((undo_len(&s), strs(&r["existing"])), (before + 1, vec!["Dots"])); + assert!(r["placed"].is_u64()); + assert!(s.execute("symbol.addFromLibrary", &json!({"library": id, "name": "Nope"})).is_err()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn other_documents_symbols_load_and_unreadable_files_are_errors() { + let mut s = kit(); + // Another document's symbols load as a library, without its art. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let r = run(&mut s, "symbol.loadLibrary", json!({"dataBase64": b64, "name": "Poster.vectorcraft"})); + assert_eq!((r["name"].as_str(), r["count"].as_u64()), (Some("Poster"), Some(3))); + let (_, lib) = cmd::symbollib::library(&s, r["library"].as_str().unwrap()).unwrap(); + assert!(lib.doc.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty()))); + // So do an SVG's. + let svg = r##""##; + let r = run(&mut s, "symbol.loadLibrary", json!({"data": svg, "name": "Dots.svg"})); + assert_eq!(r["count"].as_u64(), Some(1), "{r}"); + // Files that aren't documents, missing files and damaged native files are errors. + assert!(s.execute("symbol.loadLibrary", &json!({"data": "hello", "name": "x.txt"})).is_err()); + assert!(s.execute("symbol.loadLibrary", &json!({"path": "/no/such/Library.vectorcraft"})).is_err()); + let dir = folder("bad"); + std::fs::create_dir_all(&dir).unwrap(); + let bad = dir.join("Broken.vectorcraft"); + std::fs::write(&bad, b"{\"format\": \"vectorcraft\", \"doc\": 7").unwrap(); + assert!(s.execute("symbol.loadLibrary", &json!({"path": bad.to_string_lossy()})).is_err()); + // A damaged file in the user folder isn't listed. + s.symbol_libraries.set_user_dir(Some(dir.to_string_lossy().to_string())); + let list = run(&mut s, "symbol.libraries", json!({})); + assert!(list["libraries"].as_array().unwrap().iter().all(|l| l["category"] != "user")); + s.symbol_libraries.set_user_dir(None); + let _ = std::fs::remove_dir_all(dir); + // Nothing to save, or no such symbol, is an error; a headless session has no user folder. + assert!(s.execute("symbol.saveLibrary", &json!({"names": ["Nope"]})).is_err()); + assert!(s.execute("symbol.saveLibrary", &json!({"user": true})).is_err()); + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + assert!(s.execute("symbol.saveLibrary", &json!({})).is_err()); + // Without a path the library comes back as the file's bytes. + let mut s = kit(); + let r = run(&mut s, "symbol.saveLibrary", json!({"names": ["Star"]})); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().symbols.len(), 1); +} diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index 4a761ec0c..b458f55fd 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -55,6 +55,9 @@ fn edit_range_replaces_and_undoes() { assert_eq!(obj(&s, id).plain_text(), "Hello!"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(obj(&s, id).plain_text(), "Hello there"); + // An offset of 2^32 is past the end too. A 32-bit build read it as 0 and inserted at the start. + s.execute("text.editRange", &json!({"id": id.0, "start": 1u64 << 32, "end": 1u64 << 32, "insert": "!"})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello there!"); assert!(s.execute("text.editRange", &json!({"start": 0})).is_err(), "id required"); let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); assert!(s.execute("text.editRange", &json!({"id": rect["id"], "insert": "x"})).is_err(), "not text"); @@ -84,6 +87,17 @@ fn set_range_style_splits_runs() { assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err()); } +/// A character style applied from byte 2^32 to 2^32 + 5 styles no character, as a range past the +/// end does. A 32-bit build read the range as bytes 0 to 5 and styled "Hello". +#[test] +fn char_style_range_past_u32_styles_nothing() { + let mut s = session(); + let id = text(&mut s, "Hello world"); + s.execute("charStyle.new", &json!({"name": "Big", "attrs": {"size": 40.0}})).unwrap(); + s.execute("charStyle.apply", &json!({"name": "Big", "id": id.0, "start": 1u64 << 32, "end": (1u64 << 32) + 5})).unwrap(); + assert!(obj(&s, id).runs.iter().all(|r| r.style.size == 20.0 && r.style.style_name.is_none()), "{:?}", obj(&s, id).runs); +} + #[test] fn set_range_style_defaults_to_all_text_and_get_range() { let mut s = session(); @@ -98,6 +112,79 @@ fn set_range_style_defaults_to_all_text_and_get_range() { assert_eq!(r["length"], json!(3)); } +/// The type commands refuse a number or an on/off option given as text or as another value of the +/// wrong kind, naming the key, and leave the document and its undo history as they were; true, +/// false, numbers and null still work. +#[test] +fn type_commands_refuse_a_number_or_boolean_given_as_text() { + use super::tests::{not_a_number, not_a_switch, not_refused}; + let mut s = session(); + let id = text(&mut s, "abc"); + let frame = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 100, "height": 50})).unwrap()["id"].clone(); + let boxed = s.execute("text.create", &json!({"x": 0, "y": 300, "text": "a", "area": {"width": 100, "height": 50}})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let area = json!({"x": 0, "y": 0, "text": "a", "area": {"width": "200", "height": 100}}); + let mut calls = vec![("text.create".to_string(), area, "`width` must be a number, not \"200\"".to_string())]; + let area = json!({"x": 0, "y": 0, "text": "a", "area": {"width": 200, "height": 100, "fit": "shrinkText", "fitMinPercent": "40"}}); + calls.push(("text.create".to_string(), area, "`fitMinPercent` must be a number, not \"40\"".to_string())); + calls.extend( + [ + ("text.create", json!({"x": 0, "y": 0, "text": "a", "size": "12"}), "size"), + ("text.createInPath", json!({"path": frame, "text": "a", "size": [12]}), "size"), + ("text.createInPath", json!({"path": frame, "mode": "area", "fitMinPercent": "40"}), "fitMinPercent"), + ("text.setRangeStyle", json!({"id": id.0, "size": "30"}), "size"), + ("text.setRangeStyle", json!({"id": id.0, "tracking": "50"}), "tracking"), + ("text.setRangeStyle", json!({"id": id.0, "hScale": true}), "hScale"), + ("text.setRangeStyle", json!({"id": id.0, "strokeWidth": "1"}), "strokeWidth"), + ("text.setRangeStyle", json!({"id": id.0, "start": "1", "size": 30}), "start"), + ("text.setRangeStyle", json!({"id": id.0, "end": [1], "size": 30}), "end"), + ("text.editRange", json!({"id": id.0, "start": 0, "end": "1", "insert": "x"}), "end"), + ("text.getRange", json!({"id": id.0, "start": "1"}), "start"), + ("text.touchType", json!({"id": id.0, "start": 0, "rotation": "10"}), "rotation"), + ("text.touchType", json!({"id": id.0, "start": "0", "rotation": 10}), "start"), + ("type.step", json!({"attribute": "size", "by": "2"}), "by"), + ("type.step", json!({"attribute": "size", "id": id.0, "start": "1"}), "start"), + ("type.size.increase", json!({"by": "2"}), "by"), + ("type.size.decrease", json!({"by": [2]}), "by"), + ("type.ruby", json!({"text": "a", "size": "60"}), "size"), + ("text.areaOptions", json!({"ids": [boxed], "width": "200"}), "width"), + ("text.areaOptions", json!({"ids": [boxed], "height": [80]}), "height"), + ("text.areaOptions", json!({"ids": [boxed], "fit": "shrinkText", "fitMinPercent": "40"}), "fitMinPercent"), + ] + .map(|(cmd, p, key)| not_a_number(cmd, p, key)), + ); + calls.extend( + [ + ("text.create", json!({"x": 0, "y": 0, "text": "a", "vertical": "true"}), "vertical"), + ("text.create", json!({"x": 0, "y": 0, "placeholder": 1}), "placeholder"), + ("text.createInPath", json!({"path": frame, "vertical": "true"}), "vertical"), + ("text.createInPath", json!({"path": frame, "placeholder": "true"}), "placeholder"), + ("text.setRangeStyle", json!({"id": id.0, "underline": "true"}), "underline"), + ("text.setRangeStyle", json!({"id": id.0, "allCaps": 1}), "allCaps"), + ("text.setRangeStyle", json!({"id": id.0, "noBreak": "true"}), "noBreak"), + ("text.setRangeStyle", json!({"id": id.0, "size": 30, "tateChuYoko": 1}), "tateChuYoko"), + ("text.getRange", json!({"id": id.0, "effective": "true"}), "effective"), + ("type.opticalMarginAlignment", json!({"on": "true"}), "on"), + ("type.systemLayout", json!({"on": "true"}), "on"), + ("type.fractionalWidths", json!({"on": "false"}), "on"), + ("type.ruby", json!({"remove": "true"}), "remove"), + ("type.ruby", json!({"text": "a", "overhang": "false"}), "overhang"), + ] + .map(|(cmd, p, key)| not_a_switch(cmd, p, key)), + ); + let wrong = not_refused(&mut s, &calls); + assert!(wrong.is_empty(), "not refused: {wrong:#?}"); + let style = json!({"id": id.0, "size": 30, "underline": true, "tracking": null, "start": null, "noBreak": true}); + s.execute("text.setRangeStyle", &style).unwrap(); + s.execute("type.size.increase", &json!({"by": null})).unwrap(); + s.execute("type.opticalMarginAlignment", &json!({"on": true})).unwrap(); + s.execute("type.ruby", &json!({"text": "a", "size": null, "overhang": false})).unwrap(); + let t = obj(&s, id); + let st = &t.runs[0].style; + assert!(st.underline && st.no_break && t.optical_margin && st.size > 30.0, "{st:?}"); + assert!(!t.ruby.is_empty() && t.ruby.iter().all(|r| !r.overhang && r.size_percent == 50.0), "{:?}", t.ruby); +} + /// Where each glyph of type object `id` is drawn from (its origin in font units), document space. fn glyph_pens(s: &Session, id: NodeId) -> Vec<(usize, Point)> { let t = obj(s, id); @@ -147,6 +234,31 @@ fn touch_type_moves_one_character_and_leaves_the_others_where_they_are() { assert!(near(moved_by(&before, &glyph_pens(&s, id), 0), (0.0, 2.0))); assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 1})).is_err(), "nothing to change"); assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 9, "dy": 1})).is_err(), "no character there"); + // A 32-bit build read 2^32 + 1 as character 1. + assert!(s.execute("text.touchType", &json!({"id": id.0, "start": (1u64 << 32) + 1, "dy": 1})).is_err(), "no character there"); +} + +/// Touch Type on the first character of a list item moves that character and leaves the bullet +/// where it is. The bullet stands for no character, so the first character still moves only up +/// and down. +#[test] +fn touch_type_moves_the_first_character_of_a_list_item_and_not_its_bullet() { + let mut s = session(); + let id = NodeId(s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 40})).unwrap()["id"].as_u64().unwrap()); + s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet"})).unwrap(); + let pens = |s: &Session| { + let t = obj(s, id); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let bullet = lay.glyphs.iter().find(|g| g.len == 0).map(|g| t.xf * (g.xf * Point::ZERO)).unwrap(); + (bullet, lay.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, t.xf * (g.xf * Point::ZERO))).collect::>()) + }; + let (bullet, before) = pens(&s); + assert!(s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dx": 5})).is_err(), "no character before it to kern"); + s.execute("text.touchType", &json!({"id": id.0, "start": 0, "dy": 3})).unwrap(); + let (bullet_after, after) = pens(&s); + assert!(near(moved_by(&before, &after, 0), (0.0, -3.0)), "the T moves 3 pt up: {:?}", moved_by(&before, &after, 0)); + assert!(near(moved_by(&before, &after, 1), (0.0, 0.0))); + assert!((bullet_after - bullet).hypot() < 1e-6, "the bullet stays: {bullet:?} {bullet_after:?}"); } /// Raising, scaling or rotating a character leaves it and the characters after it where they @@ -457,6 +569,62 @@ fn a_cmd_drag_while_typing_moves_the_type_and_keeps_the_typing() { assert!((after.x0 - moved.x0).abs() < 1e-6, "and stays where it was moved: {after:?}"); } +fn cmd_click(s: &mut Session, x: f64, y: f64) { + let (v, cmd) = (ViewInfo::default(), Mods { cmd: true, ..Default::default() }); + for k in [PointerKind::Down, PointerKind::Up] { + s.pointer(&PointerEvent::new(k, x, y).with_mods(cmd), v).unwrap(); + } +} + +/// #3779: a Cmd/Ctrl click on another object while typing selects it and ends the text edit, so +/// the keys that follow are shortcuts again and not typing. +#[test] +fn a_cmd_click_on_another_object_while_typing_ends_the_text_edit() { + let mut s = session(); + let v = ViewInfo::default(); + let r = s.execute("shape.rectangle", &json!({"x": 500, "y": 400, "width": 50, "height": 50})).unwrap(); + let rect = NodeId(r["id"].as_u64().unwrap()); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Hello", v).unwrap(); + cmd_click(&mut s, 525.0, 425.0); + assert_eq!(s.doc().unwrap().selection.objects, vec![rect], "the rectangle is selected"); + assert!(!s.tool_wants_text(), "the text edit ended"); + s.tool_text("v", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello", "a later key is not typed into the text"); + assert_eq!(s.tool_id(), "type", "the Type tool is back"); +} + +/// #3779: a Cmd/Ctrl click on empty canvas while typing deselects the text and ends its edit. +#[test] +fn a_cmd_click_on_empty_canvas_while_typing_ends_the_text_edit() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Hello", v).unwrap(); + cmd_click(&mut s, 600.0, 450.0); + assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing is selected"); + assert!(!s.tool_wants_text(), "the text edit ended"); + s.tool_text("v", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello", "a later key is not typed into the text"); +} + +/// #3779: point type a click placed and nothing was typed into goes away on a Cmd/Ctrl click +/// elsewhere, as it does on Escape. +#[test] +fn a_cmd_click_away_from_empty_point_type_discards_it() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + cmd_click(&mut s, 600.0, 450.0); + assert!(s.doc().unwrap().doc.node(id).is_none(), "the empty type is discarded"); +} + #[test] fn undo_during_a_drag_takes_back_only_the_drag() { let mut s = session(); @@ -1071,3 +1239,33 @@ fn clicking_the_out_port_then_the_canvas_threads_the_text_into_a_new_frame() { assert_eq!(format!("{}{}", obj(&s, id).plain_text(), obj(&s, thread[1]).plain_text()), long); assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]); } + +/// #2572: with the Selection tool, area type whose text fits shows both ports, and a click on its +/// in port then on empty canvas makes a new frame the story starts in, flowing on into the frame +/// clicked, in one undo step; the ports then show the thread's arrows. +#[test] +fn clicking_the_in_port_then_the_canvas_threads_a_new_frame_before_it() { + let mut s = session(); + let v = ViewInfo::default(); + let words = "Words that fit. "; + let r = s.execute("text.create", &json!({"x": 50, "y": 50, "text": words, "size": 12, "area": {"width": 100, "height": 40}})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.select_tool("selection", v).unwrap(); + let boxes = |s: &mut Session| { + s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Highlight { color: [255, 255, 255, 255], .. })).count() + }; + assert_eq!(boxes(&mut s), 2, "an in port and an out port"); + let steps = undo_labels(&s).len(); + click(&mut s, 50.0, 50.0 + vectorcraft_tools::text::PORT_RISE_PX); + assert_eq!(s.cursor(Point::new(400.0, 400.0), Mods::default(), v), vectorcraft_tools::Cursor::LoadedText); + click(&mut s, 400.0, 400.0); + let doc = &s.doc().unwrap().doc; + let [thread] = &doc.text_threads[..] else { panic!("one thread: {:?}", doc.text_threads) }; + assert_eq!(thread[1], id, "the frame clicked comes second"); + assert_eq!(format!("{}{}", obj(&s, thread[0]).plain_text(), obj(&s, id).plain_text()), words); + assert!(!obj(&s, thread[0]).plain_text().is_empty(), "the story starts in the new frame"); + assert_eq!(&undo_labels(&s)[steps..], ["Thread Text"]); + let strokes = s.overlays(v).iter().filter(|o| matches!(o, vectorcraft_tools::Overlay::Line { .. })).count(); + assert_eq!(strokes, 6, "both frames selected: the first one's out port and the second one's in port show arrows"); +} diff --git a/crates/engine/src/tests_textimport.rs b/crates/engine/src/tests_textimport.rs index 25c3a485b..90d95df15 100644 --- a/crates/engine/src/tests_textimport.rs +++ b/crates/engine/src/tests_textimport.rs @@ -209,7 +209,7 @@ const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main /// A Word document's package: its main part `body` (the inside of `w:body`), with a styles part /// and a theme part named through the main part's relationships. -fn docx(body: &str, styles: &str) -> Vec { +pub(super) fn docx(body: &str, styles: &str) -> Vec { let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; let rels = "http://schemas.openxmlformats.org/package/2006/relationships"; let types = r#""#; @@ -237,7 +237,7 @@ fn docx(body: &str, styles: &str) -> Vec { /// Document defaults in the theme's minor font at 11 pt with 8 pt after each paragraph, a default /// Normal style, a centered, bold Heading 1 in the theme's major font at 16 pt and in a color, a /// bold character style and a default character style in all caps. -const STYLES: &str = concat!( +pub(super) const STYLES: &str = concat!( r#""#, r#""#, r#""#, @@ -366,6 +366,47 @@ fn docx_tables_breaks_and_fields_place_as_paragraphs_and_tabs() { ); } +#[test] +fn docx_kashida_justification_places_with_insert_kashidas() { + use vectorcraft_doc::Justify; + use vectorcraft_doc::text::Kashida; + // The document holds right-to-left paragraphs justified with each kashida length and with `both`, a paragraph style + // that sets `mediumKashida`, that style under direct formatting, and a left-to-right paragraph. + let styles = r#""#; + let para = |ppr: &str| format!(r#"{ppr}كتاب جميل"#); + let body: String = [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] + .map(para) + .concat(); + let mut s = session(); + let r = s.execute("file.place", &json!({"name": "kashida.docx", "dataBase64": vectorcraft_format::base64_encode(&docx(&body, styles))})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone(); + let NodeKind::Text(t) = &n.kind else { panic!("not type") }; + let paras: Vec<_> = t.paragraph_styles().iter().map(|p| (p.justify, p.kashida)).collect(); + assert_eq!( + paras, + [ + (Justify::JustifyRight, Kashida::Short), + (Justify::JustifyRight, Kashida::Medium), + (Justify::JustifyRight, Kashida::Long), + (Justify::JustifyRight, Kashida::None), + (Justify::JustifyRight, Kashida::Medium), + (Justify::JustifyRight, Kashida::None), + (Justify::JustifyLeft, Kashida::Long), + (Justify::Center, Kashida::None), + ], + "low, medium and high kashida lengths are Short, Medium and Long; other alignments have none" + ); +} + #[test] fn malformed_docx_fails_cleanly() { let mut s = session(); diff --git a/crates/engine/src/tests_transformfx.rs b/crates/engine/src/tests_transformfx.rs new file mode 100644 index 000000000..7fdda3928 --- /dev/null +++ b/crates/engine/src/tests_transformfx.rs @@ -0,0 +1,169 @@ +//! The Transform effect's Transform Objects and Scale Strokes & Effects options (#885) through +//! commands: as the renderer draws them and as Expand Appearance bakes them, and files saved +//! before the options existed looking the same. + +use serde_json::{Value, json}; +use vectorcraft_doc::Node; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).unwrap().clone() +} + +/// A selected 100 pt square at (50, 50) with no fill and a 4 pt black stroke. +fn stroked_square(s: &mut Session) -> NodeId { + let id = NodeId(run(s, "shape.rectangle", json!({"x": 50, "y": 50, "width": 100, "height": 100}))["id"].as_u64().unwrap()); + run(s, "paint.setFill", json!({"none": true})); + run(s, "paint.setStroke", json!({"color": "#000000"})); + run(s, "stroke.set", json!({"weight": 4})); + id +} + +fn render(s: &Session) -> vectorcraft_render::Rendered { + let doc = s.doc().unwrap().doc.clone(); + let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + vectorcraft_render::Renderer::new().render(&doc, 200, 200, vectorcraft_geom::Affine::IDENTITY, &opts) +} + +fn dark(px: [u8; 4]) -> bool { + px[0] < 128 && px[3] > 128 +} + +/// Scale Strokes & Effects: the stroke of a square halved by the effect is drawn 2 pt wide with +/// the option, 4 pt without. The halved square's left edge is at x = 75: a 4 pt stroke covers the +/// pixel column at 73, a 2 pt one doesn't. +#[test] +fn scale_strokes_and_effects_scales_the_drawn_stroke() { + for strokes in [false, true] { + let mut s = session(); + stroked_square(&mut s); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50, "strokes": strokes}})); + let img = render(&s); + assert!(dark(img.pixel(75, 100)), "strokes={strokes}: the edge itself is stroked"); + assert_eq!(dark(img.pixel(73, 100)), !strokes, "strokes={strokes}: a 4 pt stroke reaches x = 73, a 2 pt one doesn't"); + assert!(!dark(img.pixel(50, 100)), "strokes={strokes}: the original edge is gone"); + } +} + +/// Expand Appearance bakes the option: the outlined stroke is 2 pt wide (bounds 74..126) with +/// it, 4 pt (73..127) without. +#[test] +fn expand_appearance_bakes_the_scaled_stroke() { + for strokes in [false, true] { + let mut s = session(); + let id = stroked_square(&mut s); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50, "strokes": strokes}})); + run(&mut s, "effect.expandAppearance", json!({})); + let b = node(&s, id).visual_bounds().unwrap(); + let want = if strokes { 52.0 } else { 54.0 }; + assert!((b.width() - want).abs() < 1e-6 && (b.x0 - (100.0 - want / 2.0)).abs() < 1e-6, "strokes={strokes}: {b:?}"); + } +} + +/// Transform Objects off: the geometry stays where it is (the effect then only reaches patterns +/// and strokes). +#[test] +fn transform_objects_off_keeps_the_geometry_in_place() { + for objects in [true, false] { + let mut s = session(); + let id = stroked_square(&mut s); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 60, "objects": objects}})); + let img = render(&s); + assert_eq!(dark(img.pixel(50, 100)), !objects, "objects={objects}: the original left edge"); + assert_eq!(dark(img.pixel(110, 100)), objects, "objects={objects}: the moved left edge"); + // The object itself never moves: the effect is live. + assert_eq!(node(&s, id).geometric_bounds().unwrap().x0, 50.0); + } +} + +/// An effect saved before the options existed (no `objects`, `patterns`, `strokes` keys) draws +/// exactly as one with the defaults written out, and the dialog defaults carry them. +#[test] +fn old_transform_effects_draw_as_before() { + let mut old = session(); + stroked_square(&mut old); + run(&mut old, "effect.apply", json!({"effect": "distort.transform", "params": {"moveH": 20, "scaleH": 150, "rotate": 15, "copies": 2}})); + let mut explicit = session(); + stroked_square(&mut explicit); + run( + &mut explicit, + "effect.apply", + json!({"effect": "distort.transform", "params": {"moveH": 20, "scaleH": 150, "rotate": 15, "copies": 2, "objects": true, "patterns": false, "strokes": false}}), + ); + assert_eq!(render(&old).pixels, render(&explicit).pixels); + let list = run(&mut old, "effect.list", json!({})); + let entry = list["catalog"].as_array().unwrap().iter().find(|e| e["id"] == "distort.transform").unwrap(); + assert_eq!( + (&entry["defaults"]["objects"], &entry["defaults"]["patterns"], &entry["defaults"]["strokes"]), + (&json!(true), &json!(false), &json!(false)) + ); + assert!(entry["params"].as_str().unwrap().contains("Scale Strokes & Effects")); +} + +/// A group's Transform effect with Scale Strokes & Effects scales its members' strokes (#885 +/// review): two 40 pt squares with 4 pt strokes halved as one piece about the group's centre; +/// the first square's left edge lands at x = 75, where a 4 pt stroke covers x = 73 and a 2 pt one +/// doesn't. +#[test] +fn a_group_transform_scales_its_members_strokes() { + for strokes in [false, true] { + let mut s = session(); + for x in [50, 110] { + run(&mut s, "shape.rectangle", json!({"x": x, "y": 50, "width": 40, "height": 40})); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "paint.setStroke", json!({"color": "#000000"})); + run(&mut s, "stroke.set", json!({"weight": 4})); + } + run(&mut s, "select.all", json!({})); + run(&mut s, "object.group", json!({})); + run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 50, "scaleV": 50, "strokes": strokes}})); + let img = render(&s); + assert!(dark(img.pixel(75, 70)), "strokes={strokes}: the moved edge is stroked"); + assert_eq!(dark(img.pixel(73, 70)), !strokes, "strokes={strokes}: a 4 pt stroke reaches x = 73, a 2 pt one doesn't"); + assert!(!dark(img.pixel(50, 70)), "strokes={strokes}: the original edge is gone"); + } +} + +/// The canvas and Expand Appearance (what export bakes) paint copies with scaled strokes alike +/// (#885 review): a 2 pt stroke, 150 % per copy, two copies. +#[test] +fn live_copies_and_their_expanded_art_paint_alike() { + let mut s = session(); + let id = NodeId(run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 30, "height": 30}))["id"].as_u64().unwrap()); + run(&mut s, "paint.setFill", json!({"none": true})); + run(&mut s, "paint.setStroke", json!({"color": "#000000"})); + run(&mut s, "stroke.set", json!({"weight": 2})); + run( + &mut s, + "effect.apply", + json!({"effect": "distort.transform", "params": {"scaleH": 150, "scaleV": 150, "moveH": 30, "moveV": 30, "copies": 2, "reference": 0, "strokes": true}}), + ); + let live = render(&s); + // The copies' strokes are 2, 3 and 4.5 pt: the last copy's edge at (20 + 30·2.5, …) is wider. + run(&mut s, "effect.expandAppearance", json!({})); + let expanded = render(&s); + let differ = live.pixels.chunks(4).zip(expanded.pixels.chunks(4)).filter(|(a, b)| a.iter().zip(*b).any(|(x, y)| x.abs_diff(*y) > 96)).count(); + let inked = live.pixels.chunks(4).filter(|p| p[0] < 128).count(); + assert!(inked > 500, "{inked} dark pixels"); + assert!(differ * 50 < inked, "{differ} of {inked} pixels differ"); + // Each copy's outlined stroke is an object of its own (a compound path), whatever groups hold + // them. + fn pieces(n: &Node) -> usize { + match n.children() { + Some(c) if !matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. }) => c.iter().map(|c| pieces(c)).sum(), + _ => 1, + } + } + assert_eq!(pieces(&node(&s, id)), 3, "one piece per copy"); +} diff --git a/crates/engine/src/tests_trap.rs b/crates/engine/src/tests_trap.rs new file mode 100644 index 000000000..87fb92a6f --- /dev/null +++ b/crates/engine/src/tests_trap.rs @@ -0,0 +1,158 @@ +//! Tests for Pathfinder panel › Trap (`object.pathfinder.trap`). + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::Rect; + +use super::*; + +const YELLOW: Color = Color::Cmyk { c: 0.0, m: 0.0, y: 1.0, k: 0.0 }; +const BLUE: Color = Color::Cmyk { c: 1.0, m: 0.6, y: 0.0, k: 0.0 }; + +/// A yellow rectangle (0..50) abutting a blue one (50..100), both 40 pt tall, both selected. +fn abutting() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let mut rect = |x: f64, c: Color| { + let id = NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap()); + s.edit("fill", |d, _| { + if let Some(n) = d.node_mut(id) { + n.appearance.set_fill(Paint::solid(c)); + } + Ok(()) + }) + .unwrap(); + id + }; + let (a, b) = (rect(0.0, YELLOW), rect(50.0, BLUE)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + (s, a, b) +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +fn node(s: &Session, id: NodeId) -> Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.02 +} + +/// The traps of the trap group the command made. +fn traps(s: &Session, v: &Value) -> Vec { + let g = node(s, ids(v)[0]); + assert!(matches!(g.kind, NodeKind::Group { .. })); + g.children().unwrap().iter().map(|c| (**c).clone()).collect() +} + +#[test] +fn trap_spreads_the_lighter_colour_into_the_darker_along_the_shared_edge() { + let (mut s, a, b) = abutting(); + let before = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.pathfinder.trap", &json!({"thickness": 2, "tintReduction": 100})).unwrap(); + let t = traps(&s, &r); + assert_eq!(t.len(), 1); + let bb = t[0].geometric_bounds().unwrap(); + assert!(near(bb.x0, 50.0) && near(bb.x1, 52.0) && near(bb.y0, 0.0) && near(bb.y1, 40.0), "2 pt into the blue: {bb:?}"); + let fill = t[0].appearance.fill().unwrap(); + assert_eq!(fill.paint.color(), Some(YELLOW), "filled with the lighter colour"); + assert!(fill.overprint, "set to overprint"); + assert!(t[0].appearance.stroke_paint().is_none()); + // The originals stay; the traps sit directly above the topmost one and are selected. + let st = s.doc().unwrap(); + assert_eq!(node(&s, a), *before.node(a).unwrap()); + assert_eq!(node(&s, b), *before.node(b).unwrap()); + let (parent, ib, _) = st.doc.position(b).unwrap(); + let (gparent, ig, _) = st.doc.position(ids(&r)[0]).unwrap(); + assert_eq!((gparent, ig), (parent, ib + 1)); + assert_eq!(st.selection.objects, ids(&r)); + // One undo step. + assert_eq!(st.history.undo.len(), undo_len + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn height_width_and_tint_reduction_shape_and_tint_the_trap() { + let (mut s, _, _) = abutting(); + let r = s.execute("object.pathfinder.trap", &json!({"thickness": "1 pt", "tintReduction": 25})).unwrap(); + let t = traps(&s, &r); + let [_, _, y, _] = t[0].appearance.fill_paint().color().unwrap().to_cmyk(); + assert!((y - 0.25).abs() < 1e-4, "yellow at 25%: {y}"); + let bb = t[0].geometric_bounds().unwrap(); + assert!(near(bb.x1, 51.0), "{bb:?}"); +} + +#[test] +fn reverse_traps_spread_the_darker_colour_into_the_lighter() { + let (mut s, _, _) = abutting(); + let r = s.execute("object.pathfinder.trap", &json!({"thickness": 2, "tintReduction": 100, "reverseTraps": true})).unwrap(); + let t = traps(&s, &r); + let bb = t[0].geometric_bounds().unwrap(); + assert!(near(bb.x0, 48.0) && near(bb.x1, 50.0), "2 pt into the yellow: {bb:?}"); + assert_eq!(t[0].appearance.fill_paint().color(), Some(BLUE)); +} + +#[test] +fn no_adjoining_edges_is_a_clear_error_that_changes_nothing() { + let (mut s, a, b) = abutting(); + s.edit("apart", |d, _| { + if let Some(n) = d.node_mut(b) { + n.kind = NodeKind::Path { + path: vectorcraft_geom::shapes::rectangle(Rect::new(200.0, 0.0, 250.0, 40.0)), + rule: vectorcraft_geom::FillRule::NonZero, + live: None, + clipping: false, + guide: false, + }; + } + Ok(()) + }) + .unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo_len = s.doc().unwrap().history.undo.len(); + let e = s.execute("object.pathfinder.trap", &json!({})).unwrap_err().to_string(); + assert!(e.contains("no edges of different colours meet"), "{e}"); + assert_eq!(s.doc().unwrap().doc, before); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); + // One object alone has nothing to trap with. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let e = s.execute("object.pathfinder.trap", &json!({})).unwrap_err().to_string(); + assert!(e.contains("two or more objects"), "{e}"); + // Given as ids, the objects are selected for the command, and its failure puts the + // selection back. + s.execute("select.none", &json!({})).unwrap(); + let e = s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).unwrap_err().to_string(); + assert!(e.contains("no edges of different colours meet"), "{e}"); + assert!(s.doc().unwrap().selection.objects.is_empty(), "{:?}", s.doc().unwrap().selection.objects); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert!(s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).is_err()); + assert_eq!(s.doc().unwrap().selection.objects, vec![a]); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn out_of_range_and_unreadable_numbers_are_refused_or_defaulted() { + let (mut s, _, _) = abutting(); + for p in [json!({"thickness": 1e300}), json!({"thickness": 0}), json!({"heightWidth": 0}), json!({"tintReduction": 101})] { + assert!(s.execute("object.pathfinder.trap", &p).is_err(), "{p}"); + } + // A thickness that isn't a number falls back to the default. + let r = s.execute("object.pathfinder.trap", &json!({"thickness": "NaN", "tintReduction": 100})).unwrap(); + let bb = traps(&s, &r)[0].geometric_bounds().unwrap(); + assert!(near(bb.x1, 50.25), "{bb:?}"); +} + +#[test] +fn ids_name_the_objects_to_trap() { + let (mut s, a, b) = abutting(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + let r = s.execute("object.pathfinder.trap", &json!({"ids": [a.0, b.0]})).unwrap(); + assert_eq!(traps(&s, &r).len(), 1); +} diff --git a/crates/engine/src/tests_typescale.rs b/crates/engine/src/tests_typescale.rs index 06b613271..c05326861 100644 --- a/crates/engine/src/tests_typescale.rs +++ b/crates/engine/src/tests_typescale.rs @@ -300,3 +300,24 @@ fn imported_type_with_sizes_outside_the_panels_range_opens_unchanged() { s.execute("document.open", &json!({"name": "sizes.vectorcraft", "dataBase64": saved})).unwrap(); assert_eq!(texts(&s), imported); } + +#[test] +fn scale_text_attributes_bakes_group_scale_and_undoes() { + let mut s = session(); + let id = create(&mut s, 12.0); + s.execute("object.group", &json!({})).unwrap(); + s.execute("object.scale", &json!({"sx": 200})).unwrap(); + near(em(&text(&s, id), 0).1, 24.0); + assert_eq!(text(&s, id).runs[0].style.size, 12.0); + s.execute("type.scaleTextAttributes", &json!({})).unwrap(); + near(em(&text(&s, id), 0).1, 24.0); + near(text(&s, id).runs[0].style.size, 24.0); + assert!(text(&s, id).style_scale().is_none_or(|sc| (sc.points - 1.0).abs() <= 1e-9 && (sc.horizontal - 1.0).abs() <= 1e-9)); + s.execute("text.setStyle", &json!({"size": 10})).unwrap(); + near(em(&text(&s, id), 0).1, 10.0); + s.execute("edit.undo", &json!({})).unwrap(); + near(em(&text(&s, id), 0).1, 24.0); + near(text(&s, id).runs[0].style.size, 24.0); + s.execute("edit.undo", &json!({})).unwrap(); + near(text(&s, id).runs[0].style.size, 12.0); +} diff --git a/crates/engine/src/tests_variables.rs b/crates/engine/src/tests_variables.rs index 7d2b1dadb..cae174ce5 100644 --- a/crates/engine/src/tests_variables.rs +++ b/crates/engine/src/tests_variables.rs @@ -1346,6 +1346,263 @@ fn datasets_csv_round_trips_and_rejects_bad_input() { s.execute("edit.undo", &json!({})).unwrap(); } +/// A data set of [`library`]: its name, and each variable's name with its paragraphs. +type LibrarySet<'a> = (&'a str, &'a [(&'a str, &'a [&'a str])]); + +/// A variable library written from its public grammar: a document type declaration with the +/// namespace entities, `variableSets` › `variableSet` › `variables` with one `variable` element per +/// `(varName, trait)`, then `sampleDataSets` with one `sampleDataSet` per data set, holding an +/// element per variable with a `p` element per paragraph. +fn library(vars: &[(&str, &str)], sets: &[LibrarySet]) -> String { + let mut x = String::from( + "\n\ + \n\ + \t\n\ + \t\n\ + ]>\n\n\n\n\n", + ); + for (name, kind) in vars { + x += &format!("\n"); + } + x += "\n\n"; + for (set, values) in sets { + x += &format!("\n"); + for (var, paras) in *values { + let paras: String = paras.iter().map(|p| format!("

{p}

")).collect(); + x += &format!("<{var}>{paras}\n"); + } + x += "\n"; + } + x + "\n\n\n\n" +} + +/// Load Variable Library defines the library's text variables, unbound, and adds its data sets, +/// in one undo step. A value is the text of the `p` elements, one paragraph each; an element +/// without a `p` gives no value. A file, and base64 with a UTF-8 byte-order mark, load the same. +#[test] +fn a_variable_library_defines_text_variables_and_data_sets() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let xml = library( + &[("Name", "textcontent"), ("Note", "textcontent")], + &[("Row 1", &[("Name", &["Bravo"]), ("Note", &["One", "Two & three"])]), ("Row 2", &[("Name", &["Charlie"]), ("Note", &[])])], + ); + let r = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + assert_eq!(r["variables"], json!(["Name", "Note"])); + assert_eq!((r["deleted"].clone(), r["deletedDataSets"].clone()), (json!([]), json!([]))); + assert_eq!(r["names"], json!(["Row 1", "Row 2"])); + assert_eq!((r["skipped"].clone(), r.get("warning")), (json!([]), None)); + let listed = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(listed["variables"], json!([{"name": "Name", "kind": "text", "bindings": 0}, {"name": "Note", "kind": "text", "bindings": 0}])); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "Bravo"}, "Note": {"text": "One\nTwo & three"}})); + assert_eq!(sets["datasets"][1]["values"], json!({"Name": {"text": "Charlie"}})); + assert_eq!(sets["active"], Value::Null); + assert_eq!(plain(&s, t), "Alice", "loading binds nothing and applies nothing"); + + s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap(); + s.execute("dataset.select", &json!({"name": "Row 2"})).unwrap(); + assert_eq!(plain(&s, t), "Charlie"); + + // One undo step each: the select, the bind, then the load. + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + let vars = &s.doc().unwrap().doc.variables; + assert!(vars.variables.is_empty() && vars.datasets.is_empty()); + + let dir = Folder::new("vars-library"); + let path = dir.file("library.xml"); + write(&path, xml.as_bytes()); + let from_file = s.execute("variable.loadLibrary", &json!({"path": path})).unwrap(); + assert_eq!(from_file["names"], json!(["Row 1", "Row 2"])); + let bom = [b"\xEF\xBB\xBF".as_slice(), xml.as_bytes()].concat(); + let from_base64 = s.execute("variable.loadLibrary", &json!({"dataBase64": vectorcraft_format::base64_encode(&bom)})).unwrap(); + assert_eq!((from_base64["deleted"].clone(), from_base64["deletedDataSets"].clone()), (json!(["Name", "Note"]), json!(["Row 1", "Row 2"]))); + assert_eq!(s.doc().unwrap().doc.variables.datasets.len(), 2); +} + +/// Load Variable Library replaces the document's variables and data sets: every variable goes with +/// its bindings and values, a text variable of the same name included, and every data set goes, +/// the active one included. The highlight of a variable that went highlights nothing. Undo brings +/// back the variables, bindings, data sets and active data set as they were. +#[test] +fn a_variable_library_replaces_every_variable_and_data_set() { + let mut s = session(); + let t = text(&mut s, "Alice"); + let r = rect(&mut s); + for (name, kind, id) in [("Name", "text", t), ("Title", "visibility", r), ("Kept", "visibility", r)] { + s.execute("variable.define", &json!({"name": name, "kind": kind})).unwrap(); + s.execute("variable.bind", &json!({"variable": name, "ids": [id.0]})).unwrap(); + } + s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Old", "Title": true}})).unwrap(); + s.execute("dataset.new", &json!({"name": "Other", "values": {"Name": "Stays", "Title": false, "Kept": true}})).unwrap(); + s.execute("dataset.select", &json!({"name": "Other"})).unwrap(); + s.execute("variable.highlight", &json!({"variable": "Kept"})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + + let xml = library(&[("Name", "textcontent"), ("Title", "textcontent")], &[("A", &[("Name", &["New"]), ("Title", &["Heading"])])]); + let loaded = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + assert_eq!(loaded["deleted"], json!(["Name", "Title", "Kept"])); + assert_eq!((loaded["deletedDataSets"].clone(), loaded["names"].clone()), (json!(["A", "Other"]), json!(["A"]))); + let listed = s.execute("variable.list", &json!({})).unwrap(); + assert_eq!(listed["variables"], json!([{"name": "Name", "kind": "text", "bindings": 0}, {"name": "Title", "kind": "text", "bindings": 0}])); + let sets = s.execute("dataset.list", &json!({})).unwrap(); + assert_eq!(sets["datasets"].as_array().map(Vec::len), Some(1)); + assert_eq!(sets["datasets"][0]["values"], json!({"Name": {"text": "New"}, "Title": {"text": "Heading"}})); + assert_eq!(sets["active"], Value::Null); + assert!(s.doc().unwrap().doc.variables.bindings.is_empty(), "the bindings went with the variables"); + assert!(s.doc().unwrap().variables_highlight.is_none(), "the highlighted variable went"); + assert_eq!(plain(&s, t), "Stays", "loading applies nothing"); + + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.variables, before, "one undo step brings everything back"); +} + +/// A library that is refused leaves the document's variables and data sets as they were. +#[test] +fn a_refused_variable_library_deletes_nothing() { + let mut s = session(); + let t = text(&mut s, "Alice"); + 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": "Old"}})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + let dup = library(&[("Name", "textcontent")], &[("B", &[]), ("B", &[])]); + assert!(s.execute("variable.loadLibrary", &json!({"xml": dup})).is_err()); + assert_eq!(s.doc().unwrap().doc.variables, before); +} + +/// A library's variables of other kinds are left out with their values, named in `skipped` and +/// in a warning that gives each kind once with its count; its text variables load. +#[test] +fn a_variable_library_leaves_out_variables_that_are_not_text() { + let mut s = session(); + let xml = library( + &[("Shown", "kindB"), ("Name", "textcontent"), ("Photo", "kindC"), ("Other", "kindB")], + &[("A", &[("Shown", &["true"]), ("Name", &["Bravo"]), ("Photo", &["a.png"])])], + ); + let r = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + assert_eq!(r["variables"], json!(["Name"])); + assert_eq!(r["skipped"], json!([{"name": "Shown", "trait": "kindB"}, {"name": "Photo", "trait": "kindC"}, {"name": "Other", "trait": "kindB"}])); + assert_eq!(r["warning"], "variables that aren't text were left out: kindB (2), kindC (1)"); + let vars = &s.doc().unwrap().doc.variables; + assert_eq!(vars.variables.len(), 1); + assert_eq!(vars.dataset("A").map(|d| d.values.len()), Some(1)); +} + +/// A library that isn't one, or that breaks its grammar or the caps, is refused, and the document +/// is left as it was. +#[test] +fn malformed_variable_libraries_are_refused() { + let mut s = session(); + let ok = library(&[("Name", "textcontent")], &[("A", &[("Name", &["x"])])]); + let deep = format!("{}{}", "".repeat(300), "".repeat(300)); + let markup = "\">]>&deep;".to_string(); + // A document type declaration in a comment before the real one, and markup reached through + // another entity. + let hidden = format!("{markup}"); + let nested = "]>&a;".to_string(); + let parameter = "]>".to_string(); + let element = "]>".to_string(); + // A `>` and a comment opener inside a quoted system identifier, before elements nested too deep. + let quoted = format!("", + "x".repeat(1024), + "&big;".repeat(8000), + " ".repeat(300_000) + ); + let cases = [ + ("not well-formed XML", "".to_string()), + ("no `variableSets` element", "".to_string()), + ("declares no variables", "".to_string()), + ("is declared twice", library(&[("Name", "textcontent"), ("Name", "textcontent")], &[])), + ("has no `trait`", ok.replace(" trait=\"textcontent\"", "")), + ("missing `varName`", ok.replace("varName=\"Name\"", "varName=\" \"")), + ("missing `dataSetName`", ok.replace(" dataSetName=\"A\"", "")), + ("data set `A` is declared twice", library(&[("Name", "textcontent")], &[("A", &[]), ("A", &[])])), + ("which the library doesn't declare", library(&[("Name", "textcontent")], &[("A", &[("Nmae", &["x"])])])), + ("holds two values for `Name`", library(&[("Name", "textcontent")], &[("A", &[("Name", &["x"]), ("Name", &["y"])])])), + ("nests elements more than 256 deep", deep), + ("something other than entities of plain text", markup), + ("something other than entities of plain text", hidden), + ("something other than entities of plain text", nested), + ("something other than entities of plain text", parameter), + ("something other than entities of plain text", element), + ("nests elements more than 256 deep", quoted), + ("more than 64 entities", many_entities), + ("entities expand to more than", expanding), + ("entities expand to more than", then_literal), + ("larger than", format!("{ok}", " ".repeat(8 << 20))), + ]; + for (why, xml) in cases { + let e = s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap_err().to_string(); + assert!(e.contains(why), "{why}: {e}"); + } + let names: Vec = (0..257).map(|i| format!("V{i}")).collect(); + let many: Vec<(&str, &str)> = names.iter().map(|n| (n.as_str(), "textcontent")).collect(); + let e = s.execute("variable.loadLibrary", &json!({"xml": library(&many, &[])})).unwrap_err().to_string(); + assert!(e.contains("more than 256 variables"), "{e}"); + // A Latin-1 é in a comment. + let latin1 = [ok.as_bytes(), b""].concat(); + let e = s.execute("variable.loadLibrary", &json!({"dataBase64": vectorcraft_format::base64_encode(&latin1)})).unwrap_err().to_string(); + assert!(e.contains("not UTF-8"), "{e}"); + let e = s.execute("variable.loadLibrary", &json!({})).unwrap_err().to_string(); + assert!(e.contains("give `path`, `xml` or `dataBase64`"), "{e}"); + let vars = &s.doc().unwrap().doc.variables; + assert!(vars.variables.is_empty() && vars.datasets.is_empty(), "a refused library loads nothing"); +} + +/// Entities whose values hold plain text expand to it, in attribute values too, and a reference in +/// a CDATA section stays as written. +#[test] +fn a_variable_library_expands_entities_of_plain_text() { + let mut s = session(); + let xml = "]>\ + \ +

&a;-&b;-&a;

"; + s.execute("variable.loadLibrary", &json!({"xml": xml})).unwrap(); + let value = s.doc().unwrap().doc.variables.dataset("y \"z\"").and_then(|d| d.values.get("Name")).cloned(); + assert_eq!(value, Some(vectorcraft_doc::DataValue::Text("x-y \"z\"-x&a;".into()))); +} + +/// While the variables are locked, a library that would delete or define a variable is refused, as +/// Delete Variable and New Variable are, and the document keeps its variables and data sets. That +/// covers a library whose only variable is a text variable the document already has, and one +/// without text variables loaded into a document with variables. A library without text variables +/// loads into a document without variables, replacing its data sets. +#[test] +fn locked_variables_refuse_a_library_that_deletes_or_defines_variables() { + let mut s = session(); + s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap(); + s.execute("dataset.new", &json!({"name": "A", "values": {"Name": "Old"}})).unwrap(); + s.execute("variable.lock", &json!({"locked": true})).unwrap(); + let before = s.doc().unwrap().doc.variables.clone(); + for vars in [&[("Name", "textcontent")][..], &[("Shown", "other")][..]] { + let e = s.execute("variable.loadLibrary", &json!({"xml": library(vars, &[("B", &[])])})).unwrap_err(); + assert!(matches!(&e, EngineError::Disabled(cmd, why) if cmd == "variable.loadLibrary" && why == "variables are locked"), "{e}"); + assert_eq!(s.doc().unwrap().doc.variables, before); + } + + let mut s = session(); + s.execute("dataset.new", &json!({"name": "A"})).unwrap(); + s.execute("variable.lock", &json!({"locked": true})).unwrap(); + let e = s.execute("variable.loadLibrary", &json!({"xml": library(&[("Name", "textcontent")], &[])})).unwrap_err(); + assert!(matches!(&e, EngineError::Disabled(_, why) if why == "variables are locked"), "{e}"); + let r = s.execute("variable.loadLibrary", &json!({"xml": library(&[("Shown", "other")], &[("B", &[])])})).unwrap(); + assert_eq!((r["deletedDataSets"].clone(), r["names"].clone()), (json!(["A"]), json!(["B"]))); + let vars = &s.doc().unwrap().doc.variables; + assert!(vars.variables.is_empty() && vars.locked); + assert_eq!(vars.datasets.iter().map(|d| d.name.as_str()).collect::>(), ["B"]); +} + #[test] fn graph_variable_applies_csv_and_captures() { let mut s = session(); diff --git a/crates/engine/src/tests_webexport.rs b/crates/engine/src/tests_webexport.rs index 277c5e101..bbf4db2e1 100644 --- a/crates/engine/src/tests_webexport.rs +++ b/crates/engine/src/tests_webexport.rs @@ -342,6 +342,28 @@ fn an_export_says_when_an_image_could_not_be_decoded() { assert!(dir.join("images").join("page.gif").exists(), "the files are still written"); } +#[test] +fn explicit_artboards_and_unclipped_art_keep_their_pixels_and_dimensions() { + let mut s = session(60.0, 40.0); + rect(&mut s, [0.0, 0.0, 60.0, 40.0], "#ff0000"); + run(&mut s, "artboard.new", json!({"x": 100, "y": 0, "width": 32, "height": 24})); + rect(&mut s, [100.0, 0.0, 32.0, 24.0], "#0000ff"); + for (params, size, color) in [ + (json!({"format": "png24", "artboard": 0}), (60, 40), [255, 0, 0, 255]), + (json!({"format": "png24", "artboard": 1}), (32, 24), [0, 0, 255, 255]), + (json!({"format": "png24"}), (60, 40), [255, 0, 0, 255]), + ] { + let image = pixels(&one(&mut s, params)); + assert_eq!(image.dimensions(), size); + assert_eq!(image.get_pixel(8, 8).0, color); + } + let full = pixels(&one(&mut s, json!({"format": "png24", "artboard": 1, "clipToArtboard": false}))); + assert_eq!(full.dimensions(), (132, 40)); + assert_eq!(full.get_pixel(8, 8).0, [255, 0, 0, 255]); + assert_eq!(full.get_pixel(108, 8).0, [0, 0, 255, 255]); + assert_eq!(full.get_pixel(80, 8).0, [0, 0, 0, 0]); +} + #[test] fn image_size_region_and_metadata() { let mut s = two_colours(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1a6809688..4ee682f2a 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -1,8 +1,10 @@ //! Hosting the active tool: pointer/key events → actions → commands. use serde_json::{Map, Value}; +use vectorcraft_doc::Selection; use vectorcraft_geom::Point; use vectorcraft_tools::distort::perspective::widget::WidgetPlace; +use vectorcraft_tools::draw2::FEEDBACK; use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, settings}; use crate::{EngineError, Prefs, Result, Session}; @@ -66,6 +68,9 @@ pub enum UiRequest { /// A message for the status bar: the `warning` of a command the tool ran (Liquify skipping /// type under the brush). Status(String), + /// Place a file with these `file.place` params: a click or drag of the place cursor, when + /// [`Session::place_cursor_requests`] is set. + Place(Value), } impl Session { @@ -73,6 +78,20 @@ impl Session { self.tool.id() } + /// How many times switching or closing documents, or replacing the active one (File › Revert), + /// has started the tool afresh, also inside a `command.batch` or `action.batch`. A frontend + /// compares it before and after a command. + pub fn tool_resets(&self) -> u64 { + self.tool_resets + } + + /// Whether the tool's options were set ([`Self::set_tool_option`], `tool.setOption`) after + /// [`Self::tool_resets`] last counted, as a Paste or Cut with the Artboard tool sets them after a + /// batch's Revert step. + pub fn tool_set_since_reset(&self) -> bool { + self.tool_set_since_reset + } + /// Choose a tool (the toolbar, its shortcut, MCP), finishing any pending tool work first. pub fn select_tool(&mut self, id: &str, view: ViewInfo) -> Result<()> { // Choosing a perspective tool shows the document's perspective grid; Hide Grid hides it @@ -92,10 +111,19 @@ impl Session { if self.tool.id() == id { return Ok(()); } + // Sampling type keeps its highlighted characters as the target. Capture before the + // Type tool deactivates (which commits typing and clears its editing state). + let is_type = |id: &str| matches!(id, "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath"); + let text_selection = ((id == "eyedropper" && is_type(self.tool.id())) || (self.tool.id() == "eyedropper" && is_type(id))) + .then(|| self.tool.options()) + .filter(|o| o.get("editing").is_some_and(|v| !v.is_null()) && o.get("start") != o.get("end")); let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); self.apply_actions(acts)?; self.keep_tool_settings(); self.tool = self.make_tool(id); + if let Some(selection) = text_selection { + self.tool.set_option("textSelection", &selection); + } if vectorcraft_tools::catalog::is_selection_tool(id) { self.last_selection_tool = Some(self.tool.id()); } @@ -104,10 +132,18 @@ impl Session { /// The selection tool Cmd lends tool `id` for a drag: the one chosen last; until one is, /// Direct Selection to the tools that edit anchors (the Pen) and Selection to the others. - /// None for the selection tools themselves and the view tools. - fn lent_tool(&self, id: &str) -> Option<&'static str> { + /// Cmd+Alt lends Group Selection while Direct Selection is active. Cmd still lends the last + /// selection tool from other tools (Alt on the lent Direct Selection gesture stays copy/subtract). + /// None for the other selection tools and the view tools. + fn lent_tool(&self, id: &str, mods: &Mods) -> Option<&'static str> { use vectorcraft_tools::catalog::{edits_anchors, is_selection_tool}; - if is_selection_tool(id) || matches!(id, "hand" | "zoom" | "rotateView") { + if matches!(id, "hand" | "zoom" | "rotateView") { + return None; + } + if id == "directSelection" && mods.cmd && mods.alt { + return Some("groupSelection"); + } + if is_selection_tool(id) { return None; } Some(self.last_selection_tool.unwrap_or(if edits_anchors(id) { "directSelection" } else { "selection" })) @@ -120,7 +156,7 @@ impl Session { if ev.kind != PointerKind::Down || !ev.mods.cmd || self.lender.is_some() { return *ev; } - let Some(id) = self.lent_tool(self.tool.id()) else { return *ev }; + let Some(id) = self.lent_tool(self.tool.id(), &ev.mods) else { return *ev }; // Typing in progress is kept as its own step first: the lent tool's drag previews from the // document its interaction started from, which would drop the typing (#1881). if crate::cmd::typing_in_progress(self) { @@ -133,13 +169,15 @@ impl Session { } /// The lent selection tool's gesture is over: the tool it was lent to is back as it was (the - /// Pen goes on drawing its path). + /// Pen goes on drawing its path), less the work the gesture ended as a command would: a + /// Cmd+click that takes the type being edited out of the selection ends its edit (#3779). pub(crate) fn give_back_tool(&mut self, view: ViewInfo) -> Result<()> { let Some(lender) = self.lender.take() else { return Ok(()) }; let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx)); let r = self.apply_actions(acts); self.keep_tool_settings(); self.tool = lender; + self.after_command(); r.map(drop) } @@ -205,6 +243,8 @@ impl Session { // wins instead of pixels quietly rounding every drag to whole points (#1816). snap_to_pixel: view.snap_to_pixel && !view.snap_to_grid, snap_to_point: view.snap_to_point, + snap_tangent: self.prefs.snap_tangent, + snap_perpendicular: self.prefs.snap_perpendicular, snap_to_glyph: view.snap_to_glyph && view.smart_guides && !view.snap_to_grid, corner_widgets: view.corner_widgets, fill_active: self.fill_active, @@ -215,6 +255,7 @@ impl Session { freeform_point: self.selected_freeform_point(), raster_sample: self.prefs.eyedropper.sample_size, preview_bounds: self.prefs.use_preview_bounds, + preview_memo: Some(&self.preview_memo), unit, stroke_unit, paste_plain_text: self.prefs.paste_text_formatting == "plain", @@ -241,6 +282,7 @@ impl Session { measurement_labels: self.prefs.measurement_labels, transform_tools_guides: self.prefs.transform_tools_guides, spacing_guides: self.prefs.spacing_guides, + distance_guides: self.prefs.distance_guides, snapping_tolerance: self.prefs.snapping_tolerance, construction_angles: if self.prefs.construction_guides { vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles) @@ -272,6 +314,11 @@ impl Session { /// Feed a pointer event to the active tool. Returns requests for the UI. pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { self.last_view = view; + match ev.kind { + PointerKind::Down | PointerKind::Drag => self.pointer_held = true, + PointerKind::Up | PointerKind::Move => self.pointer_held = false, + PointerKind::DoubleClick => {} + } // The Plane Switching Widget takes its clicks whatever the tool. if let Some(r) = self.plane_widget_pointer(ev, view) { return r; @@ -392,10 +439,28 @@ impl Session { self.with_tool_cx(view, |t, cx| t.ime_caret(cx)) } + /// The pointer stopped hovering for the active tool ([`vectorcraft_tools::Tool::hover_ended`]). + pub fn tool_hover_ended(&mut self, view: ViewInfo) { + self.with_tool_cx(view, |t, cx| t.hover_ended(cx)); + } + + /// Whether the active tool's hover depends on what lies under a still pointer + /// ([`vectorcraft_tools::Tool::wants_rehover`]). + pub fn tool_wants_rehover(&self) -> bool { + self.tool.wants_rehover() + } + pub fn tool_busy(&self) -> bool { self.tool.busy() } + /// Is the pointer button held: was the last pointer event a press or a drag, not a release or a + /// move? A tool that is busy while the button is not held is waiting between its clicks (the + /// Dimension tool after its first point). + pub fn pointer_held(&self) -> bool { + self.pointer_held + } + /// Is the active tool moving, scaling or rotating the selection with a drag? pub fn tool_transforming(&self) -> bool { self.tool.transforming() @@ -408,21 +473,29 @@ impl Session { pub fn overlays(&mut self, view: ViewInfo) -> Vec { let mut v = self.with_tool_cx(view, |t, cx| t.overlays(cx)); - // The Type tools draw the overflow markers of selected type themselves. + // The Type tools draw the ports of selected type themselves. if !matches!(self.tool.id(), "type" | "areaType" | "typeOnPath" | "verticalType" | "verticalAreaType" | "verticalTypeOnPath") { - let mut marks = std::mem::take(&mut self.overflow_marks); - v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::overflow_markers(&mut marks, cx))); - self.overflow_marks = marks; + let mut marks = std::mem::take(&mut self.port_marks); + v.extend(self.with_tool_cx(view, |_, cx| vectorcraft_tools::text::thread_ports(&mut marks, cx))); + self.port_marks = marks; } if let Some(g) = self.ruler_guide.take() { v.extend(self.with_tool_cx(view, |_, cx| g.overlays(cx))); self.ruler_guide = Some(g); } + let deferred_preview = self.unapplied_interaction_preview(); if let Some(d) = self.active() { let w = self.prefs.perspective_widget; let place = w.show.then_some(WidgetPlace { screen: view.screen.as_ref(), corner: w.position }); v.splice(0..0, vectorcraft_tools::distort::perspective::grid_overlays_in(&d.doc, 1.0 / view.zoom.max(1e-9), place)); } + // Global Edit: the objects edited with the selected one are highlighted. + for (path, color) in crate::cmd::globaledit::highlights(self) { + v.push(Overlay::Path { path, color, width: 1.0, dashed: false }); + } + if let Some((cmd, p)) = deferred_preview { + v.extend(self.deferred_preview_overlays_from_command(&cmd, &p)); + } v } @@ -430,7 +503,7 @@ impl Session { /// tools' a crosshair, [`Cursor::precise`]). With Cmd held, the pointer of the selection tool a /// press would borrow ([`Self::lend_selection_tool`]). pub fn cursor(&mut self, p: Point, mods: Mods, view: ViewInfo) -> Cursor { - let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id()) } else { None }; + let lent = if mods.cmd && self.lender.is_none() { self.lent_tool(self.tool.id(), &mods) } else { None }; let c = match lent { Some(id) => { let lent = self.make_tool(id); @@ -449,6 +522,7 @@ impl Session { } pub fn set_tool_option(&mut self, key: &str, v: &Value) { self.tool.set_option(key, v); + self.tool_set_since_reset = true; self.keep_tool_settings(); } @@ -462,6 +536,7 @@ impl Session { /// Liquify tools' Global Brush Dimensions). Returns the tool's options. pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map) -> Value { let active = id.is_none_or(|id| id == self.tool.id()); + self.tool_set_since_reset |= active; let mut other = (!active).then(|| self.make_tool(id.unwrap_or_default())); let t = other.as_deref_mut().unwrap_or(self.tool.as_mut()); for (k, v) in values { @@ -489,6 +564,13 @@ impl Session { if let Some(k) = p.get("key").and_then(Value::as_str) { values.insert(k.to_string(), p.get("value").cloned().unwrap_or(Value::Null)); } + // The Place queue is read later, by a click outside any automation scope: its files must + // be readable now, under the roots of whoever sets it. + if let Some(entries) = values.get("queue").and_then(|q| q.get("entries")).and_then(Value::as_array) { + for path in entries.iter().filter_map(|e| e.pointer("/params/path").and_then(Value::as_str)) { + crate::file_access::check_read(path)?; + } + } if tool.unwrap_or(self.tool_id()) == "dimension" { if let Some(style) = values.get("style") { vectorcraft_doc::dimension::DimensionOptions::from_params(style.clone())?; @@ -503,9 +585,9 @@ impl Session { } /// Let the active tool finish work a command from outside it ended ([`vectorcraft_tools::Tool::after_command`]): - /// the Type tool stops editing text that is no longer selected. Only while it edits text. + /// the Type tool and the Eyedropper clear a character range whose text is no longer selected. pub(crate) fn after_command(&mut self) { - if self.in_tool_actions || !self.tool.wants_text() { + if self.in_tool_actions || (!self.tool.wants_text() && self.tool.id() != "eyedropper") { return; } let acts = self.with_tool_cx(self.last_view, |t, cx| t.after_command(cx)); @@ -528,25 +610,57 @@ impl Session { for a in acts { match a { Action::Begin(label) => self.begin_interaction(&label)?, - Action::Preview(cmd, p) => match self.preview(&cmd, &p) { - Ok(v) => { - if let Some(w) = v.get("warning").and_then(Value::as_str) - && !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w)) - { - ui.push(UiRequest::Status(w.to_string())); + Action::Preview(cmd, p) => { + // Performance › Real-time Drawing and Editing (#394): off, remember the latest + // params and apply them once on Commit instead of every move. + if !self.prefs.real_time_drawing { + self.clear_deferred_overlay_cache(); + let st = self.doc_mut()?; + if let Some(it) = &mut st.interaction { + it.preview = Some((cmd, p)); } + continue; } - Err(e) => log::warn!("preview {cmd}: {e}"), - }, + self.clear_deferred_overlay_cache(); + match self.preview(&cmd, &p) { + Ok(v) => { + if let Some(w) = v.get("warning").and_then(Value::as_str) + && !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w)) + { + ui.push(UiRequest::Status(w.to_string())); + } + } + Err(e) => log::warn!("preview {cmd}: {e}"), + } + } // The drag is over: the dabs of a Liquify stroke have no further use. Action::Commit => { self.liquify_stroke = None; + let deferred = self.unapplied_interaction_preview(); + if let Some((cmd, p)) = deferred { + match self.preview(&cmd, &p) { + Ok(v) => { + if let Some(w) = v.get("warning").and_then(Value::as_str) + && !ui.iter().any(|r| matches!(r, UiRequest::Status(s) if s == w)) + { + ui.push(UiRequest::Status(w.to_string())); + } + } + Err(e) => { + log::warn!("preview {cmd}: {e}"); + self.cancel_interaction()?; + continue; + } + } + } self.commit_interaction()? } Action::Cancel => { self.liquify_stroke = None; self.cancel_interaction()? } + // The place cursor's file, placed by the frontend that asked for it. + Action::Exec(cmd, p) if cmd == "file.place" && self.place_cursor_requests => ui.push(UiRequest::Place(p)), Action::Exec(cmd, p) => { // A command's warning (Crop Image's when the image couldn't be decoded) goes to the // status bar too. @@ -564,4 +678,50 @@ impl Session { } Ok(ui) } + + /// Canvas feedback while Real-time Drawing is off: run the pending preview command once, take + /// outlines from the objects it touched, then put the document back at the interaction snapshot. + fn deferred_preview_overlays_from_command(&mut self, cmd: &str, p: &Value) -> Vec { + if self.deferred_overlay_cache.as_ref().is_some_and(|(c, params, _)| c == cmd && params == p) { + return self.deferred_overlay_cache.as_ref().map(|(_, _, o)| o.clone()).unwrap_or_default(); + } + let (snapshot, selection, revision, paint, interaction_fields) = { + let Some(st) = self.active() else { return vec![] }; + let Some(it) = st.interaction.as_ref() else { return vec![] }; + if !std::sync::Arc::ptr_eq(&it.doc, &st.doc) { + return vec![]; + } + let interaction_fields = (it.preview.clone(), it.copies.clone(), it.perspective_again.clone()); + (st.doc.clone(), st.selection.clone(), st.revision, self.pending_paint.clone(), interaction_fields) + }; + let overlays = if self.preview(cmd, p).is_ok() { + self.active().map(|st| outlines_for_selection(&st.doc, &st.selection)).unwrap_or_default() + } else { + vec![] + }; + if let Ok(st) = self.doc_mut() { + st.doc = snapshot; + st.selection = selection; + st.revision = revision; + if let Some(it) = &mut st.interaction { + it.preview = interaction_fields.0; + it.copies = interaction_fields.1; + it.perspective_again = interaction_fields.2; + } + } + self.pending_paint = paint; + self.deferred_overlay_cache = Some((cmd.to_string(), p.clone(), overlays.clone())); + overlays + } +} + +fn outlines_for_selection(doc: &vectorcraft_doc::Document, selection: &Selection) -> Vec { + selection + .objects + .iter() + .filter_map(|&id| { + let n = doc.node(id)?; + vectorcraft_render::drawn_outline(doc, n).map(|(path, _)| Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: false }) + }) + .collect() } diff --git a/crates/engine/tests/affinity_text.rs b/crates/engine/tests/affinity_text.rs index feb704813..42224bfcb 100644 --- a/crates/engine/tests/affinity_text.rs +++ b/crates/engine/tests/affinity_text.rs @@ -93,13 +93,17 @@ fn a_frame_too_short_for_its_first_line_is_made_taller_with_a_warning() { // #1423: a title taller than its frame (60 pt type in a 50 pt frame) drew nothing. let lines = |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines.len(); let title = |size: f64| import(vec![block(utf8("T"), vec![run(1, Some(attrs("Frame", "", 400, false, size)))], &[3])], true, 144.0, IDENTITY); - let short = title(120.0); - let t = texts(&short.doc)[0]; - let TextKind::Area { frame } = &t.kind else { panic!("expected area type") }; - let b = frame.bounds().unwrap(); - assert!((b.x0, b.y0, b.width()) == (0.0, 0.0, 100.0) && b.height() > 50.0 && b.height() < 100.0, "taller from its top: {b:?}"); - assert_eq!(lines(t), 1, "the line shows"); - assert!(short.warnings.iter().any(|w| w.contains("too short for their first line")), "{:?}", short.warnings); + // Its baseline (at cap height) is inside the frame, its descenders hang out of it; and an + // 80 pt title whose capitals don't fit either. + for (size, warning, max) in [(120.0, "made taller so the lines show", 100.0), (160.0, "too short for their first line", 150.0)] { + let short = title(size); + let t = texts(&short.doc)[0]; + let TextKind::Area { frame } = &t.kind else { panic!("expected area type") }; + let b = frame.bounds().unwrap(); + assert!((b.x0, b.y0, b.width()) == (0.0, 0.0, 100.0) && b.height() > 50.0 && b.height() < max, "{size}: taller from its top: {b:?}"); + assert_eq!(lines(t), 1, "the line shows"); + assert!(short.warnings.iter().any(|w| w.contains(warning)), "{size}: {:?}", short.warnings); + } // A frame that holds its line keeps its height, and nothing is said. let fits = title(20.0); let t = texts(&fits.doc)[0]; diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index 74ccfc0b4..80c189b3f 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -283,7 +283,7 @@ fn the_eyedropper_takes_an_image_pixel_into_the_working_rgb_space() { let l = d.layers[0].id; let id = d.alloc_id(); let xf = Affine::translate((300.0, 0.0)) * Affine::scale(100.0); - let im = ImageObject { key: "px".into(), width: 1, height: 1, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: "px".into(), width: 1, height: 1, xf, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| vectorcraft_engine::EngineError::Other(e.to_string())) }) .unwrap(); diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 93e53dbde..1881fafc4 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -138,6 +138,41 @@ fn distribute_with_a_huge_spacing_from_the_key_object() { } } +#[test] +fn distribute_with_a_huge_spacing_from_a_key_guide() { + for (vertical, pos) in [(true, 50.0), (true, 10000.0), (false, -10000.0)] { + for p in [json!({"horizontal": "left", "spacing": 1e308}), json!({"vertical": "bottom", "spacing": -1e308})] { + let mut s = Fixture::Multi.session(); + s.execute("guide.add", &json!({"vertical": vertical, "pos": pos})).unwrap(); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + s.execute("select.key", &json!({"guide": 0})).unwrap(); + if no_panic(s.execute("object.distribute", &p)).is_ok() { + check_session(&s).unwrap_or_else(|e| panic!("guide at {pos}, {p}: {e}")); + vectorcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("guide at {pos}, {p}: {e}")); + } + } + } +} + +#[test] +fn distribute_guides_selected_alone_with_a_huge_spacing() { + for (cmd, p) in [ + ("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 1e308})), + ("object.distributeSpacing", json!({"axis": "vertical", "spacing": -1e308})), + ("object.distribute", json!({"horizontal": "center", "to": "artboard", "artboard": 99})), + ] { + let mut s = Fixture::Empty.session(); + for (v, pos) in [(true, 10.0), (true, 20.0), (true, 9000.0), (false, -9000.0), (false, 0.0)] { + s.execute("guide.add", &json!({"vertical": v, "pos": pos})).unwrap(); + } + s.execute("guide.select", &json!({"indexes": [0, 1, 2, 3, 4]})).unwrap(); + if no_panic(s.execute(cmd, &p)).is_ok() { + check_session(&s).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + vectorcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")); + } + } +} + #[test] fn bug_artboard_huge_props_break_svg() { for key in ["x", "y", "width", "height"] { @@ -335,9 +370,18 @@ fn structured_junk() { ("effect.apply", json!({"effect": "artistic.sponge", "params": {"brushSize": -1e308, "definition": 1e308, "smoothness": null}})), ("effect.apply", json!({"effect": "artistic.watercolor", "params": {"brushDetail": "1e999", "shadowIntensity": -1, "texture": 1e308}})), ("effect.apply", json!({"effect": "artistic.cutout", "params": {"levels": 1e308, "edgeSimplicity": "NaN", "edgeFidelity": -1e308}})), + ("effect.apply", json!({"effect": "artistic.dryBrush", "params": {"brushSize": -1e308, "brushDetail": "NaN", "texture": 1e308}})), ("effect.apply", json!({"effect": "artistic.neonGlow", "params": {"glowSize": -1e308, "glowBrightness": "1e999", "color": [1]}})), ("effect.apply", json!({"effect": "artistic.posterEdges", "params": {"edgeThickness": 1e308, "edgeIntensity": [], "posterization": -7}})), + ( + "effect.apply", + json!({"effect": "artistic.roughPastels", "params": {"strokeLength": 1e308, "strokeDetail": "NaN", "texture": -1e308, "scaling": "bad", "lightDirection": "nope"}}), + ), ("effect.apply", json!({"effect": "sketch.chrome", "params": {"detail": -1e308, "smoothness": "NaN"}})), + ( + "effect.apply", + json!({"effect": "sketch.conteCrayon", "params": {"foregroundLevel": "NaN", "backgroundLevel": 1e308, "texture": [1], "scaling": -1e308, "relief": "1e999", "lightDirection": 3, "invert": "yes"}}), + ), ("effect.apply", json!({"effect": "sketch.graphicPen", "params": {"strokeLength": -1e308, "lightDarkBalance": "NaN", "strokeDirection": 3}})), ("effect.apply", json!({"effect": "sketch.halftonePattern", "params": {"size": -1e308, "contrast": "NaN", "patternType": 3}})), ("effect.apply", json!({"effect": "sketch.basRelief", "params": {"detail": "NaN", "smoothness": 1e308, "lightDirection": 3}})), @@ -432,6 +476,8 @@ fn structured_junk() { ("object.blend.make", json!({"ids": [1, 2, 3], "starts": [u64::MAX, -1, "x", null, 1e308]})), ("object.blend.make", json!({"ids": [2, 2], "starts": {"a": 1}, "steps": 1e308})), ("object.blend.options", json!({"spacing": "distance", "value": -1e308, "orientation": 5})), + ("object.blend.options", json!({"ramp": 1e308, "colorShift": -1e308, "easingType": [1]})), + ("object.blend.make", json!({"ids": [1, 2], "ramp": f64::NAN, "colorShift": [1]})), ("object.blend.spine.moveAnchor", json!({"id": 2, "anchor": u64::MAX, "x": 1e308, "y": -1e308, "handle": "sideways"})), ("object.blend.spine.addAnchor", json!({"id": u64::MAX, "x": 0, "y": 0})), ("object.blend.spine.reshapeSegment", json!({"id": 2, "segment": u64::MAX, "t": f64::NAN, "dx": 1e308, "dy": -1e308})), @@ -449,6 +495,21 @@ fn structured_junk() { ("symbol.options", json!({"name": "Dot", "nineSlice": true, "nineSliceGuides": [1e308, -1e308, 0, 0]})), ("symbol.options", json!({"nineSlice": 7, "nineSliceGuides": []})), ("symbol.options", json!({"nineSlice": false, "nineSliceGuides": null})), + // Shape Builder Gap Detection: the `gap` param and the tool options. + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": 1e308})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80], [120, 90]], "gap": -1e308})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": f64::MAX})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": "NaN"})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": "1e999"})), + ("shapeBuilder.merge", json!({"ids": [2, 3], "points": [[100, 80]], "gap": null})), + ("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": [1, 2]})), + ("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": {"a": 1}, "erase": true})), + ("shapeBuilder.merge", json!({"points": [[100, 80]], "gap": 1e-300, "erase": true, "tolerance": 1e308})), + ("shapeBuilder.regions", json!({"gap": 1e308})), + ("shapeBuilder.regions", json!({"ids": [0, 1, 2, 3], "gap": -1})), + ("shapeBuilder.regions", json!({"ids": [2, 3], "gap": "x"})), + ("shapeBuilder.regions", json!({"gap": true})), + ("shapeBuilder.regions", json!({"gap": 12, "ids": [u64::MAX]})), ]; let mut failures = vec![]; for fx in Fixture::ALL { @@ -461,6 +522,44 @@ fn structured_junk() { assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); } +/// Shape Builder Tool Options (Gap Detection) with junk values: never a panic, and the tool still +/// answers with options and works on a drag afterwards. +#[test] +fn shape_builder_options_junk() { + let cases = [ + json!({"values": {"gapDetection": true, "gapLength": 1e308}}), + json!({"values": {"gapDetection": "yes", "gapLength": -1e308}}), + json!({"values": {"gapDetection": null, "gapLength": "NaN"}}), + json!({"values": {"gapDetection": [true], "gapLength": [1, 2]}}), + json!({"values": {"gapDetection": true, "gapLength": "1e999"}}), + json!({"values": {"gapLength": {"a": 1}, "bogus": 1}}), + json!({"key": "gapLength", "value": f64::MAX}), + json!({"key": "gapLength", "value": f64::MIN_POSITIVE}), + json!({"key": "gapDetection"}), + json!({"key": "gapDetection", "value": 1}), + json!({"values": [1, 2]}), + json!({"values": "x"}), + ]; + let mut failures = vec![]; + for fx in Fixture::ALL { + for p in &cases { + let mut s = fx.session(); + let r = catch_quiet(|| { + let _ = s.select_tool("shapeBuilder", Default::default()); + let _ = s.set_tool_option_cmd(p); + let _ = s.set_tool_option_cmd(&json!({"values": {"gapDetection": true}})); + no_panic(s.execute("shapeBuilder.regions", &json!({}))) + }); + if let Err(msg) = r { + failures.push(format!("PANIC {p} [{fx:?}]: {msg}")); + } else if let Err(e) = check_session(&s) { + failures.push(format!("{p} [{fx:?}]: {e}")); + } + } + } + assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n")); +} + /// Area Type Options with junk fit values, on selected area type (the fixtures have none), with /// text that overflows: never a panic, the document stays sound, and editing still works. #[test] @@ -790,7 +889,7 @@ fn gradient_mesh_from_images_with_junk_params() { d.images.insert("pic".into(), ImageBlob::png(bytes.clone())); let id = d.alloc_id(); let link = linked.then(|| LinkInfo { path: "/nonexistent/pic.png".into(), ..Default::default() }); - let im = ImageObject { key: "pic".into(), width: *w, height: *h, xf: *xf, link, placement: Default::default() }; + let im = ImageObject { key: "pic".into(), width: *w, height: *h, xf: *xf, link, placement: Default::default(), pixels: None }; d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?; sel.set([id]); Ok(()) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index b530f7883..53f812f0a 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -268,6 +268,133 @@ proptest! { } } +// ---------- Affinity documents with every feature the reader maps ---------- + +#[path = "support/affinity_docs.rs"] +mod affinity_docs; +#[path = "../../affinity/tests/support/text_fixture.rs"] +mod text_fixture; + +/// One sample document: its object stream and, for embedded content, the embedded document's. +struct AffinitySample { + name: &'static str, + stream: Vec, + embedded: Option>, +} + +/// Original synthetic documents (not an Affinity writer) reaching what the reader maps beyond +/// [`affinity_native_stream`]: layer effects and colour overlays (also over tables), stroke +/// styles, dashes and brushes, paragraph alignment, frame text, underline, strikethrough and +/// line breaks inside a paragraph, tables with merged and two-line cells, master pages, CMYK +/// pixel layers with and without the document's CMYK profile, and embedded documents. +fn affinity_feature_samples() -> Vec { + use affinity_docs::*; + let identity = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0]; + let fx = vec![shadow(), effect(b"OutG", 6.0), effect(b"InnS", 3.0), effect(b"BevE", 4.0), outline(0, 3.0), outline(2, 4.0), colour_overlay(0.5)]; + let canvas = canvas_stream(vec![ + with_effects(stroked(10, 1, &[]), fx), + stroked(11, 0, &[]), + stroked(12, 2, &[4.0, 2.0]), + stroked(13, 3, &[]), + mixed_alignment(1, 2), + two_line_frame(150.0), + decorated_text(70), + with_effects(table_with(30, identity, ["A", "B\u{2028}two", "C", "D", "E"]), vec![colour_overlay(1.0)]), + ]); + let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GENERIC_CMYK).unwrap(); + let inner = canvas_stream(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![colour_overlay(1.0)]), artistic_text(20, "Hi", 40.0)]); + vec![ + AffinitySample { name: "effects, strokes, text and tables", stream: canvas, embedded: None }, + AffinitySample { name: "master pages", stream: master_stream(3), embedded: None }, + AffinitySample { name: "CMYK pixels with a profile", stream: cmyk_stream(false, Some(&icc)), embedded: None }, + AffinitySample { name: "CMYK pixels with transparency", stream: cmyk_stream(true, None), embedded: None }, + AffinitySample { name: "embedded document", stream: canvas_stream(vec![embedded_node(20)]), embedded: Some(inner) }, + ] +} + +/// A sample's file: its streams stored uncompressed, so mutations reach the object graph +/// instead of failing a decompressor's checks. +fn affinity_sample_file(stream: &[u8], embedded: Option<&[u8]>) -> Vec { + use vectorcraft_affinity::synth::Method; + let inner = embedded.map(|e| affinity_docs::packed(e, None, Method::Stored)); + affinity_docs::packed(stream, inner.as_deref(), Method::Stored) +} + +#[test] +fn the_affinity_feature_samples_reach_what_they_are_for() { + use vectorcraft_doc::NodeKind; + let samples = affinity_feature_samples(); + let open = |i: usize| { + let s: &AffinitySample = &samples[i]; + let l = vectorcraft_engine::cmd::fileio::load("x.af", &affinity_sample_file(&s.stream, s.embedded.as_deref())).unwrap(); + assert!(!l.preview_only, "{}: {:?}", s.name, l.warnings); + l + }; + fn all(d: &Document) -> Vec<&vectorcraft_doc::Node> { + fn walk<'a>(nodes: &'a [std::sync::Arc], out: &mut Vec<&'a vectorcraft_doc::Node>) { + for n in nodes { + out.push(n); + if let Some(c) = n.children() { + walk(c, out); + } + } + } + let mut out = Vec::new(); + walk(&d.layers, &mut out); + out + } + let l = open(0); + let nodes = all(&l.doc); + let runs: Vec<&vectorcraft_doc::text::TextRun> = + nodes.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.runs.iter()) } else { None }).flatten().collect(); + assert!(runs.iter().any(|r| r.style.underline) && runs.iter().any(|r| r.style.strikethrough)); + assert!(runs.iter().any(|r| r.text.contains("two")), "the table's two-line cell"); + assert!(nodes.iter().any(|n| n.appearance.effects.iter().any(|e| e.id == "stylize.dropShadow"))); + assert!(l.warnings.iter().any(|w| w.contains("bevel")), "{:?}", l.warnings); + // Line styles: solid, None (no stroke), dashed and brush. + use vectorcraft_doc::appearance::{AppearanceItem, StrokeLayer}; + let strokes = |n: &vectorcraft_doc::Node| -> Vec { + n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s.clone()) } else { None }).collect() + }; + let boxes: Vec<_> = nodes.iter().filter(|n| n.name.as_deref() == Some("Magenta box")).collect(); + assert_eq!(boxes.len(), 4); + assert_eq!(boxes.iter().filter(|b| strokes(b).is_empty()).count(), 1, "line style None"); + assert!(boxes.iter().any(|b| strokes(b).iter().any(|s| s.dash.is_some())), "dashed"); + let l = open(1); + assert_eq!(l.doc.artboards.len(), 3); + assert!(l.doc.layers.iter().any(|n| n.name.as_deref() == Some("Master A"))); + for i in [2, 3] { + assert!(all(&open(i).doc).iter().any(|n| matches!(n.kind, NodeKind::Image(_))), "{}", samples[i].name); + } + assert!(all(&open(4).doc).iter().any(|n| n.name.as_deref() == Some("Magenta box"))); +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn affinity_feature_documents_with_mutated_streams_render_and_export( + sample in 0usize..5, + inner in any::(), + cut in 0usize..8192, + edits in prop::collection::vec((0usize..8192, any::()), 0..24), + ) { + let samples = affinity_feature_samples(); + let s = &samples[sample % samples.len()]; + let (mut stream, mut embedded) = (s.stream.clone(), s.embedded.clone()); + // Mutate the embedded document's stream instead, where there is one. + let target = match embedded.as_mut() { + Some(e) if inner => e, + _ => &mut stream, + }; + let n = target.len(); + for (at, b) in edits { target[at % n] = b; } + target.truncate(cut.max(16)); + let bytes = affinity_sample_file(&stream, embedded.as_deref()); + survive(s.name, || vectorcraft_engine::cmd::fileio::load("x.af", &bytes).ok().map(|l| l.doc))?; + } +} + /// Import must not panic; whatever comes back must render and export without panicking either. fn survive(what: &str, import: impl FnOnce() -> Option) -> Result<(), TestCaseError> { let r = catch_quiet(|| { @@ -1252,6 +1379,68 @@ proptest! { } } +// ---------- variable library XML files ---------- + +/// A variable library written from its public grammar: two text variables (`Title` is a +/// variable of [`merge_session`]'s document), a variable of another kind and two data sets. +const VARIABLE_LIBRARY: &str = "\n\ +\n\n]>\n\ +\n\n\n\n\ +\n\n\ +\n\n\ +\n\ +\n

One

Two & three

\n

x

\n

true

\n\n\ +\n<p>Four</p>\n\n\n\ +\n\n
\n\n"; + +/// `variable.loadLibrary` with `params` on [`merge_session`]'s document, the first three data sets +/// it read selected in turn and every data set exported; the document then renders and exports. +fn variable_library(what: &str, params: Value) -> Result<(), TestCaseError> { + survive(what, || { + let mut s = merge_session(); + if let Ok(r) = no_panic(s.execute("variable.loadLibrary", ¶ms)) { + for name in r["names"].as_array().into_iter().flatten().take(3) { + let _ = no_panic(s.execute("dataset.select", &json!({"name": name}))); + } + } + let _ = no_panic(s.execute("dataset.export", &json!({}))); + Some((*s.doc().ok()?.doc).clone()) + }) +} + +proptest! { + #![proptest_config(config())] + + #[test] + fn variable_library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec![ + "", + "", + "", + "<p>", + "<!DOCTYPE svg [<!ENTITY a \"", + ])) { + variable_library("variable library garbage", json!({"xml": format!("{head}{s}")}))?; + } + + #[test] + fn variable_library_bytes_never_panic(bytes in prop::collection::vec(any::<u8>(), 0..300)) { + variable_library("variable library bytes", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)}))?; + } + + /// The library cut short and with characters replaced, then with `nest` elements nested in one + /// another inside its `svg` element. + #[test] + fn mutated_variable_libraries_never_panic( + cut in 0usize..1_200, + edits in prop::collection::vec((0usize..1_200, prop::sample::select(vec!['<', '>', '"', '/', '=', '&', ';', '!', '[', ']', 'p', 'x', ' '])), 0..10), + nest in prop::sample::select(vec![0usize, 0, 0, 240, 100_000]), + ) { + let xml = mutate_text(VARIABLE_LIBRARY, cut, &edits); + let xml = xml.replacen("<svg>", &format!("<svg>{}", "<a>".repeat(nest)), 1).replacen("</svg>", &format!("{}</svg>", "</a>".repeat(nest)), 1); + variable_library("mutated variable library", json!({"xml": xml}))?; + } +} + // ---------- shortcut sets ---------- /// Shortcut set `bytes` read with `shortcutSet.read`, with import matches saved to annotate it: an @@ -3164,19 +3353,40 @@ fn deeply_nested_svg_is_refused_or_merged() { "<svg {SVG_NS} width=\"9\" height=\"9\"><defs>{links}<rect id=\"u3000\" width=\"1\" height=\"1\"/></defs><use href=\"#u0\"/></svg>" ); assert!(open_deep("chain.svg", chain.as_bytes()).is_none()); + // SVG images in SVG images, read on the same stack: each too deep, of a type that is read + // as SVG too (`text/plain`), or each within the cap but not all together (#3562). What is + // past the cap is left out. + let images = [nested_images(3, 1000), nested_images(12, most - 4)]; + for (i, file) in images.iter().enumerate() { + let (_, warnings) = open_deep("images.svg", file.as_bytes()).expect("opens"); + assert!(warnings.iter().any(|w| w.contains("SVG images were left out")), "{i}: {warnings:?}"); + } // Placed and pasted, as opened. let mut s = vectorcraft_engine::Session::new(); s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); - for n in [40, 2 * most] { - let p = json!({"name": "deep.svg", "dataBase64": vectorcraft_format::base64_encode(nested_svg(n).as_bytes())}); + for svg in [nested_svg(40), nested_svg(2 * most)].iter().chain(&images) { + let p = json!({"name": "deep.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())}); let _ = no_panic(s.execute("file.place", &p)); - let _ = no_panic(s.execute("clipboard.importSvg", &json!({"svg": nested_svg(n)}))); + let _ = no_panic(s.execute("clipboard.importSvg", &json!({"svg": svg}))); let _ = no_panic(s.execute("edit.paste", &json!({}))); assert!(!s.doc().unwrap().doc.too_deep(None)); } }); } +/// An SVG showing a chain of `levels` SVG images from `text/plain` data URLs, each `n` groups +/// deep around the next. +fn nested_images(levels: usize, n: usize) -> String { + let image = |svg: &str| { + format!("<image href=\"data:text/plain;base64,{}\" width=\"9\" height=\"9\"/>", vectorcraft_format::base64_encode(svg.as_bytes())) + }; + let mut svg = nested_svg(0); + for _ in 0..levels { + svg = format!("<svg {SVG_NS} width=\"9\" height=\"9\">{}{}{}</svg>", "<g>".repeat(n), image(&svg), "</g>".repeat(n)); + } + format!("<svg {SVG_NS} width=\"9\" height=\"9\">{}</svg>", image(&svg)) +} + /// A page whose content is `content`, with `resources`. fn deep_pdf(content: &str, resources: &str) -> Vec<u8> { handmade_pdf(content, resources, "[0 0 100 100]") @@ -3984,3 +4194,15 @@ fn ai_cut_inside_an_array_or_dictionary_opens_without_panicking() { survive("ai cut inside an array or dictionary", || vectorcraft_engine::cmd::fileio::load("saved.ai", &b).ok().map(|l| l.doc)).unwrap(); } } + +/// Found by the fuzzer above: a shape 3.3 million points long and 50 high, with a drop shadow, +/// made the PDF export rasterise its shadow wider than the renderer can address (within the +/// effect image's area limit, past any side), which panicked. +#[test] +fn a_long_thin_shape_with_a_shadow_renders_and_exports() { + use affinity_docs::*; + let stream = canvas_stream(vec![with_effects(rectangle(10, (0.0, 0.0), (3_276_800.0, 50.0)), vec![shadow()])]); + let bytes = affinity_sample_file(&stream, None); + let doc = vectorcraft_engine::cmd::fileio::load("long.af", &bytes).unwrap().doc; + survive("long shape", || Some(doc)).unwrap(); +} diff --git a/crates/engine/tests/it/affinity_features.rs b/crates/engine/tests/it/affinity_features.rs new file mode 100644 index 000000000..cfabffc9a --- /dev/null +++ b/crates/engine/tests/it/affinity_features.rs @@ -0,0 +1,704 @@ +//! Affinity → VectorCraft mapping of pages, master pages, tables, paragraph formats, text scale, +//! layer effects and embedded documents, on original synthetic documents built with the reader's +//! `synth` feature (no Affinity software or files were used). The fixtures follow DesignCraft's +//! Affinity import tests, whose reader VectorCraft shares. + +use crate::affinity_docs; +use crate::text_fixture; + +use std::sync::Arc; + +use affinity_docs::*; + +use kurbo::Rect; +use text_fixture::{array_f64, array_i32, attrs, block, run, utf8}; +use vectorcraft_affinity::synth::{F, tag}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::appearance::{AppearanceItem, StrokeAlign}; +use vectorcraft_doc::text::{TextKind, TextObject, VerticalAlign}; +use vectorcraft_doc::{Document, Node, NodeKind}; +use vectorcraft_engine::cmd::fileio::{Loaded, load}; + +fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-6 +} + +fn open(name: &str, bytes: &[u8]) -> Loaded { + let loaded = load(name, bytes).unwrap(); + assert!(!loaded.preview_only, "{:?}", loaded.warnings); + loaded +} + +/// Every node of the document, depth first. +fn all(d: &Document) -> Vec<&Node> { + fn walk<'a>(nodes: &'a [Arc<Node>], out: &mut Vec<&'a Node>) { + for n in nodes { + out.push(n); + if let Some(c) = n.children() { + walk(c, out); + } + } + } + let mut out = Vec::new(); + walk(&d.layers, &mut out); + out +} + +fn named<'a>(d: &'a Document, name: &str) -> &'a Node { + all(d).into_iter().find(|n| n.name.as_deref() == Some(name)).unwrap_or_else(|| panic!("no node {name}")) +} + +fn texts(d: &Document) -> Vec<&TextObject> { + all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect() +} + +fn content(t: &TextObject) -> String { + t.runs.iter().map(|r| r.text.as_str()).collect() +} + +/// An area text's frame in document space. +fn frame(t: &TextObject) -> Rect { + let TextKind::Area { frame } = &t.kind else { panic!("area type: {:?}", t.kind) }; + t.xf.transform_rect_bbox(frame.bounds().unwrap()) +} + +fn rect_near(a: Rect, b: Rect) -> bool { + near(a.x0, b.x0) && near(a.y0, b.y0) && near(a.x1, b.x1) && near(a.y1, b.y1) +} + +fn layer_names(d: &Document) -> Vec<&str> { + d.layers.iter().map(|l| l.name.as_deref().unwrap_or("")).collect() +} + +#[test] +fn publisher_pages_become_artboards_in_points() { + let d = open("brochure.afpub", &publisher()).doc; + let boards: Vec<_> = d.artboards.iter().map(|b| (b.name.as_str(), b.rect)).collect(); + assert_eq!(boards.len(), 2); + assert_eq!((boards[0].0, boards[1].0), ("Page 1", "Page 2")); + assert!(rect_near(boards[0].1, Rect::new(0.0, 0.0, 2480.0 * PT, 3508.0 * PT)), "{boards:?}"); + assert!(rect_near(boards[1].1, Rect::new(2480.0 * PT, 0.0, 4960.0 * PT, 3508.0 * PT)), "{boards:?}"); + assert!(layer_names(&d).contains(&"Background")); + let rect = named(&d, "Magenta box"); + assert!(rect_near(rect.geometric_bounds().unwrap(), Rect::new(100.0 * PT, 100.0 * PT, 200.0 * PT, 300.0 * PT))); + let Some(AppearanceItem::Fill(fill)) = rect.appearance.items.first() else { panic!("{:?}", rect.appearance) }; + assert_eq!(fill.paint, Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), "CMYK stays CMYK"); +} + +/// Several spreads of several pages: every page is an artboard, the spreads side by side. +#[test] +fn every_page_of_every_spread_is_an_artboard() { + let file = publisher_with(vec![ + spread(2, 1, vec![rectangle(10, (10.0, 10.0), (20.0, 20.0))]), + spread(3, 2, vec![rectangle(20, (2500.0, 10.0), (20.0, 20.0))]), + spread(4, 2, vec![]), + ]); + let d = open("book.afpub", &file).doc; + let names: Vec<_> = d.artboards.iter().map(|b| b.name.as_str()).collect(); + assert_eq!(names, ["Page 1", "Page 2", "Page 3", "Page 4", "Page 5"]); + assert_eq!(d.artboards.iter().map(|b| b.id).collect::<Vec<_>>(), [1, 2, 3, 4, 5]); + let (w, h) = (2480.0 * PT, 3508.0 * PT); + for b in &d.artboards { + assert!(near(b.rect.width(), w) && near(b.rect.height(), h), "{b:?}"); + } + // The two pages of a spread touch; spreads are 36 pt apart. + let x: Vec<f64> = d.artboards.iter().map(|b| b.rect.x0).collect(); + assert!(near(x[1], w + 36.0) && near(x[2], 2.0 * w + 36.0) && near(x[3], 3.0 * w + 72.0), "{x:?}"); + // The second spread's box is on its second page. + let boxes: Vec<Rect> = all(&d).into_iter().filter(|n| n.name.as_deref() == Some("Magenta box")).map(|n| n.geometric_bounds().unwrap()).collect(); + assert_eq!(boxes.len(), 2); + assert!(d.artboards[2].rect.contains(boxes[1].center()), "{boxes:?} {:?}", d.artboards[2]); + let reopened = vectorcraft_format::load_file(&vectorcraft_format::save_file(&d)).unwrap(); + assert_eq!(reopened.doc.artboards, d.artboards); +} + +/// Artboards on one canvas keep their names, sizes, places and own content; objects beside them +/// stay on the canvas. +#[test] +fn several_artboards_keep_their_own_content() { + let file = canvas_with( + vec![ + board(10, "Icon", (0.0, 0.0), (64.0, 64.0), vec![named_rect(11, "Icon art", (8.0, 8.0), (48.0, 48.0))]), + named_rect(20, "Loose", (80.0, 0.0), (10.0, 10.0)), + board(30, "Banner", (100.0, 0.0), (300.0, 100.0), vec![named_rect(31, "Banner art", (110.0, 10.0), (280.0, 80.0))]), + board(40, "Card", (0.0, 200.0), (85.0, 55.0), vec![]), + ], + None, + ); + let d = open("set.af", &file).doc; + let boards: Vec<_> = d.artboards.iter().map(|b| (b.name.as_str(), b.rect)).collect(); + assert_eq!( + boards, + [("Icon", Rect::new(0.0, 0.0, 64.0, 64.0)), ("Banner", Rect::new(100.0, 0.0, 400.0, 100.0)), ("Card", Rect::new(0.0, 200.0, 85.0, 255.0))] + ); + // Each artboard is a layer holding its clip group, its art inside. + for (board, art) in [("Icon", "Icon art"), ("Banner", "Banner art")] { + let layer = d.layers.iter().find(|l| l.name.as_deref() == Some(board)).unwrap(); + let group = &layer.children().unwrap()[0]; + assert!(matches!(group.kind, NodeKind::Group { clip: true, .. })); + assert!(group.children().unwrap().iter().any(|c| c.name.as_deref() == Some(art)), "{board}"); + } + assert!(rect_near(named(&d, "Loose").geometric_bounds().unwrap(), Rect::new(80.0, 0.0, 90.0, 10.0))); +} + +#[test] +fn artistic_text_keeps_its_paragraphs_size_and_leading() { + let d = open("brochure.afpub", &publisher()).doc; + let t = texts(&d)[0]; + assert_eq!(content(t), "Hello\nWorld", "paragraph marks become paragraphs; the final one goes"); + assert!(near(t.runs[0].style.size, 100.0 * PT)); + assert_eq!(t.runs[0].style.font_style, "Bold"); + assert_eq!(t.para.justify, vectorcraft_doc::text::Justify::Center); + assert!(matches!(t.kind, TextKind::Point)); + // Centred on the anchor (x = 1000 + 200), first baseline 80 px below the frame top (y = 500). + let origin = t.xf * kurbo::Point::ZERO; + assert!(near(origin.x, 1200.0 * PT) && near(origin.y, 580.0 * PT), "{origin:?}"); + // Lines are the font size plus the font's line gap apart (VectorCraft's default is 120 %). + let face = vectorcraft_text::FontDb::global().face(&t.runs[0].style.font_family, &t.runs[0].style.font_style).unwrap(); + let expected = 100.0 * (1.0 + face.line_gap() / face.units_per_em()); + assert!(near(t.para.justification.auto_leading, expected), "{} vs {expected}", t.para.justification.auto_leading); +} + +#[test] +fn tables_become_cell_text_and_edge_lines() { + // Stretched to twice its width by its transform: the grid follows, the type doesn't. + let loaded = open("table.afpub", &page_with(table_node(30, [2.0, 0.0, 400.0, 0.0, 1.0, 600.0]))); + let d = &loaded.doc; + let table = named(d, "Table"); + let cells: Vec<&TextObject> = + table.children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect(); + assert_eq!(cells.iter().map(|t| content(t)).collect::<Vec<_>>(), ["A", "B", "C", "D", "E"]); + let a = cells[0]; + assert!(near(a.runs[0].style.size, 40.0 * PT), "type keeps its size"); + assert!(near(a.runs[0].style.h_scale, 100.0), "type isn't stretched with the grid"); + assert_eq!(a.area.vertical_align, VerticalAlign::Center); + // Insets (5, 6, 7, 8 px) inside the cell, scaled with the grid; the text centred in them as + // Affinity centres it. + let f = frame(a); + assert!(near(f.x0, (400.0 + 10.0) * PT) && near(f.width(), 176.0 * PT), "{f:?}"); + let (top, last) = cap_to_last_baseline(a); + assert!(((top + last) / 2.0 - (600.0 + 24.0) * PT).abs() < 1e-6, "{top} {last}"); + // The merged cell spans two columns. + assert!(near(frame(cells[3]).width(), (400.0 - 24.0) * PT), "{:?}", frame(cells[3])); + // 8 vertical edges less the one inside the merged cell, and 9 horizontal ones. + let lines: Vec<&Arc<Node>> = table.children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect(); + assert_eq!(lines.len(), 16); + let Some(AppearanceItem::Stroke(s)) = lines[0].appearance.items.first() else { panic!() }; + assert_eq!(s.paint, Paint::solid(Color::cmyk(1.0, 0.5, 0.0, 0.0))); + assert!(s.width > 0.0); +} + +/// Where a cell's text goes in the document: its first line's capitals' tops and its last +/// baseline (y). +fn cap_to_last_baseline(t: &TextObject) -> (f64, f64) { + let db = vectorcraft_text::FontDb::global(); + // Hung from the frame's top by its cap height, the first baseline is that far down. + let mut top = t.clone(); + top.area.vertical_align = VerticalAlign::Top; + let cap = vectorcraft_text::layout(db, &top).lines[0].baseline; + let lines = vectorcraft_text::layout(db, t).lines; + let y = t.xf.translation().y; + (y + lines[0].baseline - cap, y + lines.last().unwrap().baseline) +} + +#[test] +fn cell_text_taller_than_its_cell_is_centred_on_it_and_shows() { + // "A" over "a" (a line break inside the paragraph) in a cell 36 px tall inside its insets. + let loaded = open("cells.afpub", &page_with(table_with(30, [1.0, 0.0, 400.0, 0.0, 1.0, 600.0], ["A\u{2028}a", "B", "C", "D", "E"]))); + assert!(!loaded.warnings.iter().any(|w| w.contains("don't fit")), "{:?}", loaded.warnings); + let a = texts(&loaded.doc)[0]; + assert_eq!(content(a), "A\na"); + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), a); + assert_eq!(lay.lines.len(), 2, "both lines show"); + assert!(!lay.overflow); + assert!(frame(a).height() > 36.0 * PT, "the frame grew: {:?}", frame(a)); + let (top, last) = cap_to_last_baseline(a); + assert!(top < (600.0 + 6.0) * PT, "reaching out of the cell"); + assert!(((top + last) / 2.0 - (600.0 + 24.0) * PT).abs() < 1e-6, "centred: {top} {last}"); +} + +#[test] +fn line_breaks_inside_a_paragraph_get_no_paragraph_spacing() { + // Doub slots: 4 first line, 5 space before, 6 after (see the test below). + let attrs_para = F::Obj(tag(b"PAtt"), vec![(tag(b"Doub"), array_f64(b"Doub", &[1.0, 0.0, 0.0, 0.0, 25.0, 9.0, 17.0]))]); + let blk = F::Obj( + tag(b"StBl"), + vec![ + (tag(b"Glyp"), utf8("One\u{2028}two\u{2029}Three\u{2029}")), + ( + tag(b"GAtt"), + F::Obj( + tag(b"GlAS"), + vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 30.0)))]))], + ), + ), + ( + tag(b"PAtt"), + F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![vec![(tag(b"Indx"), F::I32(14)), (tag(b"Item"), attrs_para)]]))]), + ), + ], + ); + let node = F::Def( + 40, + vec![tag(b"TxtF")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, 300.0]))])), + ], + ); + let t = texts(&open("breaks.afpub", &page_with(node)).doc)[0].clone(); + assert_eq!(content(&t), "One\ntwo\nThree"); + let p = &t.paras; + let spacing: Vec<(f64, f64, f64)> = p.iter().map(|p| (p.space_before / PT, p.space_after / PT, p.first_line_indent / PT)).collect(); + let r = |v: (f64, f64, f64)| ((v.0 * 1e6).round() / 1e6, (v.1 * 1e6).round() / 1e6, (v.2 * 1e6).round() / 1e6); + assert_eq!( + spacing.into_iter().map(r).collect::<Vec<_>>(), + [(9.0, 0.0, 25.0), (0.0, 17.0, 0.0), (0.0, 17.0, 25.0)], + "space before the paragraph and after it, the first-line indent on its first line" + ); +} + +#[test] +fn each_paragraph_keeps_its_alignment_indents_and_spacing() { + // Doub slots: 1 leading (Ints slot 1 = 2), 2 left, 3 right, 4 first line, 5 space before, 6 after. + let para = |end: i32, align: i32| { + vec![ + (tag(b"Indx"), F::I32(end)), + ( + tag(b"Item"), + F::Obj( + tag(b"PAtt"), + vec![ + (tag(b"Doub"), array_f64(b"Doub", &[1.0, 5.0, 40.0, 3.0, 25.0, 9.0, 17.0])), + (tag(b"Ints"), array_i32(b"Ints", &[align, 2])), + ], + ), + ), + ] + }; + let blk = F::Obj( + tag(b"StBl"), + vec![ + (tag(b"Glyp"), utf8("One\u{2029}Two\u{2029}")), + ( + tag(b"GAtt"), + F::Obj( + tag(b"GlAS"), + vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 30.0))))]))], + ), + ), + (tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(4, 0), para(8, 2)]))])), + ], + ); + let node = F::Def( + 40, + vec![tag(b"TxtF")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, 300.0]))])), + ], + ); + let loaded = open("paragraphs.afpub", &page_with(node)); + assert!(!loaded.warnings.iter().any(|w| w.contains("paragraph")), "{:?}", loaded.warnings); + let t = texts(&loaded.doc)[0]; + assert_eq!(content(t), "One\nTwo"); + assert_eq!(t.paras.len(), 2, "{:?}", t.paras); + let p = &t.paras; + use vectorcraft_doc::text::Justify; + assert_eq!((p[0].justify, p[1].justify), (Justify::Left, Justify::Right)); + assert!(near(p[0].left_indent, 40.0 * PT) && near(p[0].first_line_indent, -15.0 * PT) && near(p[0].right_indent, 3.0 * PT), "{:?}", p[0]); + assert!(near(p[0].space_before, 9.0 * PT) && near(p[0].space_after, 17.0 * PT)); + // The larger of 17 after "One" and 9 before "Two": nothing more before "Two". + assert!(near(p[1].space_before, 0.0) && near(p[1].space_after, 17.0 * PT)); + assert!(t.runs.iter().all(|r| r.style.leading.is_some_and(|l| near(l, 5.0 * PT))), "the paragraph's fixed leading"); +} + +#[test] +fn frame_type_follows_the_frame_text_scale_not_the_frame_transform() { + // The same frame scaled 3× by its transform, without and with a text scale (FTxS) of 3. + let frame_node = |ftxs: bool| { + let story = block(utf8("Hi"), vec![run(2, Some(attrs("Inter", "Inter-Regular", 400, false, 10.0)))], &[]); + let mut fields = vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])), + (tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 50.0]))])), + (tag(b"Xfrm"), F::F64s(vec![3.0, 0.0, 0.0, 0.0, 3.0, 0.0])), + ]; + if ftxs { + fields.push((tag(b"FTxS"), F::F64s(vec![3.0, 0.0, 0.0, 0.0, 3.0, 0.0]))); + } + F::Def(40, vec![tag(b"TxtF")], fields) + }; + for (ftxs, size) in [(false, 10.0), (true, 30.0)] { + let d = open("frame.afpub", &page_with(frame_node(ftxs))).doc; + let t = texts(&d)[0]; + assert!(near(frame(t).width(), 300.0 * PT), "the frame scales either way: {:?}", frame(t)); + assert!(near(t.runs[0].style.size, size * PT), "FTxS {ftxs}: {}", t.runs[0].style.size); + assert!(near(t.xf.determinant(), 1.0), "the type isn't scaled by the placement"); + } +} + +#[test] +fn embedded_affinity_documents_come_in_as_their_content() { + let inner = canvas_with(vec![rectangle(10, (0.0, 0.0), (100.0, 50.0))], None); + let outer = canvas_with(vec![embedded_node(20)], Some(&inner)); + let loaded = open("logo.af", &outer); + let rect = named(&loaded.doc, "Magenta box"); + // Content bounds (0,0)-(100,50), centre (50,25) → origin, then 2× at (500,300). + assert!(rect_near(rect.geometric_bounds().unwrap(), Rect::new(400.0, 250.0, 600.0, 350.0)), "{:?}", rect.geometric_bounds()); + assert!(!loaded.warnings.iter().any(|w| w.contains("cached picture")), "{:?}", loaded.warnings); +} + +/// An embedded document's artboard frames its content but is no artboard of the document it is in. +#[test] +fn artboards_of_embedded_documents_are_not_artboards_here() { + let inner = canvas_with(vec![board(10, "Logo board", (0.0, 0.0), (100.0, 50.0), vec![rectangle(11, (10.0, 10.0), (80.0, 30.0))])], None); + let outer = canvas_with(vec![board(30, "Poster", (0.0, 0.0), (1000.0, 800.0), vec![embedded_node(20)])], Some(&inner)); + let d = open("poster.af", &outer).doc; + assert_eq!(d.artboards.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Poster"]); + // The logo still comes in, clipped by its own board. + let logo = named(&d, "Logo board"); + assert!(matches!(logo.kind, NodeKind::Group { clip: true, .. })); + assert!(rect_near(named(&d, "Magenta box").geometric_bounds().unwrap(), Rect::new(420.0, 270.0, 580.0, 330.0))); +} + +#[test] +fn embedded_documents_nest_only_so_deep() { + let mut doc = canvas_with(vec![rectangle(10, (0.0, 0.0), (100.0, 50.0))], None); + for _ in 0..6 { + doc = canvas_with(vec![embedded_node(20)], Some(&doc)); + } + let loaded = open("deep.af", &doc); + assert!(loaded.warnings.iter().any(|w| w.contains("nested more than four deep")), "{:?}", loaded.warnings); +} + +/// An embedded document's master pages aren't kept, so its instances of them are left out: in the +/// document it is embedded in they would show that document's masters. +#[test] +fn master_pages_of_embedded_documents_are_left_out() { + use vectorcraft_affinity::model::{Kind, Node as AfNode}; + fn instances(nodes: &[AfNode]) -> usize { + nodes.iter().map(|n| usize::from(matches!(n.kind, Kind::MasterInstance { .. })) + instances(&n.children)).sum() + } + let outer = canvas_with(vec![embedded_node(20)], Some(&with_master(1))); + let d = vectorcraft_affinity::read(&outer, vectorcraft_affinity::Limits::default()).unwrap(); + assert_eq!(instances(&d.spreads[0].nodes), 0); + assert!(d.warnings.iter().any(|w| w.contains("master pages of embedded documents")), "{:?}", d.warnings); + // Its own objects still come in. + let loaded = open("poster.af", &outer); + named(&loaded.doc, "Page box"); +} + +/// A dash pattern has a few lengths; a crafted one of thousands would be copied with every stroke. +#[test] +fn dash_patterns_of_more_than_64_lengths_import_as_solid_lines() { + use vectorcraft_affinity::model::Kind; + for (lengths, dashed) in [(64, true), (65, false)] { + let dash: Vec<f64> = (0..lengths).map(|i| 1.0 + f64::from(i)).collect(); + let d = vectorcraft_affinity::read(&canvas_with(vec![stroked(10, 2, &dash)], None), vectorcraft_affinity::Limits::default()).unwrap(); + let Kind::Shape { strokes, .. } = &d.spreads[0].nodes[0].kind else { panic!("{:?}", d.spreads[0].nodes[0].kind) }; + assert_eq!(strokes[0].dash.as_ref().map(|(d, _)| d.len()), dashed.then_some(64)); + assert_eq!(d.warnings.iter().any(|w| w.contains("dash patterns of more than 64")), !dashed, "{:?}", d.warnings); + } +} + +#[test] +fn master_pages_show_on_the_pages_that_use_them() { + let loaded = open("master.afpub", &with_master(1)); + let d = &loaded.doc; + assert!(!loaded.warnings.iter().any(|w| w.contains("master")), "{:?}", loaded.warnings); + assert_eq!(d.artboards.len(), 1, "a master spread is no artboard"); + // Behind the page's own objects, on a locked layer of its own. + let master = &d.layers[0]; + assert_eq!(master.name.as_deref(), Some("Master A")); + assert!(master.locked); + // Only the second master page's box, moved onto the page. + assert!(all(d).iter().all(|n| n.name.as_deref() != Some("Left master box"))); + let b = named(d, "Right master box").geometric_bounds().unwrap(); + assert!(rect_near(b, Rect::new(10.0, 10.0, 40.0, 50.0)), "{b:?}"); + assert!(named(d, "Page box").geometric_bounds().is_some()); +} + +#[test] +fn a_master_page_shows_on_every_page_it_covers() { + // Two pages show master pages 2 and 3 (the master spread has two: the last repeats). + let d = open("master2.afpub", &with_master(2)).doc; + assert_eq!(d.artboards.len(), 2); + let boxes: Vec<Rect> = + all(&d).into_iter().filter(|n| n.name.as_deref() == Some("Right master box")).map(|n| n.geometric_bounds().unwrap()).collect(); + assert_eq!(boxes.len(), 2, "{boxes:?}"); + for (b, board) in boxes.iter().zip(&d.artboards) { + assert!(board.rect.contains(b.center()), "{b:?} on {:?}", board.rect); + } +} + +fn pixel(d: &Document, x: usize, y: usize) -> [u8; 4] { + let img = vectorcraft_render::Renderer::new().render_region(d, d.artboards[0].rect, 1.0, false); + img.to_straight()[(y * img.width as usize + x) * 4..][..4].try_into().unwrap() +} + +#[test] +fn shadows_go_where_affinity_puts_them() { + // A blue shadow 10 px straight below (angle π/2), blurred 2 px, at half opacity. + let shadow = F::Obj( + tag(b"Shad"), + vec![ + (tag(b"Enab"), F::Bool(true)), + (tag(b"Opac"), F::F64(0.5)), + (tag(b"Radi"), F::F64(2.0)), + (tag(b"Offs"), F::F64(10.0)), + (tag(b"Angl"), F::F64(std::f64::consts::FRAC_PI_2)), + (tag(b"Colr"), rgba(0.0, 0.0, 1.0)), + ], + ); + let bevel = F::Obj(tag(b"BevE"), vec![(tag(b"Enab"), F::Bool(true))]); + let loaded = open("shadow.af", &canvas_with(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![shadow, bevel])], None)); + let d = &loaded.doc; + let rect = named(d, "Magenta box"); + let fx = &rect.appearance.effects; + assert_eq!(fx.len(), 1, "{fx:?}"); + assert_eq!(fx[0].id, "stylize.dropShadow"); + let p = &fx[0].params; + assert!(near(p["x"].as_f64().unwrap(), 0.0) && near(p["y"].as_f64().unwrap(), 10.0), "{p}"); + assert_eq!((p["blur"].as_f64(), p["opacity"].as_f64()), (Some(2.0), Some(50.0))); + // A bevel without a radius draws nothing, in Affinity as here. + assert!(!loaded.warnings.iter().any(|w| w.contains("bevel")), "{:?}", loaded.warnings); + // Drawn: blue at half strength below the box, the box itself magenta. + let [r, g, b, a] = pixel(d, 25, 57); + assert!(r < 10 && g < 10 && b > 245 && (100..160).contains(&a), "{:?}", [r, g, b, a]); + assert_eq!(pixel(d, 25, 25)[3], 255); +} + +#[test] +fn glows_inner_shadows_and_bevels_are_kept_or_reported() { + let effect = |class: &[u8; 4], radius: f64| { + F::Obj( + tag(class), + vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(0.8)), (tag(b"Radi"), F::F64(radius)), (tag(b"Colr"), rgba(1.0, 1.0, 0.0))], + ) + }; + let node = with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![effect(b"OutG", 6.0), effect(b"InnS", 3.0), effect(b"BevE", 4.0)]); + let loaded = open("glow.af", &canvas_with(vec![node], None)); + let fx = &named(&loaded.doc, "Magenta box").appearance.effects; + assert_eq!(fx.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["stylize.outerGlow", "stylize.innerGlow"]); + assert_eq!((fx[0].params["blur"].as_f64(), fx[0].params["opacity"].as_f64()), (Some(6.0), Some(80.0))); + for w in ["inner shadows (imported as inner glows", "bevel and emboss effects (left out)"] { + assert!(loaded.warnings.iter().any(|x| x.contains(w)), "{w}: {:?}", loaded.warnings); + } +} + +#[test] +fn outline_effects_become_strokes() { + let outline = |align: u16| { + F::Obj( + tag(b"Strk"), + vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Radi"), F::F64(3.0)), (tag(b"Alig"), F::Enum(align, 0)), (tag(b"Colr"), rgba(1.0, 0.0, 0.0))], + ) + }; + let shape = with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![outline(0)]); + let text = with_effects(artistic_text(20, "Hi", 40.0), vec![outline(2)]); + let d = open("outline.af", &canvas_with(vec![shape, text], None)).doc; + let rect = named(&d, "Magenta box"); + let Some(AppearanceItem::Stroke(s)) = rect.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!() }; + assert_eq!((s.width, s.align), (3.0, StrokeAlign::Outside)); + assert_eq!(s.paint, Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + let t = texts(&d)[0]; + assert_eq!((t.runs[0].style.stroke.clone(), t.runs[0].style.stroke_width), (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 3.0)); +} + +#[test] +fn colour_overlays_are_baked_into_the_paint() { + let overlay = F::Obj(tag(b"ColO"), vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(1.0)), (tag(b"Colr"), rgba(0.0, 1.0, 0.0))]); + let d = open("overlay.af", &canvas_with(vec![with_effects(rectangle(10, (0.0, 0.0), (50.0, 50.0)), vec![overlay])], None)).doc; + let rect = named(&d, "Magenta box"); + assert!(rect.appearance.effects.is_empty()); + let [r, g, b, a] = pixel(&d, 25, 25); + assert!(r < 5 && g > 250 && b < 5 && a == 255, "{:?}", [r, g, b, a]); +} + +#[test] +fn outline_effects_on_stroked_shapes_go_around_their_stroke() { + let outline = |align: u16, width: f64| { + F::Obj( + tag(b"Strk"), + vec![ + (tag(b"Enab"), F::Bool(true)), + (tag(b"Radi"), F::F64(width)), + (tag(b"Alig"), F::Enum(align, 0)), + (tag(b"Colr"), rgba(1.0, 0.0, 0.0)), + ], + ) + }; + // A black stroke 2 px wide, centred on the path: it reaches 1 px out of the shape. + let stroked = |id: u32| { + let mut node = rectangle(id, (0.0, 0.0), (50.0, 50.0)); + if let F::Def(_, _, fields) = &mut node { + fields.push((tag(b"PFil"), cmyk(0.0, 0.0, 0.0, 1.0))); + fields.push((tag(b"LSty"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))); + } + node + }; + let loaded = open("outline.af", &canvas_with(vec![with_effects(stroked(10), vec![outline(2, 4.0), outline(0, 3.0)])], None)); + assert!(!loaded.warnings.iter().any(|w| w.contains("outline")), "{:?}", loaded.warnings); + let strokes: Vec<(f64, StrokeAlign, Paint)> = named(&loaded.doc, "Magenta box") + .appearance + .items + .iter() + .filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some((s.width, s.align, s.paint.clone())) } else { None }) + .collect(); + let (red, black) = (Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0))); + assert_eq!( + strokes, + [ + // Outside: from the path to 3 px past the stroke, under it. + (4.0, StrokeAlign::Outside, red.clone()), + (2.0, StrokeAlign::Center, black), + // Centred on the stroke's outer edge, over it (as far in as out). + (6.0, StrokeAlign::Center, red), + ] + ); +} + +#[test] +fn artistic_text_aligns_its_lines_within_the_longest() { + // (first, second alignment) → where "Mid" goes in the span of "Wide line" (0 its left end, + // 0.5 centred, 1 its right end). + for (first, second, at) in [(0, 1, 0.5), (0, 2, 1.0), (1, 0, 0.0), (2, 1, 0.5), (1, 2, 1.0)] { + let d = open("mixed.af", &canvas_with(vec![mixed_alignment(first, second)], None)).doc; + let t = texts(&d)[0]; + assert!(matches!(t.kind, TextKind::Point)); + let lines = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines; + let (wide, mid) = (&lines[0], &lines[1]); + let w = wide.x1 - wide.x0; + assert!(w > 0.0); + // The block stays where the first paragraph's alignment puts it on the anchor. + let start = [0.0, -0.5, -1.0][first as usize] * w; + assert!((wide.x0 - start).abs() < 1e-6, "{first}/{second}: {} vs {start}", wide.x0); + let expected = wide.x0 + (w - (mid.x1 - mid.x0)) * at; + assert!((mid.x0 - expected).abs() < 1e-6, "{first}/{second}: {} vs {expected}", mid.x0); + // Each paragraph keeps its own alignment. + use vectorcraft_doc::text::Justify; + let j = |a: i32| [Justify::Left, Justify::Center, Justify::Right][a as usize]; + assert_eq!((t.paras[0].justify, t.paras[1].justify), (j(first), j(second))); + } + // One alignment throughout: no indents. + let d = open("same.af", &canvas_with(vec![mixed_alignment(1, 1)], None)).doc; + assert!(texts(&d)[0].paras.iter().all(|p| p.left_indent == 0.0 && p.right_indent == 0.0)); +} + +#[test] +fn frame_text_hangs_from_cap_height_and_shows_lines_whose_baseline_fits() { + use vectorcraft_doc::text::FirstBaseline; + let db = vectorcraft_text::FontDb::global(); + // Room for both lines: where they go. + let loaded = open("tall.af", &canvas_with(vec![two_line_frame(1000.0)], None)); + let t = texts(&loaded.doc)[0]; + assert_eq!(t.area.first_baseline, FirstBaseline::CapHeight); + let lines = vectorcraft_text::layout(db, t).lines; + assert_eq!(lines.len(), 2); + // Higher than VectorCraft's default, the font's ascent. + let ascent = vectorcraft_text::layout(db, &TextObject { area: Default::default(), ..t.clone() }).lines[0].baseline; + assert!(lines[0].baseline < ascent - 1.0, "first baseline {} vs ascent {ascent}", lines[0].baseline); + let (baseline, bottom) = (lines[1].baseline, lines[1].baseline + lines[1].descent); + // A frame the second line's baseline fits in but not its descenders: Affinity shows the line. + let height = (baseline + bottom) / 2.0; + let loaded = open("short.af", &canvas_with(vec![two_line_frame(height)], None)); + assert!(loaded.warnings.iter().any(|w| w.contains("made taller so the lines show")), "{:?}", loaded.warnings); + let t = texts(&loaded.doc)[0]; + let lay = vectorcraft_text::layout(db, t); + assert_eq!(lay.lines.len(), 2); + assert!(!lay.overflow); + assert!(frame(t).height() > height && frame(t).height() < bottom + 1.0, "{:?}", frame(t)); + // One whose second baseline is outside it too: that line stays out, as in Affinity. + let loaded = open("shorter.af", &canvas_with(vec![two_line_frame(baseline - 5.0)], None)); + let t = texts(&loaded.doc)[0]; + assert_eq!(vectorcraft_text::layout(db, t).lines.len(), 1); + assert!((frame(t).height() - (baseline - 5.0)).abs() < 1e-6); +} + +#[test] +fn underlined_and_struck_through_characters_stay_so() { + let decorated = |ints: &[i32]| { + let mut a = attrs("Inter", "Inter-Regular", 400, false, 40.0); + if let F::Obj(_, fields) = &mut a { + for (t, v) in fields.iter_mut() { + if *t == tag(b"Ints") { + *v = array_i32(b"Ints", ints); + } + } + } + a + }; + let blk = block( + utf8("Plain under struck"), + vec![ + run(6, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0))), + run(12, Some(decorated(&[1, 0, 0, 0, 1]))), + run(18, Some(decorated(&[0, 1, 0, 0, 1]))), + ], + &[0], + ); + let node = F::Def( + 70, + vec![tag(b"TxtA")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])), + ], + ); + let loaded = open("underline.af", &canvas_with(vec![node], None)); + assert!(!loaded.warnings.iter().any(|w| w.contains("decoration")), "{:?}", loaded.warnings); + let t = texts(&loaded.doc)[0]; + assert_eq!( + t.runs.iter().map(|r| (r.text.as_str(), r.style.underline, r.style.strikethrough)).collect::<Vec<_>>(), + [("Plain ", false, false), ("under ", true, false), ("struck", false, true)] + ); +} + +/// The image of the node named "Photo", and the blob it shows. +fn photo(d: &Document) -> &vectorcraft_doc::ImageBlob { + let NodeKind::Image(img) = &named(d, "Photo").kind else { panic!("an image") }; + &d.images[&img.key] +} + +#[test] +fn cmyk_pixel_layers_show_through_the_documents_cmyk_profile() { + use vectorcraft_color::cms::{self, IccProfile, Intent}; + // A CMYK profile written from a built-in space, embedded as the document's. + let icc = cms::icc_bytes(cms::GENERIC_CMYK).unwrap(); + let loaded = open("photo.af", &cmyk_layer(false, Some(&icc))); + let blob = photo(&loaded.doc); + assert!(!blob.is_cmyk()); + let px = image::load_from_memory(&blob.bytes).unwrap().to_rgba8(); + let profile = IccProfile::from_bytes(None, &icc).unwrap(); + let srgb = cms::icc::builtin_rgb(cms::SRGB).unwrap(); + let want = profile.to_rgb_of(&srgb, &[1.0, 0.0, 0.0, 0.0], Intent::RelativeColorimetric, true).unwrap().map(|v| (v * 255.0).round() as i32); + let got = px.get_pixel(8, 8).0; + assert!((0..3).all(|i| (i32::from(got[i]) - want[i]).abs() <= 2) && got[3] == 255, "{got:?} vs {want:?}"); + // Not the profile-free formula's pure cyan. + assert_ne!(&got[..3], &[0, 255, 255]); + assert!(!loaded.warnings.iter().any(|w| w.contains("CMYK")), "{:?}", loaded.warnings); +} + +#[test] +fn cmyk_pixel_layers_without_a_profile_keep_their_inks_or_use_the_working_profile() { + // Opaque: a CMYK image with the layer's inks. + let loaded = open("photo.af", &cmyk_layer(false, None)); + let inks = photo(&loaded.doc).cmyk().expect("a CMYK image"); + assert_eq!((inks.width, inks.height, &inks.data[..4]), (16, 16, &[255, 0, 0, 0][..])); + // With transparency: RGBA through the working CMYK profile, and said. + let loaded = open("photo.af", &cmyk_layer(true, None)); + let blob = photo(&loaded.doc); + assert!(!blob.is_cmyk()); + assert_eq!(image::load_from_memory(&blob.bytes).unwrap().to_rgba8().get_pixel(8, 8).0[3], 0); + assert!(loaded.warnings.iter().any(|w| w.contains("CMYK pixel layers with transparency")), "{:?}", loaded.warnings); +} + +#[test] +fn objects_beyond_the_document_range_are_left_out() { + // 5 million points from the origin, past `MAX_COORD`. + let far = named_rect(11, "Far box", (5.0e6, 0.0), (10.0, 10.0)); + let loaded = open("far.af", &canvas_with(vec![rectangle(10, (0.0, 0.0), (50.0, 50.0)), far], None)); + let names: Vec<_> = all(&loaded.doc).iter().filter_map(|n| n.name.clone()).collect(); + assert!(names.iter().any(|n| n == "Magenta box") && !names.iter().any(|n| n == "Far box"), "{names:?}"); + assert!(loaded.warnings.iter().any(|w| w.contains("beyond the document range")), "{:?}", loaded.warnings); +} diff --git a/crates/engine/tests/it/affinity_local.rs b/crates/engine/tests/it/affinity_local.rs new file mode 100644 index 000000000..b9854c6ba --- /dev/null +++ b/crates/engine/tests/it/affinity_local.rs @@ -0,0 +1,83 @@ +//! Local Affinity documents (never committed): every file under `AFFINITY_SAMPLES` (searched +//! recursively) opens as editable art, and each artboard is compared with the thumbnail Affinity +//! stored in the file. Reports the mean difference per artboard (0–255, both on white); with +//! `AFFINITY_SAMPLES_OUT` set, writes each board's thumbnail crop and render side by side there. +//! Skipped when `AFFINITY_SAMPLES` is unset (as in CI). + +use std::path::{Path, PathBuf}; + +fn files(dir: &Path, out: &mut Vec<PathBuf>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + files(&p, out); + } else if p + .extension() + .and_then(|x| x.to_str()) + .is_some_and(|x| ["af", "afdesign", "afpub", "afphoto"].contains(&x.to_ascii_lowercase().as_str())) + { + out.push(p); + } + } +} + +fn on_white(p: &[u8]) -> [f64; 3] { + let a = f64::from(p[3]) / 255.0; + [0, 1, 2].map(|i| f64::from(p[i]) * a + 255.0 * (1.0 - a)) +} + +#[test] +fn local_affinity_documents_render_like_their_thumbnails() { + let Some(root) = std::env::var_os("AFFINITY_SAMPLES").map(PathBuf::from) else { return }; + let out = std::env::var_os("AFFINITY_SAMPLES_OUT").map(PathBuf::from); + let mut paths = Vec::new(); + files(&root, &mut paths); + paths.sort(); + for path in paths { + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let bytes = std::fs::read(&path).unwrap(); + let loaded = vectorcraft_engine::cmd::fileio::load(&name, &bytes).unwrap(); + assert!(!loaded.preview_only, "{name}: {:?}", loaded.warnings); + let d = &loaded.doc; + eprintln!("{name}: {} artboards, {} layers", d.artboards.len(), d.layers.len()); + for w in &loaded.warnings { + eprintln!(" {w}"); + } + let Ok(preview) = vectorcraft_affinity::preview(&bytes) else { continue }; + let thumb = image::load_from_memory(preview.png).unwrap().to_rgba8(); + let union = d.artboards.iter().map(|a| a.rect).reduce(|a, b| a.union(b)).unwrap(); + let scale = f64::from(thumb.width()) / union.width(); + let mut total = 0.0; + for (i, board) in d.artboards.iter().enumerate() { + let r = board.rect; + let crop = |v: f64| (v * scale).round().max(0.0) as u32; + let (x, y) = (crop(r.x0 - union.x0).min(thumb.width() - 1), crop(r.y0 - union.y0).min(thumb.height() - 1)); + let (w, h) = (crop(r.width()).min(thumb.width() - x).max(1), crop(r.height()).min(thumb.height() - y).max(1)); + let expected = image::imageops::crop_imm(&thumb, x, y, w, h).to_image(); + let k = (512.0 / r.width().max(r.height())).max(scale); + let rendered = vectorcraft_render::Renderer::new().render_region(d, r, k, false); + let big = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.to_straight()).unwrap(); + let got = image::imageops::resize(&big, w, h, image::imageops::FilterType::Triangle); + let (mut sum, mut n) = (0.0, 0usize); + for (p, q) in expected.as_raw().as_chunks::<4>().0.iter().zip(got.as_raw().as_chunks::<4>().0.iter()) { + let (a, b) = (on_white(p), on_white(q)); + sum += (0..3).map(|c| (a[c] - b[c]).abs()).sum::<f64>(); + n += 3; + } + let diff = sum / n.max(1) as f64; + total += diff; + eprintln!(" board {i:3} {:<30} {diff:6.2}", board.name); + if let Some(dir) = &out { + let thumb_big = image::imageops::resize(&expected, big.width(), big.height(), image::imageops::FilterType::Triangle); + let mut pair = image::RgbaImage::from_pixel(big.width() * 2 + 8, big.height(), image::Rgba([255, 255, 255, 255])); + image::imageops::overlay(&mut pair, &thumb_big, 0, 0); + image::imageops::overlay(&mut pair, &big, i64::from(big.width()) + 8, 0); + let stem = path.file_stem().unwrap().to_string_lossy().to_string(); + std::fs::create_dir_all(dir).unwrap(); + pair.save(dir.join(format!("{stem}-{i:03}.png"))).unwrap(); + } + } + eprintln!(" mean over boards: {:.2}", total / d.artboards.len().max(1) as f64); + } +} diff --git a/crates/engine/tests/artboard_click.rs b/crates/engine/tests/it/artboard_click.rs similarity index 99% rename from crates/engine/tests/artboard_click.rs rename to crates/engine/tests/it/artboard_click.rs index ff0066154..c9a7df8d0 100644 --- a/crates/engine/tests/artboard_click.rs +++ b/crates/engine/tests/it/artboard_click.rs @@ -1,7 +1,6 @@ //! Artboard clicks fit the object without changing its art or selection, and are one undo step. //! The second click of a double-click on an object does not fit another artboard, and that //! double-click does not open Artboard Options. Empty artboard space still does. -#![allow(clippy::unwrap_used)] use serde_json::json; use vectorcraft_doc::NodeId; diff --git a/crates/engine/tests/clear_commands.rs b/crates/engine/tests/it/clear_commands.rs similarity index 99% rename from crates/engine/tests/clear_commands.rs rename to crates/engine/tests/it/clear_commands.rs index 1cb639e20..50a6fbd86 100644 --- a/crates/engine/tests/clear_commands.rs +++ b/crates/engine/tests/it/clear_commands.rs @@ -1,5 +1,4 @@ //! Clear deletes its exact targets, including compound members (#786). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; use vectorcraft_doc::NodeId; diff --git a/crates/engine/tests/compound_attributes.rs b/crates/engine/tests/it/compound_attributes.rs similarity index 98% rename from crates/engine/tests/compound_attributes.rs rename to crates/engine/tests/it/compound_attributes.rs index 3366fc9c7..0bec97554 100644 --- a/crates/engine/tests/compound_attributes.rs +++ b/crates/engine/tests/it/compound_attributes.rs @@ -1,5 +1,4 @@ //! Compound appearance and fill-rule edits from a directly selected member (#1197). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_doc::{NodeId, NodeKind}; diff --git a/crates/engine/tests/dimensions.rs b/crates/engine/tests/it/dimensions.rs similarity index 81% rename from crates/engine/tests/dimensions.rs rename to crates/engine/tests/it/dimensions.rs index 99c415a75..7c4872d56 100644 --- a/crates/engine/tests/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -1,5 +1,4 @@ //! Linked measurements, history, portable artwork and hostile command input. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_engine::{ Session, ViewInfo, @@ -452,3 +451,94 @@ fn both_points_snap_onto_anchors_with_a_visible_indicator() { run(&mut s, "object.scale", json!({"sx":200,"sy":100})); assert_eq!(text(&s, id), "144.00 pt", "linked to both anchors"); } + +/// The page content streams of exported PDF `v`, inflated. +fn pdf_content(v: &Value) -> String { + let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap(); + let mut out = String::new(); + let mut rest = &bytes[..]; + while let Some(at) = rest.windows(7).position(|w| w == b"stream\n" || w == b"stream\r") { + let body = &rest[at + 7..]; + let body = body.strip_prefix(b"\n").unwrap_or(body); + let end = body.windows(9).position(|w| w == b"endstream").unwrap_or(body.len()); + let mut raw = Vec::new(); + if std::io::Read::read_to_end(&mut flate2::read::ZlibDecoder::new(&body[..end]), &mut raw).is_ok() { + out.push_str(&String::from_utf8_lossy(&raw)); + } + rest = &body[(end + 9).min(body.len())..]; + } + out +} + +/// #3233: in a CMYK document a dimension's black lines and label print on the black plate only +/// (`0 0 0 1 K`), not as a four-ink rich black; other colours convert to CMYK; RGB documents keep +/// RGB. +#[test] +fn dimension_colours_follow_the_document_colour_model() { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width":600,"height":500,"colorMode":"cmyk"})); + let line = edge(&mut s); + run(&mut s, "object.hide", json!({"ids":[line]})); + let id = dim(&mut s, line); + let colours = |s: &Session, id: u64| { + let mut v = Vec::new(); + s.doc().unwrap().doc.node(NodeId(id)).unwrap().walk(&mut |n| { + v.extend([n.appearance.fill_paint(), n.appearance.stroke_paint()].iter().filter_map(|p| p.color())); + }); + v + }; + let black = colours(&s, id); + assert!(!black.is_empty() && black.iter().all(|c| *c == vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0)), "{black:?}"); + let pdf = pdf_content(&run(&mut s, "document.exportPdf", json!({"preset":"PDF/X-4:2010"}))); + // PDF/X-4 paints in the output intent's CMYK space: `/c0 CS 0 0 0 1 SCN` strokes, `scn` fills. + let operands = |op: &str| pdf.match_indices(op).map(|(i, _)| pdf[i.saturating_sub(7)..i].to_string()).collect::<Vec<_>>(); + let (strokes, fills) = (operands(" SCN"), operands(" scn")); + assert!(!strokes.is_empty() && !fills.is_empty(), "{pdf}"); + assert!(strokes.iter().chain(&fills).all(|v| v == "0 0 0 1"), "K-only colours: {strokes:?} {fills:?}"); + run(&mut s, "dimension.update", json!({"ids":[id],"options":{"lineColor":[255,0,0]}})); + let red = colours(&s, id); + assert!(red.iter().any(|c| matches!(c, vectorcraft_color::Color::Cmyk { m, y, .. } if *m > 0.5 && *y > 0.5)), "{red:?}"); + assert!(red.iter().all(|c| matches!(c, vectorcraft_color::Color::Cmyk { .. })), "{red:?}"); + // RGB documents are unchanged. + let mut s = session(); + let line = edge(&mut s); + let id = dim(&mut s, line); + assert!(colours(&s, id).iter().all(|c| *c == vectorcraft_color::Color::rgb8(0, 0, 0))); + // Document Color Mode redraws dimensions in the new mode: K-only black in CMYK, RGB black back + // in RGB (not a profile conversion of the old art). + let k = vectorcraft_color::Color::cmyk(0.0, 0.0, 0.0, 1.0); + run(&mut s, "file.documentColorMode", json!({"mode":"cmyk"})); + let after = colours(&s, id); + assert!(!after.is_empty() && after.iter().all(|c| *c == k), "{after:?}"); + run(&mut s, "file.documentColorMode", json!({"mode":"rgb"})); + assert!(colours(&s, id).iter().all(|c| *c == vectorcraft_color::Color::rgb8(0, 0, 0)), "{:?}", colours(&s, id)); + // A CMYK file saved with RGB dimension art (before dimensions followed the colour mode) opens + // with its dimensions redrawn in CMYK. + let mut old = (*s.doc().unwrap().doc).clone(); + old.color_mode = vectorcraft_doc::ColorMode::Cmyk; + s.add_document(old, None); + let opened = colours(&s, id); + assert!(!opened.is_empty() && opened.iter().all(|c| *c == k), "{opened:?}"); + // Only the label's characters in RGB (their fill is saved apart from the node's): redrawn too. + let mut old = (*s.doc().unwrap().doc).clone(); + let label = { + let mut found = None; + old.node(NodeId(id)).unwrap().walk(&mut |n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)) { + found = Some(n.id); + } + }); + found.expect("a dimension label") + }; + if let Some(vectorcraft_doc::NodeKind::Text(t)) = old.node_mut(label).map(|n| &mut n.kind) { + for r in &mut t.runs { + r.style.fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0)); + } + } + s.add_document(old, None); + let runs = |s: &Session| match &s.doc().unwrap().doc.node(label).unwrap().kind { + vectorcraft_doc::NodeKind::Text(t) => t.runs.iter().filter_map(|r| r.style.fill.color()).collect::<Vec<_>>(), + _ => Vec::new(), + }; + assert!(!runs(&s).is_empty() && runs(&s).iter().all(|c| *c == k), "{:?}", runs(&s)); +} diff --git a/crates/engine/tests/eyedropper_sources.rs b/crates/engine/tests/it/eyedropper_sources.rs similarity index 99% rename from crates/engine/tests/eyedropper_sources.rs rename to crates/engine/tests/it/eyedropper_sources.rs index 9dc0ebf25..9639f3b8e 100644 --- a/crates/engine/tests/eyedropper_sources.rs +++ b/crates/engine/tests/it/eyedropper_sources.rs @@ -1,5 +1,4 @@ //! Visible art is readable by the Eyedropper without making it editable (#1251). -#![allow(clippy::unwrap_used)] use serde_json::json; use vectorcraft_doc::NodeId; diff --git a/crates/engine/tests/find_replace_paras.rs b/crates/engine/tests/it/find_replace_paras.rs similarity index 90% rename from crates/engine/tests/find_replace_paras.rs rename to crates/engine/tests/it/find_replace_paras.rs index b511ddf08..05217dd49 100644 --- a/crates/engine/tests/find_replace_paras.rs +++ b/crates/engine/tests/it/find_replace_paras.rs @@ -1,7 +1,5 @@ //! `edit.findReplace` keeps per-paragraph styles lined up with the text when a find or replace //! string holds a line break, as `text.editRange` does. -// 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 serde_json::json; use vectorcraft_doc::{Justify, NodeId, NodeKind}; diff --git a/crates/engine/tests/inline_text.rs b/crates/engine/tests/it/inline_text.rs similarity index 97% rename from crates/engine/tests/inline_text.rs rename to crates/engine/tests/it/inline_text.rs index 5b009ea61..a7c23c836 100644 --- a/crates/engine/tests/inline_text.rs +++ b/crates/engine/tests/it/inline_text.rs @@ -1,7 +1,5 @@ //! Inline graphics in text drawn on the canvas and written to SVG and PDF: a symbol (a red //! circle drawn in code) set inline in a line of type. -// 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 serde_json::{Value, json}; use vectorcraft_doc::{Document, NodeId, NodeKind, TextObject}; diff --git a/crates/engine/tests/known_bugs.rs b/crates/engine/tests/it/known_bugs.rs similarity index 92% rename from crates/engine/tests/known_bugs.rs rename to crates/engine/tests/it/known_bugs.rs index 61f1bf6da..ea394663a 100644 --- a/crates/engine/tests/known_bugs.rs +++ b/crates/engine/tests/it/known_bugs.rs @@ -1,7 +1,5 @@ //! Minimal reproductions of engine bugs found by the property tests. Each is `#[ignore]`d with a //! `BUG:` reason until fixed; remove the attribute when the fix lands. -// 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 serde_json::json; use vectorcraft_engine::Session; diff --git a/crates/engine/tests/lossy_overwrite.rs b/crates/engine/tests/it/lossy_overwrite.rs similarity index 98% rename from crates/engine/tests/lossy_overwrite.rs rename to crates/engine/tests/it/lossy_overwrite.rs index c4ee60b4d..559e45a33 100644 --- a/crates/engine/tests/lossy_overwrite.rs +++ b/crates/engine/tests/it/lossy_overwrite.rs @@ -1,8 +1,6 @@ //! A file whose import left something out isn't written over silently: its hidden text, art or //! layers would be gone for good. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - use serde_json::json; use vectorcraft_engine::Session; diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs new file mode 100644 index 000000000..b0409cffd --- /dev/null +++ b/crates/engine/tests/it/main.rs @@ -0,0 +1,29 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +#[path = "../support/affinity_docs.rs"] +mod affinity_docs; +#[path = "../../../affinity/tests/support/text_fixture.rs"] +mod text_fixture; + +mod affinity_features; +mod affinity_local; +mod artboard_click; +mod clear_commands; +mod compound_attributes; +mod dimensions; +mod eyedropper_sources; +mod find_replace_paras; +mod inline_text; +mod known_bugs; +mod lossy_overwrite; +mod model_based; +mod param_keys; +mod pen_click_after_smooth; +mod pen_endpoint_picking; +mod ruler_origin; +mod selection_commands; +mod stroke_visibility; +mod text_stroke_scale; +mod three_d_light_color; diff --git a/crates/engine/tests/model_based.rs b/crates/engine/tests/it/model_based.rs similarity index 98% rename from crates/engine/tests/model_based.rs rename to crates/engine/tests/it/model_based.rs index 13a210324..b11e3b059 100644 --- a/crates/engine/tests/model_based.rs +++ b/crates/engine/tests/it/model_based.rs @@ -1,7 +1,5 @@ //! Model-based / stateful property tests: random command sequences drawn from the registry, with //! structural invariants checked after every step and history/serialization invariants at the end. -// 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 proptest::prelude::*; use serde_json::json; diff --git a/crates/engine/tests/param_keys.rs b/crates/engine/tests/it/param_keys.rs similarity index 99% rename from crates/engine/tests/param_keys.rs rename to crates/engine/tests/it/param_keys.rs index fbde80a7a..3da393ca8 100644 --- a/crates/engine/tests/param_keys.rs +++ b/crates/engine/tests/it/param_keys.rs @@ -1,6 +1,5 @@ //! A caller's params are checked against the command's description, but a journal replays as it //! was recorded (#1442). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; use vectorcraft_engine::{Session, check_params}; diff --git a/crates/engine/tests/pen_click_after_smooth.rs b/crates/engine/tests/it/pen_click_after_smooth.rs similarity index 96% rename from crates/engine/tests/pen_click_after_smooth.rs rename to crates/engine/tests/it/pen_click_after_smooth.rs index d36a701b6..21b53a860 100644 --- a/crates/engine/tests/pen_click_after_smooth.rs +++ b/crates/engine/tests/it/pen_click_after_smooth.rs @@ -1,5 +1,4 @@ //! A click after a smooth anchor: the new corner has no handles, so the segment curves from the previous anchor's side only. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; use vectorcraft_doc::NodeKind; diff --git a/crates/engine/tests/pen_endpoint_picking.rs b/crates/engine/tests/it/pen_endpoint_picking.rs similarity index 99% rename from crates/engine/tests/pen_endpoint_picking.rs rename to crates/engine/tests/it/pen_endpoint_picking.rs index cfba10538..b2e77e52e 100644 --- a/crates/engine/tests/pen_endpoint_picking.rs +++ b/crates/engine/tests/it/pen_endpoint_picking.rs @@ -1,5 +1,4 @@ //! Original tiny-geometry controls for Pen endpoint/Alt integration with continue-any-path. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::json; use vectorcraft_doc::{Node, NodeId, NodeKind}; diff --git a/crates/engine/tests/ruler_origin.rs b/crates/engine/tests/it/ruler_origin.rs similarity index 99% rename from crates/engine/tests/ruler_origin.rs rename to crates/engine/tests/it/ruler_origin.rs index 353f0ccab..4b5c06286 100644 --- a/crates/engine/tests/ruler_origin.rs +++ b/crates/engine/tests/it/ruler_origin.rs @@ -1,5 +1,4 @@ //! #1212: ruler coordinates are saved metadata, independent of the artwork's coordinates. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_doc::RulerMode; diff --git a/crates/engine/tests/selection_commands.rs b/crates/engine/tests/it/selection_commands.rs similarity index 84% rename from crates/engine/tests/selection_commands.rs rename to crates/engine/tests/it/selection_commands.rs index 94db94a7b..4e9b8e10b 100644 --- a/crates/engine/tests/selection_commands.rs +++ b/crates/engine/tests/it/selection_commands.rs @@ -1,5 +1,4 @@ //! Selection command results and validation for agent callers (#784). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_doc::NodeId; @@ -133,3 +132,32 @@ fn select_key_and_document_inspect_report_the_key_object() { assert_eq!(s.execute("select.key", &json!({"id": b.0})).unwrap(), json!({"key": null})); assert_eq!(inspected(&mut s), Some(Value::Null)); } + +#[test] +fn select_segments_mode_toggle_and_add() { + let (mut s, a, _) = session(); + // Select segment 0 with mode set + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 0]], "mode": "set"})).unwrap(); + let sel = &s.active().unwrap().selection; + assert_eq!(sel.segments.get(&a), Some(&[(0, 0)].into())); + assert_eq!(sel.anchors.get(&a), Some(&[(0, 0), (0, 1)].into())); + + // Toggle segment 1 on (add) + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 1]], "mode": "toggle"})).unwrap(); + let sel = &s.active().unwrap().selection; + assert_eq!(sel.segments.get(&a), Some(&[(0, 0), (0, 1)].into())); + assert_eq!(sel.anchors.get(&a), Some(&[(0, 0), (0, 1), (0, 2)].into())); + + // Toggle segment 0 off + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 0]], "mode": "toggle"})).unwrap(); + let sel = &s.active().unwrap().selection; + assert_eq!(sel.segments.get(&a), Some(&[(0, 1)].into())); + assert_eq!(sel.anchors.get(&a), Some(&[(0, 1), (0, 2)].into())); + + // Toggle segment 1 off -> clears selection for this path + s.execute("select.segments", &json!({"id": a.0, "segments": [[0, 1]], "mode": "toggle"})).unwrap(); + let sel = &s.active().unwrap().selection; + assert!(!sel.contains(a)); + assert!(sel.segments.is_empty()); + assert!(sel.anchors.is_empty()); +} diff --git a/crates/engine/tests/stroke_visibility.rs b/crates/engine/tests/it/stroke_visibility.rs similarity index 99% rename from crates/engine/tests/stroke_visibility.rs rename to crates/engine/tests/it/stroke_visibility.rs index f9c574ab7..8f9ecdff0 100644 --- a/crates/engine/tests/stroke_visibility.rs +++ b/crates/engine/tests/it/stroke_visibility.rs @@ -1,5 +1,4 @@ //! Painting a previously unpainted zero-width stroke restores the existing 1 pt default. -#![allow(clippy::unwrap_used)] use serde_json::json; use vectorcraft_doc::NodeId; diff --git a/crates/engine/tests/it/text_stroke_scale.rs b/crates/engine/tests/it/text_stroke_scale.rs new file mode 100644 index 000000000..bece66d47 --- /dev/null +++ b/crates/engine/tests/it/text_stroke_scale.rs @@ -0,0 +1,41 @@ +//! A stroke weight set on type is a document-space weight, whether the type was enlarged by its +//! font size or by a transform (issue #3241). + +use serde_json::json; +use vectorcraft_doc::{NodeId, NodeKind}; +use vectorcraft_engine::Session; + +const MM: f64 = 72.0 / 25.4; + +fn text(s: &mut Session, x: f64) -> NodeId { + NodeId(s.execute("text.create", &json!({"x": x, "y": 100, "text": "Ab", "size": 30.0 * MM})).unwrap()["id"].as_u64().unwrap()) +} + +/// The weights the type's character strokes draw at in document space. +fn drawn(s: &Session, id: NodeId) -> Vec<f64> { + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("not type") }; + let k = t.xf.determinant().abs().sqrt(); + t.runs.iter().map(|r| r.style.stroke_width * k).collect() +} + +#[test] +fn stroke_weight_on_scaled_type_matches_type_resized_by_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let a = text(&mut s, 10.0); + let b = text(&mut s, 300.0); + s.execute("text.setStyle", &json!({"ids": [a.0], "size": 60.0 * MM})).unwrap(); + s.execute("object.scale", &json!({"ids": [b.0], "sx": 200, "sy": 200})).unwrap(); + s.execute("stroke.set", &json!({"ids": [a.0, b.0], "weight": MM, "dash": [MM, 2.0 * MM]})).unwrap(); + for w in drawn(&s, a).into_iter().chain(drawn(&s, b)) { + assert!((w - MM).abs() < 1e-9, "drawn stroke weight {w}, want {MM}"); + } + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(b).unwrap().kind else { panic!("not type") }; + let dash = t.runs[0].style.stroke_dash.as_ref().unwrap().pattern.iter().map(|d| d * 2.0).collect::<Vec<_>>(); + assert!((dash[0] - MM).abs() < 1e-9 && (dash[1] - 2.0 * MM).abs() < 1e-9, "drawn dash {dash:?}"); + + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let shown = s.shown_stroke().unwrap(); + assert!((shown.width - MM).abs() < 1e-9, "Stroke panel shows {}, want {MM}", shown.width); + assert!((shown.dash.unwrap().pattern[0] - MM).abs() < 1e-9); +} diff --git a/crates/engine/tests/three_d_light_color.rs b/crates/engine/tests/it/three_d_light_color.rs similarity index 99% rename from crates/engine/tests/three_d_light_color.rs rename to crates/engine/tests/it/three_d_light_color.rs index f9de119f1..cb04c5649 100644 --- a/crates/engine/tests/three_d_light_color.rs +++ b/crates/engine/tests/it/three_d_light_color.rs @@ -1,5 +1,4 @@ //! Colored 3D lighting remains a live command and survives native and vector output. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId}; use vectorcraft_engine::Session; diff --git a/crates/engine/tests/plugin_export.rs b/crates/engine/tests/plugin_export.rs index 868ec71e9..1a49f800a 100644 --- a/crates/engine/tests/plugin_export.rs +++ b/crates/engine/tests/plugin_export.rs @@ -104,6 +104,23 @@ fn the_exporter_is_given_the_native_file_s_json() { assert!(image.get("blob").is_some() && image.get("data").is_none(), "{image}"); } +/// What filter plug-ins keep with the document reaches an exporter with the rest of the document: +/// a plotter's exporter finds the pens a setup filter kept (`document.unknown.pluginData`, by +/// plug-in id). +#[test] +fn an_exporter_finds_what_filters_keep_with_the_document() { + let mut s = session(); + s.execute("select.none", &json!({})).unwrap(); + let pens = json!({"pens": [{"slot": 1, "width": 0.3}, {"slot": 2, "width": 0.5}]}); + let setup = json!({"objects": [], "documentData": pens}); + install(&mut s, &tpl::returning(&tpl::manifest("test.setup", "filter", "{}"), &setup.to_string())); + s.execute("plugin.run", &json!({"id": "test.setup"})).unwrap(); + install(&mut s, &tpl::echo_exporter(&tpl::exporter("test.pens", r#"["tsd"]"#, "{}"), Echo::Input)); + let r = s.execute("document.export", &json!({"format": "plugin.test.pens"})).unwrap(); + let doc: Value = serde_json::from_slice(&exported(&r)).unwrap(); + assert_eq!(doc["document"]["unknown"][vectorcraft_doc::plugin_data::PLUGIN_DATA], json!({"test.setup": pens})); +} + #[test] fn the_exporter_is_given_its_parameters_and_the_file_it_writes() { let dir = tmp_dir("params"); diff --git a/crates/engine/tests/support/affinity_docs.rs b/crates/engine/tests/support/affinity_docs.rs new file mode 100644 index 000000000..c42546776 --- /dev/null +++ b/crates/engine/tests/support/affinity_docs.rs @@ -0,0 +1,469 @@ +//! Original synthetic Affinity documents for the engine's Affinity tests and the import fuzzer, +//! built with the reader's `synth` feature in the layout the reader accepts (no Affinity +//! software or files were used). +#![allow(dead_code)] + +use crate::text_fixture::{array_f64, array_i32, attrs, block, run, shared_objects, utf8}; +use vectorcraft_affinity::synth::{self, F, Method, tag}; + +pub type Field = (vectorcraft_affinity::stream::Tag, F); + +/// Encode a stream whose shared arrays may list inline objects. +pub fn stream(mut root: Vec<Field>) -> Vec<u8> { + let mut next_id = 1000; + for (_, value) in &mut root { + shared_objects(value, &mut next_id); + } + synth::stream(&root) +} + +/// 300 dpi: a document pixel is 0.24 pt. +pub const DPI: f64 = 300.0; + +pub const PT: f64 = 72.0 / DPI; + +pub fn rgba(r: f32, g: f32, b: f32) -> F { + F::Obj(tag(b"RGBA"), vec![(tag(b"_col"), F::Struct([r, g, b, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]) +} + +pub fn cmyk(c: f32, m: f32, y: f32, k: f32) -> F { + F::Obj( + tag(b"FilS"), + vec![(tag(b"Colr"), F::Obj(tag(b"CMYK"), vec![(tag(b"_col"), F::Struct([c, m, y, k, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]))], + ) +} + +/// A magenta rectangle named `name`. +pub fn named_rect(id: u32, name: &str, at: (f64, f64), size: (f64, f64)) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![ + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])), + (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])), + (tag(b"BFil"), cmyk(0.0, 1.0, 0.0, 0.0)), + (tag(b"Desc"), F::Str(name.into())), + ], + ) +} + +pub fn rectangle(id: u32, at: (f64, f64), size: (f64, f64)) -> F { + named_rect(id, "Magenta box", at, size) +} + +/// An artboard (`ABEn`) named `name` over `bounds` at `at`, holding `children`. +pub fn board(id: u32, name: &str, at: (f64, f64), size: (f64, f64), children: Vec<F>) -> F { + F::Def( + id, + vec![tag(b"ShpN")], + vec![ + (tag(b"Desc"), F::Str(name.into())), + (tag(b"ABEn"), F::Bool(true)), + (tag(b"Shpe"), F::Def(id + 10_000, vec![tag(b"ShNR")], vec![])), + (tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size.0, size.1])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, at.0, 0.0, 1.0, at.1])), + (tag(b"Chld"), F::Shared(children)), + ], + ) +} + +pub fn artistic_text(id: u32, text: &str, size: f64) -> F { + F::Def( + id, + vec![tag(b"TxtA")], + vec![ + ( + tag(b"StSt"), + F::Obj( + tag(b"Stry"), + vec![( + tag(b"Blok"), + F::Shared(vec![block( + utf8(text), + vec![run(text.chars().count() as i32, Some(attrs("Inter", "Inter-Bold", 700, false, size)))], + &[1], + )]), + )], + ), + ), + (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 1000.0, 0.0, 1.0, 500.0])), + ], + ) +} + +pub fn page(x0: f64, w: f64, h: f64) -> F { + F::Obj(tag(b"PagR"), vec![(tag(b"rctp"), F::F64s(vec![x0, 0.0, x0 + w, h]))]) +} + +/// A Publisher spread of A4 pages at 300 dpi holding `nodes`. +pub fn spread(id: u32, pages: usize, nodes: Vec<F>) -> F { + let w = 2480.0 * pages as f64; + F::Def( + id, + vec![tag(b"Sprd")], + vec![ + (tag(b"SprB"), F::F64s(vec![0.0, 0.0, w, 3508.0])), + ( + tag(b"SpMd"), + F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(2480.0 * i as f64, 2480.0, 3508.0)).collect()))]), + ), + (tag(b"Chld"), F::Shared(nodes)), + ], + ) +} + +pub fn publisher_with(spreads: Vec<F>) -> Vec<u8> { + let data = stream(vec![ + (tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(DPI))])), + (tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(spreads))])), + ]); + synth::container(&[("doc.dat", &data, Method::Zstd)], None) +} + +/// A two-page spread: a layer with a CMYK rectangle, and centred artistic text. +pub fn publisher() -> Vec<u8> { + let layer = F::Def( + 3, + vec![tag(b"Scop")], + vec![(tag(b"Desc"), F::Str("Background".into())), (tag(b"Chld"), F::Shared(vec![rectangle(10, (100.0, 100.0), (100.0, 200.0))]))], + ); + publisher_with(vec![spread(2, 2, vec![layer, artistic_text(20, "Hello\u{2029}World\u{2029}", 100.0)])]) +} + +/// One A4 page at 300 dpi holding `node`. +pub fn page_with(node: F) -> Vec<u8> { + publisher_with(vec![spread(2, 1, vec![node])]) +} + +/// A document at 72 dpi holding `nodes` on a canvas without pages; `embedded` is `edc/1`. +pub fn canvas_with(nodes: Vec<F>, embedded: Option<&[u8]>) -> Vec<u8> { + packed(&canvas_stream(nodes), embedded, Method::Zstd) +} + +/// The object stream of [`canvas_with`]. +pub fn canvas_stream(nodes: Vec<F>) -> Vec<u8> { + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 1000.0, 1000.0])), (tag(b"Chld"), F::Shared(nodes))]); + stream(vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]) +} + +/// A document of object stream `data`, packed with `method`; `embedded` is entry `edc/1`. +pub fn packed(data: &[u8], embedded: Option<&[u8]>, method: Method) -> Vec<u8> { + match embedded { + Some(inner) => { + let mut entry = b"EmDc\0\0\0\0".to_vec(); + entry.extend_from_slice(inner); + synth::container(&[("doc.dat", data, method), ("edc/1", &entry, method)], None) + } + None => synth::container(&[("doc.dat", data, method)], None), + } +} + +/// An instance of the embedded document `edc/1`, scaled 2× with its content's centre at (500, 300). +pub fn embedded_node(id: u32) -> F { + let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]); + F::Def( + id, + vec![tag(b"EmbN")], + vec![ + (tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content), (tag(b"PBBx"), F::Enum(2, 0))])), + (tag(b"Xfrm"), F::F64s(vec![2.0, 0.0, 500.0, 0.0, 2.0, 300.0])), + ], + ) +} + +/// A blue 2 px line on a table edge. +pub fn table_edge() -> F { + F::Obj( + tag(b"TEdg"), + vec![ + (tag(b"Line"), F::Obj(tag(b"LDsc"), vec![(tag(b"LDeL"), F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]))])), + (tag(b"Fill"), F::Obj(tag(b"FDsc"), vec![(tag(b"FDeF"), cmyk(1.0, 0.5, 0.0, 0.0))])), + ], + ) +} + +/// A 3 × 2 table whose cell (1, 1) is merged into (1, 0); cells "A".."E" in reading order, each +/// ended by a cell break. Cells are 100 × 50 px from (0, 0). +pub fn table_node(id: u32, transform: [f64; 6]) -> F { + table_with(id, transform, ["A", "B", "C", "D", "E"]) +} + +/// [`table_node`] with these cell texts. +pub fn table_with(id: u32, transform: [f64; 6], texts: [&str; 5]) -> F { + let brk = || F::Obj(tag(b"BrGl"), vec![(tag(b"HdBk"), F::Enum(4, 1))]); + let segments: Vec<F> = texts + .iter() + .enumerate() + .map(|(i, t)| { + let mut fields = vec![(tag(b"Utf8"), F::Str((*t).into()))]; + if i > 0 { + fields.insert(0, (tag(b"Glys"), F::Shared(vec![brk()]))); + } + F::Obj(tag(b"GSeg"), fields) + }) + .collect(); + let len = (texts.iter().map(|t| t.chars().count()).sum::<usize>() + texts.len() - 1) as i32; + let glyphs = F::Obj(tag(b"GStr"), vec![(tag(b"Mixd"), F::Shared(segments))]); + let story = block(glyphs, vec![run(len, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))], &[]); + let cell = |merged: bool| { + let mut f = vec![(tag(b"AliY"), F::Enum(1, 0)), (tag(b"Inse"), F::F64s(vec![5.0, 6.0, 7.0, 8.0]))]; + if merged { + f.push((tag(b"BrLf"), F::Bool(true))); + } + F::Obj(tag(b"TCel"), f) + }; + let pos = |values: &[f64]| F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", values))]); + let edges = |n: usize| F::Obj(tag(b"TEds"), vec![(tag(b"Edge"), F::Shared((0..n).map(|_| table_edge()).collect()))]); + let table = F::Obj( + tag(b"Tabl"), + vec![ + (tag(b"CPos"), pos(&[0.0, 100.0, 200.0, 300.0])), + (tag(b"RPos"), pos(&[0.0, 50.0, 100.0])), + (tag(b"CEdg"), edges(8)), + (tag(b"REdg"), edges(9)), + (tag(b"Cell"), F::Obj(tag(b"TCls"), vec![(tag(b"Cell"), F::Shared((0..6).map(|i| cell(i == 4)).collect()))])), + ], + ); + F::Def( + id, + vec![tag(b"TxtT")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![story]))])), + (tag(b"TxtH"), F::Obj(tag(b"TbFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 300.0, 100.0])), (tag(b"Tabl"), table)])), + (tag(b"Xfrm"), F::F64s(transform.to_vec())), + ], + ) +} + +/// Character attributes without their own fixed leading. +pub fn auto_leading(mut attrs: F) -> F { + if let F::Obj(_, fields) = &mut attrs { + for (t, v) in fields.iter_mut() { + if *t == tag(b"Ints") { + *v = array_i32(b"Ints", &[0, 0, 0, 0, 0]); + } + } + } + attrs +} + +/// A two-page master spread (pages 0–100 and 100–200 wide) with a box on each page; the +/// document's page shows its second page, as Affinity's instance says (MPOf 1). +pub fn with_master(pages: usize) -> Vec<u8> { + packed(&master_stream(pages), None, Method::Zstd) +} + +/// The object stream of [`with_master`]. +pub fn master_stream(pages: usize) -> Vec<u8> { + let master = F::Def( + 7, + vec![tag(b"Sprd")], + vec![ + (tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared(vec![page(0.0, 100.0, 150.0), page(100.0, 100.0, 150.0)]))])), + ( + tag(b"Chld"), + F::Shared(vec![ + named_rect(30, "Left master box", (10.0, 10.0), (20.0, 20.0)), + named_rect(31, "Right master box", (110.0, 10.0), (30.0, 40.0)), + ]), + ), + ], + ); + let instance = F::Def( + 5, + vec![tag(b"MPIN")], + vec![ + (tag(b"SLnk"), F::Obj(tag(b"ILSN"), vec![(tag(b"ILOb"), F::Shared(vec![master, F::Ref(5)]))])), + (tag(b"PgOf"), F::U32(0)), + (tag(b"MPOf"), F::U32(1)), + (tag(b"PgCt"), F::U32(pages as u32)), + ], + ); + let spread = F::Def( + 2, + vec![tag(b"Sprd")], + vec![ + (tag(b"SpMd"), F::Obj(tag(b"SpMd"), vec![(tag(b"PagR"), F::Shared((0..pages).map(|i| page(100.0 * i as f64, 100.0, 150.0)).collect()))])), + (tag(b"Chld"), F::Shared(vec![instance, named_rect(40, "Page box", (50.0, 100.0), (10.0, 10.0))])), + ], + ); + stream(vec![( + tag(b"DocR"), + F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread])), (tag(b"MpCh"), F::Shared(vec![F::Ref(7)]))]), + )]) +} + +pub fn with_effects(mut node: F, effects: Vec<F>) -> F { + if let F::Def(_, _, fields) = &mut node { + fields.push((tag(b"FiEf"), F::Shared(effects))); + } + node +} + +/// Artistic text "Wide line" over "Mid", the paragraphs aligned `first` and `second` +/// (0 left, 1 centre, 2 right). +pub fn mixed_alignment(first: i32, second: i32) -> F { + let para = |end: i32, align: i32| { + vec![(tag(b"Indx"), F::I32(end)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[align]))]))] + }; + let blk = F::Obj( + tag(b"StBl"), + vec![ + (tag(b"Glyp"), utf8("Wide line\u{2029}Mid\u{2029}")), + ( + tag(b"GAtt"), + F::Obj( + tag(b"GlAS"), + vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), vec![run(14, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0)))]))], + ), + ), + (tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), vec![para(10, first), para(14, second)]))])), + ], + ); + F::Def( + 50, + vec![tag(b"TxtA")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])), + (tag(b"Xfrm"), F::F64s(vec![1.0, 0.0, 100.0, 0.0, 1.0, 100.0])), + ], + ) +} + +/// Frame text of two 100 px lines in a frame `height` tall. +pub fn two_line_frame(height: f64) -> F { + let blk = block(utf8("One\u{2029}Two\u{2029}"), vec![run(8, Some(auto_leading(attrs("Inter", "Inter-Regular", 400, false, 100.0))))], &[0]); + F::Def( + 60, + vec![tag(b"TxtF")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"FrFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 500.0, height]))])), + ], + ) +} + +/// A 16 × 16 CMYK pixel layer of pure cyan, opaque or (`transparent`) with no alpha, in a +/// document that embeds `profile` as its CMYK profile. +pub fn cmyk_layer(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> { + packed(&cmyk_stream(transparent, profile), None, Method::Zstd) +} + +/// The object stream of [`cmyk_layer`]. +pub fn cmyk_stream(transparent: bool, profile: Option<&[u8]>) -> Vec<u8> { + let mut bitmap = vec![(tag(b"Frmt"), F::Enum(4, 0)), (tag(b"BmpW"), F::I32(16)), (tag(b"BmpH"), F::I32(16))]; + for c in 1..=5 { + // Tile state 2: every sample 255; 0: every sample 0. + let full = c == 1 || (c == 5 && !transparent); + bitmap.push((tag(&[b'T', b'W', b'i', b'0' + c]), F::I32(1))); + bitmap.push((tag(&[b'T', b'H', b'i', b'0' + c]), F::I32(1))); + bitmap.push((tag(&[b'S', b't', b'a', b'0' + c]), F::U8s(vec![if full { 2 } else { 0 }]))); + } + let layer = F::Def(10, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap)), (tag(b"Desc"), F::Str("Photo".into()))]); + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 16.0, 16.0])), (tag(b"Chld"), F::Shared(vec![layer]))]); + let mut root = vec![(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]; + if let Some(icc) = profile { + root.push((tag(b"CMYP"), F::Obj(tag(b"ICCP"), vec![(tag(b"Data"), blob(b"Data", icc))]))); + } + stream(root) +} + +/// A byte blob field, as `LSty` `Data` and `ICCP` `Data` are stored. +pub fn blob(field: &[u8; 4], bytes: &[u8]) -> F { + let mut b = vec![0x2d]; + b.extend(tag(field).0.to_le_bytes()); + b.extend((bytes.len() as u32).to_le_bytes()); + b.extend_from_slice(bytes); + F::Raw(b) +} + +/// A drop shadow 3 px below, blurred 2 px. +pub fn shadow() -> F { + F::Obj( + tag(b"Shad"), + vec![ + (tag(b"Enab"), F::Bool(true)), + (tag(b"Opac"), F::F64(0.5)), + (tag(b"Radi"), F::F64(2.0)), + (tag(b"Offs"), F::F64(3.0)), + (tag(b"Angl"), F::F64(std::f64::consts::FRAC_PI_2)), + (tag(b"Colr"), rgba(0.0, 0.0, 1.0)), + ], + ) +} + +/// An effect of `class` (`OutG`, `InnS`, `BevE` …) with this radius, yellow at 80 %. +pub fn effect(class: &[u8; 4], radius: f64) -> F { + F::Obj( + tag(class), + vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(0.8)), (tag(b"Radi"), F::F64(radius)), (tag(b"Colr"), rgba(1.0, 1.0, 0.0))], + ) +} + +/// A red outline effect `width` wide, aligned `align` (0 outside, 1 inside, 2 centred). +pub fn outline(align: u16, width: f64) -> F { + F::Obj( + tag(b"Strk"), + vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Radi"), F::F64(width)), (tag(b"Alig"), F::Enum(align, 0)), (tag(b"Colr"), rgba(1.0, 0.0, 0.0))], + ) +} + +/// A green colour overlay at `opacity`. +pub fn colour_overlay(opacity: f64) -> F { + F::Obj(tag(b"ColO"), vec![(tag(b"Enab"), F::Bool(true)), (tag(b"Opac"), F::F64(opacity)), (tag(b"Colr"), rgba(0.0, 1.0, 0.0))]) +} + +/// A rectangle with a black stroke 2 px wide in line style `style` (`LSty` `Data` byte 10: +/// 0 none, 1 solid, 2 dashed, 3 brush), dashed when `dash` has lengths. +pub fn stroked(id: u32, style: u8, dash: &[f64]) -> F { + let mut node = rectangle(id, (0.0, 0.0), (50.0, 50.0)); + let mut data = 10.0f64.to_le_bytes().to_vec(); + data.extend([0, 0, style]); + let mut lsty = vec![(tag(b"Wght"), F::F64(2.0)), (tag(b"Data"), blob(b"Data", &data))]; + if !dash.is_empty() { + lsty.push((tag(b"Patn"), array_f64(b"Patn", dash))); + } + if let F::Def(_, _, fields) = &mut node { + fields.push((tag(b"PFil"), cmyk(0.0, 0.0, 0.0, 1.0))); + fields.push((tag(b"LSty"), F::Obj(tag(b"LSty"), lsty))); + } + node +} + +/// Character attributes of 40 px Inter with these `Ints` (slot 0 underline, 1 strikethrough). +pub fn decorated(ints: &[i32]) -> F { + let mut a = attrs("Inter", "Inter-Regular", 400, false, 40.0); + if let F::Obj(_, fields) = &mut a { + for (t, v) in fields.iter_mut() { + if *t == tag(b"Ints") { + *v = array_i32(b"Ints", ints); + } + } + } + a +} + +/// Artistic text with an underlined run, a struck-through one and a line break inside a +/// paragraph. +pub fn decorated_text(id: u32) -> F { + let blk = block( + utf8("Plain under\u{2028}struck\u{2029}"), + vec![ + run(6, Some(attrs("Inter", "Inter-Regular", 400, false, 40.0))), + run(12, Some(decorated(&[1, 0, 0, 0, 1]))), + run(19, Some(decorated(&[0, 1, 0, 0, 1]))), + ], + &[0], + ); + F::Def( + id, + vec![tag(b"TxtA")], + vec![ + (tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))])), + (tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 400.0, 100.0])), (tag(b"ArtV"), F::F64(80.0))])), + ], + ) +} diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 0968cd87d..6743c2a2a 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -29,8 +29,11 @@ ruzstd = { workspace = true } hayro-ccitt = { workspace = true } # Live effects' params, as the art styles that name them give them. serde_json = { workspace = true } +# Substring search for PostScript `search`. +memchr = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } +vectorcraft-text = { workspace = true, features = ["test-fonts"] } # Tests: a Group 4 encoder and the T.4 run codes, to write CCITT test images. fax = "0.2" diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 4866ffad1..b5a3ee574 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -24,9 +24,42 @@ const MAX_PATCHES: usize = 4096; /// 34 × 34 points, so the patch cap alone doesn't bound them. const MAX_POINTS: usize = MAX_PATCHES * 16; +/// The largest grid `/Size` read as one mesh, a side. +const MAX_SIDE: usize = 64; +/// The most rows or columns a mesh stitched from its patches may have once the lines through the +/// anchors on its patches' edges are added. +const MAX_LINES: usize = 256; + +/// A patch as read: its place in the grid (`/P`), its corners and the anchors on its edges. +#[derive(Debug, Clone)] +struct Cell { + at: Option<(i64, i64)>, + corners: [Corner; 4], + anchors: Vec<(usize, [f64; 6])>, +} + +/// Why the patches of a mesh don't make one grid. +#[derive(Debug, PartialEq)] +pub(super) enum NotOne { + /// They don't say where they are in it (no `/Size` or `/P`, or ones that don't add up): read + /// as separate patches, as files that don't say are. + Unplaced, + /// They fill the grid but don't join up (corners or edge anchors that don't meet, or more + /// lines than are kept). + Unjoined, +} + #[derive(Debug, Default)] pub(super) struct MeshReader { space: String, + /// `/Type` (`Cartesian` in the files read). + kind: Option<String>, + /// `/Size [a b]`: the grid's patches along the first and the second index of `/P`. + size: Option<(i64, i64)>, + /// The current patch's `/P [i j]`. + at: Option<(i64, i64)>, + /// The patches read, in order, for [`Self::one_mesh`]. + cells: Vec<Cell>, corners: Vec<Vec<f64>>, /// The patch's anchors between its corners (`/A`, `/S`): the edge, and a point and its two handles. anchors: Vec<(usize, [f64; 6])>, @@ -45,6 +78,9 @@ impl MeshReader { let value = at.checked_sub(1).and_then(|i| vals.get(i)); match key.as_str() { "CS" => self.space = value.and_then(V::text).unwrap_or_default(), + "Type" => self.kind = value.and_then(V::text), + "Size" => self.size = pair(value), + "P" => self.at = pair(value), "N" => { if let Some(V::Arr(a)) = value { if self.corners.len() < 4 { @@ -70,6 +106,12 @@ impl MeshReader { "E" => { let corners = std::mem::take(&mut self.corners); let anchors = std::mem::take(&mut self.anchors); + let at = self.at.take(); + if let Some(c) = self.read_corners(&corners, to_doc) + && self.cells.len() < MAX_PATCHES + { + self.cells.push(Cell { at, corners: c, anchors: anchors.clone() }); + } match self.patch(&corners, &anchors, to_doc) { Some(m) if self.patches.len() < MAX_PATCHES && self.points + m.points.len() <= MAX_POINTS => { self.points += m.points.len(); @@ -92,7 +134,8 @@ impl MeshReader { }) } - fn patch(&self, corners: &[Vec<f64>], anchors: &[(usize, [f64; 6])], to_doc: Affine) -> Option<GradientMesh> { + /// The four corners of a patch, in the document. + fn read_corners(&self, corners: &[Vec<f64>], to_doc: Affine) -> Option<[Corner; 4]> { let [n0, n1, n2, n3] = corners else { return None }; // (anchor, towards the next corner, towards the previous one, colour, opacity) let read = |n: &Vec<f64>| -> Option<(Point, Vec2, Vec2, Color, f32)> { @@ -121,9 +164,13 @@ impl MeshReader { let color = self.color(n.get(..comps)?)?; Some((at, to_doc * Point::new(nx, ny) - at, to_doc * Point::new(px, py) - at, color, opacity.clamp(0.0, 1.0) as f32)) }; - let (a, b, c, d) = (read(n0)?, read(n1)?, read(n2)?, read(n3)?); + Some([read(n0)?, read(n1)?, read(n2)?, read(n3)?]) + } + + fn patch(&self, corners: &[Vec<f64>], anchors: &[(usize, [f64; 6])], to_doc: Affine) -> Option<GradientMesh> { + let [a, b, c, d] = self.read_corners(corners, to_doc)?; if !anchors.is_empty() { - return Some(coons([a, b, c, d], anchors)); + return Some(coons([a, b, c, d], anchors, None)); } // Rows run from the first corner to the second, columns from the first to the fourth. let point = |(p, _, _, color, opacity): (Point, Vec2, Vec2, Color, f32), handles: [(usize, Vec2); 2]| { @@ -145,13 +192,150 @@ impl MeshReader { } } +impl MeshReader { + /// The mesh as one grid of shared points: each patch placed by its `/P` in a grid of `/Size`, + /// the corners neighbours share joined, and where a patch's edge bends through anchors, a mesh + /// line through the whole row or column there (every patch in it cut at the same place), so + /// each cell's edges stay single cubics. In each patch, rows run from its first corner to its + /// second and columns from its first to its fourth; which index of `/P` is which is taken from + /// the one under which the shared corners meet. + pub fn one_mesh(&self) -> Result<GradientMesh, NotOne> { + if self.unread || self.kind.as_deref().is_some_and(|k| k != "Cartesian") { + return Err(NotOne::Unplaced); + } + let Some((a, b)) = self.size else { return Err(NotOne::Unplaced) }; + let side = |n: i64| usize::try_from(n).ok().filter(|n| (1..=MAX_SIDE).contains(n)); + let (Some(a), Some(b)) = (side(a), side(b)) else { return Err(NotOne::Unplaced) }; + if self.cells.len() != a * b { + return Err(NotOne::Unplaced); + } + // Each patch's place, once each. + let mut seen = vec![false; a * b]; + let mut places = Vec::with_capacity(self.cells.len()); + for cell in &self.cells { + let Some((i, j)) = cell.at else { return Err(NotOne::Unplaced) }; + let (Ok(i), Ok(j)) = (usize::try_from(i), usize::try_from(j)) else { return Err(NotOne::Unplaced) }; + if i >= a || j >= b { + return Err(NotOne::Unplaced); + } + let Some(slot) = seen.get_mut(i * b + j) else { return Err(NotOne::Unplaced) }; + if std::mem::replace(slot, true) { + return Err(NotOne::Unplaced); + } + places.push((i, j)); + } + // The first index along the columns (`/P [i j]` at row j, column i), or along the rows. + for across in [true, false] { + let (rows, cols) = if across { (b, a) } else { (a, b) }; + let at: Vec<(usize, usize)> = places.iter().map(|&(i, j)| if across { (j, i) } else { (i, j) }).collect(); + if let Some(mesh) = stitch(&self.cells, &at, rows, cols) { + return Ok(mesh); + } + } + Err(NotOne::Unjoined) + } +} + +/// The cuts (0 to 1) of a patch's columns and rows: where the anchors on its edges along each +/// direction cut it, the pieces of an edge taking equal shares (as [`coons`] lays them out). +fn own_cuts(cell: &Cell) -> (Vec<f64>, Vec<f64>) { + let pieces = |e: usize| cell.anchors.iter().filter(|(edge, ..)| *edge == e).count() + 1; + let cuts = |n: usize| (0..=n).map(|i| i as f64 / n as f64).collect::<Vec<_>>(); + // Columns along the second and fourth edges (u), rows along the first and third (v). + let merge = |x: Vec<f64>, y: Vec<f64>| union(&x, &y); + (merge(cuts(pieces(1)), cuts(pieces(3))), merge(cuts(pieces(0)), cuts(pieces(2)))) +} + +/// The sorted union of two lists of cuts. +fn union(a: &[f64], b: &[f64]) -> Vec<f64> { + let mut c: Vec<f64> = a.iter().chain(b).copied().filter(|v| v.is_finite()).collect(); + c.sort_by(f64::total_cmp); + c.dedup_by(|x, y| (*x - *y).abs() < 1e-9); + c +} + +/// The patches `cells`, each at `at` (row, column) of a `rows` × `cols` grid, as one mesh; `None` +/// when their shared points don't meet, a value isn't finite, or the mesh would be too large. +fn stitch(cells: &[Cell], at: &[(usize, usize)], rows: usize, cols: usize) -> Option<GradientMesh> { + // The cuts each row and column of patches takes: those of every patch in it. + let (mut col_cuts, mut row_cuts) = (vec![vec![0.0, 1.0]; cols], vec![vec![0.0, 1.0]; rows]); + for (cell, &(r, c)) in cells.iter().zip(at) { + let (us, vs) = own_cuts(cell); + let cc = col_cuts.get_mut(c)?; + *cc = union(cc, &us); + let rc = row_cuts.get_mut(r)?; + *rc = union(rc, &vs); + } + // Where each row and column of patches starts among the mesh's lines. + let starts = |cuts: &[Vec<f64>]| -> Vec<usize> { + cuts.iter() + .scan(0usize, |n, c| { + let s = *n; + *n += c.len().saturating_sub(1); + Some(s) + }) + .collect() + }; + let (col_at, row_at) = (starts(&col_cuts), starts(&row_cuts)); + let total = |cuts: &[Vec<f64>]| cuts.iter().map(|c| c.len().saturating_sub(1)).sum::<usize>(); + let (mesh_cols, mesh_rows) = (total(&col_cuts), total(&row_cuts)); + if mesh_cols == 0 || mesh_rows == 0 || mesh_cols > MAX_LINES || mesh_rows > MAX_LINES || (mesh_rows + 1) * (mesh_cols + 1) > MAX_POINTS { + return None; + } + let width = mesh_cols + 1; + let mut points: Vec<Option<MeshPoint>> = vec![None; (mesh_rows + 1) * width]; + for (cell, &(r, c)) in cells.iter().zip(at) { + let (us, vs) = (col_cuts.get(c)?, row_cuts.get(r)?); + let part = coons(cell.corners, &cell.anchors, Some((us, vs))); + let part_width = part.cols as usize + 1; + for (k, q) in part.points.into_iter().enumerate() { + let (pr, pc) = (k / part_width, k % part_width); + let slot = points.get_mut((row_at.get(r)? + pr) * width + col_at.get(c)? + pc)?; + match slot { + None => *slot = Some(q), + Some(had) => { + // A point two patches share: where both have it. + if (had.p - q.p).hypot() > 1e-6 * (1.0 + had.p.to_vec2().hypot()) { + return None; + } + // Its handles towards each side come from the patch on that side. + for (h, n) in had.handles.iter_mut().zip(q.handles) { + if h.hypot() == 0.0 { + *h = n; + } + } + } + } + } + } + let points: Vec<MeshPoint> = points.into_iter().collect::<Option<_>>()?; + let finite = |v: f64| v.is_finite(); + if !points.iter().all(|q| finite(q.p.x) && finite(q.p.y) && q.opacity.is_finite() && q.handles.iter().all(|h| finite(h.x) && finite(h.y))) { + return None; + } + Some(GradientMesh { rows: mesh_rows as u32, cols: mesh_cols as u32, points }) +} + +/// A two-number value (`[a b]`) as whole numbers. +fn pair(v: Option<&V>) -> Option<(i64, i64)> { + let V::Arr(a) = v? else { return None }; + let n: Vec<f64> = a.iter().filter_map(V::num).collect(); + match n.as_slice() { + &[x, y] if x.fract() == 0.0 && y.fract() == 0.0 && x.abs() < 1e6 && y.abs() < 1e6 => Some((x as i64, y as i64)), + _ => None, + } +} + type Corner = (Point, Vec2, Vec2, Color, f32); type Cubic = [Point; 4]; /// A patch whose edges bend through more anchors: its edges (first corner → second → third → fourth /// → first) as runs of cubics, each anchor on the edge it lies along, and the Coons patch they bound /// as a mesh cut wherever an edge has an anchor, so each cell's edges are single cubics again. -fn coons(corners: [Corner; 4], anchors: &[(usize, [f64; 6])]) -> GradientMesh { +/// +/// `cuts`: the columns and rows (0 to 1) to cut it at instead of its own, which they include: those +/// of the mesh's whole column and row of patches ([`stitch`]). +fn coons(corners: [Corner; 4], anchors: &[(usize, [f64; 6])], cuts_at: Option<(&[f64], &[f64])>) -> GradientMesh { let runs: Vec<Vec<Cubic>> = (0..4) .map(|e| { let (p, q) = (&corners[e], &corners[(e + 1) % 4]); @@ -202,7 +386,10 @@ fn coons(corners: [Corner; 4], anchors: &[(usize, [f64; 6])]) -> GradientMesh { c.dedup_by(|x, y| (*x - *y).abs() < 1e-9); c }; - let (us, vs) = (cuts(&top, &bottom), cuts(&left, &right)); + let (us, vs) = match cuts_at { + Some((us, vs)) => (us.to_vec(), vs.to_vec()), + None => (cuts(&top, &bottom), cuts(&left, &right)), + }; let (ra, rb, rc, rd) = (&corners[0], &corners[1], &corners[2], &corners[3]); let mut points = Vec::with_capacity(us.len() * vs.len()); for (r, &v) in vs.iter().enumerate() { @@ -376,4 +563,167 @@ mod tests { } assert!(reader.unread && reader.corners.len() == 4); } + + /// Hand-written grid of grey patches: `/Type`, `/Size [cols rows]` and each patch at `/P [x y]` + /// (the first index along x, as the files read write it), its corners from the one nearest the + /// origin up, across and down (`a b c d`), straight edges with handles a third of the way, the + /// grey of a point `at(x, y)`. `moved` puts the points listed elsewhere; `anchor` adds an anchor + /// to the edges at x = 10 (between the first two columns), at height 5 and 1 to the right. + struct Grid { + cols: usize, + rows: usize, + moved: Vec<((f64, f64), (f64, f64))>, + anchor: bool, + place: bool, + } + + impl Grid { + fn new(cols: usize, rows: usize) -> Self { + Grid { cols, rows, moved: vec![], anchor: false, place: true } + } + fn at(&self, x: f64, y: f64) -> (f64, f64) { + self.moved.iter().find(|(from, _)| *from == (x, y)).map_or((x, y), |(_, to)| *to) + } + fn read(&self, size: [f64; 2]) -> MeshReader { + let mut r = MeshReader::default(); + let name = |n: &str| V::Name(n.into()); + let arr = |v: &[f64]| V::Arr(v.iter().map(|x| V::Num(*x)).collect()); + r.entry(&[name("DeviceGray"), name("CS")], Affine::IDENTITY); + r.entry(&[name("Cartesian"), name("Type")], Affine::IDENTITY); + r.entry(&[arr(&size), name("Size")], Affine::IDENTITY); + for i in 0..self.cols { + for j in 0..self.rows { + let (x0, y0, x1, y1) = (i as f64 * 10.0, j as f64 * 10.0, (i + 1) as f64 * 10.0, (j + 1) as f64 * 10.0); + if self.place { + r.entry(&[arr(&[i as f64, j as f64]), name("P")], Affine::IDENTITY); + } + let third = |p: (f64, f64), q: (f64, f64)| (p.0 + (q.0 - p.0) / 3.0, p.1 + (q.1 - p.1) / 3.0); + let (a, b, c, d) = (self.at(x0, y0), self.at(x0, y1), self.at(x1, y1), self.at(x1, y0)); + let grey = |p: (f64, f64)| (p.0 + p.1) / 40.0; + for (k, (p, next, prev)) in [(a, b, d), (b, c, a), (c, d, b), (d, a, c)].into_iter().enumerate() { + let (n, q) = (third(p, next), third(p, prev)); + r.entry(&[arr(&[grey(p), 1.0, p.0, p.1, n.0, n.1, 1.0, q.0, q.1, 1.0]), name("N")], Affine::IDENTITY); + // The edge at x = 10: the third edge (c → d) of the first column, the first + // (a → b) of the second; listed upwards either way. + let on_shared = (k == 2 && i == 0) || (k == 0 && i == 1); + if self.anchor && on_shared { + r.entry(&[arr(&[11.0, 5.0, 11.0, 3.0, 11.0, 7.0, 1.0]), name("A")], Affine::IDENTITY); + } + } + r.entry(&[V::Num(0.0), name("E")], Affine::IDENTITY); + } + } + r + } + } + + /// A 2 × 2 mesh comes in as one mesh of 9 shared points, each where the patches put it, with + /// the colours the separate patches had at every place. + #[test] + fn a_grid_of_patches_is_one_mesh() { + let g = Grid::new(2, 2); + let r = g.read([2.0, 2.0]); + let mesh = r.one_mesh().unwrap(); + assert_eq!((mesh.rows, mesh.cols, mesh.points.len()), (2, 2, 9)); + for row in 0..=2 { + for col in 0..=2 { + let p = mesh.points[mesh.idx(row, col)].p; + assert!((p - Point::new(col as f64 * 10.0, row as f64 * 10.0)).hypot() < 1e-9, "({row}, {col}) at {p:?}"); + } + } + // The colour across each cell is that of the patch it was (patches are read x first). + for (k, patch) in r.patches.iter().enumerate() { + let (col, row) = (k / 2, k % 2); + for (u, v) in [(0.25, 0.25), (0.5, 0.5), (0.9, 0.1)] { + let (one, _) = mesh.color_at(row, col, u, v); + let (own, _) = patch.color_at(0, 0, u, v); + assert!( + one.to_rgb().iter().zip(own.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), + "cell ({row}, {col}) at {u} {v}: {one:?} vs {own:?}" + ); + } + } + // Inner handles point at the neighbours, a third of the way. + let inner = &mesh.points[mesh.idx(1, 1)]; + assert!((inner.handles[H_RIGHT] - Vec2::new(10.0 / 3.0, 0.0)).hypot() < 1e-9); + assert!((inner.handles[H_UP] - Vec2::new(0.0, -10.0 / 3.0)).hypot() < 1e-9); + } + + /// A mesh point moved inside the grid is one point of the mesh, which the four cells round it + /// share: moving it moves them all, leaving no hole. + #[test] + fn a_moved_inner_point_stays_shared() { + let mut g = Grid::new(2, 2); + g.moved = vec![((10.0, 10.0), (13.0, 8.0))]; + let mut mesh = g.read([2.0, 2.0]).one_mesh().unwrap(); + let k = mesh.idx(1, 1); + assert!((mesh.points[k].p - Point::new(13.0, 8.0)).hypot() < 1e-9); + mesh.points[k].p = Point::new(15.0, 15.0); + for (r, c) in [(0, 0), (0, 1), (1, 0), (1, 1)] { + let corner = mesh.eval(r, c, 1.0 - c as f64, 1.0 - r as f64); + assert!((corner - Point::new(15.0, 15.0)).hypot() < 1e-9, "cell ({r}, {c}): {corner:?}"); + } + } + + /// An anchor on the edge two patches share runs a mesh line through the whole mesh there: one + /// mesh, three points across that edge, the anchor's the middle one, its handles along the edge. + #[test] + fn an_anchor_on_a_shared_edge_adds_a_line_through_the_mesh() { + let mut g = Grid::new(2, 1); + g.anchor = true; + let r = g.read([2.0, 1.0]); + let mesh = r.one_mesh().unwrap(); + assert_eq!((mesh.rows, mesh.cols), (2, 2), "split at the anchor's height"); + let mid = &mesh.points[mesh.idx(1, 1)]; + assert!((mid.p - Point::new(11.0, 5.0)).hypot() < 1e-9, "{:?}", mid.p); + assert!( + (mid.handles[H_UP] - Vec2::new(0.0, -2.0)).hypot() < 1e-9 && (mid.handles[H_DOWN] - Vec2::new(0.0, 2.0)).hypot() < 1e-9, + "{:?}", + mid.handles + ); + // The patch without an anchor of its own is cut on the same line, on its straight edge. + let left = mesh.points[mesh.idx(1, 0)].p; + assert!((left - Point::new(0.0, 5.0)).hypot() < 1e-9, "{left:?}"); + } + + /// A patch whose first corner and the anchors on its edges sit on one point (no handles) + /// comes in, every value a number. + #[test] + fn a_collapsed_patch_comes_in_with_finite_values() { + let mut r = MeshReader::default(); + let name = |n: &str| V::Name(n.into()); + let arr = |v: &[f64]| V::Arr(v.iter().map(|x| V::Num(*x)).collect()); + r.entry(&[name("DeviceGray"), name("CS")], Affine::IDENTITY); + r.entry(&[arr(&[1.0, 1.0]), name("Size")], Affine::IDENTITY); + r.entry(&[arr(&[0.0, 0.0]), name("P")], Affine::IDENTITY); + // An anchor on the edge back to the first corner, before it; then the corner on that point. + r.entry(&[arr(&[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 1.0]), name("A")], Affine::IDENTITY); + r.entry(&[arr(&[0.5, 1.0, 5.0, 5.0, 5.0, 5.0, 1.0, 5.0, 5.0, 1.0]), name("N")], Affine::IDENTITY); + r.entry(&[arr(&[5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 1.0]), name("A")], Affine::IDENTITY); + for (x, y) in [(5.0, 10.0), (10.0, 10.0), (10.0, 5.0)] { + r.entry(&[arr(&[0.2, 1.0, x, y, x, y, 1.0, x, y, 1.0]), name("N")], Affine::IDENTITY); + } + r.entry(&[V::Num(0.0), name("E")], Affine::IDENTITY); + let mesh = r.one_mesh().unwrap(); + assert!(mesh.points.iter().all(|q| q.p.x.is_finite() && q.p.y.is_finite() && q.handles.iter().all(|h| h.x.is_finite() && h.y.is_finite()))); + } + + /// Patches that don't say where they are, or not as many as the grid has, are read as before: + /// each a mesh of its own. Ones that fill the grid but don't meet say so. + #[test] + fn unplaced_or_unjoined_patches_stay_separate() { + let mut g = Grid::new(2, 2); + g.place = false; + assert_eq!(g.read([2.0, 2.0]).one_mesh().unwrap_err(), NotOne::Unplaced); + let g = Grid::new(2, 2); + assert_eq!(g.read([3.0, 2.0]).one_mesh().unwrap_err(), NotOne::Unplaced); + assert_eq!(g.read([f64::NAN, 2.0]).one_mesh().unwrap_err(), NotOne::Unplaced); + assert_eq!(g.read([2.0, 2.0]).patches.len(), 4, "the separate patches are still read"); + // A corner that only one of the patches sharing it moved. + let mut r = Grid::new(2, 1).read([2.0, 1.0]); + if let Some(c) = r.cells.get_mut(1) { + c.corners[0].0 = Point::new(10.0, 1.0); + } + assert_eq!(r.one_mesh().unwrap_err(), NotOne::Unjoined); + } } diff --git a/crates/eps/src/import/ai/mod.rs b/crates/eps/src/import/ai/mod.rs index 901c37085..eb0785553 100644 --- a/crates/eps/src/import/ai/mod.rs +++ b/crates/eps/src/import/ai/mod.rs @@ -31,7 +31,7 @@ use super::lex::find; use crate::ps; pub(crate) use frames::text_frames; -pub use read::{Structure, artboards, read, variables}; +pub use read::{IMAGE_TOO_LARGE, Structure, artboards, read, variables}; pub(crate) use read::{slot_frame, slot_of}; #[cfg(test)] diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index 438a52bdb..14c626026 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -127,7 +127,7 @@ impl GradientDef { let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None })?; let nums: Vec<f64> = vals.iter().filter_map(V::num).collect(); let kind = if nums.first().copied() == Some(1.0) { GradientKind::Radial } else { GradientKind::Linear }; - Some(Self { name, gradient: Gradient { kind, stops: vec![] } }) + Some(Self { name, gradient: Gradient::new(kind, vec![]) }) } /// A stop (`Bs`). diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f12e5953b..99b0eac77 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -76,6 +76,9 @@ const MAX_NODES: usize = 4_000_000; const MAX_POINTS: usize = 4_000_000; /// Largest image read (pixels). const MAX_PIXELS: u64 = 1 << 25; +/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads +/// larger images (#1479). +pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out"; /// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size). const MAX_HEX: usize = (MAX_PIXELS as usize) * 10; /// Largest side of the document and its artboards (points). @@ -116,6 +119,9 @@ pub struct Structure { pub hidden_unread: BTreeSet<String>, /// The names of text objects, by their slot's id. pub slot_names: HashMap<NodeId, String>, + /// Where the drawn looks lay that objects read with their art style took the place of (the + /// document's coordinates): the page draws Illustrator's rendering of those styles there. + pub restyled: Vec<Rect>, pub warnings: Vec<String>, } @@ -359,7 +365,12 @@ const BLENDS: [BlendMode; 16] = [ /// The name effects take for blend mode `n` of `Xy` (and of effects' `blnd`). pub(super) fn blend_name(n: usize) -> String { - format!("{:?}", BLENDS.get(n).copied().unwrap_or(BlendMode::Normal)) + format!("{:?}", blend_mode(n)) +} + +/// Blend mode `n` of `Xy` (Normal when it is out of range). +pub(super) fn blend_mode(n: usize) -> BlendMode { + BLENDS.get(n).copied().unwrap_or(BlendMode::Normal) } #[derive(Debug, Default)] @@ -461,7 +472,11 @@ struct Reader<'a> { instance: Option<Instance>, pending_fill: Option<Paint>, pending_stroke: Option<Paint>, + /// The named colours the colour operators define (`x`, `Xx`, `Xk`…). spots: Vec<Named>, + /// The named colours gradient stops name. A stop doesn't say whether its colour is an ink or + /// a global process colour, so these make swatches only for names no colour operator defines. + stop_spots: Vec<Named>, raster: Option<Raster>, /// The placed file being read (`%AI5_BeginPlace` … `~`). place: Option<Place>, @@ -503,6 +518,8 @@ struct Reader<'a> { mesh: Option<(super::mesh::MeshReader, bool)>, /// The art styles (`%AI9_BeginArtStyles`), by name. styles: HashMap<String, super::style::ArtStyle>, + /// See [`Structure::restyled`]. + restyled: Vec<Rect>, /// CMYK samples converted, by value. cmyk_cache: HashMap<[u8; 4], [u8; 3]>, /// A `/ForeignObject`'s `/Data`: its PDF as ASCII85 in comment lines, until the object closes. @@ -626,6 +643,7 @@ impl<'a> Reader<'a> { pending_fill: None, pending_stroke: None, spots: Vec::new(), + stop_spots: Vec::new(), raster: None, place: None, foreign_data: None, @@ -650,6 +668,7 @@ impl<'a> Reader<'a> { group_ended: false, layer_state: None, styles: HashMap::new(), + restyled: vec![], mesh: None, cmyk_cache: HashMap::new(), done: false, @@ -879,6 +898,7 @@ impl<'a> Reader<'a> { self.doc.images.entry(key).or_insert(blob); } self.spots.extend(sub.spots); + self.stop_spots.extend(sub.stop_spots); self.cmyk_colors += sub.cmyk_colors; self.rgb_colors += sub.rgb_colors; for w in &sub.warnings { @@ -1155,7 +1175,7 @@ impl<'a> Reader<'a> { let vals = self.take_head(vals); if let Some(d) = self.gradient_def.as_mut() { match w { - "Bs" => d.stop(&vals, &mut self.spots), + "Bs" => d.stop(&vals, &mut self.stop_spots), "BD" => { if let Some((name, g)) = self.gradient_def.take().and_then(GradientDef::finish) { self.gradients.insert(name, g); @@ -1412,17 +1432,33 @@ impl<'a> Reader<'a> { return Ok(()); } let Some(f) = self.frames.pop() else { return Ok(()) }; - let Kind::Obj(mut o) = f.kind else { return Ok(()) }; - obj::close(&mut o, &mut self.stack, f.base); - self.obj_hidden = f.hidden; + let Frame { kind, children, base, hidden, gs, after, .. } = 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) isn't kept. - self.gs = f.gs; - self.after_object = f.after; + self.gs = gs; + self.after_object = after; if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) { return self.push(V::Obj(Rc::new(*o))); } match o.ty.as_str() { - "ArtDictionary" => self.art_dictionary(&o), + "ArtDictionary" => { + let envelope = o.get("AIDeformOptionsKey").is_some(); + self.art_dictionary(&o); + if envelope { + let mut slots = vec![]; + for child in &children { + envelope_text_slots(child, &mut slots); + } + for mut slot in slots { + // The editing data keeps the source text inside the envelope's options; + // its visible result is the outlined art the PDF page carries. + slot.visible = false; + self.add(slot, false)?; + } + } + } "Document" => self.document(&o), "AI11Text" => self.text_slot(&o)?, "SymbolInstance" => self.unreadable("symbols"), @@ -1676,14 +1712,10 @@ impl<'a> Reader<'a> { return; }; let matrix = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { paint::affine(&Self::nums(a)) } else { None }); - // The tile's art (y down from the tile's top left) → the pattern's space → art space. The - // tile's lower left is the pattern's origin wherever the definition drew it, as the file's - // PDF part tiles it. - let xf = self.space() * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); - if xf.determinant().abs() < 1e-12 || !xf.as_coeffs().iter().all(|v| v.is_finite()) { + let Some(xf) = pattern_xf(self.space(), matrix, tile) else { self.unreadable("pattern fills"); return; - } + }; let p = Paint::Pattern { pattern: name.unwrap_or_default(), xf }; if w == "p" { self.gs.fill = p; @@ -1775,6 +1807,7 @@ impl<'a> Reader<'a> { if code != 1.0 || style.is_empty() || !after { return; } + let space = self.space(); let Some(f) = self.frames.last_mut() else { return }; let n = f.children.len(); let (Some(object), Some(look)) = (n.checked_sub(1).and_then(|i| f.children.get(i)), n.checked_sub(2).and_then(|i| f.children.get(i))) else { @@ -1789,9 +1822,16 @@ impl<'a> Reader<'a> { return; } // A style read here: the object, painted as it says. - if let Some(appearance) = self.styles.get(&style).and_then(|st| styled(object, st)) { + if let Some(appearance) = self + .styles + .get(&style) + .and_then(|st| styled(object, look, st, &StylePaints { gradients: &self.gradients, patterns: &self.pattern_tiles, space })) + { let Some(mut object) = f.children.pop() else { return }; let Some(look) = f.children.pop() else { return }; + if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { + self.restyled.push(b); + } object.appearance = appearance.into(); // The object's transparency is written on its look, after the look's group ends. (object.opacity, object.blend, object.isolate, object.knockout) = (look.opacity, look.blend, look.isolate, look.knockout); @@ -1972,6 +2012,18 @@ impl<'a> Reader<'a> { return Ok(()); } let gs = self.gs.clone(); + // One mesh where the patches join up into the grid they say they make; else each patch is + // a mesh of its own, in a group. + match m.one_mesh() { + Ok(one) => { + let mut node = self.new_node(NodeKind::Mesh(one), &gs); + node.opacity = gs.opacity; + node.blend = gs.blend; + return self.add(node, hidden); + } + Err(super::mesh::NotOne::Unjoined) => self.warn("a gradient mesh whose patches don't join up comes in as a group of its patches"), + Err(super::mesh::NotOne::Unplaced) => {} + } let mut patches: Vec<Node> = m.patches.into_iter().map(|p| self.new_node(NodeKind::Mesh(p), &gs)).collect(); let mut node = match patches.len() { 1 => patches.pop().unwrap_or_else(|| self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs)), @@ -2047,7 +2099,11 @@ impl<'a> Reader<'a> { return Ok(()); }; let (w, h) = (*w as u64, *h as u64); - if w == 0 || h == 0 || w.saturating_mul(h) > MAX_PIXELS || ![1.0, 8.0].contains(bits) { + if w.saturating_mul(h) > MAX_PIXELS { + self.warn(IMAGE_TOO_LARGE); + return Ok(()); + } + if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) { self.warn("an image VectorCraft can't read was left out"); return Ok(()); } @@ -2143,7 +2199,7 @@ impl<'a> Reader<'a> { let mask = alpha.and_then(|a| self.alpha_mask(a, w, h, xf)); let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); - self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }, mask, false) + self.add_image_masked(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }, mask, false) } /// An image's alpha channel (`w` × `h` bytes) as a grayscale image over the image (`xf`): its @@ -2159,7 +2215,7 @@ impl<'a> Reader<'a> { self.doc.images.entry(key.clone()).or_insert(blob); let art = Node::new( self.doc.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), ); Some(OpacityMask::new(art, true)) } @@ -2204,7 +2260,10 @@ impl<'a> Reader<'a> { let key = blob.content_key(); self.doc.images.entry(key.clone()).or_insert(blob); self.warn(LINKED_PREVIEWS); - self.add_image(ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default() }, hidden) + self.add_image( + ImageObject { key, width: w, height: h, xf, link: Some(LinkInfo::new(path)), placement: Default::default(), pixels: None }, + hidden, + ) } // ---- the document ---------------------------------------------------------------------- @@ -2289,13 +2348,27 @@ impl<'a> Reader<'a> { id: i as u32 + 1, name, rect: to_doc.transform_rect_bbox(r), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }) .collect(); if doc.artboards.is_empty() { let rect = doc.art_bounds().filter(|r| sane(*r) && r.area() > 0.0).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0)); - doc.artboards = vec![Artboard { id: 1, name: "Artboard 1".into(), rect, show_center_mark: false, show_cross_hairs: false }]; + doc.artboards = vec![Artboard { + id: 1, + name: "Artboard 1".into(), + rect, + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + }]; } if self.color_model.unwrap_or(self.cmyk_colors > self.rgb_colors) { doc.color_mode = ColorMode::Cmyk; @@ -2305,7 +2378,9 @@ impl<'a> Reader<'a> { if let Some(u) = self.ruler_units.and_then(ruler_unit) { doc.units = u; } - for n in &self.spots { + // The colour operators' definitions first: a gradient, defined before the art, names a + // global process colour as a stop and would otherwise make it an ink (#1771). + for n in self.spots.iter().chain(&self.stop_spots) { if doc.swatch(&n.name).is_none() { doc.swatches.push(Swatch { name: n.name.clone(), paint: Paint::solid(n.color), global: true, spot: n.spot }); } @@ -2331,11 +2406,23 @@ impl<'a> Reader<'a> { template: self.template, hidden_unread: self.hidden_unread, slot_names: self.slot_names, + restyled: self.restyled, warnings, }) } } +/// The source text inside an envelope options dictionary, carried through its containing metadata. +fn envelope_text_slots(node: &Node, out: &mut Vec<Node>) { + if slot_of(node.name.as_deref()).is_some() { + out.push(node.clone()); + } else if let Some(children) = node.children() { + for child in children { + envelope_text_slots(child, out); + } + } +} + /// The text objects written twice, plainly and on `%_` lines: the commented copy (`hidden`) of a /// story that also has a plain one goes. fn drop_commented_copies(layers: &mut [Arc<Node>], hidden: &HashSet<NodeId>) { @@ -2418,26 +2505,116 @@ fn path_rule(n: &Node) -> FillRule { } } -/// `object`'s appearance with style `st`: `None` when the style isn't one this reads, or asks a -/// group for fills or strokes. -fn styled(object: &Node, st: &super::style::ArtStyle) -> Option<Appearance> { +/// The tile's art (y down from the tile's top left) → the pattern's space (`matrix`) → art space +/// → the document (`space`). The tile's lower left is the pattern's origin wherever the +/// definition drew it, as the file's PDF part tiles it. `None` when it can't be inverted. +fn pattern_xf(space: Affine, matrix: Option<Affine>, tile: Rect) -> Option<Affine> { + let xf = space * matrix.unwrap_or(Affine::IDENTITY) * FLIP * Affine::translate((0.0, -tile.height())); + (xf.determinant().abs() >= 1e-12 && xf.as_coeffs().iter().all(|v| v.is_finite())).then_some(xf) +} + +/// What a style's fills paint with: the file's gradients and pattern tiles, and art space → the document. +struct StylePaints<'a> { + gradients: &'a HashMap<String, Gradient>, + patterns: &'a HashMap<String, Rect>, + space: Affine, +} + +/// The gradients and patterns `look` paints with, placed as it draws them, by their swatch's name +/// (the first of each; a pattern's name after `PATTERN_KEY`). +fn placed_paints(look: &Node, out: &mut HashMap<String, Paint>, depth: usize) { + if depth > MAX_DEPTH { + return; + } + for item in &look.appearance.items { + let key = match item.paint() { + Paint::Gradient(g) => g.swatch.clone(), + Paint::Pattern { pattern, .. } => Some(format!("{PATTERN_KEY}{pattern}")), + _ => None, + }; + if let Some(key) = key { + out.entry(key).or_insert_with(|| item.paint().clone()); + } + } + for c in look.children().into_iter().flatten() { + placed_paints(c, out, depth + 1); + } +} + +/// What [`placed_paints`] keys a pattern's name with, apart from the gradients' names. +const PATTERN_KEY: &str = "\u{1}pattern "; + +/// `object`'s appearance with style `st`, its drawn look `look`: `None` when the style isn't one +/// this reads, or asks a group for fills or strokes. +fn styled(object: &Node, look: &Node, st: &super::style::ArtStyle, paints: &StylePaints) -> Option<Appearance> { use super::style::StyleItem; - if !st.plain || (object.is_container() && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) { + // A text object's slot (an empty group standing for it) takes the style as the type will. + let slot = slot_of(object.name.as_deref()).is_some(); + if !st.plain + || (!slot && !st.simple) + || (object.is_container() && !slot && !matches!(object.kind, NodeKind::Compound { .. }) && !st.items.is_empty()) + { return None; } + // A style with one gradient fill: the object's own gradient, placed as the object's fill is. + let gradients = st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count(); + let own_gradient = object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_))); + // A style's gradients are written in a space of the object's bounds that isn't documented; its + // drawn look has each where the app placed it. + let mut placed = HashMap::new(); + placed_paints(look, &mut placed, 0); let mut items = vec![]; - for item in &st.items { - items.push(match item { + for (n, item) in st.items.iter().enumerate() { + let mut layer = match item { StyleItem::Fill(paint) => AppearanceItem::Fill(FillLayer::new(paint.clone())), - StyleItem::ObjectFill => AppearanceItem::Fill(object.appearance.fill().filter(|f| matches!(f.paint, Paint::Gradient(_)))?.clone()), + StyleItem::Gradient { name, angle } => { + let paint = match (name.as_ref().and_then(|n| placed.get(n)), gradients, own_gradient) { + // Other objects than type: the object's own, as they always were. + (_, 1, Some(own)) if !slot => own.paint.clone(), + (Some(p), ..) => p.clone(), + (None, 1, Some(own)) => own.paint.clone(), + _ => { + let gradient = name.as_ref().and_then(|n| paints.gradients.get(n))?; + let mut g = vectorcraft_color::gradient::GradientPaint::new(gradient.clone()); + // Fitted to the object's bounds at its angle (art space turns counter-clockwise, as the document's angles do). + g.angle = *angle; + Paint::Gradient(Box::new(g)) + } + }; + AppearanceItem::Fill(FillLayer::new(paint)) + } + StyleItem::Pattern { name, matrix } => match placed.get(&format!("{PATTERN_KEY}{name}")) { + Some(p) => AppearanceItem::Fill(FillLayer::new(p.clone())), + None => { + let tile = paints.patterns.get(name).copied()?; + let xf = pattern_xf(paints.space, matrix.and_then(|m| paint::affine(&m)), tile)?; + AppearanceItem::Fill(FillLayer::new(Paint::Pattern { pattern: name.clone(), xf })) + } + }, StyleItem::Stroke { paint, width, cap, join, miter, dash, profile } => { let mut layer = StrokeLayer::new(paint.clone(), *width); (layer.cap, layer.join, layer.miter_limit, layer.dash, layer.profile) = (*cap, *join, *miter, dash.clone(), profile.clone()); AppearanceItem::Stroke(layer) } - }); + }; + if let Some(look) = st.looks.get(n) { + match &mut layer { + AppearanceItem::Fill(f) => { + (f.visible, f.opacity, f.blend, f.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) + } + AppearanceItem::Stroke(s) => { + (s.visible, s.opacity, s.blend, s.effects) = (look.visible, look.opacity, look.blend, look.effects.clone()) + } + } + } + items.push(layer); } - Some(Appearance { items, effects: st.effects.clone(), ..(*object.appearance).clone() }) + let contents_index = if object.is_container() || matches!(object.kind, NodeKind::Text(_) | NodeKind::Image(_)) { + st.contents_index + } else { + object.appearance.contents_index + }; + Some(Appearance { items, effects: st.effects.clone(), contents_index }) } /// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). diff --git a/crates/eps/src/import/ai/style.rs b/crates/eps/src/import/ai/style.rs index 07ffb0728..68739fb05 100644 --- a/crates/eps/src/import/ai/style.rs +++ b/crates/eps/src/import/ai/style.rs @@ -1,63 +1,121 @@ //! Art styles (`%AI9_BeginArtStyles`; not publicly documented): the appearance an object's -//! `1 (style) XW` names, a chain of filters — a stack of fills and strokes round the object's -//! contents (`Conduit Filter`), then live effects, then its transparency (`Blend Style Filter`): +//! `1 (style) XW` names, a tree of filters. A compound filter (`/CompoundFilter :`) holds the +//! filters after it until the `;` that closes it; a basic one (`/BasicFilter :`) ends at its +//! `/Part ,`: //! //! ```text //! /KnownStyle : //! (name) /Name , //! /ActiveStyle : //! /CompoundFilter : -//! (Chain Style Filter) 0 0 /Filter , -//! … (Stack Style Filter) … (Stroke Style Filter) … (Conduit Filter) … (Fill Style Filter) … -//! (Adobe Outer Glow) 1 0 /Filter , +//! (Chain Style Filter) 0 0 /Filter , the object: its effects, its stack, its transparency //! 1 /Visible , -//! 2 /Int (blnd) , -//! 0.75 /Real (opac) , -//! 5 /Real (blur) , -//! /FillStyle : 0 O -//! c m y k r g b Xa -//! /Def ; -//! (sclr) , +//! /BasicFilter : +//! (Adobe Deform) 1 0 /Filter , an effect on the whole object +//! … /Part , +//! /CompoundFilter : +//! (Stack Style Filter) 0 0 /Filter , its fills and strokes, the top one first +//! … (Stroke Style Filter) … /Part , a stroke with no effects of its own +//! /CompoundFilter : +//! (Chain Style Filter) 0 0 /Filter , a fill with effects and a transparency of its own +//! … (Adobe Offset Path) … (Fill Style Filter) … (Adobe Transform) … (Blend Style Filter) … +//! ; +//! /Part , +//! … (Conduit Filter) … the object's contents (type's characters, a group's members) +//! ; +//! /Part , +//! (Blend Style Filter) … the object's own transparency (written on its look too) +//! ; //! ``` //! -//! A style is *plain* when it is one fill and one stroke at most, in solid colours (a fill's gradient -//! is the object's own), and only effects read here (Outer Glow, Drop Shadow, Feather) after them. Such -//! objects are read as themselves with that fill, stroke and those effects; others keep their -//! drawn look. A fill's or stroke's style says whether it paints with `stroke fill Xd`. +//! A fill's style paints with a colour (`c m y k r g b Xa`), a pattern (`(name) … [matrix] p`) or a +//! gradient (`Bb` … `flag (name) x y angle length … Bg` … `BB`); `stroke fill Xd` says whether it +//! paints at all. A blend filter's `mode opacity isolate knockout shape Xy` is its transparency. +//! +//! A style is *plain* when every filter in it is one read here: fills and strokes (in a colour; fills +//! with a pattern or gradient too), each with its own transparency and effects, and the effects of +//! [`effect`]. Such objects are read as themselves with an appearance of those fills, strokes and +//! effects; others keep their drawn look. use std::collections::HashMap; use serde_json::json; -use vectorcraft_color::Paint; +use vectorcraft_color::{BlendMode, Paint}; use vectorcraft_doc::{Dash, Effect, LineCap, LineJoin, WidthProfile}; use super::lex::{Lexer, Tok}; use super::obj::V; use super::paint; -/// The filters a plain style may chain. -const PLAIN: [&str; 8] = - ["Chain Style Filter", "Stack Style Filter", "Stroke Style Filter", "Fill Style Filter", "Conduit Filter", "Blend Style Filter", GLOW, FEATHER]; const GLOW: &str = "Adobe Outer Glow"; // brand-ok: the filter's name in the files const FEATHER: &str = "Adobe Feather"; // brand-ok: the filter's name in the files const SHADOW: &str = "Adobe Drop Shadow"; // brand-ok: the filter's name in the files +const DEFORM: &str = "Adobe Deform"; // brand-ok: the filter's name in the files +const OFFSET: &str = "Adobe Offset Path"; // brand-ok: the filter's name in the files +const TRANSFORM: &str = "Adobe Transform"; // brand-ok: the filter's name in the files +const GAUSSIAN: &str = "Adobe PSL Gaussian Blur"; // brand-ok: the filter's name in the files +const BAS_RELIEF: &str = "PSAdapter_plugin_BsRl"; + +const CHAIN: &str = "Chain Style Filter"; +const STACK: &str = "Stack Style Filter"; +const FILL: &str = "Fill Style Filter"; +const STROKE: &str = "Stroke Style Filter"; +const CONDUIT: &str = "Conduit Filter"; +const BLEND: &str = "Blend Style Filter"; + +/// The warp styles by their number in `DeformStyle` (Effect › Warp's list, from 1). +const WARPS: [&str; 15] = [ + "warp.arc", + "warp.arcLower", + "warp.arcUpper", + "warp.arch", + "warp.bulge", + "warp.shellLower", + "warp.shellUpper", + "warp.flag", + "warp.wave", + "warp.fish", + "warp.rise", + "warp.fisheye", + "warp.inflate", + "warp.squeeze", + "warp.twist", +]; /// A plain style's paint, as the object comes in. #[derive(Clone, Debug, Default, PartialEq)] pub(super) struct ArtStyle { /// The style reads as [`Self::items`] and [`Self::effects`]. pub plain: bool, - /// Its fill and stroke, bottom first. + /// It is one fill and one stroke at most, in solid colours or the object's own gradient, with + /// no transparency or effects of their own, and only Outer Glow, Feather and Drop Shadow on the + /// object: what any object is read with. Type takes the other plain styles too (its drawn look + /// can't be retyped); other objects keep their drawn look, which is the app's own rendering. + pub simple: bool, + /// Its fills and strokes, bottom first. pub items: Vec<StyleItem>, - /// Its live effects, in the order they apply. + /// Each item's own visibility, transparency and effects, in the order of [`Self::items`]. + pub looks: Vec<ItemLook>, + /// The object's live effects, in the order they apply. pub effects: Vec<Effect>, + /// How many of [`Self::items`] paint below the object's contents (`None`: none do). + pub contents_index: Option<usize>, } #[derive(Clone, Debug, PartialEq)] pub(super) enum StyleItem { Fill(Paint), - /// A fill with a gradient: the object's own. - ObjectFill, + /// A fill with a gradient: the swatch `name` at `angle` degrees, fitted to the object (the + /// object's own gradient when it is the style's only one). + Gradient { + name: Option<String>, + angle: f64, + }, + /// A fill with pattern swatch `name`, its space taken to art space by `matrix`. + Pattern { + name: String, + matrix: Option<[f64; 6]>, + }, Stroke { paint: Paint, width: f64, @@ -69,10 +127,28 @@ pub(super) enum StyleItem { }, } +/// A fill's or stroke's own state in the stack. +#[derive(Clone, Debug, PartialEq)] +pub(super) struct ItemLook { + pub visible: bool, + pub opacity: f32, + pub blend: BlendMode, + pub effects: Vec<Effect>, +} + +impl Default for ItemLook { + fn default() -> Self { + Self { visible: true, opacity: 1.0, blend: BlendMode::Normal, effects: vec![] } + } +} + #[derive(Debug)] struct Filter { name: String, visible: bool, + /// The compound filter it is in (`None`: the style's first). + parent: Option<usize>, + compound: bool, params: HashMap<String, f64>, /// Its dictionary's arrays (`/Array : v /Real , … ; (key) ,`), by key. arrays: HashMap<String, Vec<f64>>, @@ -86,6 +162,12 @@ struct Filter { /// `stroke fill Xd`. paints: (bool, bool), gradient: bool, + /// The gradient's swatch and angle (`Bg`). + gradient_of: Option<(Option<String>, f64)>, + /// `(name) … [matrix] p`. + pattern: Option<(String, Option<[f64; 6]>)>, + /// `mode opacity … Xy`. + xy: Option<(usize, f32)>, } impl Default for Filter { @@ -93,6 +175,8 @@ impl Default for Filter { Self { name: String::new(), visible: true, + parent: None, + compound: false, params: HashMap::new(), arrays: HashMap::new(), fill: None, @@ -104,16 +188,27 @@ impl Default for Filter { dash: None, paints: (false, false), gradient: false, + gradient_of: None, + pattern: None, + xy: None, } } } +/// Most filters read in one style. +const MAX_FILTERS: usize = 4096; + /// The styles of the section `data` (between its markers), by name. pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> { let mut styles = HashMap::new(); let mut name: Option<String> = None; let mut filters: Vec<Filter> = vec![]; let mut operands: Vec<V> = vec![]; + // The compound filters not yet closed, the kind the next `/Filter` is, and whether a basic + // filter's dictionaries are being read (their `;` close those, not a compound filter). + let mut open: Vec<usize> = vec![]; + let mut pending: Option<bool> = None; + let mut in_basic = false; let mut finish = |name: Option<String>, filters: &mut Vec<Filter>| { if let Some(name) = name { styles.insert(name, style(filters)); @@ -135,9 +230,28 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> { b"KnownStyle" => { finish(name.take(), &mut filters); operands.clear(); + open.clear(); + (pending, in_basic) = (None, false); } b"Name" if name.is_none() && filters.is_empty() => name = operands.iter().rev().find_map(V::text), - b"Filter" => filters.push(Filter { name: operands.iter().rev().find_map(V::text).unwrap_or_default(), ..Filter::default() }), + b"CompoundFilter" => pending = Some(true), + b"BasicFilter" => pending = Some(false), + b"Part" => in_basic = false, + b"Filter" => { + let compound = pending.take().unwrap_or(false); + if filters.len() < MAX_FILTERS { + filters.push(Filter { + name: operands.iter().rev().find_map(V::text).unwrap_or_default(), + parent: open.last().copied(), + compound, + ..Filter::default() + }); + if compound { + open.push(filters.len() - 1); + } + } + in_basic = !compound; + } b"Visible" => { if let Some(f) = filters.last_mut() { f.visible = operands.iter().rev().find_map(V::num) != Some(0.0); @@ -181,6 +295,9 @@ pub(super) fn art_styles(data: &[u8]) -> HashMap<String, ArtStyle> { operands.clear(); continue; } + if op == ";" && !in_basic { + open.pop(); + } if let Some(f) = filters.last_mut() { paint_op(f, op, &operands); } @@ -245,7 +362,37 @@ fn paint_op(f: &mut Filter, op: &str, vals: &[V]) { f.paints = (*stroke != 0.0, *fill != 0.0); } } - "Bg" => f.gradient = true, + "Xy" => { + let n: Vec<f64> = vals.iter().filter_map(V::num).collect(); + if let [mode, opacity, ..] = n.as_slice() + && mode.is_finite() + && opacity.is_finite() + { + f.xy = Some((mode.max(0.0) as usize, opacity.clamp(0.0, 1.0) as f32)); + } + } + // `flag (name) x y angle length a b c d tx ty Bg`. + "Bg" => { + f.gradient = true; + let at = vals.iter().rposition(|v| matches!(v, V::Str(_))); + let name = at.and_then(|i| vals.get(i)).and_then(V::text); + let n: Vec<f64> = at.map_or(&[][..], |i| vals.get(i + 1..).unwrap_or_default()).iter().filter_map(V::num).collect(); + let angle = n.get(2).copied().filter(|a| a.is_finite()).unwrap_or(0.0); + f.gradient_of = Some((name, angle)); + } + "p" => { + let name = vals.iter().find_map(|v| if let V::Str(_) = v { v.text() } else { None }); + let matrix = vals.iter().rev().find_map(|v| match v { + V::Arr(a) => { + let n: Vec<f64> = a.iter().filter_map(V::num).collect(); + <[f64; 6]>::try_from(n.as_slice()).ok().filter(|m| m.iter().all(|v| v.is_finite())) + } + _ => None, + }); + if let Some(name) = name { + f.pattern = Some((name, matrix)); + } + } _ => {} } } @@ -275,91 +422,237 @@ fn width_profile(arrays: &HashMap<String, Vec<f64>>) -> Option<WidthProfile> { (!uniform).then_some(WidthProfile { points }) } -fn style(filters: &[Filter]) -> ArtStyle { - let count = |name: &str| filters.iter().filter(|f| f.name == name).count(); - let painted = filters.iter().rposition(|f| matches!(f.name.as_str(), "Stroke Style Filter" | "Fill Style Filter" | "Conduit Filter")); - let mut plain = count("Stroke Style Filter") <= 1 && count("Fill Style Filter") <= 1; - let mut effects = vec![]; - let mut items = vec![]; - // The stack lists its fills and strokes top first. - for f in filters.iter().rev().filter(|f| f.visible) { - match f.name.as_str() { - "Fill Style Filter" if f.paints.1 && f.gradient => items.push(StyleItem::ObjectFill), - "Fill Style Filter" if f.paints.1 => match &f.fill { - Some(p) => items.push(StyleItem::Fill(p.clone())), - None => plain = false, - }, - // Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end - // and the pattern, fitted, between them. - "Stroke Style Filter" if f.paints.0 && f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => plain = false, - "Stroke Style Filter" if f.paints.0 => match &f.stroke { - Some(p) if !f.gradient => items.push(StyleItem::Stroke { - paint: p.clone(), - width: f.width, - cap: f.cap, - join: f.join, - miter: f.miter, - dash: f.dash.clone(), - profile: width_profile(&f.arrays), - }), - _ => plain = false, - }, - _ => {} - } - } - for (i, f) in filters.iter().enumerate() { - let effect = f.name == GLOW || f.name == FEATHER || f.name == SHADOW; - if !PLAIN.contains(&f.name.as_str()) && f.name != SHADOW { - plain = false; - } - // An effect inside the stack is a fill's or a stroke's, not the object's. - if effect && painted.is_some_and(|p| i < p) { - plain = false; - } - if !f.visible { - // A hidden fill or stroke leaves the object painting differently; a hidden effect, nothing. - plain &= effect; - continue; - } - let param = |k: &str| f.params.get(k).copied(); - let mode = |default: usize| super::read::blend_name(param("blnd").map_or(default, |v| v.max(0.0) as usize)); - let color = f.fill.as_ref().and_then(Paint::color).unwrap_or(vectorcraft_color::Color::BLACK); - match f.name.as_str() { - // `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ. - GLOW => effects.push(Effect { - id: "stylize.outerGlow".into(), - params: json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}), - visible: true, - }), - // LiveEffect XML uses `radius`; art styles abbreviate it `rad` (public scripting posts on applyEffect). - FEATHER => effects.push(Effect { - id: "stylize.feather".into(), - params: json!({"radius": param("rad").or_else(|| param("radius")).filter(|v| v.is_finite()).unwrap_or(5.0)}), - visible: true, - }), - SHADOW => { - let mut params = json!({ - "mode": mode(1), - "opacity": param("opac").unwrap_or(0.75) * 100.0, - "blur": 2.0 * param("blur").unwrap_or(5.0), - // Undocumented; read as the dialog's offsets, not flipped like art-space y: its default 7/7 pt is - // right and down (https://helpx.adobe.com/illustrator/using/drop-shadows-glows-feathering.html). - "x": param("horz").unwrap_or(7.0), - "y": param("vert").unwrap_or(7.0), - "color": color, - }); - // A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as - // Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite. - if param("csrc").unwrap_or(0.0) != 0.0 { - params["shade"] = json!("darkness"); - params["darkness"] = json!(param("dark").filter(|v| v.is_finite()).map_or(100.0, |v| v.clamp(0.0, 100.0))); - } - effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true }); +/// The live effect filter `f` is, when it is one read here. +fn effect(f: &Filter) -> Option<Effect> { + let param = |k: &str| f.params.get(k).copied().filter(|v| v.is_finite()); + let flag = |k: &str| param(k).is_some_and(|v| v != 0.0); + let mode = |default: usize| super::read::blend_name(param("blnd").map_or(default, |v| v.max(0.0) as usize)); + let color = f.fill.as_ref().and_then(Paint::color).unwrap_or(vectorcraft_color::Color::BLACK); + let (id, params) = match f.name.as_str() { + // `blur`'s scale isn't publicly documented: read as the Gaussian's σ, an effect's blur being 2σ. + GLOW => ( + "stylize.outerGlow", + json!({"mode": mode(2), "opacity": param("opac").unwrap_or(0.75) * 100.0, "blur": 2.0 * param("blur").unwrap_or(5.0), "color": color}), + ), + // LiveEffect XML uses `radius`; art styles abbreviate it `rad` (public scripting posts on applyEffect). + FEATHER => ("stylize.feather", json!({"radius": param("rad").or_else(|| param("radius")).unwrap_or(5.0)})), + SHADOW => { + let mut params = json!({ + "mode": mode(1), + "opacity": param("opac").unwrap_or(0.75) * 100.0, + "blur": 2.0 * param("blur").unwrap_or(5.0), + // Undocumented; read as the dialog's offsets, not flipped like art-space y: its default 7/7 pt is + // right and down (https://helpx.adobe.com/illustrator/using/drop-shadows-glows-feathering.html). + "x": param("horz").unwrap_or(7.0), + "y": param("vert").unwrap_or(7.0), + "color": color, + }); + // A `csrc` of 1 is Darkness, the object's own colors with black added; any value other than 0 is read as + // Darkness. `dark` is the percent of black (0 to 100, where `opac` is a fraction), and 100 when it is missing or not finite. + if param("csrc").unwrap_or(0.0) != 0.0 { + params["shade"] = json!("darkness"); + params["darkness"] = json!(param("dark").map_or(100.0, |v| v.clamp(0.0, 100.0))); } - _ => {} + ("stylize.dropShadow", params) + } + // Effect › Warp: `DeformStyle` numbers the styles from 1 as the dialog lists them, `Rotate` + // is Vertical, and the bend and distortions are fractions. + DEFORM => { + let style = param("DeformStyle").map_or(1, |v| v as i64); + let id = WARPS.get(usize::try_from(style - 1).ok()?)?; + let pct = |k: &str| (param(k).unwrap_or(0.0) * 100.0).clamp(-100.0, 100.0); + ( + *id, + json!({ + "bend": pct("DeformValue"), + "horizontal": pct("DeformHoriz"), + "vertical": pct("DeformVert"), + "orientation": if flag("Rotate") { "vertical" } else { "horizontal" }, + }), + ) + } + // `jntp`: 0 round, 1 bevel, 2 miter (the joins LiveEffect XML writes in public scripts). + OFFSET => ( + "path.offsetPath", + json!({ + "offset": param("ofst").unwrap_or(10.0).clamp(-1e5, 1e5), + "joins": match param("jntp").map(|v| v as i64) { Some(0) => "round", Some(1) => "bevel", _ => "miter" }, + "miterLimit": param("mlim").unwrap_or(4.0).clamp(1.0, 500.0), + }), + ), + // Art space has y up: a move up is a negative `moveV` in the document. + TRANSFORM => ( + "distort.transform", + json!({ + "scaleH": param("scaleH_Percent").unwrap_or(100.0), + "scaleV": param("scaleV_Percent").unwrap_or(100.0), + "moveH": param("moveH_Pts").unwrap_or(0.0), + "moveV": -param("moveV_Pts").unwrap_or(0.0), + "rotate": param("rotate_Degrees").unwrap_or(0.0), + "copies": param("numCopies").unwrap_or(0.0).clamp(0.0, 1000.0) as i64, + "reflectX": flag("reflectX"), + "reflectY": flag("reflectY"), + "reference": param("pinPoint").unwrap_or(4.0).clamp(0.0, 8.0) as i64, + "random": flag("randomize"), + }), + ), + GAUSSIAN => ("blur.gaussian", json!({"radius": param("blur").unwrap_or(5.0).clamp(0.0, 1000.0)})), + // Its settings are in a Photoshop plug-in's binary block: the effect's defaults stand in. + BAS_RELIEF => ("sketch.basRelief", json!({"detail": 13.0, "smoothness": 3.0, "lightDirection": "bottom"})), + _ => return None, + }; + Some(Effect { id: id.into(), params, visible: f.visible }) +} + +/// The fill or stroke basic filter `f` paints, `Ok(None)` when it paints nothing, `Err` when it +/// paints in a way this doesn't read. +fn paint_item(f: &Filter) -> Result<Option<StyleItem>, ()> { + match f.name.as_str() { + FILL if !f.paints.1 => Ok(None), + FILL => match (&f.pattern, &f.gradient_of, &f.fill) { + (Some((name, matrix)), ..) => Ok(Some(StyleItem::Pattern { name: name.clone(), matrix: *matrix })), + (None, Some((name, angle)), _) => Ok(Some(StyleItem::Gradient { name: name.clone(), angle: *angle })), + (None, None, _) if f.gradient => Ok(Some(StyleItem::Gradient { name: None, angle: 0.0 })), + (None, None, Some(p)) => Ok(Some(StyleItem::Fill(p.clone()))), + (None, None, None) => Err(()), + }, + STROKE if !f.paints.0 => Ok(None), + // Dashes aligned to corners and path ends draw as the look's pieces: a solid end at each end + // and the pattern, fitted, between them. + STROKE if f.params.get("BSDashAdj").is_some_and(|v| *v != 0.0) => Err(()), + STROKE => match &f.stroke { + Some(p) if !f.gradient && f.pattern.is_none() => Ok(Some(StyleItem::Stroke { + paint: p.clone(), + width: f.width, + cap: f.cap, + join: f.join, + miter: f.miter, + dash: f.dash.clone(), + profile: width_profile(&f.arrays), + })), + _ => Err(()), + }, + _ => Err(()), + } +} + +/// Builds an [`ArtStyle`] from a style's filter tree. +struct Builder<'a> { + filters: &'a [Filter], + plain: bool, +} + +impl Builder<'_> { + fn children(&self, parent: usize) -> Vec<usize> { + self.filters.iter().enumerate().filter(|(_, f)| f.parent == Some(parent)).map(|(i, _)| i).collect() + } + + /// The fill or stroke a stack holds as `id`: a basic one, or a chain of one with its effects + /// and transparency. + fn item(&mut self, id: usize) -> Option<(StyleItem, ItemLook)> { + let filters = self.filters; + let f = &filters[id]; + if !f.compound { + return match paint_item(f) { + Ok(item) => item.map(|i| (i, ItemLook { visible: f.visible, ..ItemLook::default() })), + Err(()) => { + self.plain = false; + None + } + }; + } + if f.name != CHAIN { + self.plain = false; + return None; + } + let mut look = ItemLook { visible: f.visible, ..ItemLook::default() }; + let mut painted: Option<StyleItem> = None; + let mut paints = 0; + for k in self.children(id) { + let c = &filters[k]; + match c.name.as_str() { + _ if c.compound => self.plain = false, + FILL | STROKE => { + paints += 1; + look.visible &= c.visible; + match paint_item(c) { + Ok(item) => painted = item, + Err(()) => self.plain = false, + } + } + BLEND => { + if let Some((mode, opacity)) = c.xy { + (look.blend, look.opacity) = (super::read::blend_mode(mode), opacity); + } + } + _ => match effect(c) { + Some(e) => look.effects.push(e), + None => self.plain = false, + }, + } + } + if paints != 1 { + self.plain = false; + } + painted.map(|p| (p, look)) + } + + /// A stack's fills and strokes, bottom first, and how many of them paint below its contents. + fn stack(&mut self, id: usize, st: &mut ArtStyle) { + // Listed top first. + let mut top_first = vec![]; + let mut contents_at = None; + for k in self.children(id) { + if self.filters[k].name == CONDUIT { + contents_at = Some(top_first.len()); + continue; + } + if let Some(item) = self.item(k) { + top_first.push(item); + } + } + let below = contents_at.map(|at| top_first.len() - at); + for (item, look) in top_first.into_iter().rev() { + st.items.push(item); + st.looks.push(look); + } + st.contents_index = below.filter(|n| *n > 0); + } +} + +fn style(filters: &[Filter]) -> ArtStyle { + let mut st = ArtStyle { plain: true, ..ArtStyle::default() }; + let Some(root) = filters.iter().position(|f| f.parent.is_none()) else { return st }; + let mut b = Builder { filters, plain: true }; + let top = if filters[root].compound { b.children(root) } else { vec![root] }; + for id in top { + let f = &filters[id]; + match f.name.as_str() { + STACK if f.compound => b.stack(id, &mut st), + FILL | STROKE | CHAIN => { + if let Some((item, look)) = b.item(id) { + st.items.push(item); + st.looks.push(look); + } + } + CONDUIT => {} + // The object's own transparency, which its look carries too. + BLEND => {} + _ => match effect(f) { + Some(e) => st.effects.push(e), + None => b.plain = false, + }, } } - ArtStyle { plain, items, effects } + st.plain = b.plain; + let count = |stroke: bool| st.items.iter().filter(|i| matches!(i, StyleItem::Stroke { .. }) == stroke).count(); + st.simple = st.plain + && count(true) <= 1 + && count(false) <= 1 + && st.items.iter().all(|i| matches!(i, StyleItem::Fill(_) | StyleItem::Stroke { .. } | StyleItem::Gradient { .. })) + && st.looks.iter().all(|l| l.visible && l.opacity >= 1.0 && l.blend == BlendMode::Normal && l.effects.is_empty()) + && st.effects.iter().all(|e| matches!(e.id.as_str(), "stylize.outerGlow" | "stylize.feather" | "stylize.dropShadow")); + st } #[cfg(test)] @@ -471,6 +764,19 @@ mod tests { assert_eq!(feather.params["radius"].as_f64(), Some(5.0)); } + #[test] + fn art_styles_ignore_filters_beyond_the_cap() { + const UNIT: &[u8] = b"/BasicFilter :\n(Adobe Feather) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n; /Dict ;\n /Part ,\n"; + let mut data = + Vec::from(b"/KnownStyle :\n(Capped) /Name ,\n/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n"); + for _ in 0..MAX_FILTERS + 16 { + data.extend_from_slice(UNIT); + } + data.extend_from_slice(b";\n"); + let style = art_styles(&data).remove("Capped").unwrap_or_default(); + assert!(style.effects.len() <= MAX_FILTERS); + } + #[test] fn hostile_dash_offsets_and_width_profiles_are_tamed() { let mut f = Filter::default(); @@ -483,4 +789,120 @@ mod tests { ]); assert_eq!(width_profile(&arrays).map(|p| p.points), Some(vec![(0.0, 2.0, 2.0), (0.5, 0.5, 0.5), (1.0, 0.0, 0.0)])); } + /// A basic filter `name` whose dictionary holds `body`. + fn basic(name: &str, body: &str) -> String { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") + } + + /// A compound filter `name` holding `kids`. + fn compound(name: &str, kids: &[String]) -> String { + format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()) + } + + fn fill(paint: &str) -> String { + basic( + "Fill Style Filter", + &format!("0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"), + ) + } + + fn blend(mode: u8, opacity: f64) -> String { + basic("Blend Style Filter", &format!("/BlendStyle : {mode} {opacity} 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n/Def ;\n (BlendStyle) ,\n")) + } + + /// A comic-lettering style: a warp over a stack of a textured fill, a gradient, a white rim + /// repeated by Transform, and the characters at the bottom. + fn lettering() -> String { + let stack = compound( + "Stack Style Filter", + &[ + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 1 w 10 M []0 d0 XR\n0 0 Xd\n/Def ;\n (StrokeStyle) ,\n"), + compound( + "Chain Style Filter", + &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p"), basic("PSAdapter_plugin_BsRl", "300 /Int (PrevDres) ,\n"), blend(5, 0.48)], + ), + fill("Bb\n0 0 0 0 Bh\n2 (Red ramp) 0 0 90 1 1 0 0 1 0 0 1 Bg\n0 BB"), + compound( + "Chain Style Filter", + &[ + basic("Adobe Offset Path", "3.5 /Real (ofst) ,\n0 /Int (jntp) ,\n4 /Real (mlim) ,\n"), + fill("0 0 0 0 1 1 1 Xa"), + basic( + "Adobe Transform", + "6 /Int (numCopies) ,\n0.75 /Real (moveH_Pts) ,\n-0.25 /Real (moveV_Pts) ,\n100 /Real (scaleH_Percent) ,\n100 /Real (scaleV_Percent) ,\n4 /Int (pinPoint) ,\n", + ), + ], + ), + basic("Conduit Filter", ""), + ], + ); + let root = compound( + "Chain Style Filter", + &[ + basic( + "Adobe Deform", + "0 /Bool (Rotate) ,\n0.02 /Real (DeformVert) ,\n4 /Int (DeformStyle) ,\n0.05 /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n", + ), + stack, + blend(0, 1.0), + ], + ); + format!("/KnownStyle :\n(Lettering) /Name ,\n/ActiveStyle :\n{root}\n/Execution ;\n /Def ;\n") + } + + #[test] + fn a_stack_of_fills_with_effects_of_their_own_is_read_as_itself() { + // GUARDS: more than one fill, or an effect on a fill, made the style not plain, and type + // with it came in as its drawn look (outlines) instead of type. + let styles = art_styles(lettering().as_bytes()); + let st = &styles["Lettering"]; + assert!(st.plain, "{st:?}"); + // Bottom first: the rim, the gradient, the textured fill; the stroke paints nothing. + let [StyleItem::Fill(rim), StyleItem::Gradient { name, angle }, StyleItem::Pattern { name: pattern, .. }] = st.items.as_slice() else { + panic!("{:?}", st.items) + }; + assert!(matches!(rim, Paint::Solid { .. }), "{rim:?}"); + assert_eq!((name.as_deref(), *angle, pattern.as_str()), (Some("Red ramp"), 90.0, "Dots")); + let ids = |l: &ItemLook| l.effects.iter().map(|e| e.id.clone()).collect::<Vec<_>>(); + assert_eq!(ids(&st.looks[0]), ["path.offsetPath", "distort.transform"]); + assert!(ids(&st.looks[1]).is_empty()); + assert_eq!(ids(&st.looks[2]), ["sketch.basRelief"]); + assert_eq!((st.looks[2].blend, st.looks[2].opacity), (BlendMode::HardLight, 0.48)); + let transform = &st.looks[0].effects[1].params; + assert_eq!((&transform["copies"], &transform["moveH"], &transform["moveV"]), (&json!(6), &json!(0.75), &json!(0.25))); + assert_eq!(st.looks[0].effects[0].params["joins"], json!("round")); + // The warp is the whole object's; the characters paint below every item. + let [warp] = st.effects.as_slice() else { panic!("{:?}", st.effects) }; + assert_eq!((warp.id.as_str(), &warp.params["bend"], &warp.params["orientation"]), ("warp.arch", &json!(5.0), &json!("horizontal"))); + assert_eq!(st.contents_index, None); + } + + #[test] + fn contents_above_some_items_count_them() { + let stack = compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), basic("Conduit Filter", ""), fill("0 0 0 1 0 0 0 Xa")]); + let data = format!("/KnownStyle :\n(Over) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[stack])); + let st = &art_styles(data.as_bytes())["Over"]; + assert!(st.plain, "{st:?}"); + assert_eq!((st.items.len(), st.contents_index), (2, Some(1))); + } + + #[test] + fn an_effect_not_read_here_keeps_the_drawn_look() { + let item = compound("Chain Style Filter", &[fill("0 0 0 1 0 0 0 Xa"), basic("Adobe Scribble Fill", "")]); + let data = + format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); + assert!(!art_styles(data.as_bytes())["Odd"].plain); + } + + #[test] + fn a_hidden_fill_comes_in_hidden() { + let hidden = fill("0 0 0 1 0 0 0 Xa").replacen("1 /Visible", "0 /Visible", 1); + let data = format!( + "/KnownStyle :\n(Hidden) /Name ,\n/ActiveStyle :\n{}", + compound("Chain Style Filter", &[compound("Stack Style Filter", &[fill("0 0 0 0 1 1 1 Xa"), hidden])]) + ); + let st = &art_styles(data.as_bytes())["Hidden"]; + assert!(st.plain, "{st:?}"); + assert_eq!(st.looks.iter().map(|l| l.visible).collect::<Vec<_>>(), [false, true]); + } } diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 0ab7b1b67..3bd312297 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -173,6 +173,29 @@ fn global_process_colours_are_global_swatches_at_their_tint() { assert_eq!(doc.swatch("Black global").unwrap().paint.color(), Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)), "the swatch is the full colour"); } +/// A gradient stop naming a global process colour doesn't make it an ink (#1771): the colour +/// operator's definition (`Xk`) makes the swatch, though the gradient, defined first, names the +/// colour first. The lines are the ones posted in #1771, with a neutral name. +#[test] +fn a_gradient_stop_naming_a_process_colour_leaves_its_swatch_process() { + let defs = "%AI5_BeginGradient: (Brand)\n(Brand) 0 2 Bd\n[\n<00FF>\n0\n\ + 0.930000007152557 0.100000001490116 0 0 1 1 6 50 0 %_BS\n\ + %_0.930000007152557 0.100000001490116 0 0 (Test Cyan) 0 0 5 1 6 50 0 Bs\n\ + %_0 0 0 0 1 0 0 2 1 6 50 100 Bs\nBD\n%AI5_EndGradient\n"; + let art_ = format!("0.930000007152557 0.100000001490116 0 0 (Test Cyan) 0 0 Xk\n{}f\n", rect_path(0.0, 0.0, 10.0, 10.0)); + let near = + |c: Option<Color>| matches!(c, Some(Color::Cmyk { c, m, y, k }) if (c - 0.93).abs() < 1e-3 && (m - 0.1).abs() < 1e-3 && y == 0.0 && k == 0.0); + let doc = read_ok(&format!("{defs}{}", layer(&art_))).doc; + let sw = doc.swatch("Test Cyan").unwrap(); + assert!(sw.global && !sw.spot, "a global process colour, not an ink: {sw:?}"); + assert!(near(sw.paint.color()), "C=93 M=10: {sw:?}"); + assert!(near(fill(&art(&doc)[0]).color()), "the fill: {:?}", fill(&art(&doc)[0])); + // Named only by the stop, the colour is still a swatch of its own, at the stop's values. + let doc = read_ok(&format!("{defs}{}", layer(&format!("{}f\n", rect_path(0.0, 0.0, 10.0, 10.0))))).doc; + let sw = doc.swatch("Test Cyan").unwrap(); + assert!(near(sw.paint.color()), "{sw:?}"); +} + /// A pattern's definition (`%AI3_BeginPattern`): its name and tile (8 points square here, y up), /// then its art on `%_` lines (a group of the tile's unpainted box and a dot, a round-capped /// stroke of no length), `E`. diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index 0a1b674ad..24a3a2736 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -362,14 +362,28 @@ impl Texts { (rest.is_empty() && !chunks.is_empty() && chunks.len() <= MAX_SEGMENTS).then(|| chunks.to_vec()) }; match carries.get("0").and_then(Val::num).unwrap_or(0.0) as i64 { - 0 => Some(FrameKind::Point(carries.get("2")?.nums()?.try_into().ok()?)), + // Point type that is neither turned nor scaled has no matrix (Illustrator 24 leaves it out): + // its layout is already where it goes on the canvas. + 0 => Some(FrameKind::Point(match carries.get("2") { + None => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0], + Some(m) => m.nums()?.try_into().ok()?, + })), 1 => Some(FrameKind::Area(segments()?)), 2 => { let range = carries.get("6").and_then(Val::nums).unwrap_or_default(); let segments = segments()?; let n = segments.len() as f64; - let start = range.first().copied().unwrap_or(0.0).clamp(0.0, n); - let end = range.get(1).copied().unwrap_or(n).clamp(start, n); + let finite = |v: Option<&f64>| v.copied().filter(|v| v.is_finite()); + let (start, end) = if closed(&segments) { + // Round a closed path the end is written unwrapped (the start plus the run, so + // past the last segment), or wrapped the other way: both brackets are places on + // the loop. + let start = finite(range.first()).unwrap_or(0.0).rem_euclid(n); + (start, finite(range.get(1)).map_or(start, |e| e.rem_euclid(n))) + } else { + let start = finite(range.first()).unwrap_or(0.0).clamp(0.0, n); + (start, finite(range.get(1)).unwrap_or(n).clamp(start, n)) + }; Some(FrameKind::OnPath { segments, start, end }) } _ => None, @@ -525,13 +539,18 @@ fn path_of(segments: &[[f64; 8]], template: (f64, f64), to_doc: Affine) -> PathD } bp.curve_to(at(*x1, *y1), at(*x2, *y2), at(*x3, *y3)); } - let closed = matches!((segments.first(), segments.last()), (Some(f), Some(l)) if (f[0] - l[6]).abs() < 1e-3 && (f[1] - l[7]).abs() < 1e-3); + let closed = closed(segments); if closed { bp.close_path(); } PathData::from_bezpath(&bp) } +/// Do `segments` end where they start (a closed path)? +fn closed(segments: &[[f64; 8]]) -> bool { + matches!((segments.first(), segments.last()), (Some(f), Some(l)) if (f[0] - l[6]).abs() < 1e-3 && (f[1] - l[7]).abs() < 1e-3) +} + /// Where on `segments` the place `t` (segment index and the place in it) is, as a fraction of their /// length. fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 { @@ -633,7 +652,9 @@ impl Story { FrameKind::Area(s) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }), FrameKind::OnPath { segments, start, end } => { let n = segments.len() as f64; - let end = (*end < n - 0.02).then(|| length_fraction(segments, *end)); + // A closed path keeps its end bracket wherever it is (the same place as the start: a + // full turn, see `TextKind::path_span`); an open one's end at its end is none. + let end = (closed(segments) || *end < n - 0.02).then(|| length_fraction(segments, *end)); (1.0, Affine::IDENTITY, TextKind::OnPath { path: path_of(segments, template, to_doc), start: length_fraction(segments, *start), end }) } }; @@ -837,6 +858,84 @@ mod tests { assert!(read(deep.as_bytes()).is_none()); } + /// A text document's frame on a path of `segments` (each `x0 y0 x1 y1 x2 y2 x3 y3`) with the + /// range `range`, written by hand. + fn on_path_frame(segments: &[[f64; 8]], range: [f64; 2]) -> Option<FrameKind> { + let nums = |v: &[f64]| v.iter().map(|n| format!("{n}")).collect::<Vec<_>>().join(" "); + let all: Vec<f64> = segments.iter().flatten().copied().collect(); + let doc = format!("/0 << /1 << /0 [ {} ] >> /2 << /0 2 /6 [ {} ] >> >>", nums(&all), nums(&range)); + let texts = Texts { + stories: vec![], + fonts: vec![], + defaults: Val::Dict(BTreeMap::new()), + para_defaults: Val::Dict(BTreeMap::new()), + frames: vec![read(doc.as_bytes()).unwrap()], + }; + texts.frame(0) + } + + /// A circle of radius 100 round the origin in four cubics, from its right, closed. + fn circle() -> Vec<[f64; 8]> { + let k = 55.228475; + vec![ + [100.0, 0.0, 100.0, k, k, 100.0, 0.0, 100.0], + [0.0, 100.0, -k, 100.0, -100.0, k, -100.0, 0.0], + [-100.0, 0.0, -100.0, -k, -k, -100.0, 0.0, -100.0], + [0.0, -100.0, k, -100.0, 100.0, -k, 100.0, 0.0], + ] + } + + /// The text object of type `text` on `frame`, and how much of its path its brackets span. + fn on_path(text: &str, frame: FrameKind) -> (TextObject, f64) { + let mut st = story(text, &[0.0], &Para::default()); + st.frames = vec![frame]; + let node = st.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY).unwrap(); + let NodeKind::Text(t) = node.kind else { panic!("not type") }; + let (a, b) = t.kind.path_span().unwrap(); + (*t, b - a) + } + + /// Round a closed path the file writes the end bracket unwrapped, past the last segment (the + /// start plus the run): the type keeps that run, nearly the whole circle here, not the bit + /// from the start to the path's end. + #[test] + fn type_on_a_closed_path_keeps_its_unwrapped_end() { + let Some(FrameKind::OnPath { start, end, .. }) = on_path_frame(&circle(), [3.86426, 7.85426]) else { panic!("on a path") }; + assert!((start - 3.86426).abs() < 1e-9 && (end - 3.85426).abs() < 1e-9, "{start} {end}"); + let (t, span) = on_path("type", on_path_frame(&circle(), [3.86426, 7.85426]).unwrap()); + assert!((span - 0.9975).abs() < 1e-3, "{span}"); + assert_eq!(t.plain_text(), "type"); + // Wrapped the other way, or below zero, the brackets are the same places. + for range in [[3.86426, 3.85426], [-0.13574, 3.85426]] { + let (_, other) = on_path("type", on_path_frame(&circle(), range).unwrap()); + assert!((other - span).abs() < 1e-6, "{range:?}: {other}"); + } + // Values that aren't numbers fall back to the start of the path, a full turn. + let Some(FrameKind::OnPath { start, end, .. }) = on_path_frame(&circle(), [f64::NAN, f64::INFINITY]) else { panic!("on a path") }; + assert!(start.is_finite() && end.is_finite()); + } + + /// A range inside the loop is kept as it is, and a run of exactly the loop is a full turn, + /// where a short line of type doesn't overflow. + #[test] + fn type_on_a_closed_path_keeps_a_range_inside_it_and_a_full_turn() { + let (_, half) = on_path("type", on_path_frame(&circle(), [0.5, 2.5]).unwrap()); + assert!((half - 0.5).abs() < 1e-3, "{half}"); + let (t, full) = on_path("type", on_path_frame(&circle(), [1.0, 5.0]).unwrap()); + assert!((full - 1.0).abs() < 1e-9, "{full}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "a short line fits a full turn"); + } + + /// An open path keeps clamping: a range past its end stops at it. + #[test] + fn type_on_an_open_path_clamps_its_range() { + let open: Vec<[f64; 8]> = circle().into_iter().take(2).collect(); + let Some(FrameKind::OnPath { start, end, .. }) = on_path_frame(&open, [0.5, 7.0]) else { panic!("on a path") }; + assert_eq!((start, end), (0.5, 2.0)); + let (_, span) = on_path("type", on_path_frame(&open, [0.5, 7.0]).unwrap()); + assert!((span - 0.75).abs() < 1e-3, "{span}"); + } + /// A point-type story of `text` whose lines start at the heights `ys` (the layout's own: where /// the paragraph spacing has put them), every paragraph styled `para`. fn story(text: &str, ys: &[f64], para: &Para) -> Story { diff --git a/crates/eps/src/import/data.rs b/crates/eps/src/import/data.rs index b1724d39c..4db791972 100644 --- a/crates/eps/src/import/data.rs +++ b/crates/eps/src/import/data.rs @@ -536,6 +536,7 @@ impl Interp<'_> { break; } Some(b'\r') => { + self.skip_lf(&f); ended = true; break; } @@ -688,6 +689,25 @@ impl Interp<'_> { }) } + /// After `readline` consumes CR, consume only an immediately following LF. PostScript + /// treats CR/LF as one line ending (PLRM §3.8); raw reads must keep every other byte. + /// The preceding `read` has already materialized any pending filter. + fn skip_lf(&mut self, f: &Rc<RefCell<Stream>>) { + match &mut *f.borrow_mut() { + Stream::Current => { + if self.lex.rest().first() == Some(&b'\n') { + self.lex.advance(1); + } + } + Stream::Buf { data, pos, .. } => { + if data.get(*pos) == Some(&b'\n') { + *pos += 1; + } + } + Stream::Pending(_) => {} + } + } + /// Up to `n` bytes read as hexadecimal digits from `f` (other characters skipped). fn read_hex(&mut self, f: &Rc<RefCell<Stream>>, n: usize) -> Res<Vec<u8>> { let mut out = Vec::with_capacity(n); @@ -711,6 +731,12 @@ impl Interp<'_> { self.push_interval(Obj::Str(s), 0, got.len()) } + /// Count `n` decoded bytes against the memory budget, and the time to decode them. + fn charge_decoded(&mut self, n: usize) -> Res { + self.alloc(n)?; + self.spend((n / 4096) as u64) + } + /// `filter`: a file that decodes its source. Over the program's own data it decodes when /// first read (the data follows the operator that reads it); over other data, at once. fn filter(&mut self) -> Res { @@ -755,6 +781,7 @@ impl Interp<'_> { let mut b = f.borrow_mut(); let Stream::Buf { data, pos, .. } = &mut *b else { return io("filter") }; let (out, used, rgb) = decode(&spec, data.get(*pos..).unwrap_or_default())?; + self.charge_decoded(out.len())?; *pos = pos.saturating_add(used); Stream::Buf { data: out, pos: 0, rgb } } @@ -763,11 +790,13 @@ impl Interp<'_> { Obj::Str(s) => { self.alloc(s.borrow().len())?; let (data, _, rgb) = decode(&spec, &s.borrow())?; + self.charge_decoded(data.len())?; Stream::Buf { data, pos: 0, rgb } } p @ Obj::Array { .. } => { let raw = self.gather(&p, MAX_DATA)?; let (data, _, rgb) = decode(&spec, &raw)?; + self.charge_decoded(data.len())?; Stream::Buf { data, pos: 0, rgb } } _ => return ps_err("typecheck", "filter"), @@ -810,13 +839,13 @@ impl Interp<'_> { None => { let raw = self.lex.data(); let (data, used, rgb) = decode(spec, raw)?; - self.alloc(data.len())?; + self.charge_decoded(data.len())?; self.lex.advance(used); (data, rgb) } Some((prev, was)) => { let (data, _, rgb) = decode(spec, &prev)?; - self.alloc(data.len())?; + self.charge_decoded(data.len())?; (data, rgb.or(was)) } }); @@ -1064,6 +1093,11 @@ impl Interp<'_> { let row = row_bits.div_ceil(8); // Several sources are read in turn, a row at a time each (zeros past their ends). let sources = if multi { &s.sources[..] } else { s.sources.get(..1).ok_or(PsError::Ps("undefined", "DataSource".into()))? }; + if sources.len() > n { + return ps_err("rangecheck", "DataSource"); + } + let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?; + self.alloc(plane.saturating_mul(sources.len()))?; let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?; for p in &mut planes { p.resize(row * s.h, 0); @@ -1191,7 +1225,7 @@ impl Interp<'_> { fn add_image(&mut self, blob: ImageBlob, w: u32, h: u32, xf: Affine, mask: Option<OpacityMask>) -> Res { let key = blob.content_key(); self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }; + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; let clips = self.g.clips.clone(); self.out.push(Node::new(NodeId(0), NodeKind::Image(Box::new(im))).with_mask(mask.map(Box::new)), &clips); Ok(()) @@ -1208,7 +1242,7 @@ impl Interp<'_> { let blob = ImageBlob::new("image/png", png); let key = blob.content_key(); self.out.doc.images.entry(key.clone()).or_insert(blob); - let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }; + let im = ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None }; Some(OpacityMask::new(Node::new(self.out.doc.alloc_id(), NodeKind::Image(Box::new(im))), true)) } } diff --git a/crates/eps/src/import/graphics.rs b/crates/eps/src/import/graphics.rs index 0ffc89f2f..5d4f7b9fa 100644 --- a/crates/eps/src/import/graphics.rs +++ b/crates/eps/src/import/graphics.rs @@ -15,7 +15,7 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, FillLayer, Lin use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; use super::interp::{Interp, MAX_GSAVE, matrix_obj}; -use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err}; +use super::obj::{DictRef, Key, Obj, Op, PsError, Res, Shared, clamp_between, ps_err}; /// Most objects one file makes. const MAX_NODES: usize = 1 << 20; @@ -1144,7 +1144,7 @@ impl Interp<'_> { } else { (GradientKind::Linear, GradientGeom { start: m * Point::new(c(0), c(1)), end: m * Point::new(c(2), c(3)), aspect: 1.0, focal: None }) }; - let mut g = GradientPaint::new(Gradient { kind: gk, stops }); + let mut g = GradientPaint::new(Gradient::new(gk, stops)); g.geom = Some(geom); Ok(Some(g)) } @@ -1192,7 +1192,7 @@ impl Interp<'_> { let domain = nums(get("Domain")); let t = x.first().copied().unwrap_or(0.0); let t = match domain.as_slice() { - [a, b, ..] => t.clamp(a.min(*b), a.max(*b)), + [a, b, ..] => clamp_between(t, *a, *b), _ => t, }; match get("FunctionType").and_then(|o| o.as_num()) { @@ -1330,11 +1330,13 @@ impl Interp<'_> { } GStateNew => { self.spend_elements(self.g.path.elements().len())?; + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; self.push(Obj::GState(Rc::new(self.g.clone())))?; } CurrentGState => { self.pop()?; self.spend_elements(self.g.path.elements().len())?; + self.alloc(std::mem::size_of_val(self.g.path.elements()))?; self.push(Obj::GState(Rc::new(self.g.clone())))?; } SetGState => match self.pop()? { diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index b60aac39d..fc8c9de37 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -567,11 +567,15 @@ impl<'a> Interp<'a> { None => { let (x, y) = (a.as_num().ok_or(PsError::Ps("typecheck", String::new()))?, b.as_num().ok_or(PsError::Ps("typecheck", String::new()))?); - Obj::Real(match op { + let r = match op { Add => x + y, Sub => x - y, _ => x * y, - }) + }; + if !r.is_finite() { + return ps_err("undefinedresult", ""); + } + Obj::Real(r) } }; self.push(r)?; @@ -947,6 +951,7 @@ impl<'a> Interp<'a> { let seek = self.pop_str()?.to_vec(); let s = self.pop_str()?; let text = s.to_vec(); + self.spend((text.len() / 64) as u64)?; let at = if op == Search { super::lex::find(&text, &seek) } else { text.starts_with(&seek).then_some(0) }; match at { Some(i) => { diff --git a/crates/eps/src/import/lex.rs b/crates/eps/src/import/lex.rs index 1d4918393..94f2b541b 100644 --- a/crates/eps/src/import/lex.rs +++ b/crates/eps/src/import/lex.rs @@ -252,7 +252,7 @@ pub(crate) fn find(hay: &[u8], needle: &[u8]) -> Option<usize> { if needle.is_empty() { return Some(0); } - hay.windows(needle.len()).position(|w| w == needle) + memchr::memmem::find(hay, needle) } /// Hexadecimal digits (whitespace ignored, an odd last digit padded with 0); `None` on others. diff --git a/crates/eps/src/import/mod.rs b/crates/eps/src/import/mod.rs index 1d300872f..5a7a9b491 100644 --- a/crates/eps/src/import/mod.rs +++ b/crates/eps/src/import/mod.rs @@ -156,17 +156,44 @@ impl Dsc { } } -/// What went wrong, for the user (and for us, reading a report): the PostScript error, the -/// operator that raised it and the procedure it ran in (`fault`). +/// The PostScript errors the reader raises and what each means, which import warnings and errors +/// show after the error's name. `undefined` is not listed: its message names the undefined name +/// and leaves the error's name out. +pub const POSTSCRIPT_ERRORS: &[(&str, &str)] = &[ + ("dictstackunderflow", "the program ends a dictionary it didn't begin"), + ("invalidfileaccess", "the program opens a file, which the reader can't do"), + ("invalidrestore", "the program restores a saved state that was already restored"), + ("ioerror", "data the program reads is damaged or cut short, or the program asks for a position in a file that has none"), + ("limitcheck", "the program goes past one of the reader's limits"), + ("nocurrentpoint", "the path has no point to continue from"), + ("rangecheck", "a value is outside the range the operator takes"), + ("stackunderflow", "the operator needs more values than the stack holds"), + ("syntaxerror", "part of the program isn't valid PostScript text"), + ("typecheck", "a value has the wrong type for the operator"), + ("undefinedfilename", "the program runs, deletes or renames a file, which the reader can't do"), + ("undefinedresource", "the program asks for a resource that isn't defined"), + ("undefinedresult", "a calculation has no result, as in a division by zero"), + ("unmatchedmark", "the operator looks for a mark on the stack and finds none"), +]; + +/// What went wrong, for the user (and for us, reading a report): the PostScript error with what +/// it means ([`POSTSCRIPT_ERRORS`]), the operator that raised it and the procedure it ran in +/// (`fault`). fn reason(e: &PsError, fault: Option<&Fault>) -> String { let what = match e { PsError::Ps("undefined", at) => format!("it uses `{at}`, which VectorCraft's PostScript reader doesn't know"), - PsError::Ps(name, at) => match fault.and_then(|f| f.op) { - Some(op) if at.is_empty() || at == op => format!("a PostScript error ({name} in `{op}`)"), - Some(op) => format!("a PostScript error ({name} in `{op}`: {at})"), - None if at.is_empty() => format!("a PostScript error ({name})"), - None => format!("a PostScript error ({name} in `{at}`)"), - }, + PsError::Ps(name, at) => { + let detail = match fault.and_then(|f| f.op) { + Some(op) if at.is_empty() || at == op => format!("{name} in `{op}`"), + Some(op) => format!("{name} in `{op}`: {at}"), + None if at.is_empty() => name.to_string(), + None => format!("{name} in `{at}`"), + }; + match POSTSCRIPT_ERRORS.iter().find(|(n, _)| n == name) { + Some((_, meaning)) => format!("a PostScript error ({detail}; {meaning})"), + None => format!("a PostScript error ({detail})"), + } + } PsError::Limit(what) => return what.to_string(), PsError::Exit | PsError::Stop => return "the program stopped with an error".into(), PsError::Quit => return "the program ended early".into(), @@ -206,6 +233,12 @@ pub fn page(bytes: &[u8]) -> Result<Rect, String> { /// [`import`], an Illustrator EPS through the editing copy of its art only when `editing_data` /// (else what its page prints, as any EPS). pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> { + import_trusting(bytes, editing_data, false) +} + +/// [`import_with`]; `trust`: an Illustrator EPS's layers are used even where they draw differently +/// from its page, with a note (`editingData: "always"`, as for a `.ai`). +pub fn import_trusting(bytes: &[u8], editing_data: bool, trust: bool) -> Result<Imported, String> { let ps = program(bytes)?; let dsc = Dsc::read(ps); // A legacy `.ai` VectorCraft wrote: its prolog prints the art plainly (gradients flat, no @@ -233,7 +266,7 @@ pub fn import_with(bytes: &[u8], editing_data: bool) -> Result<Imported, String> } let drew = !out.drawn.is_empty(); let why = match (&result, drew) { - (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out))), + (Ok(()), true) if editing_data => return Ok(native::layered(ps, finish(out), trust)), (Ok(()), true) => return Ok(finish(out)), (Ok(()), false) => "it draws nothing VectorCraft's PostScript reader can show".to_string(), (Err(e), _) => reason(e, fault.as_ref()), diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index 0d2b9e111..99164aaa8 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -234,6 +234,8 @@ fn fill_slots( let frame = ai::slot_frame(n.name.as_deref()); let state = (n.opacity, n.blend, n.isolate, n.knockout); let transparent = see_through(n); + // An art style read onto the slot (fills, strokes and effects round the characters) is the type's. + let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone()); let replacement = match content.next() { Some(Content::Page(parts)) if !parts.is_empty() => { // The page's type (and the strokes round it) take the slot's place as they are. @@ -286,6 +288,11 @@ fn fill_slots( made.push((story, frame, text.id)); } text.visible = visible; + // Type with an art style is what the page draws as the style's look: it is seen. + let seen = seen || styled.is_some(); + if let Some(appearance) = styled { + text.appearance = appearance; + } // Type the page shows is kept; other made type is weighed against the page. text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) }; let extra: Vec<Arc<Node>> = extra.iter().map(|p| reid(doc, p)).collect(); @@ -549,7 +556,7 @@ fn plan( region = path.bounds(); } TextKind::OnPath { path: on, .. } => { - *on = path.clone(); + *on = closed_where_ends_meet(path.clone()); region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5)); } TextKind::Point => {} @@ -833,6 +840,10 @@ struct PageView { page: vectorcraft_render::Rendered, /// Where the page's own type is, in its pixels. page_type: Vec<Rect>, + /// Where objects read with their art style lie, in its pixels: the page has Illustrator's + /// rendering of the style there, the layers VectorCraft's live effects, and the two aren't + /// held to look alike. + restyled: Vec<Rect>, layers: vectorcraft_render::Renderer, scale: f64, samples: u32, @@ -848,7 +859,18 @@ impl PageView { let page = comparison_render(&mut vectorcraft_render::Renderer::new(), frame.page, ra, scale, samples); let mut page_type = vec![]; type_areas(&frame.page.layers, ra, scale, &mut page_type); - Self { page, page_type, layers: vectorcraft_render::Renderer::new(), scale, samples } + Self { page, page_type, restyled: vec![], layers: vectorcraft_render::Renderer::new(), scale, samples } + } + + /// Leave the drawn looks in `looks` (on `region`, the page's area in the layers' document) out + /// of the comparisons ([`Structure::restyled`](ai::Structure)). + fn restyled(&mut self, looks: &[Rect], region: Rect) { + let s = self.scale; + self.restyled = looks + .iter() + .map(|r| r.inflate(OUTLINE_REACH, OUTLINE_REACH)) + .map(|r| Rect::new((r.x0 - region.x0) * s, (r.y0 - region.y0) * s, (r.x1 - region.x0) * s, (r.y1 - region.y0) * s)) + .collect(); } /// How `b` on `rb` (a rectangle the page's size) draws differently from the page ([`compared`]). @@ -859,7 +881,7 @@ impl PageView { let y = comparison_render(&mut self.layers, b, rb, self.scale, self.samples); let mut areas = self.page_type.clone(); type_areas(&b.layers, rb, self.scale, &mut areas); - compared(&self.page, &y, &areas) + compared(&self.page, &y, &areas, &self.restyled) } /// The share (0 to 1) of the pixels where `b` on `rb` draws another colour than the page @@ -890,19 +912,22 @@ fn difference(a: &Document, ra: Rect, b: &Document, rb: Rect) -> Difference { let mut areas = vec![]; type_areas(&a.layers, ra, scale, &mut areas); type_areas(&b.layers, rb, scale, &mut areas); - compared(&x, &y, &areas) + compared(&x, &y, &areas, &[]) } /// How two drawings of the same size differ, each drawn on nothing and seen on white, away from -/// the type in `areas` (pixels). Pixels are compared by lightness, and by whether they are inked +/// the type in `areas` (pixels), pixels in `skip` counting as alike. Pixels are compared by lightness, and by whether they are inked /// at all ([`differ`]): the page of an RGB document is written in the colours' CMYK equivalents, /// and what the comparison looks for is art that is missing or out of place. -fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect]) -> Difference { +fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered, areas: &[Rect], skip: &[Rect]) -> Difference { if x.width == 0 || x.height == 0 || x.width != y.width || x.height != y.height { return Difference { any: 1.0, art: 1.0, solid: 1.0 }; } - let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect(); let (w, h) = (x.width as usize, x.height as usize); + // Pixels in `skip` count as alike. + let skipped = in_areas(skip, w, h); + let differs: Vec<bool> = + x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).zip(&skipped).map(|((p, q), s)| !*s && differ(p, q)).collect(); let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false); let typed = in_areas(areas, w, h); let art = differs.iter().zip(&typed).filter(|(d, t)| **d && !**t).count(); @@ -1002,7 +1027,8 @@ struct Frame<'a> { fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, trust: bool, unit: f64) -> Result<(Document, Vec<String>), String> { // An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art // space, as its PDF part may (#2031). - let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, warnings } = ai::read(data, matches!(page, Page::Art))?; + let Structure { mut doc, to_doc, bbox, artboard, template, hidden_unread, slot_names, restyled, warnings } = + ai::read(data, matches!(page, Page::Art))?; // Editing data in the units of the page's user space ([`in_units`]) is compared with the page // scaled down to those units, and the document read from it is scaled up by `unit`. let board = artboard.map(|a| to_doc.transform_rect_bbox(a)); @@ -1153,6 +1179,7 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool, tr match frame { Some(frame) => { let mut view = PageView::new(&frame); + view.restyled(&restyled, frame.rect); let unseen = prune_unseen(&frame, &mut view, &mut doc); if unseen > 0 { notes.push(format!("{unseen} {UNSEEN_TEXT}")); @@ -1327,9 +1354,9 @@ fn compare(frame: &Frame<'_>, view: &mut PageView, layered: &Document) -> Result /// hidden ones too), groups, compound paths, clipping groups and artboards, with the text and the /// strokes around it of the page. When the file has no editing copy, `visible` as it was; when it /// has one that can't be used, `visible` with a warning that says why. -pub(super) fn layered(ps: &[u8], visible: Imported) -> Imported { +pub(super) fn layered(ps: &[u8], visible: Imported, trust: bool) -> Imported { let Some(data) = editing_copy(ps) else { return visible }; - let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, false, 1.0)); + let result = data.and_then(|data| build(&data, Some(&visible.document), Page::Art, false, trust, 1.0)); match result { Ok((document, mut warnings)) => { for w in &visible.warnings { @@ -1374,20 +1401,27 @@ pub fn layered_ai(private: &[u8], visible: Document, warnings: Vec<String>, outl if !has_layers(&data) { return (visible, warnings, false); } + // The PDF part, its pages placed and named as the editing data's artboards are, when the + // layers can't be used (`why`). + let from_pdf_part = |mut visible: Document, warnings: Vec<String>, why: &str| { + place_pages(&ai::artboards(&data), &mut visible, unit); + let mut all = unread(warnings, why); + all.extend(ai::variables(&data).note()); + (visible, all, false) + }; match build(&data, Some(&visible), Page::Artboard, outlined, trust, unit) { + // An image too large for the editing data's reader would be missing from the layers, while + // the PDF part has it: the file comes in as its PDF part instead (#1479). + Ok((_, notes)) if notes.iter().any(|n| n == ai::IMAGE_TOO_LARGE) => { + from_pdf_part(visible, warnings, "an image is too large to read from them") + } Ok((mut done, notes)) => { done.setup.bleed = visible.setup.bleed; let mut all = warnings; all.extend(notes.into_iter().filter(|n| !all.contains(n)).collect::<Vec<_>>()); (done, all, true) } - Err(why) => { - let mut visible = visible; - place_pages(&ai::artboards(&data), &mut visible, unit); - let mut all = unread(warnings, &why); - all.extend(ai::variables(&data).note()); - (visible, all, false) - } + Err(why) => from_pdf_part(visible, warnings, &why), } } @@ -1607,6 +1641,7 @@ mod tests { xf: Affine::scale(1.0 / 1.6), link: None, placement: Default::default(), + pixels: None, })), ); page.insert(Some(page.layers[0].id), usize::MAX, image).unwrap(); @@ -1615,7 +1650,7 @@ mod tests { let mut renderer = vectorcraft_render::Renderer::new(); let a = comparison_render(&mut renderer, &page, rect, 0.4, 1); let b = comparison_render(&mut vectorcraft_render::Renderer::new(), &vector, rect, 0.4, 1); - assert!(compared(&a, &b, &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page"); + assert!(compared(&a, &b, &[], &[]).solid > MAX_SOLID_DIFFERENCE, "the old point sampling falsely refused this page"); let frame = Frame { page: &page, page_rect: rect, rect, whole: false }; let mut view = PageView::new(&frame); assert!(compare(&frame, &mut view, &vector).is_ok()); @@ -1624,3 +1659,48 @@ mod tests { assert!(view.difference(&vector, shifted).any > MAX_DIFFERENCE, "wrong framing remains an error"); } } + +/// `path` with each open subpath whose last anchor lies on its first (within a thousandth of a +/// point) closed there: the object's own path of type round a closed path is written open, ending +/// on its start, and type on an open path stops at its end, so it had no room to run past it. +fn closed_where_ends_meet(mut path: PathData) -> PathData { + for sp in &mut path.subpaths { + let (Some(first), Some(last)) = (sp.anchors.first(), sp.anchors.last()) else { continue }; + if sp.closed || sp.anchors.len() < 3 || first.p.distance(last.p) >= 1e-3 { + continue; + } + if let Some(l) = sp.anchors.pop() + && let Some(a0) = sp.anchors.first_mut() + { + a0.h_in = l.h_in; + } + sp.closed = true; + } + path +} + +#[cfg(test)] +mod closed_ends_tests { + use super::*; + + /// A path drawn round to its start (the last anchor on the first) is closed there, its first + /// anchor taking the last one's incoming handle; one whose ends are apart stays open. + #[test] + fn a_path_ending_on_its_start_is_closed() { + let mut bp = kurbo::BezPath::new(); + bp.move_to((100.0, 0.0)); + bp.curve_to((100.0, 55.0), (55.0, 100.0), (0.0, 100.0)); + bp.curve_to((-55.0, 100.0), (-100.0, 55.0), (-100.0, 0.0)); + bp.curve_to((-100.0, -55.0), (-55.0, -100.0), (0.0, -100.0)); + bp.curve_to((55.0, -100.0), (100.0, -55.0), (100.0, 0.0001)); + let closed = closed_where_ends_meet(PathData::from_bezpath(&bp)); + let sp = &closed.subpaths[0]; + assert!(sp.closed && sp.anchors.len() == 4, "{sp:?}"); + assert_eq!(sp.anchors[0].h_in, Point::new(100.0, -55.0)); + let mut open = kurbo::BezPath::new(); + open.move_to((0.0, 0.0)); + open.line_to((50.0, 0.0)); + open.line_to((50.0, 50.0)); + assert!(!closed_where_ends_meet(PathData::from_bezpath(&open)).subpaths[0].closed); + } +} diff --git a/crates/eps/src/import/obj.rs b/crates/eps/src/import/obj.rs index f540500c4..b4dc34364 100644 --- a/crates/eps/src/import/obj.rs +++ b/crates/eps/src/import/obj.rs @@ -231,11 +231,11 @@ impl Obj { Self::Dict(DictRef::new(d)) } - /// The number this is (integers and reals). + /// The number this is (integers and finite reals: a NaN or infinite real is not a number). pub fn as_num(&self) -> Option<f64> { match *self { Self::Int(i) => Some(i as f64), - Self::Real(r) => Some(r), + Self::Real(r) if r.is_finite() => Some(r), _ => None, } } @@ -406,6 +406,13 @@ pub(crate) enum PsError { pub(crate) type Res<T = ()> = Result<T, PsError>; +/// `v` limited to the range between `a` and `b` (in either order). Unlike `f64::clamp` this never +/// panics: when one bound is NaN the range collapses to the other bound (both NaN gives `v`), and a +/// NaN `v` gives the lower bound. +pub(crate) fn clamp_between(v: f64, a: f64, b: f64) -> f64 { + v.max(a.min(b)).min(a.max(b)) +} + /// A PostScript error raised at `at`. pub(crate) fn ps_err<T>(name: &'static str, at: &str) -> Res<T> { Err(PsError::Ps(name, at.to_string())) diff --git a/crates/eps/src/import/preview.rs b/crates/eps/src/import/preview.rs index 046043c30..0e1c09750 100644 --- a/crates/eps/src/import/preview.rs +++ b/crates/eps/src/import/preview.rs @@ -48,8 +48,10 @@ fn image_doc(img: image::RgbaImage, frame: Rect) -> Option<Document> { let key = blob.content_key(); doc.images.insert(key.clone(), blob); let xf = Affine::scale_non_uniform(frame.width() / f64::from(w), frame.height() / f64::from(h)); - let mut n = - Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))); + let mut n = Node::new( + doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ); n.name = Some("Preview".into()); let layer = doc.layers.first().map(|l| l.id)?; doc.insert(Some(layer), 0, n).ok()?; diff --git a/crates/eps/src/import/shading.rs b/crates/eps/src/import/shading.rs index 2e2a523ad..c59881a98 100644 --- a/crates/eps/src/import/shading.rs +++ b/crates/eps/src/import/shading.rs @@ -12,7 +12,7 @@ use vectorcraft_geom::{Affine, Point}; use super::graphics::Space; use super::interp::{Interp, matrix_of}; -use super::obj::{DictRef, Key, Obj, PsError, Res, ps_err}; +use super::obj::{DictRef, Key, Obj, PsError, Res, clamp_between, ps_err}; /// Most patches (or triangles) one shading makes: past it, it is left out with a warning. const MAX_PATCHES: usize = 4096; @@ -348,7 +348,7 @@ impl Interp<'_> { decode.get(2 * j + 1).or(range.get(2 * j + 1)).copied().unwrap_or(1.0), ); let (r0, r1) = (range.get(2 * j).copied().unwrap_or(0.0), range.get(2 * j + 1).copied().unwrap_or(1.0)); - (d0 + raw * (d1 - d0) / max).clamp(r0.min(r1), r0.max(r1)) + clamp_between(d0 + raw * (d1 - d0) / max, r0, r1) }; // Each input into sample space. let mut pos = Vec::with_capacity(m); @@ -356,9 +356,9 @@ impl Interp<'_> { let (a, b) = (domain.get(2 * i).copied().unwrap_or(0.0), domain.get(2 * i + 1).copied().unwrap_or(1.0)); let s = size.get(i).copied().unwrap_or(1.0); let (e0, e1) = (encode.get(2 * i).copied().unwrap_or(0.0), encode.get(2 * i + 1).copied().unwrap_or(s - 1.0)); - let xi = x.get(i).copied().unwrap_or(a).clamp(a.min(b), a.max(b)); + let xi = clamp_between(x.get(i).copied().unwrap_or(a), a, b); let e = if b != a { e0 + (xi - a) * (e1 - e0) / (b - a) } else { e0 }; - pos.push(e.clamp(0.0, s - 1.0)); + pos.push(clamp_between(e, 0.0, s - 1.0)); } if let ([p], Some(s)) = (pos.as_slice(), size.first()) { let (lo, hi) = (p.floor() as usize, (p.ceil() as usize).min(*s as usize - 1)); diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 678ae993b..7a503a905 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -324,6 +324,36 @@ fn shadings_become_gradients() { assert!(near(stops[1].1.to_rgb(), blue.to_rgb()), "{stops:?}"); } +/// #3585: arithmetic that overflows to infinity or NaN is an `undefinedresult` error, so a +/// non-finite number never reaches a shading function's `Domain` (where it used to panic). +#[test] +fn non_finite_arithmetic_is_undefinedresult() { + let errs = |body: &str| { + let r = read(&format!("0 0 10 10 rectfill {body}")); + assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings); + r.warnings.iter().any(|w| w.contains("undefinedresult")) + }; + for body in [ + "1.7e308 1.7e308 add pop", + "-1.7e308 1.7e308 sub pop", + "1e300 1e300 mul pop", + "-1 ln pop", + "0 ln pop", + "-1 log pop", + "-1 sqrt pop", + "10 400 exp pop", + ] { + assert!(errs(body), "{body}"); + } + let axial = + "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [n n] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >>"; + assert!(errs(&format!("/n 1e300 1e300 mul dup sub def 0 0 100 100 rectclip {axial} shfill"))); + // A sampled function with a Size below 1 is a rangecheck, not a panic. + let sampled = "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function << /FunctionType 0 /Domain [0 1] /Range [0 1 0 1 0 1] /Size [0.5] /BitsPerSample 8 /DataSource (abc) >> >>"; + let r = read(&format!("0 0 10 10 rectfill gsave 0 0 100 100 rectclip {sampled} shfill grestore")); + assert!(!objects(&r.document).is_empty(), "{:?}", r.warnings); +} + /// Tiling patterns and spot inks are found again through a hash of what tells them apart, so a /// program that makes 200,000 patterns and 200,001 inks reads within [`read_soon`]'s 20 seconds. /// Patterns are named Pattern 1, Pattern 2 and so on in the order they are made. A pattern made @@ -568,6 +598,7 @@ fn rich_doc() -> Document { xf: Affine::new([10.0, 0.0, 0.0, 10.0, 110.0, 110.0]), link: None, placement: Default::default(), + pixels: None, }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); add(&mut d, n); @@ -633,6 +664,46 @@ pub(super) fn check(cond: &str) { blue(&format!("{cond} {{ 0 0 1 setrgbcolor }} {{ 1 0 0 setrgbcolor }} ifelse")); } +/// #3547: a line before counted metadata ends in CR/LF. Leaving LF unread shifts the +/// metadata by one byte and makes its final `>` look like PostScript instead of data. +#[test] +fn readline_keeps_counted_metadata_aligned_for_every_line_ending() { + let tiff = crate::tiff::encode(&Raster { width: 4, height: 4, rgba: [0, 128, 0, 255].repeat(16) }, false, false).unwrap(); + for eol in ["\n", "\r", "\r\n"] { + let ps = eps(&format!( + "/consume {{ currentfile 80 string readline pop pop currentfile 4 string readstring pop pop }} bind def\n\ + consume\nheader{eol}<x/>\n0 0 1 setrgbcolor 10 20 30 40 rectfill" + )); + for bytes in [ps.clone(), crate::dos_eps(&ps, &tiff).unwrap()] { + let r = import(&bytes).unwrap(); + assert!(!r.preview, "valid art must not become the low-resolution TIFF preview"); + assert!(r.warnings.is_empty(), "{eol:?}: {:?}", r.warnings); + let art = objects(&r.document); + assert_eq!(art.len(), 1); + assert!(matches!(art[0].kind, NodeKind::Path { .. })); + assert_eq!(fill(&art[0]), Some(&Paint::solid(Color::rgb(0.0, 0.0, 1.0)))); + } + } +} + +/// CR/LF is one terminator in buffered and decoded streams too; lone CR never consumes +/// the next payload byte, EOF is safe, and ordinary `readstring` preserves binary CR/LF. +#[test] +fn readline_streams_preserve_the_next_payload_byte() { + for eol in ["\n", "\r", "\r\n"] { + let bytes = format!("header{eol}XY"); + let hex: String = bytes.bytes().map(|b| format!("{b:02x}")).collect(); + for source in [format!("<{hex}> /ReusableStreamDecode filter"), format!("({hex}>) /ASCIIHexDecode filter")] { + check(&format!( + "/f {source} def f 32 string readline exch (header) eq and \ + f 2 string readstring exch (XY) eq and and" + )); + } + } + check("/f <6865616465720d> /ReusableStreamDecode filter def f 32 string readline exch (header) eq and f read not and"); + check("/f <0d0a5859> /ReusableStreamDecode filter def f 4 string readstring exch <0d0a5859> eq and"); +} + #[test] fn the_generic_category_defines_new_categories() { // #234: the reference app's prolog. @@ -795,16 +866,24 @@ fn hostile_executable_files_and_type_loops_are_refused() { /// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes /// more than 20 s. fn read_soon(body: &str) -> crate::Imported { + read_within(body, 20) +} + +/// Reads `body` as [`read`] does, on a thread of its own, and fails the test when reading takes +/// more than `secs` seconds. +fn read_within(body: &str, secs: u64) -> crate::Imported { let (tx, rx) = std::sync::mpsc::channel(); let ps = eps(body); std::thread::spawn(move || tx.send(import(&ps)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() + rx.recv_timeout(std::time::Duration::from_secs(secs)).unwrap_or_else(|_| panic!("reading {body} did not finish")).unwrap() } -/// Reads `body` after a square through [`read_soon`]: the square stays, and the program runs out -/// of the operation budget. +/// Reads `body` after a square: the square stays, and the program runs out of the operation +/// budget. Running to the end of the budget can take over 20 s on a slow CI runner +/// (`search_in_a_loop_runs_out_of_the_budget` searches about 3 GB, 14 s on a fast desktop), so the +/// limit here only catches a program that never stops. fn runs_out_of_budget(body: &str) { - let r = read_soon(&format!("0 0 10 10 rectfill {body}")); + let r = read_within(&format!("0 0 10 10 rectfill {body}"), 120); assert_eq!(objects(&r.document).len(), 1, "{body}: {:?}", r.warnings); assert!(r.warnings.iter().any(|w| w.contains("the program runs too long")), "{body}: {:?}", r.warnings); } @@ -829,7 +908,7 @@ fn paths_made_in_a_loop_run_out_of_the_budget() { /// A loop of operators that read or copy the whole current path or clip path runs out of the /// operation budget (PLRM 3rd ed., chapter 8): `pathbbox`, `clip`, `clippath`, `shfill` (which /// reads the clip path for the area it paints), `pathforall` (which copies the path before it runs -/// a procedure), `gstate`, `currentgstate` and `setgstate`. +/// a procedure) and `setgstate`. `gstate` and `currentgstate` run out of memory first. #[test] fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() { // 10,001 elements reaching 10,000,000 points to the right: what `shfill` paints in a clip of @@ -843,8 +922,6 @@ fn paths_read_or_copied_in_a_loop_run_out_of_the_budget() { "clip newpath { clippath } loop", "clip newpath { sh shfill } loop", "{ { pop pop exit } { } { } { } pathforall } loop", - "{ gstate pop } loop", - "/g gstate def { g currentgstate pop } loop", "/g gstate def { g setgstate } loop", ] { runs_out_of_budget(&format!("{sh} {path} {body}")); @@ -1052,3 +1129,54 @@ fn a_damaged_palette_preview_is_refused_without_panicking() { assert!(e.contains("can't be read"), "{e}"); } } + +/// #3582: `search` in a loop on multi-megabyte strings, with a needle half as +/// long as the string that differs in its last byte, finish quickly and run out of the operation +/// budget. +#[test] +fn search_in_a_loop_runs_out_of_the_budget() { + let make = "/h 4194304 string def /n 2097152 string def n 2097152 1 sub 1 put"; + runs_out_of_budget(&format!("{make} {{ h n search {{ pop pop pop }} {{ pop }} ifelse }} loop")); +} + +/// #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() { + let sources = "<80> ".repeat(64); + let e = import(&eps(&format!( + "/DeviceGray setcolorspace << /ImageType 1 /Width 64 /Height 64 /BitsPerComponent 8 /MultipleDataSources true \ + /ImageMatrix [64 0 0 64 0 0] /DataSource [{sources}] >> image" + ))) + .unwrap_err(); + assert!(e.contains("rangecheck") && e.contains("DataSource"), "{e}"); +} + +/// #3568: every data source's plane is charged against the memory budget, not only the RGBA +/// output: two large four-source images together go over it. +#[test] +fn the_planes_of_multi_source_images_are_charged() { + let image = "/DeviceCMYK setcolorspace << /ImageType 1 /Width 8192 /Height 8192 /BitsPerComponent 8 \ + /MultipleDataSources true /ImageMatrix [8192 0 0 8192 0 0] /DataSource [<80> <80> <80> <80>] >> image\n"; + let r = import(&eps(&image.repeat(2))).unwrap(); + assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings); +} + +/// #3568: a single sample plane over the data limit is refused. +#[test] +fn an_oversized_image_plane_is_refused() { + let e = import(&eps("/DeviceCMYK setcolorspace << /ImageType 1 /Width 8192 /Height 8192 /BitsPerComponent 16 \ + /ImageMatrix [8192 0 0 8192 0 0] /DataSource <80> >> image")) + .unwrap_err(); + assert!(e.contains("too large"), "{e}"); +} + +/// Graphics states kept with `gstate` or `currentgstate` each hold a copy of the current path, and +/// the copies count against the memory budget as `gsave` does. +#[test] +fn stored_graphics_states_use_up_the_memory_budget() { + let path = "newpath 0 0 moveto 0 1 1000000 { pop 1 0 rlineto } for"; + for body in ["30 { gstate } repeat", "30 { 0 currentgstate } repeat"] { + let r = read_soon(&format!("0 0 10 10 rectfill {path} {body}")); + assert!(r.warnings.iter().any(|w| w.contains("uses too much memory")), "{body}: {:?}", r.warnings); + } +} diff --git a/crates/eps/src/import/tests_generators.rs b/crates/eps/src/import/tests_generators.rs index c7584df9c..892bcf417 100644 --- a/crates/eps/src/import/tests_generators.rs +++ b/crates/eps/src/import/tests_generators.rs @@ -733,13 +733,62 @@ fn masked_images_in_every_interleaving() { #[test] fn errors_name_the_operator_and_the_procedures() { let r = read("{ 1 (a) add } stopped pop 0 0 10 10 rectfill /inner { 1 (a) add } def /outer { inner } def outer"); - assert!(r.warnings[0].contains("(typecheck in `add`), in `inner` in `outer`"), "{:?}", r.warnings); + assert!(r.warnings[0].contains("(typecheck in `add`; a value has the wrong type for the operator), in `inner` in `outer`"), "{:?}", r.warnings); let r = read("0 0 10 10 rectfill /p { frobnicate } def p"); assert!(r.warnings[0].contains("it uses `frobnicate`, which VectorCraft's PostScript reader doesn't know, in `p`"), "{:?}", r.warnings); let r = read("0 0 10 10 rectfill 0 0 moveto (x) cvn moveto"); assert!(r.warnings[0].contains("typecheck in `moveto`: a number"), "{:?}", r.warnings); } +/// The warning for a file read only up to an error says what the error's name means in the +/// reader, after the name, which stays as PostScript spells it. +#[test] +fn errors_say_what_the_postscript_error_means() { + for (program, want) in [ + ("1 (a) add", "(typecheck in `add`; a value has the wrong type for the operator)"), + ("[1 2] 5 get", "(rangecheck in `get`; a value is outside the range the operator takes)"), + ("add", "(stackunderflow in `add`; the operator needs more values than the stack holds)"), + ("1 0 div", "(undefinedresult in `div`; a calculation has no result, as in a division by zero)"), + ("newpath 10 10 lineto", "(nocurrentpoint in `lineto`; the path has no point to continue from)"), + ("cleartomark", "(unmatchedmark in `cleartomark`; the operator looks for a mark on the stack and finds none)"), + ("save dup restore restore", "(invalidrestore in `restore`; the program restores a saved state that was already restored)"), + ("(x) (r) file", "(invalidfileaccess in `file`; the program opens a file, which the reader can't do)"), + ("(x) run", "(undefinedfilename in `run`; the program runs, deletes or renames a file, which the reader can't do)"), + ("/Nope /ProcSet findresource", "(undefinedresource in `findresource`: Nope; the program asks for a resource that isn't defined)"), + ("<12", "(syntaxerror in `<`; part of the program isn't valid PostScript text)"), + ] { + let r = read(&format!("0 0 10 10 rectfill {program}")); + assert!(r.warnings[0].contains(want), "{program}: {:?}", r.warnings); + } +} + +/// Every PostScript error the reader raises has a meaning in [`crate::POSTSCRIPT_ERRORS`] but +/// `undefined`, which the warning words on its own. +#[test] +fn every_postscript_error_the_reader_raises_has_a_meaning() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src").join("import"); + let mut raised = std::collections::BTreeSet::new(); + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default(); + if !name.ends_with(".rs") || name.starts_with("tests") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + for marker in ["Ps(\"", "ps_err(\""] { + for (at, _) in text.match_indices(marker) { + let rest = &text[at + marker.len()..]; + raised.insert(rest[..rest.find('"').unwrap()].to_string()); + } + } + } + assert!(raised.len() >= 15 && raised.contains("typecheck"), "{raised:?}"); + let missing: Vec<_> = raised.iter().filter(|n| *n != "undefined" && !crate::POSTSCRIPT_ERRORS.iter().any(|(e, _)| e == n)).collect(); + assert!(missing.is_empty(), "no meaning for {missing:?}"); + let unused: Vec<_> = crate::POSTSCRIPT_ERRORS.iter().filter(|(e, _)| !raised.contains(*e)).collect(); + assert!(unused.is_empty(), "never raised: {unused:?}"); +} + /// Operators programs use to probe the interpreter or measure things: file `status`, `token`, /// `bytesavailable`, `pathforall`, `strokepath`, `nulldevice`, `currenthsbcolor`, the cache and /// device parameter queries. @@ -1054,3 +1103,19 @@ fn restore_puts_dictionaries_back() { // take, is positive (chapter 8), and programs divide by it. super::tests::check("cachestatus 7 1 roll 6 { pop } repeat 0 gt"); } + +/// #3567: what a `filter` decodes counts against the memory budget, not just its input. A +/// small string that inflates to 16 MiB, decoded over and over (each result dropped), ends +/// the program; a single one does not. +#[test] +fn filter_output_counts_against_the_memory_budget() { + fn hex(data: &[u8]) -> String { + data.iter().map(|b| format!("{b:02x}")).collect() + } + let z = hex(&deflate(&vec![0u8; 16 << 20])); + let once = import(&file("t", &format!("/z <{z}> def z /FlateDecode filter pop 0 0 1 1 rectfill"))).unwrap(); + assert!(once.warnings.iter().all(|w| !w.contains("memory")), "{:?}", once.warnings); + let many = import(&file("t", &format!("/z <{z}> def 100 {{ z /FlateDecode filter pop }} repeat 0 0 1 1 rectfill"))); + let Err(err) = many else { panic!("the budget ends the program") }; + assert!(err.contains("too much memory"), "{err}"); +} diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index c0cd26c31..4087b2fd9 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -243,6 +243,29 @@ fn in_points(mut d: Document, k: f64) -> Document { d } +/// #1479: an image larger than the editing data's reader takes would be missing from the layers, +/// while the PDF part has it (it reads larger images): the file comes in as its PDF part, with a +/// note saying why, rather than without the image. +#[test] +fn an_ai_file_with_an_image_too_large_for_its_editing_data_comes_in_as_its_pdf_part() { + let raster = |w: u32, h: u32| { + format!( + "%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceGray XN\n[ 1 0 0 1 0 100 ] {w} {h} 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 {w} {h} {w} {h} 8 1 0 0 1 0 4 4 0 0\n%%BeginData: 1\rXI\n\u{0}%%EndData\r\nXH\r\n%AI5_EndRaster\r\nN" + ) + }; + // 6,000 × 6,000 pixels: past the reader's 2^25. + let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(6000, 6000)))); + let (d, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(!read, "the PDF part is used: {warnings:?}"); + assert!(warnings.iter().any(|w| w.contains("an image is too large to read from them")), "{warnings:?}"); + assert_eq!(d.layers[0].children().map_or(0, |c| c.len()), 1, "the PDF part's art"); + // An image the reader takes keeps the layers. + let art = native(&layer("Texture", true, &format!("{}\n{}", square(10, 10), raster(1, 1)))); + let (_, warnings, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, true, 1.0); + assert!(read, "{warnings:?}"); +} + #[test] fn an_ai_file_gets_its_layers_and_the_art_outside_its_artboard() { // A square on the artboard, and one above it (the artboard is y 0 to 100 here). @@ -1332,3 +1355,101 @@ fn raster_print_page_recovers_legacy_mesh_vector_mask_and_editable_curve() { let r = import(wrong.as_bytes()).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("for a 100 × 100 pt page, not the file's 120 × 100 pt page")), "{:?}", r.warnings); } + +#[test] +fn envelope_options_keep_the_source_text_hidden_under_its_outlined_appearance() { + let doc = text_document(&[("CHONKYCATS\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let slot: String = text_object(0).lines().map(|line| format!("%_{line}\n")).collect(); + let envelope = + format!("%_/ArtDictionary :\n%_1 /Int (AIDeformOptionsKey) ,\n%_198 /Real (AIDeformArtHeightKey) ,\n%_X=\n%_0 Ae\n%_u\n{slot}%_U\n%_;\n"); + let art = native_with_text(&layer("Art", true, &format!("{}\n{envelope}", square(10, 10))), &doc); + let r = import(&eps(&page_square(10, 10), &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + assert_eq!(kinds(&r.document.layers[0]), ["path", "text"], "{:?}", r.warnings); + let mut texts = vec![]; + r.document.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push((t.plain_text(), n.visible)); + } + }); + assert_eq!(texts, [("CHONKYCATS".to_string(), false)], "{:?}", r.warnings); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); +} + +/// An art style section with style `Comic`: a white fill offset and repeated by Transform under a +/// red fill with its own blend mode, the characters at the bottom. +fn comic_style() -> String { + let basic = |name: &str, body: &str| { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") + }; + let fill = |paint: &str| basic("Fill Style Filter", &format!("/FillStyle : 0 O\n{paint}\n0 1 0 0 0 Xy\n0 1 Xd\n/Def ;\n (FillStyle) ,\n")); + let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let rim = compound( + "Chain Style Filter", + &[ + basic("Adobe Offset Path", "3 /Real (ofst) ,\n0 /Int (jntp) ,\n"), + fill("0 0 0 0 1 1 1 Xa"), + basic("Adobe Transform", "6 /Int (numCopies) ,\n1 /Real (moveH_Pts) ,\n"), + ], + ); + let red = compound( + "Chain Style Filter", + &[fill("0 1 1 0 1 0 0 Xa"), basic("Blend Style Filter", "/BlendStyle : 1 0.5 0 0 0 Xy\n/Def ;\n (BlendStyle) ,\n")], + ); + let stack = compound("Stack Style Filter", &[red, rim, basic("Conduit Filter", "")]); + format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Comic) /Name ,\n/ActiveStyle :\n{}%AI9_EndArtStyles\n", compound("Chain Style Filter", &[stack])) +} + +#[test] +fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() { + // GUARDS: a style of more than one fill (or with effects on a fill) left the type as its drawn look, + // outlines that can't be retyped (comic lettering made with Illustrator's Appearance panel). + use vectorcraft_color::BlendMode; + use vectorcraft_doc::AppearanceItem; + let doc = text_document(&[("Bang\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + // The look: the style's drawing round where the type lies (its anchor is at (58.5, 61.5)). + let drawing = "0 0 1 0 k\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + let look = format!("u\n/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n{drawing}\nU\n9 () XW\n"); + let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); + let art = native_with_text(&(comic_style() + &layer("Art", true, &format!("{look}{own}1 (Comic) XW\n"))), &doc); + let check = |d: &Document, notes: &[String]| { + let mut texts = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + texts.push((t.plain_text(), n.appearance.clone())); + } + }); + let [(text, a)] = texts.as_slice() else { panic!("{texts:?} {notes:?}") }; + assert_eq!(text, "Bang"); + // Bottom first: the white rim with its effects, then the red fill at half opacity, multiplied. + let [AppearanceItem::Fill(rim), AppearanceItem::Fill(red)] = a.items.as_slice() else { panic!("{a:?}") }; + assert_eq!(rim.effects.iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["path.offsetPath", "distort.transform"]); + assert_eq!((red.blend, red.opacity), (BlendMode::Multiply, 0.5)); + assert!(!notes.iter().any(|w| w.contains("drawn look")), "{notes:?}"); + }; + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + check(&d, ¬es); + // From an EPS whose page draws Illustrator's rendering of the style (here, its look's rectangle): + // the layers are used all the same, the style's area not being held to look like the page. + let page = "0 0 1 0 setcmykcolor 54 56 moveto 100 56 lineto 100 80 lineto 54 80 lineto closepath fill"; + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Art"], "{:?}", r.warnings); + check(&r.document, &r.warnings); +} + +#[test] +fn point_type_without_a_matrix_is_placed_by_its_layout() { + // GUARDS: Illustrator 24 leaves an unturned, unscaled point type's matrix out of its frame; such + // stories weren't read, and the type was left out. + let with = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let without = with.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << >>"); + assert_ne!(with, without); + let place = |doc: &str| { + let art = native_with_text(&layer("Spare", false, &text_object(0)), doc); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + let texts = text_of(&d.layers[0]); + assert_eq!(texts.len(), 1, "{notes:?}"); + (texts[0].plain_text(), texts[0].xf) + }; + assert_eq!(place(&without), place(&with)); +} diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 6b400ab05..01293bbf9 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -14,8 +14,8 @@ //! `%AI5_ArtSize` and an empty `%AI3_TemplateBox` at its centre, which puts the ruler origin at //! its bottom-left corner (§2.2), `%AI5_NumLayers`. //! - Setup (§3): `n Bn`, then each gradient: `%AI5_BeginGradient: (name)`, `(name) type n Bd`, -//! `[`, its stops last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, -//! `%AI5_EndGradient`. +//! `[`, its stops (`colorSpec colorStyle midPoint rampPoint %_Bs`), listed from the last for a +//! linear gradient and listed from the first for a radial one, `BD`, `%AI5_EndGradient`. //! - Layers (§9): `%AI5_BeginLayer`, `visible preview enabled printing dimmed 0 colorIndex r g b //! Lb`, `(name) Ln`, the art, `LB`, `%AI5_EndLayer--`. //! - Groups `u` … `U`, clipping groups `q` … `Q` with the clipping path painted `h W n` (§5.6), @@ -64,8 +64,8 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageObject, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, - Node, NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, + AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node, + NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2}; @@ -83,7 +83,7 @@ const HEX_LINE: usize = 60; pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines"; -pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs) are left out"; +pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted lists) are left out"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; @@ -98,7 +98,10 @@ const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, gradients, brushes, arrowheads) are written as filled outlines"; const IMAGE_ALPHA: &str = "transparent image pixels are written over white"; const LIVE: &str = "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art"; -const UNREADABLE_IMAGE: &str = "an image that can't be decoded or is too large was left out"; +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; +const LARGE_IMAGE: &str = "an image that is empty or too large for legacy .ai was left out"; const ELLIPTICAL: &str = "elliptical radial gradients are written circular"; /// What to write in the header, and how to write what the format can't hold. @@ -338,12 +341,20 @@ end const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops -/// last first (`colorSpec colorStyle midPoint rampPoint %_Bs`), `BD`, `%AI5_EndGradient`. +/// (`colorSpec colorStyle midPoint rampPoint %_Bs`, each with its midpoint to the next stop), `BD`, +/// `%AI5_EndGradient`. A linear gradient's stops are listed from the last to the first (§1.4.3). A +/// radial gradient's stops are listed from the first to the last, as in §3.5.2's radial +/// definition (10, then 100). fn gradient_def(s: &mut String, w: &Writer, name: &str, g: &Gradient) { let name = latin_string(name); - let kind = if g.kind == GradientKind::Radial { 1 } else { 0 }; + let radial = g.kind == GradientKind::Radial; + let kind = if radial { 1 } else { 0 }; let _ = writeln!(s, "%AI5_BeginGradient: {name}\n{name} {kind} {} Bd\n[", g.stops.len()); - for stop in g.stops.iter().rev() { + let mut stops: Vec<_> = g.stops.iter().collect(); + if !radial { + stops.reverse(); + } + for stop in stops { let paint = Paint::Solid { color: stop.color, swatch: stop.swatch.clone(), tint: stop.tint }; match w.ink(&paint) { Ink::Gray(v) => { @@ -667,7 +678,7 @@ impl<'a> Writer<'a> { } NodeKind::Image(im) => { self.lock(locked); - self.image(im); + self.image(self.doc.image_blob(im), im.width, im.height, im.xf); } NodeKind::SymbolInstance { symbol, xf } => { self.warn(LIVE); @@ -1137,6 +1148,12 @@ impl<'a> Writer<'a> { if lost_attributes(t) { self.warn(TYPE_ATTRIBUTES); } + // Area type that Shrink Text to Fit scales down is written in the styles it draws in. + let fitted = matches!((&t.kind, t.area.fit), (TextKind::Area { .. }, vectorcraft_doc::AreaFit::ShrinkText { .. })) + .then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale) + .filter(|f| *f != 1.0) + .map(|f| vectorcraft_text::scaled(t, f)); + let t = fitted.as_ref().unwrap_or(t); let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); let (kind, path, start) = match &t.kind { TextKind::Point => (0, None, 0.0), @@ -1271,6 +1288,16 @@ impl<'a> Writer<'a> { n.appearance = vectorcraft_doc::Appearance { items, ..vectorcraft_doc::Appearance::default() }.into(); self.shape(&n, &path, FillRule::NonZero); } + // Colour bitmap glyphs (emoji): their images, which the runs' paint doesn't paint. + for g in &layout.glyphs { + if let Some((image, to_text)) = g.placed_image() { + self.image(Some(&image.blob), image.width, image.height, t.xf * to_text); + } + // COLR glyphs: their layers, as art. + if let Some(art) = g.placed_colour(t.xf) { + self.node(&art, false); + } + } self.line("U"); } @@ -1281,25 +1308,30 @@ impl<'a> Writer<'a> { /// operands with `XI`, its RGB samples in hex on comment lines of [`HEX_LINE`] digits, /// `%AI5_EndRaster`, then `F`. §8.4's examples have no `F`; without it, an app reading the file /// can leave out an image in a clipping group and the art after it. No path is open there, so - /// the `F` paints nothing. - fn image(&mut self, im: &ImageObject) { + /// the `F` paints nothing. `blob` holds the pixels, placed as a grid of `width` × `height` + /// by `xf`. + fn image(&mut self, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine) { let flip = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); - let m = self.to_art * im.xf * flip; + let m = self.to_art * xf * flip; if !invertible(m) { return; } - let Some(img) = self.doc.images.get(&im.key).and_then(|b| image::load_from_memory(&b.bytes).ok()) else { - self.warn(UNREADABLE_IMAGE); + if blob.is_some_and(|b| b.bytes.is_empty()) { + self.warn(LARGE_IMAGE); + return; + } + let Some(img) = blob.and_then(|b| image::load_from_memory(&b.bytes).ok()) else { + self.warn(UNDECODED); return; }; let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS { - self.warn(UNREADABLE_IMAGE); + self.warn(LARGE_IMAGE); return; } // The object places the image's own pixel grid (its width × height) wherever the file's is. - let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(w), f64::from(im.height.max(1)) / f64::from(h)); - let m = self.to_art * im.xf * fit * flip; + let fit = Affine::scale_non_uniform(f64::from(width.max(1)) / f64::from(w), f64::from(height.max(1)) / f64::from(h)); + let m = self.to_art * xf * fit * flip; let rgba = img.to_rgba8(); let mut hex = String::with_capacity(rgba.len() / 4 * 7); hex.push('%'); @@ -1364,7 +1396,7 @@ fn lost_attributes(t: &TextObject) -> bool { || !s.features.is_empty() || !s.font_axes.is_empty() }; - let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty(); + let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty() || p.list.is_some(); t.runs.iter().any(|r| run_lost(&r.style)) || t.paragraph_styles().iter().any(para_lost) || (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default()) diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index 7e25903d3..59926fe18 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -37,7 +37,7 @@ mod ps; mod scene; mod tiff; -pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai, page}; +pub use import::{Imported, POSTSCRIPT_ERRORS, ai_alone, family_style, import, import_trusting, import_with, is_loss, layered_ai, page}; pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai}; pub use print::{PrintJob, PrintPage, print}; diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index b00c1fbab..3f4b1049f 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -33,7 +33,14 @@ pub(crate) fn num(v: f64) -> String { /// Append [`num`]`(v)` to `s`. pub(crate) fn push_num(s: &mut String, v: f64) { - let v = if v.is_finite() { (v * 10_000.0).round() / 10_000.0 } else { 0.0 }; + // Beyond ~1.8e304 the scaling overflows; such values have no fractional digits to round. + let v = if !v.is_finite() { + 0.0 + } else if (v * 10_000.0).is_finite() { + (v * 10_000.0).round() / 10_000.0 + } else { + v + }; if v == v.trunc() && v.abs() < 1e15 { let _ = write!(s, "{}", v as i64); return; @@ -180,6 +187,10 @@ pub(crate) fn ascii85(bytes: &[u8]) -> String { } raw.push_str("~>"); let mut out = String::with_capacity(raw.len() + raw.len() / LINE + 2); + if raw.starts_with('%') { + // Whitespace is ignored by readers, and keeps the first line from starting with `%`. + out.push(' '); + } let mut col = 0; for ch in raw.chars() { if col >= LINE && ch != '%' { @@ -277,3 +288,24 @@ pub(crate) fn inflate(data: &[u8], max: u64) -> Option<Vec<u8>> { flate2::read::ZlibDecoder::new(data).take(max + 1).read_to_end(&mut out).ok()?; (out.len() as u64 <= max).then_some(out) } + +#[cfg(test)] +mod tests { + use super::{ascii85, ascii85_decode, num}; + + #[test] + fn ascii85_first_line_never_starts_with_percent() { + let data = [0x0C, 0x80, 0, 0, 1, 2, 3, 4]; + let s = ascii85(&data); + assert!(s.lines().all(|l| !l.starts_with('%')), "got {s:?}"); + assert_eq!(ascii85_decode(&s).as_deref(), Some(&data[..])); + } + + #[test] + fn huge_finite_numbers_are_valid_postscript() { + for v in [1.5e305, -1.5e305, f64::MAX, f64::MIN] { + let s = num(v); + assert!(!s.is_empty() && s.chars().all(|c| c.is_ascii_digit() || c == '-' || c == '.'), "{v}: got {s:?}"); + } + } +} diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index d11cae6f0..acbd58849 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use kurbo::Shape; use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ - AppearanceItem, ColorMode, Document, Effect, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, + AppearanceItem, ColorMode, Document, Effect, ImageBlob, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, SymbolBoxes, TextObject, }; use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2}; @@ -33,6 +33,10 @@ const TRANSPARENCY: &str = const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out: EPS has no transparency, flatten them first"; const IMAGE_ALPHA_L2: &str = "transparent image pixels are written white at PostScript Level 2 (Level 3 masks them out)"; const IMAGE_PARTLY: &str = "partly transparent image pixels are written over white"; +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; +const LARGE_IMAGE: &str = "an image that is too large for EPS was left out"; const SPOT_GRADIENTS: &str = "gradients of spot colours are written in process colours"; const MISSING_PATTERN: &str = "missing patterns are written as mid-grey"; @@ -284,7 +288,7 @@ impl<'a> Scene<'a> { self.shape(n, &bp, *rule); } NodeKind::Text(t) => self.text(n, t), - NodeKind::Image(im) => self.image(im), + NodeKind::Image(im) => self.image(&im.key, self.doc.image_blob(im), im.width, im.height, im.xf), NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST @@ -582,6 +586,15 @@ impl<'a> Scene<'a> { let mut bp = BezPath::new(); for g in layout.glyphs.iter().filter(|g| g.run == i) { bp.extend(g.outline.iter()); + // Colour bitmap glyphs (emoji): their images, which the run's paint doesn't paint. + if let Some((image, to_text)) = g.placed_image() { + let key = format!("\u{0}glyph\u{0}{}", image.blob.content_key()); + self.image(&key, Some(&image.blob), image.width, image.height, to_text); + } + // COLR glyphs: their layers, as art (in text space, as the images). + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(&art, true); + } } if bp.elements().is_empty() { continue; @@ -836,18 +849,18 @@ impl<'a> Scene<'a> { } } - /// A placed image: its pixel grid stretched over its `width` × `height` (each image file - /// encoded once). - fn image(&mut self, im: &ImageObject) { - let place = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)), f64::from(im.height.max(1))); + /// A placed image (`blob`, cached under `key`): its pixel grid of `width` × `height` placed + /// by `xf` (each image file encoded once). + fn image(&mut self, key: &str, blob: Option<&ImageBlob>, width: u32, height: u32, xf: Affine) { + let place = xf * Affine::scale_non_uniform(f64::from(width.max(1)), f64::from(height.max(1))); if !invertible(place) { return; } - let encoded = match self.images.get(&im.key) { + let encoded = match self.images.get(key) { Some(e) => e.clone(), None => { - let e = self.decode_image(&im.key).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new); - self.images.insert(im.key.clone(), e.clone()); + let e = self.decode_image(blob).and_then(|img| self.encode_image(img.as_raw(), img.width(), img.height())).map(Arc::new); + self.images.insert(key.to_string(), e.clone()); e } }; @@ -856,14 +869,14 @@ impl<'a> Scene<'a> { } } - /// Image `key` as RGBA pixels; `None` (with a warning) when it can't be written. - fn decode_image(&mut self, key: &str) -> Option<image::RgbaImage> { - let Some(img) = self.doc.images.get(key).and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else { - self.warn(&format!("image '{key}' could not be decoded and was left out")); + /// Image `blob` as RGBA pixels; `None` (with a warning) when it can't be written. + fn decode_image(&mut self, blob: Option<&ImageBlob>) -> Option<image::RgbaImage> { + let Some(img) = blob.and_then(|blob| image::load_from_memory(&blob.bytes).ok()) else { + self.warn(UNDECODED); return None; }; if u64::from(img.width()) * u64::from(img.height()) > MAX_IMAGE_PIXELS { - self.warn(&format!("image '{key}' is too large for EPS and was left out")); + self.warn(LARGE_IMAGE); return None; } Some(img.to_rgba8()) diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index 4cab46284..73ee2890a 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -150,7 +150,7 @@ fn gradient_doc(kind: GradientKind) -> Document { GradientStop::new(0.4, Color::rgb(1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)), ]; - let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))); doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(20.0, 20.0, 180.0, 120.0)), paint, Paint::None, 0.0)]) } @@ -268,6 +268,7 @@ fn png_doc() -> Document { xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0), link: None, placement: Default::default(), + pixels: None, }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); let layer = d.layers[0].id; @@ -499,3 +500,39 @@ fn a_rich_document_is_well_formed_at_both_levels() { } } } + +/// A colour bitmap glyph (emoji) is written as an image where it sits; the type's fill doesn't +/// paint it. +#[test] +fn colour_bitmap_glyphs_are_images() { + use vectorcraft_doc::{CharStyle, TextObject}; + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let d = doc_with(|d| { + let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 32.0, ..CharStyle::default() }; + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 100.0), &COLOUR_CHAR.to_string(), st))))] + }); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + checked_page(&ps); + assert!(ps.contains("/Width 16 /Height 16"), "{ps}"); + // One em (32 pt) square, its top 24 pt above the baseline (12 of 16 px), 4 pt right of the + // origin (2 px), in text space. + assert!(ps.contains("[32 0 0 32 4 -24] cm"), "{ps}"); +} + +#[test] +fn colr_glyphs_are_vector_art() { + use vectorcraft_doc::{CharStyle, TextObject}; + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font}; + vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap()); + let d = doc_with(|d| { + let fill = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 1.0)); + let st = CharStyle { font_family: colr_family(false).into(), size: 32.0, fill, ..CharStyle::default() }; + vec![Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 100.0), &COLOUR_CHAR.to_string(), st))))] + }); + let ps = text(&export(&d, &opts(&d), None, None).unwrap()); + checked_page(&ps); + assert!(!ps.contains("/Width"), "no image: {ps}"); + assert!(ps.contains("1 0 0 rg"), "the red layer: {ps}"); + assert!(ps.contains("0 0 1 rg"), "the foreground layer in the fill: {ps}"); +} diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index 3644576d6..2fa1483a4 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -48,10 +48,10 @@ fn sample() -> Document { let clipped = shape(&mut doc, shapes::rectangle(Rect::new(150.0, 10.0, 190.0, 50.0)), Paint::solid(Color::gray(0.25)), Paint::None); let window = Node::new(doc.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(clipped)], clip: true }); doc.insert(Some(back), usize::MAX, window).unwrap(); - let g = Gradient { - kind: GradientKind::Linear, - stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], - }; + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 0.0))], + ); let shaded = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 60.0, 50.0, 90.0)), Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None); doc.insert(Some(back), usize::MAX, shaded).unwrap(); let ellipse = shape(&mut doc, shapes::ellipse(Rect::new(100.0, 60.0, 160.0, 90.0)), red(), Paint::None); @@ -62,8 +62,15 @@ fn sample() -> Document { let blob = ImageBlob::new("image/png", png); let key = blob.content_key(); doc.images.insert(key.clone(), blob); - let im = - ImageObject { key, width: 2, height: 1, xf: Affine::new([10.0, 0.0, 0.0, 10.0, 170.0, 70.0]), link: None, placement: Default::default() }; + let im = ImageObject { + key, + width: 2, + height: 1, + xf: Affine::new([10.0, 0.0, 0.0, 10.0, 170.0, 70.0]), + link: None, + placement: Default::default(), + pixels: None, + }; let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); doc.insert(Some(front), usize::MAX, im).unwrap(); if let Some(l) = doc.layers.get_mut(1) { @@ -92,20 +99,44 @@ fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput { /// hidden objects shown when `o` writes them visible, and its gradient fills and strokes painted /// in their first stop's color. fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { - // A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type). - fn outlined(t: &vectorcraft_doc::TextObject) -> Node { + // A run's glyphs, laid out, as one path with the run's fill (the renderer draws no type), and + // its colour bitmap glyphs as images (their pixels added to `images`), its COLR glyphs as their + // layers. + fn outlined(t: &vectorcraft_doc::TextObject, images: &mut std::collections::BTreeMap<String, ImageBlob>) -> Node { let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let runs = t.runs.iter().enumerate().map(|(i, run)| { - let mut bp = kurbo::BezPath::new(); - for g in layout.glyphs.iter().filter(|g| g.run == i) { - bp.extend(g.outline.iter()); - } - let path = PathData::from_bezpath(&bp).transformed(t.xf); - Arc::new(Node::path(vectorcraft_doc::NodeId(0), path, Appearance::basic(run.style.fill.clone(), Paint::None, 0.0))) - }); - Node::group(vectorcraft_doc::NodeId(0), runs.collect()) + let mut nodes: Vec<Arc<Node>> = t + .runs + .iter() + .enumerate() + .map(|(i, run)| { + let mut bp = kurbo::BezPath::new(); + for g in layout.glyphs.iter().filter(|g| g.run == i) { + bp.extend(g.outline.iter()); + } + let path = PathData::from_bezpath(&bp).transformed(t.xf); + Arc::new(Node::path(vectorcraft_doc::NodeId(0), path, Appearance::basic(run.style.fill.clone(), Paint::None, 0.0))) + }) + .collect(); + for g in &layout.glyphs { + let Some((image, to_text)) = g.placed_image() else { continue }; + let key = image.blob.content_key(); + images.insert(key.clone(), image.blob.clone()); + let im = ImageObject { + key, + width: image.width, + height: image.height, + xf: t.xf * to_text, + link: None, + placement: Default::default(), + pixels: None, + }; + nodes.push(Arc::new(Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))))); + } + // COLR glyphs: their layers. + nodes.extend(layout.glyphs.iter().filter_map(|g| g.placed_colour(t.xf)).map(Arc::new)); + Node::group(vectorcraft_doc::NodeId(0), nodes) } - fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions) { + fn plain(nodes: &mut Vec<Arc<Node>>, o: &LegacyAiOptions, images: &mut std::collections::BTreeMap<String, ImageBlob>) { nodes.retain(|n| match &n.kind { NodeKind::Text(t) => o.outline_text || !writable(t), NodeKind::Layer { printable, .. } => *printable, @@ -113,7 +144,7 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { }); for n in nodes { if let NodeKind::Text(t) = &n.kind { - *n = Arc::new(outlined(t)); + *n = Arc::new(outlined(t, images)); } let n = Arc::make_mut(n); if o.hidden == HiddenObjects::Visible && !matches!(n.kind, NodeKind::Layer { .. }) { @@ -129,12 +160,14 @@ fn as_printed(doc: &Document, o: &LegacyAiOptions) -> Document { } } if let Some(c) = n.children_mut() { - plain(c, o); + plain(c, o, images); } } } let mut d = doc.clone(); - plain(&mut d.layers, o); + let mut images = std::mem::take(&mut d.images); + plain(&mut d.layers, o, &mut images); + d.images = images; d } @@ -246,8 +279,10 @@ fn a_rotated_image_keeps_its_place() { doc.images.insert(key.clone(), blob); // Turned 90° clockwise on the page: the pixel rows run down, the next row is to the left. let xf = Affine::new([0.0, 10.0, -10.0, 0.0, 100.0, 40.0]); - let im = - Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: 2, height: 1, xf, link: None, placement: Default::default() }))); + let im = Node::new( + doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key, width: 2, height: 1, xf, link: None, placement: Default::default(), pixels: None })), + ); doc.layers.clear(); let layer = doc.add_layer(Some("Art")); doc.insert(Some(layer), usize::MAX, im).unwrap(); @@ -279,7 +314,10 @@ fn picture(doc: &mut Document, r: Rect) -> Node { let key = blob.content_key(); doc.images.insert(key.clone(), blob); let xf = Affine::new([r.width() / 16.0, 0.0, 0.0, r.height() / 16.0, r.x0, r.y0]); - Node::new(doc.alloc_id(), NodeKind::Image(Box::new(ImageObject { key, width: 16, height: 16, xf, link: None, placement: Default::default() }))) + Node::new( + doc.alloc_id(), + NodeKind::Image(Box::new(ImageObject { key, width: 16, height: 16, xf, link: None, placement: Default::default(), pixels: None })), + ) } /// A layer `Below` holding a clipping group (a circle clipping an image, as in #2692), a group @@ -382,13 +420,35 @@ fn images_are_written_in_the_form_the_specification_shows() { assert!(rgb.iter().zip([247.0, 148.0, 29.0]).all(|(c, want)| (c * 255.0 - want).abs() < 0.1), "{rgb:?}"); } +/// An image whose data is empty is left out with the note for empty or too large images, and an +/// image whose data can't be decoded is left out with the decode note. +#[test] +fn empty_images_get_the_size_note_and_undecodable_ones_the_decode_note() { + let notes = |bytes: Vec<u8>| { + let mut doc = Document::new(100.0, 100.0); + doc.layers.clear(); + let layer = doc.add_layer(Some("Images")); + let blob = ImageBlob::new("image/png", bytes); + let key = blob.content_key(); + doc.images.insert(key.clone(), blob); + let im = ImageObject { key, width: 4, height: 2, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; + let node = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); + doc.insert(Some(layer), usize::MAX, node).unwrap(); + let out = export_legacy_ai(&doc, &LegacyAiOptions::default()).unwrap(); + assert!(rasters(&String::from_utf8(out.bytes).unwrap()).is_empty()); + out.warnings + }; + assert_eq!(notes(vec![]), [LARGE_IMAGE]); + assert_eq!(notes(b"not an image".to_vec()), [UNDECODED]); +} + #[test] fn a_gradient_fill_and_its_stroke_are_one_object() { let mut doc = Document::new(100.0, 100.0); - let g = Gradient { - kind: GradientKind::Radial, - stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))], - }; + let g = Gradient::new( + GradientKind::Radial, + vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 1.0))], + ); let mut paint = GradientPaint::new(g); paint.geom = Some(vectorcraft_color::GradientGeom { start: vectorcraft_geom::Point::new(30.0, 30.0), @@ -413,7 +473,7 @@ fn a_gradient_fill_and_its_stroke_are_one_object() { /// to the object). fn white_to_navy(kind: GradientKind, geom: Option<vectorcraft_color::GradientGeom>) -> Paint { let navy = Color::rgb(27.0 / 255.0, 42.0 / 255.0, 107.0 / 255.0); - let g = Gradient { kind, stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, navy)] }; + let g = Gradient::new(kind, vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 1.0)), GradientStop::new(1.0, navy)]); Paint::Gradient(Box::new(GradientPaint { geom, ..GradientPaint::new(g) })) } @@ -462,11 +522,10 @@ fn radials() -> Document { } /// Every radial gradient instance has its highlight `Bh` (§3.5.5): `0 0 0 0 Bh` when it is at the -/// center (#2687: without it the reference app drew the circle in its outer color), on fills, -/// strokes, in groups, compound paths and clipping groups, and an off-center one within the -/// gradient's circle. Linear instances have none. VectorCraft reads back one linear and at least -/// seven radial gradients, each with its stops, and the circle, the square and the off-center -/// square in their place. +/// center (§3.5.6), on fills, strokes, in groups, compound paths and clipping groups, and an +/// off-center one within the gradient's circle. Linear instances have none. VectorCraft reads +/// back one linear and at least seven radial gradients, each with its stops, and the circle, the +/// square and the off-center square in their place. #[test] fn radial_gradients_have_their_highlight() { let out = write(&radials()); @@ -540,6 +599,66 @@ fn radial_gradients_have_their_highlight() { assert!(near(off.end, 320.0, 240.0) && off.focal.is_some_and(|f| near(f, 300.0, 220.0)), "{off:?}"); } +/// A radial gradient's stops are listed from the first to the last, as §3.5.2's radial definition +/// lists them (10, then 100), and a linear gradient's stops are listed from the last to the first +/// (§1.4.3). Each stop keeps its color and its midpoint to the next stop. The reference app read a +/// radial gradient's stops listed from the last as one color (two stops) or inverted (three +/// stops), reported on #2807. Both gradients read back with their stops. +#[test] +fn radial_gradient_stops_are_listed_from_the_first() { + let white = Color::rgb(1.0, 1.0, 1.0); + let red = Color::rgb(1.0, 0.0, 0.0); + let navy = Color::rgb(27.0 / 255.0, 42.0 / 255.0, 107.0 / 255.0); + let stop = |offset: f32, color: Color, midpoint: f32| GradientStop { midpoint, ..GradientStop::new(offset, color) }; + let stops = vec![stop(0.0, white, 0.3), stop(0.3, red, 0.7), stop(1.0, navy, 0.5)]; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for (kind, x) in [(GradientKind::Radial, 10.0), (GradientKind::Linear, 110.0)] { + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops.clone())))); + let n = shape(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 80.0, 90.0)), paint, Paint::None); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = write(&doc); + let text = String::from_utf8(out.bytes.clone()).unwrap(); + // Each `%_Bs` line of a definition: its RGB color (`c m y k r g b 2`), midpoint and ramp point. + let listed = |head: &str| -> Vec<([f64; 3], f64, f64)> { + let def = text.split(head).nth(1).and_then(|t| t.split("BD\n").next()).unwrap_or_else(|| panic!("no {head:?} in\n{text}")); + def.lines() + .map(|l| { + let n: Vec<f64> = l.trim_end_matches(" %_Bs").split_whitespace().map(|v| v.parse().unwrap()).collect(); + let [_, _, _, _, r, g, b, style, mid, ramp] = n.as_slice() else { panic!("{l}") }; + assert_eq!(*style, 2.0, "{l}"); + ([*r, *g, *b], *mid, *ramp) + }) + .collect() + }; + let rgb = |c: Color| c.to_rgb().map(f64::from); + let first_first = [(rgb(white), 30.0, 0.0), (rgb(red), 70.0, 30.0), (rgb(navy), 50.0, 100.0)]; + let mut last_first = first_first; + last_first.reverse(); + let same = |got: &[([f64; 3], f64, f64)], want: &[([f64; 3], f64, f64)]| { + got.len() == want.len() + && got.iter().zip(want).all(|((c, m, r), (wc, wm, wr))| { + c.iter().zip(wc).all(|(a, b)| (a - b).abs() < 1e-3) && (m - wm).abs() < 1e-3 && (r - wr).abs() < 1e-3 + }) + }; + let radial = listed("(Gradient) 1 3 Bd\n[\n"); + assert!(same(&radial, &first_first), "{radial:?}\n{text}"); + let linear = listed("(Gradient 2) 0 3 Bd\n[\n"); + assert!(same(&linear, &last_first), "{linear:?}\n{text}"); + // Read back: both gradients, each stop at its offset with its color and midpoint. + let doc = crate::import(&out.bytes).unwrap().document; + let read: Vec<_> = children(&doc.layers[0]) + .iter() + .filter_map(|n| if let Paint::Gradient(g) = n.appearance.fill_paint() { Some(g.gradient.clone()) } else { None }) + .collect(); + assert_eq!(read.iter().map(|g| g.kind).collect::<Vec<_>>(), [GradientKind::Radial, GradientKind::Linear], "{read:#?}"); + for g in &read { + let got: Vec<_> = g.stops.iter().map(|s| (rgb(s.color), f64::from(s.midpoint) * 100.0, f64::from(s.offset) * 100.0)).collect(); + assert!(same(&got, &first_first), "{g:#?}"); + } +} + #[test] fn reads_back_with_layers_groups_and_paths() { let out = write(&sample()); @@ -634,7 +753,7 @@ fn spots_and_gradient(spots: &[&str], gradient: &str) -> Document { } let mut first = GradientStop::new(0.0, Color::cmyk(0.0, 0.5, 1.0, 0.0)); first.swatch = Some(spots[0].into()); - let g = Gradient { kind: GradientKind::Linear, stops: vec![first, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))] }; + let g = Gradient::new(GradientKind::Linear, vec![first, GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))]); let mut paint = GradientPaint::new(g); paint.swatch = Some(gradient.into()); let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None); @@ -701,10 +820,10 @@ fn spot_gradient_and_title_names_are_written_in_windows_1252() { fn names_outside_windows_1252_are_replaced_and_kept_apart() { // 赤 and 青 (spot colors), 渐变 and グラ (gradients), 日本 (the title). let mut doc = spots_and_gradient(&["\u{8d64}", "\u{9752}"], "\u{6e10}\u{53d8}"); - let g = Gradient { - kind: GradientKind::Linear, - stops: vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))], - }; + let g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 0.0, 0.0))], + ); let mut paint = GradientPaint::new(g); paint.swatch = Some("\u{30b0}\u{30e9}".into()); let n = shape(&mut doc, shapes::rectangle(Rect::new(60.0, 50.0, 100.0, 90.0)), Paint::Gradient(Box::new(paint)), Paint::None); @@ -898,6 +1017,21 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// A legacy `.ai` paragraph has no list: type with a bulleted list is written as its text, with the +/// warning that the bullets are left out. +#[test] +fn bulleted_lists_are_warned_as_left_out() { + let mut doc = typed(); + let id = texts(&doc)[0].0.id; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Square)); + t.normalize_paras(); + } + let out = write(&doc); + assert!(out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES && w.contains("bulleted lists")), "{:?}", out.warnings); + assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); +} + #[test] fn type_reads_back_as_text() { use vectorcraft_doc::{Justify, TextKind}; @@ -934,6 +1068,34 @@ fn type_reads_back_as_text() { assert!((b.x0 - 150.0).abs() < 1e-3 && (b.y1 - 190.0).abs() < 1e-3, "{b:?}"); } +/// Area type that Shrink Text to Fit scales down is written at the sizes, leading and baseline +/// shifts it draws at, so it reads back fitting its frame. +#[test] +fn shrunk_area_type_is_written_at_the_size_it_draws_at() { + use vectorcraft_doc::{TextKind, TextObject, TextRun}; + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut area = TextObject::point(vectorcraft_geom::Point::ZERO, "Area type that is too long ", style(14.0, Paint::solid(Color::BLACK))); + let raised = vectorcraft_doc::CharStyle { baseline_shift: 3.0, leading: Some(20.0), ..style(14.0, red()) }; + area.runs.push(TextRun::new("for its frame shrinks", raised)); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 40.0)) }; + area.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 }; + area.xf = Affine::translate((20.0, 80.0)); + let fit = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &area).fit_scale; + assert!(fit < 0.9, "the text is shrunk to fit: {fit}"); + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(area))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let imported = crate::import(&write(&doc).bytes).unwrap(); + let found = texts(&imported.document); + let (_, back) = found.first().expect("the area type reads back"); + let sizes: Vec<(f64, f64, Option<f64>)> = back.runs.iter().map(|r| (r.style.size, r.style.baseline_shift, r.style.leading)).collect(); + assert_eq!(sizes.len(), 2, "{sizes:?}"); + assert!((sizes[0].0 - 14.0 * fit).abs() < 1e-3 && (sizes[1].0 - 14.0 * fit).abs() < 1e-3, "{sizes:?} at {fit}"); + assert!((sizes[1].1 - 3.0 * fit).abs() < 1e-3, "{sizes:?} at {fit}"); + assert!(sizes[1].2.is_some_and(|l| (l - 20.0 * fit).abs() < 1e-3), "{sizes:?} at {fit}"); + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).overflow, "it fits its frame as written"); +} + #[test] fn type_8_bit_text_cant_hold_and_type_asked_for_as_outlines_are_outlines() { use vectorcraft_doc::TextObject; @@ -1032,3 +1194,39 @@ fn layer_names_outside_latin_1_are_replaced_and_warned() { // A name Latin-1 can hold is written as it is, with no warning. assert!(!layered("Caf\u{e9}").warnings.iter().any(|w| w == LAYER_NAME)); } + +/// Type written as outlines writes its colour bitmap glyphs (emoji) as images where they sit. +#[test] +fn colour_bitmap_glyphs_of_outlined_type_are_images() { + use vectorcraft_doc::{CharStyle, TextObject}; + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font}; + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut doc = Document::new(200.0, 100.0); + let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 32.0, ..CharStyle::default() }; + // Cyrillic, which 8-bit text can't hold: the type is written as outlines. + let t = TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), &format!("{COLOUR_CHAR}\u{416}"), st); + let l = doc.layers[0].id; + let id = doc.alloc_id(); + doc.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let text = String::from_utf8(write(&doc).bytes).unwrap(); + let images = rasters(&text); + assert_eq!(images.len(), 1, "{text}"); + assert!(images[0][2].ends_with(" ] 0 0 16 16 16 16 8 3 0 0 0 0 XI"), "{:?}", images[0]); +} + +#[test] +fn colr_glyphs_of_outlined_type_are_vector_art() { + use vectorcraft_doc::{CharStyle, TextObject}; + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colr_family, colr_font}; + vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap()); + let mut doc = Document::new(200.0, 100.0); + let st = CharStyle { font_family: colr_family(false).into(), size: 32.0, ..CharStyle::default() }; + // Cyrillic, which 8-bit text can't hold: the type is written as outlines. + let t = TextObject::point(vectorcraft_geom::Point::new(20.0, 60.0), &format!("{COLOUR_CHAR}\u{416}"), st); + let l = doc.layers[0].id; + let id = doc.alloc_id(); + doc.insert(Some(l), 0, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let text = String::from_utf8(write(&doc).bytes).unwrap(); + assert!(rasters(&text).is_empty(), "no images: {text}"); + assert!(text.contains("1 0 0 Xa"), "the red layer: {text}"); +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 1317a3f45..832db19a6 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -40,6 +40,7 @@ mod unpack; use std::borrow::Cow; use std::collections::{BTreeMap, HashSet}; use std::io::{Read as _, Write}; +use std::sync::Arc; use serde::{Deserialize, Serialize}; use vectorcraft_doc::{Document, ImageBlob}; @@ -534,15 +535,20 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> { } // The bytes after the JSON, read in one pass: the images' first, then the profiles'. let entries: Vec<[u64; 2]> = f.images.values().chain(f.profiles.values()).filter_map(|i| i.blob).collect(); - let mut tail = file.entries(&entries)?.into_iter(); + let tail = file.entries(&entries)?; + // Entries naming the same bytes share them; a profile's own copy of shared bytes counts + // against the data's size, so copies never add up to more than the file has (#3574). + let mut distinct = HashSet::new(); + let mut spare: u64 = tail.iter().flatten().filter(|b| distinct.insert(Arc::as_ptr(b))).map(|b| b.len() as u64).sum(); + let mut tail = tail.into_iter(); let mut bytes_of = |img: &Image| match img.blob { Some(_) => tail.next().flatten(), - None => img.inline(), + None => img.inline().map(Arc::new), }; let mut doc = f.document; for (k, img) in f.images { let bytes = bytes_of(&img).ok_or_else(|| FormatError::BadImage(k.clone()))?; - let mut blob = ImageBlob::new(img.mime, bytes); + let mut blob = ImageBlob { mime: img.mime, bytes, proxy: None }; // Until the linked file is read, the preview stands in for it. if img.proxy { blob.proxy = Some(blob.bytes.clone()); @@ -567,13 +573,26 @@ pub fn load_file(bytes: &[u8]) -> Result<NativeFile, FormatError> { // Untrusted: the type's sizes, leading, baseline shifts and scales, its indents and paragraph // spacing, and its tab stops, each within its range (after the depth limit: the walk recurses). doc.keep_type_values_in_range(); + // Untrusted: ruby ranges over the type's text, clamped to it (after the depth limit too). + doc.fix_ruby(); // Untrusted: the raster effects resolution, within 1 to 2400 ppi (72 when it isn't finite). doc.raster_effects_ppi = Some(doc.raster_effects_ppi).filter(|v| v.is_finite()).unwrap_or(72.0).clamp(1.0, 2400.0); // Untrusted: the superscript, subscript and small caps proportions of the type, the graph // labels and the character styles, within the ranges `document.setup` takes (after the depth // limit: the walk recurses). doc.keep_scripts_in_range(); - let profiles = f.profiles.into_iter().filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))).collect(); + let profiles = f + .profiles + .into_iter() + .filter_map(|(name, icc)| Some((name, bytes_of(&icc)?))) + .map(|(name, icc)| match Arc::try_unwrap(icc) { + Ok(icc) => Ok((name, icc)), + Err(icc) => { + spare = spare.checked_sub(icc.len() as u64).ok_or(FormatError::TooBig)?; + Ok((name, icc.to_vec())) + } + }) + .collect::<Result<_, FormatError>>()?; Ok(NativeFile { doc, depth_notes, info: FileInfo { version: f.version, legacy: f.format == LEGACY_EXTENSION }, profiles }) } @@ -591,7 +610,7 @@ fn head_member(bytes: &[u8], pick: impl FnOnce(Head) -> Option<Image>) -> Option let head: Head = serde_json::from_slice(file.json()).ok()?; let img = pick(head)?; match img.blob { - Some(at) => file.entries(&[at]).ok()?.pop().flatten(), + Some(at) => file.entries(&[at]).ok()?.pop().flatten().map(Arc::unwrap_or_clone), None => img.inline(), } } @@ -706,7 +725,7 @@ mod tests { d.images.insert("k".into(), ImageBlob::new("image/png", vec![1, 2, 3, 250])); // Only images in use are saved. let img = d.alloc_id(); - let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: "k".into(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 1, Node::new(img, NodeKind::Image(Box::new(im)))).unwrap(); let bytes = save(&d, true); assert!(sniff(&bytes)); diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index 6e9af01d4..9aa3c81b2 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -3,7 +3,9 @@ //! the file is never held unpacked as a whole. Limits apply per part ([`MAX_JSON`], [`MAX_BLOB`], //! [`MAX_BLOBS`]), guarding against decompression bombs. +use std::collections::HashMap; use std::io::{self, Read}; +use std::sync::Arc; use flate2::read::GzDecoder; @@ -18,6 +20,9 @@ fn damaged(e: impl std::fmt::Display) -> FormatError { FormatError::NotVectorcraft(format!("damaged compressed data: {e}")) } +/// An entry's bytes (`None`: the data ends before it), shared by the entries naming them. +pub(crate) type Entry = Option<Arc<Vec<u8>>>; + /// A file opened for reading: its JSON, and where the data after it is. pub(crate) struct Unpacked<'a> { json: Json<'a>, @@ -116,27 +121,43 @@ impl<'a> Unpacked<'a> { } /// The entries `[offset, length]` of the data after the JSON → each one's bytes, `None` for - /// one that isn't there (the data ends before it). Errors: the compressed data is damaged, or - /// the entries of a compressed file are larger than [`MAX_BLOB`] each or [`MAX_BLOBS`] - /// together, or those of an uncompressed file larger together than both [`MAX_BLOBS`] and the - /// data (entries sharing bytes, only in hand-made files, would copy them once each). Entries - /// are read in the order of their offsets, each straight into its own buffer. - pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result<Vec<Option<Vec<u8>>>, FormatError> { + /// one that isn't there (the data ends before it). Entries naming the same bytes (only in + /// hand-made files) share one buffer. Errors: the compressed data is damaged, or the entries + /// of a compressed file are larger than [`MAX_BLOB`] each or [`MAX_BLOBS`] together, or those + /// of an uncompressed file larger together than both [`MAX_BLOBS`] and the data, or the + /// distinct entries found larger together than the data they are in (overlapping entries + /// would copy the same bytes once each, #3574). Entries are read in the order of their + /// offsets, each straight into its own buffer. + pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result<Vec<Entry>, FormatError> { self.entries_within(wanted, MAX_WORK) } /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. - fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result<Vec<Option<Vec<u8>>>, FormatError> { - let mut out: Vec<Option<Vec<u8>>> = vec![None; wanted.len()]; - let in_memory = |tail: &[u8], out: &mut Vec<Option<Vec<u8>>>| { - for (slot, &[offset, len]) in out.iter_mut().zip(wanted) { - *slot = range(offset, len).and_then(|(a, b)| tail.get(a..b)).map(<[u8]>::to_vec); + fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result<Vec<Entry>, FormatError> { + let mut out: Vec<Entry> = vec![None; wanted.len()]; + // The entries read so far, by `[offset, length]`, and the bytes copied for them. + let mut done: HashMap<[u64; 2], Entry> = HashMap::new(); + let mut copied = 0u64; + let mut in_memory = |tail: &[u8], out: &mut Vec<Entry>| { + for (slot, &w) in out.iter_mut().zip(wanted) { + if let Some(e) = done.get(&w) { + *slot = e.clone(); + continue; + } + let bytes = range(w[0], w[1]).and_then(|(a, b)| tail.get(a..b)); + copied = copied.saturating_add(bytes.map_or(0, |b| b.len() as u64)); + if copied > tail.len() as u64 { + return Err(FormatError::TooBig); + } + *slot = bytes.map(|b| Arc::new(b.to_vec())); + done.insert(w, slot.clone()); } + Ok(()) }; match self.tail { Tail::Slice(tail) => { within(wanted, u64::MAX, MAX_BLOBS.max(tail.len() as u64))?; - in_memory(tail, &mut out); + in_memory(tail, &mut out)?; } Tail::Whole(at) => { within(wanted, MAX_BLOB, MAX_BLOBS)?; @@ -144,20 +165,31 @@ impl<'a> Unpacked<'a> { Json::Whole { buf, .. } => buf.as_slice(), _ => &[], }; - in_memory(buf.get(at..).unwrap_or_default(), &mut out); + in_memory(buf.get(at..).unwrap_or_default(), &mut out)?; } - Tail::None => in_memory(&[], &mut out), + Tail::None => in_memory(&[], &mut out)?, Tail::Stream { file, at, read, rest } => { within(wanted, MAX_BLOB, MAX_BLOBS)?; drop(self.json); let mut order: Vec<usize> = (0..wanted.len()).collect(); order.sort_by_key(|&i| wanted.get(i).map_or(0, |w| w[0])); let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at, left: work }; + // How far into the data the stream has been: the bytes copied stay within it. + let mut seen = 0u64; for i in order { - let Some(&[offset, len]) = wanted.get(i) else { continue }; - if let Some(slot) = out.get_mut(i) { - *slot = s.entry(offset, len)?; + let Some(&w) = wanted.get(i) else { continue }; + let Some(slot) = out.get_mut(i) else { continue }; + if let Some(e) = done.get(&w) { + *slot = e.clone(); + continue; } + *slot = s.entry(w[0], w[1])?.map(Arc::new); + copied = copied.saturating_add(slot.as_ref().map_or(0, |e| e.len() as u64)); + seen = seen.max(s.pos); + if copied > seen { + return Err(FormatError::TooBig); + } + done.insert(w, slot.clone()); } // To the end, so the stream's checksum is verified as before. s.left = MAX_BLOBS; @@ -278,17 +310,17 @@ mod tests { }; // Entries one after the other: the tail is unpacked once. let parts = read(&[[0, 1000], [1000, 3096]], 4096).unwrap(); - assert_eq!(parts, vec![Some(tail[..1000].to_vec()), Some(tail[1000..].to_vec())]); + assert_eq!(parts, vec![Some(Arc::new(tail[..1000].to_vec())), Some(Arc::new(tail[1000..].to_vec()))]); // One byte at the far end is the whole tail's work. - assert_eq!(read(&[[4095, 1]], 4096).unwrap(), vec![Some(vec![tail[4095]])]); + assert_eq!(read(&[[4095, 1]], 4096).unwrap(), vec![Some(Arc::new(vec![tail[4095]]))]); assert!(matches!(read(&[[4095, 1]], 4000), Err(FormatError::TooBig))); // Entries over the same bytes start the stream again each time: counted, then refused. - let same = vec![[4000, 96]; 50]; + let same: Vec<[u64; 2]> = (0..50).map(|i| [4000, 1 + i]).collect(); assert!(read(&same, 50 * (4096 + 64)).is_ok()); assert!(matches!(read(&same, 3 * 4096), Err(FormatError::TooBig))); // An offset past the data is still just missing, and the default budget reads all of it. assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); - assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(tail.clone())]); + assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(Arc::new(tail.clone()))]); } #[test] @@ -303,8 +335,61 @@ mod tests { assert!(matches!(Unpacked::new(&whole).unwrap().entries(&[[0, MAX_BLOB + 1]]), Err(FormatError::TooBig))); // Uncompressed files keep no per-entry limit: a length the data doesn't have is just missing. assert_eq!(Unpacked::new(b"{}").unwrap().entries(&[[0, MAX_BLOB + 1]]).unwrap(), vec![None]); - // Within the limits, shared bytes are still read once per entry. + // Within the limits, entries naming the same bytes share them. let file = b"{}\n\0VCBLOBS\0abcd"; - assert_eq!(Unpacked::new(file).unwrap().entries(&[[0, 4], [0, 4]]).unwrap(), vec![Some(b"abcd".to_vec()), Some(b"abcd".to_vec())]); + let shared = Unpacked::new(file).unwrap().entries(&[[0, 4], [0, 4]]).unwrap(); + assert_eq!(shared, vec![Some(Arc::new(b"abcd".to_vec())), Some(Arc::new(b"abcd".to_vec()))]); + assert!(Arc::ptr_eq(shared[0].as_ref().unwrap(), shared[1].as_ref().unwrap())); + } + + /// The bytes held for `entries`, each buffer counted once however many entries share it. + macro_rules! stored { + ($entries:expr) => {{ + let mut bufs = std::collections::HashSet::new(); + $entries.iter().flatten().filter(|e| bufs.insert(e.as_ptr())).map(|e| e.len()).sum::<usize>() + }}; + } + + /// #3574: entries naming the same bytes, or overlapping ones, would copy them once each, far + /// past the data the file has: they share one buffer or are refused, in memory and streamed. + #[test] + fn entries_sharing_bytes_hold_no_more_than_the_data() { + let tail: Vec<u8> = (0..65536u32).map(|i| (i % 251) as u8).collect(); + let n = tail.len() as u64; + let same = vec![[0, n]; 50]; + let overlapping: Vec<[u64; 2]> = (0..50).map(|i| [i, n - i]).collect(); + let with = |json: &[u8]| { + let mut file = json.to_vec(); + file.extend(crate::BLOB_MAGIC); + file.extend(&tail); + file + }; + let plain = with(b"{}"); + // Compressed, unpacked whole (the marker isn't right after a `}`), and streamed. + let whole = gzip(&with(b"1")); + let streamed = gzip(&plain); + for file in [&plain, &whole, &streamed] { + let u = Unpacked::new(file).unwrap(); + let kind = match u.tail { + Tail::Slice(_) => "in place", + Tail::Whole(_) => "whole", + Tail::Stream { .. } => "streamed", + Tail::None => "no data", + }; + match u.entries(&same) { + Ok(e) => { + assert!(stored!(e) <= tail.len(), "{kind}: {} bytes held for {} of data", stored!(e), tail.len()); + assert!(e.iter().all(|e| e.as_deref().map(|b| &b[..]) == Some(&tail[..])), "{kind}"); + } + Err(e) => assert!(matches!(e, FormatError::TooBig), "{kind}: {e}"), + } + match Unpacked::new(file).unwrap().entries(&overlapping) { + Ok(e) => assert!(stored!(e) <= tail.len(), "{kind}: {} bytes held for {} of data", stored!(e), tail.len()), + Err(e) => assert!(matches!(e, FormatError::TooBig), "{kind}: {e}"), + } + // One entry over all of it is fine. + let one = Unpacked::new(file).unwrap().entries(&same[..1]).unwrap(); + assert_eq!(one[0].as_deref().map(|b| &b[..]), Some(&tail[..]), "{kind}"); + } } } diff --git a/crates/format/tests/links.rs b/crates/format/tests/it/links.rs similarity index 91% rename from crates/format/tests/links.rs rename to crates/format/tests/it/links.rs index 8b8fff3ac..e5a789f0d 100644 --- a/crates/format/tests/links.rs +++ b/crates/format/tests/it/links.rs @@ -1,7 +1,5 @@ //! Linked images in `.vectorcraft` files: their link details round-trip, and an image only linked //! images show is saved as its preview. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::sync::Arc; @@ -12,7 +10,7 @@ use vectorcraft_geom::Affine; fn image(d: &mut Document, key: &str, link: Option<LinkInfo>) { let (id, layer) = (d.alloc_id(), d.layers[0].id); - let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: 600, height: 300, xf: Affine::IDENTITY, link, placement: Default::default(), pixels: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); } diff --git a/crates/format/tests/it/main.rs b/crates/format/tests/it/main.rs new file mode 100644 index 000000000..a5f028c38 --- /dev/null +++ b/crates/format/tests/it/main.rs @@ -0,0 +1,9 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod links; +mod native_v3; +mod native_v4; +mod save_options; +mod streaming; diff --git a/crates/format/tests/native_v3.rs b/crates/format/tests/it/native_v3.rs similarity index 98% rename from crates/format/tests/native_v3.rs rename to crates/format/tests/it/native_v3.rs index fcbc43d32..33633de5c 100644 --- a/crates/format/tests/native_v3.rs +++ b/crates/format/tests/it/native_v3.rs @@ -1,7 +1,5 @@ //! `.vectorcraft` v3: compression, pruning unused images, keys from newer versions, colour //! profiles as a document field, saving for older versions and the embedded preview. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::io::Write as _; use std::sync::Arc; @@ -42,6 +40,7 @@ fn image_node(d: &mut Document, key: &str) -> Node { xf: Affine::IDENTITY, link: None, placement: Default::default(), + pixels: None, })), ) } diff --git a/crates/format/tests/native_v4.rs b/crates/format/tests/it/native_v4.rs similarity index 95% rename from crates/format/tests/native_v4.rs rename to crates/format/tests/it/native_v4.rs index a8071fa43..c24231651 100644 --- a/crates/format/tests/native_v4.rs +++ b/crates/format/tests/it/native_v4.rs @@ -1,7 +1,5 @@ //! `.vectorcraft` v4: large data after the JSON (images, the PDF, profiles), small files still plain //! JSON, older versions inline, damaged offsets refused. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::sync::Arc; @@ -17,7 +15,7 @@ fn doc_with_image(key: &str, len: usize) -> Document { d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let l = d.layers[0].id; let id = d.alloc_id(); - let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/format/tests/save_options.rs b/crates/format/tests/it/save_options.rs similarity index 97% rename from crates/format/tests/save_options.rs rename to crates/format/tests/it/save_options.rs index ede14a41d..41b91a9c5 100644 --- a/crates/format/tests/save_options.rs +++ b/crates/format/tests/it/save_options.rs @@ -1,8 +1,6 @@ //! The native save options of the file itself: Include Linked Files, embedded ICC profiles and the //! PDF a PDF-compatible file carries, each round-tripping (and ignored by readers that don't //! need them). -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::sync::Arc; @@ -30,6 +28,7 @@ fn linked_doc() -> Document { xf: Affine::IDENTITY, link: Some(LinkInfo::new("/art/photo.png")), placement: Default::default(), + pixels: None, }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut blob = ImageBlob::new("image/png", vec![1, 2, 3, 4, 5, 6]); diff --git a/crates/format/tests/streaming.rs b/crates/format/tests/it/streaming.rs similarity index 96% rename from crates/format/tests/streaming.rs rename to crates/format/tests/it/streaming.rs index 6149f1995..93a58f894 100644 --- a/crates/format/tests/streaming.rs +++ b/crates/format/tests/it/streaming.rs @@ -2,8 +2,6 @@ //! before (the same JSON and data after it, unpacked; the same gzip header), files written the old //! way still open, entries are read whatever their order, and the limits apply per part of the //! file. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::collections::BTreeMap; use std::io::{Read, Write}; @@ -20,7 +18,8 @@ fn doc_with(images: &[(&str, &str, usize)]) -> Document { let bytes: Vec<u8> = (0..len).map(|i| ((i * 7 + n * 13) % 251) as u8).collect(); d.images.insert(key.into(), ImageBlob::new(mime, bytes)); let id = d.alloc_id(); - let im = ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = + ImageObject { key: key.into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); } d diff --git a/crates/format/tests/prop_format.rs b/crates/format/tests/prop_format.rs index 66b08b264..6936feea2 100644 --- a/crates/format/tests/prop_format.rs +++ b/crates/format/tests/prop_format.rs @@ -75,7 +75,7 @@ proptest! { d.images.insert(key.clone(), ImageBlob::new("image/png", data.clone())); let layer = d.layers[0].id; let id = d.alloc_id(); - let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: key.clone(), width: 1, height: 1, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); for pretty in [false, true] { let back = load(&save(&d, pretty)).unwrap(); diff --git a/crates/geom/src/arc.rs b/crates/geom/src/arc.rs index de66388de..c4fe640ae 100644 --- a/crates/geom/src/arc.rs +++ b/crates/geom/src/arc.rs @@ -22,7 +22,7 @@ impl ArcPath { let mut segs = Vec::new(); let mut cum = 0.0; for s in path.to_bezpath().segments() { - let l = s.arclen(ACCURACY); + let l = crate::path::measured_arclen(&s, ACCURACY); if l.is_finite() && l > 1e-9 { segs.push((s, cum, l)); cum += l; @@ -51,7 +51,8 @@ impl ArcPath { let s = if self.closed && self.len > 0.0 { s.rem_euclid(self.len) } else { s.clamp(0.0, self.len) }; let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1); let Some(&(seg, c, l)) = self.segs.get(i) else { return (Point::ZERO, Vec2::new(1.0, 0.0)) }; - let t = seg.inv_arclen((s - c).min(l), ACCURACY).clamp(0.0, 1.0); + // (The solver's precision is relative to the segment's length: a long one needs a looser one.) + let t = seg.inv_arclen((s - c).min(l), ACCURACY.max(l * 1e-9)).clamp(0.0, 1.0); let p = seg.eval(t); let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0)); let d = seg.eval(t1) - seg.eval(t0); diff --git a/crates/geom/src/corners.rs b/crates/geom/src/corners.rs index 46449f43a..6fe68e576 100644 --- a/crates/geom/src/corners.rs +++ b/crates/geom/src/corners.rs @@ -39,7 +39,11 @@ impl Side { /// The side's length. fn length(&self) -> f64 { - if self.straight() { self.curve.p0.distance(self.curve.p3) } else { self.curve.arclen(ARCLEN_ACCURACY) } + if self.straight() { + self.curve.p0.distance(self.curve.p3) + } else { + crate::path::measured_arclen(&kurbo::PathSeg::Cubic(self.curve), ARCLEN_ACCURACY) + } } /// The parameter `s` along the side from the corner, the point there and the direction the diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 84162c2a0..c69a95a1c 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -58,11 +58,63 @@ pub fn constrain_angle_from(v: Vec2, step_deg: f64, base_deg: f64) -> Vec2 { if len < EPS { return v; } + // A whole turn (360°, −720°…) is no turn at all. + let base_deg = if base_deg.is_finite() { normalize_deg(base_deg) } else { 0.0 }; let (step, base) = (step_deg.to_radians(), -base_deg.to_radians()); let a = base + ((v.y.atan2(v.x) - base) / step).round() * step; Vec2::new(a.cos() * len, a.sin() * len) } +/// The page axes turned by `deg` (the Constrain Angle preference, counter-clockwise as seen on +/// the y-down page): maps a vector along the turned axes onto the page. `None` when they are the +/// page's own axes (any multiple of 360°, or a non-finite angle), so callers keep exact page +/// coordinates; multiples of 90° are exact quarter turns, without floating-point noise. +pub fn constrain_axes(deg: f64) -> Option<Affine> { + if !deg.is_finite() { + return None; + } + let d = normalize_deg(deg); + let (cos, sin) = if d == 0.0 { + return None; + } else if d == 90.0 { + (0.0, 1.0) + } else if d == 180.0 { + (-1.0, 0.0) + } else if d == -90.0 { + (0.0, -1.0) + } else { + let r = d.to_radians(); + (r.cos(), r.sin()) + }; + // Counter-clockwise on a y-down page is a negative rotation of the coordinates. + Some(Affine::new([cos, -sin, sin, cos, 0.0, 0.0])) +} + +/// A rotation by `deg` (clockwise as seen on the y-down page, as [`Affine::rotate`] takes it in +/// radians), exact at quarter turns and the identity at whole turns; 0 is `Affine::rotate(0.0)` +/// as before, and any other angle is `Affine::rotate` itself. +pub fn rotate_deg(deg: f64) -> Affine { + if deg.is_finite() && deg != 0.0 { + let d = normalize_deg(deg); + if d == 0.0 { + return Affine::IDENTITY; + } + if d % 90.0 == 0.0 + && let Some(a) = constrain_axes(-d) + { + return a; + } + } + Affine::rotate(deg.to_radians()) +} + +/// The `rotation` (degrees, as the shape and type commands take it) that turns a new object from +/// the page axes onto the axes of Constrain Angle `base`; `None` for the page's own axes (0°, a +/// whole turn, a non-finite angle), where new objects are drawn exactly as without it. +pub fn constrain_rotation(base: f64) -> Option<f64> { + constrain_axes(base).map(|_| -normalize_deg(base)) +} + /// Rect from two corner points in any order. pub fn rect_from_points(a: Point, b: Point) -> Rect { Rect::from_points(a, b) @@ -79,6 +131,39 @@ pub fn reference_point(r: Rect, index: usize) -> Point { mod tests { use super::*; + #[test] + fn constrain_axes_are_exact_and_whole_turns_are_none() { + for d in [0.0, 360.0, -360.0, 720.0, f64::NAN, f64::INFINITY] { + assert!(constrain_axes(d).is_none(), "{d}"); + } + let v = Point::new(3.0, 4.0); + // 90° counter-clockwise on the page: x points up (−y). + assert_eq!(constrain_axes(90.0).unwrap() * Point::new(1.0, 0.0), Point::new(0.0, -1.0)); + assert_eq!(constrain_axes(450.0).unwrap() * v, constrain_axes(90.0).unwrap() * v); + assert_eq!(constrain_axes(-90.0).unwrap() * v, Point::new(-4.0, 3.0)); + assert_eq!(constrain_axes(180.0).unwrap() * v, Point::new(-3.0, -4.0)); + assert_eq!(constrain_axes(-180.0).unwrap() * v, Point::new(-3.0, -4.0)); + let a = constrain_axes(30.0).unwrap() * Point::new(1.0, 0.0); + assert!((a.x - 30f64.to_radians().cos()).abs() < 1e-12 && (a.y + 0.5).abs() < 1e-12, "{a:?}"); + assert_eq!(constrain_angle_from(Vec2::new(5.0, 1.0), 45.0, 360.0), constrain_angle_from(Vec2::new(5.0, 1.0), 45.0, 0.0)); + } + + #[test] + fn rotate_deg_is_exact_at_quarter_turns() { + let v = Point::new(3.0, 4.0); + assert_eq!(rotate_deg(90.0) * v, Point::new(-4.0, 3.0)); + assert_eq!(rotate_deg(-90.0) * v, Point::new(4.0, -3.0)); + assert_eq!(rotate_deg(180.0) * v, Point::new(-3.0, -4.0)); + assert_eq!(rotate_deg(-270.0) * v, rotate_deg(90.0) * v); + assert_eq!(rotate_deg(360.0) * v, v); + assert_eq!(rotate_deg(0.0), Affine::rotate(0.0)); + assert_eq!(rotate_deg(30.0), Affine::rotate(30f64.to_radians())); + assert_eq!(constrain_rotation(30.0), Some(-30.0)); + assert_eq!(constrain_rotation(0.0), None); + assert_eq!(constrain_rotation(-360.0), None); + assert_eq!(constrain_rotation(450.0), Some(-90.0)); + } + #[test] fn angle_normalization() { assert_eq!(normalize_deg(190.0), -170.0); diff --git a/crates/geom/src/path.rs b/crates/geom/src/path.rs index 110997efc..f7828fb80 100644 --- a/crates/geom/src/path.rs +++ b/crates/geom/src/path.rs @@ -561,10 +561,27 @@ impl PathData { } /// Total length of all segments. pub fn length(&self) -> f64 { - self.to_bezpath().segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-6)).sum() + self.to_bezpath().segments().map(|s| measured_arclen(&s, 1e-6)).sum() } } +/// The largest x or y, in size, of a segment's control points for which its length is measured. +/// Document coordinates are far inside it; past it (or not finite) the integrator's arithmetic +/// overflows and it subdivides without end. +const MEASURE_LIMIT: f64 = 1e9; + +/// The length of `seg` to `accuracy`; 0 for a segment whose control points are not finite or past +/// [`MEASURE_LIMIT`]. +pub(crate) fn measured_arclen(seg: &PathSeg, accuracy: f64) -> f64 { + let ok = |p: Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MEASURE_LIMIT && p.y.abs() <= MEASURE_LIMIT; + let fits = match seg { + PathSeg::Line(l) => ok(l.p0) && ok(l.p1), + PathSeg::Quad(q) => ok(q.p0) && ok(q.p1) && ok(q.p2), + PathSeg::Cubic(c) => ok(c.p0) && ok(c.p1) && ok(c.p2) && ok(c.p3), + }; + if fits { kurbo::ParamCurveArclen::arclen(seg, accuracy) } else { 0.0 } +} + /// The largest x or y, in size, of a segment's control points for which [`segment_bounds`] /// measures the segment by its extrema. kurbo adds up to eight coordinates together when it finds a /// segment's extrema and evaluates the segment there, and a sum of eight coordinates of this size or diff --git a/crates/geom/src/shapes.rs b/crates/geom/src/shapes.rs index e677d34ff..4d9f92281 100644 --- a/crates/geom/src/shapes.rs +++ b/crates/geom/src/shapes.rs @@ -176,17 +176,33 @@ pub fn line(a: Point, b: Point) -> PathData { PathData::single(SubPath::polyline(&[a, b], false)) } -/// Quarter-ellipse arc from `a` to `b` (Arc tool default: concave, open, x-axis base). -pub fn arc(a: Point, b: Point, slope: f64, closed: bool) -> PathData { - // Corner of the arc's bounding box is at (a.x, b.y): the arc bows away from it. - let k = KAPPA * (1.0 + slope.clamp(-1.0, 1.0) * 0.0); - let corner = Point::new(b.x, a.y); - let h1 = a + (corner - a) * k; - let h2 = b + (corner - b) * k; +/// Bounding box of an arc segment when reference point `reference` (0…8) is placed on `anchor`. +pub fn arc_bbox(anchor: Point, reference: usize, length_x: f64, length_y: f64) -> Rect { + let w = length_x.abs().max(1e-9); + let h = length_y.abs().max(1e-9); + let rp = crate::reference_point(Rect::new(0.0, 0.0, w, h), reference.min(8)); + Rect::new(anchor.x - rp.x, anchor.y - rp.y, anchor.x - rp.x + w, anchor.y - rp.y + h) +} + +/// Arc segment from the Arc tool dialog: lengths, reference point, base axis, slope and open/closed. +pub fn arc_segment(anchor: Point, reference: usize, length_x: f64, length_y: f64, base_along_x: bool, slope: f64, closed: bool) -> PathData { + let r = arc_bbox(anchor, reference, length_x, length_y).abs(); + let (a, b) = if base_along_x { (Point::new(r.x0, r.y0), Point::new(r.x1, r.y1)) } else { (Point::new(r.x0, r.y1), Point::new(r.x1, r.y0)) }; + arc(a, b, slope, closed, base_along_x) +} + +/// Quarter-ellipse arc from `a` to `b` (Arc tool default: open, x-axis base when `base_along_x`). +/// `slope` is −100…100 (concave…convex); 0 is a straight chord. +pub fn arc(a: Point, b: Point, slope: f64, closed: bool, base_along_x: bool) -> PathData { + let t = slope.clamp(-100.0, 100.0) / 100.0; + let corner = if t >= 0.0 { Point::new(b.x, a.y) } else { Point::new(a.x, b.y) }; + let k = KAPPA * t.abs(); + let h1 = if k == 0.0 { a } else { a + (corner - a) * k }; + let h2 = if k == 0.0 { b } else { b + (corner - b) * k }; let mut anchors = vec![Anchor { p: a, h_in: a, h_out: h1, kind: AnchorKind::Corner }, Anchor { p: b, h_in: h2, h_out: b, kind: AnchorKind::Corner }]; if closed { - let o = Point::new(a.x, b.y); + let o = if base_along_x { Point::new(a.x, b.y) } else { Point::new(b.x, a.y) }; anchors.push(Anchor::corner(o)); } PathData::single(SubPath::new(anchors, closed)) @@ -531,7 +547,9 @@ mod tests { #[test] fn arc_open_and_closed() { - assert_eq!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, false).anchor_count(), 2); - assert!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, true).subpaths[0].closed); + assert_eq!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, false, true).anchor_count(), 2); + assert!(arc(Point::ZERO, Point::new(10.0, 10.0), 0.0, true, true).subpaths[0].closed); + let seg = arc_segment(Point::ZERO, 0, 100.0, 50.0, true, 50.0, false); + assert_eq!(seg.anchor_count(), 2); } } diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index 1ebe4ca9d..30cb6aef0 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -8,6 +8,12 @@ description = "VectorCraft MCP server: JSON-RPC 2.0 over stdio, driving a runnin [lints] workspace = true +[features] +default = ["bundled-fonts"] +# The fonts vectorcraft-text bundles for documents, through the engine's `bundled-fonts`, as +# vectorcraft-ui-egui's. Off, for an embedder that supplies every document font at run time. +bundled-fonts = ["vectorcraft-engine/bundled-fonts"] + [dependencies] vectorcraft-engine = { workspace = true } vectorcraft-render = { workspace = true } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 9442339e5..ba50f2ebc 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -168,6 +168,10 @@ impl Headless { } UiRequest::Dialog(k, p) => out.push(json!({"dialog": k, "params": p})), UiRequest::Status(msg) => out.push(json!({"status": msg})), + // The place cursor's file, placed at once. + UiRequest::Place(p) => { + self.session.execute("file.place", &p).map_err(|e| e.to_string())?; + } } } Ok(()) @@ -275,7 +279,8 @@ impl Headless { /// `app.open {path}`: any readable file as a new active document (`document.open`). pub fn open(&mut self, p: &Value) -> Result<Value, String> { let path = s(p, "path").ok_or("missing `path`")?; - self.session.execute("document.open", &json!({"path": path})).map_err(|e| e.to_string()) + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.session.execute("document.open", &json!({"path": path}))).map_err(|e| e.to_string()) } /// `app.save {path?, format?, options?}`: `document.save` (by default the document's own path and @@ -285,12 +290,14 @@ impl Headless { if s(p, "path").is_none() && st.path.is_none() { return Err("missing `path` (document was never saved)".into()); } - self.session.execute("document.save", p).map_err(|e| e.to_string()) + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.session.execute("document.save", p)).map_err(|e| e.to_string()) } /// `app.export {path?, format?, …}`: `document.export` (format defaults to the path's extension). pub fn export(&mut self, p: &Value) -> Result<Value, String> { - self.session.execute("document.export", p).map_err(|e| e.to_string()) + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.session.execute("document.export", p)).map_err(|e| e.to_string()) } } diff --git a/crates/mcp/src/prompts.rs b/crates/mcp/src/prompts.rs index 2dff4486e..0c0a43926 100644 --- a/crates/mcp/src/prompts.rs +++ b/crates/mcp/src/prompts.rs @@ -405,7 +405,10 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> { let ids: Vec<&str> = PROMPTS.iter().map(|p| p.name).collect(); return Err(format!("unknown prompt `{name}` ({})", ids.join("|"))); }; - let mut text = flow(p.text); + let template = flow(p.text); + // (placeholder, value): filled in one pass over the template, so a value that looks like a + // placeholder stays as it is. + let mut values: Vec<(String, String)> = vec![]; for a in p.arguments { let given = args.get(a.name).filter(|v| !v.is_null()).map(|v| match v { Value::String(s) => s.clone(), @@ -416,7 +419,21 @@ pub fn get(name: &str, args: &Value) -> Result<Value, String> { _ if a.required => return Err(format!("prompt `{name}` needs the `{a_name}` argument", a_name = a.name)), _ => a.default.to_string(), }; - text = text.replace(&format!("{{{}}}", a.name), &value); + values.push((format!("{{{}}}", a.name), value)); + } + let mut text = String::with_capacity(template.len()); + let mut rest = template.as_str(); + while let Some(c) = rest.chars().next() { + match values.iter().find(|(placeholder, _)| rest.starts_with(placeholder.as_str())) { + Some((placeholder, value)) => { + text.push_str(value); + rest = rest.get(placeholder.len()..).unwrap_or_default(); + } + None => { + text.push(c); + rest = rest.get(c.len_utf8()..).unwrap_or_default(); + } + } } Ok(json!({ "description": p.description, diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6007ff914..e3e0f94c9 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1184,6 +1184,31 @@ fn cmd_shift_x_resets_horizontal_scale_for_agents() { assert_eq!(runs[0]["style"]["h_scale"], json!(100.0), "{runs}"); } +/// `press_key` runs Cmd+/ and Cmd+Alt+/ for agents: a new fill and then a new stroke go on top of +/// the selected rectangle's appearance stack. +#[test] +fn cmd_slash_and_cmd_alt_slash_add_a_fill_and_a_stroke_for_agents() { + let mut s = server(); + let r = call(&mut s, 1, "run_command", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 100, "height": 100}})); + let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap(); + let r = call(&mut s, 2, "run_command", json!({"command": "select.set", "params": {"ids": [id]}})); + assert_eq!(r["isError"], false, "{r}"); + let kinds = |s: &mut Server, n: u64| { + let r = call(s, n, "run_command", json!({"command": "effect.list", "params": {}})); + let items = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["applied"][0]["items"].clone(); + items.as_array().unwrap().iter().map(|it| it["kind"].as_str().unwrap().to_string()).collect::<Vec<_>>() + }; + assert_eq!(kinds(&mut s, 3), ["fill", "stroke"]); + let r = call(&mut s, 4, "press_key", json!({"key": "/", "mods": {"cmd": true}})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["command"], "appearance.addFill", "{r}"); + assert_eq!(kinds(&mut s, 5), ["fill", "stroke", "fill"]); + let r = call(&mut s, 6, "press_key", json!({"key": "/", "mods": {"cmd": true, "alt": true}})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["command"], "appearance.addStroke", "{r}"); + assert_eq!(kinds(&mut s, 7), ["fill", "stroke", "fill", "stroke"]); +} + /// The drawing tools snap to Smart Guides over MCP as with the mouse (#506): a Rectangle drawn /// from near another object's corner starts on it, and a Pen anchor placed beside that object's /// centre lines up with it. diff --git a/crates/mcp/src/tests_drawopts.rs b/crates/mcp/src/tests_drawopts.rs index 666ff1632..bef4e59b8 100644 --- a/crates/mcp/src/tests_drawopts.rs +++ b/crates/mcp/src/tests_drawopts.rs @@ -138,3 +138,45 @@ fn schemas_list_the_options() { assert_eq!(v["result"]["isError"], false, "{tool}: {v}"); } } + +/// A paint the engine refuses (a fill or stroke it can't read, a swatch the document lacks) fails +/// the whole call: no object, no undo step, nothing in the SVG (#3545). +#[test] +fn a_bad_paint_draws_nothing() { + let mut h = headless(); + let (before, undo, svg) = + (objects(&h), h.session.doc().unwrap().history.undo.len(), vectorcraft_svg::export(&h.session.doc().unwrap().doc, &Default::default())); + for bad in [ + json!({"stroke": "currentColor"}), + json!({"fill": "currentColor"}), + json!({"fill": "#550505", "stroke": "not-a-colour"}), + json!({"fill": {"swatch": "No Such Swatch"}}), + json!({"stroke": "#000000", "strokeWidth": "thick"}), + ] { + for (tool, base) in + [("draw_path", json!({"d": "M0 0 L10 10"})), ("draw_shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10}))] + { + let mut args = base.clone(); + for (k, v) in bad.as_object().unwrap() { + args[k] = v.clone(); + } + let r = call_tool(&mut h, tool, &args); + assert!(r.is_error, "{tool} {args} must fail"); + let st = h.session.doc().unwrap(); + assert_eq!(objects(&h), before, "{tool} {args} drew something"); + assert_eq!(st.history.undo.len(), undo, "{tool} {args} left an undo step"); + assert_eq!(vectorcraft_svg::export(&st.doc, &Default::default()), svg, "{tool} {args} changed the SVG"); + } + } + // A good call is one undo step, named as the drawing command names its own. + for (tool, args, label) in [ + ("draw_path", json!({"d": "M0 0 L10 10", "stroke": "#c19a4b", "strokeWidth": 2, "opacity": 50}), "Pen"), + ("draw_shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "fill": "#000000", "stroke": "none"}), "Rectangle"), + ] { + let undo = h.session.doc().unwrap().history.undo.len(); + draw(&mut h, tool, args); + let history = &h.session.doc().unwrap().history.undo; + assert_eq!(history.len(), undo + 1, "{tool}"); + assert_eq!(history.last().unwrap().label, label); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7587d67aa..3f37bce16 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -528,20 +528,19 @@ fn paint_params(v: &Value) -> Result<Value, String> { }) } -fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option<Value>) -> Result<Value, String> { - let with_ids = |mut p: Value| { - if let Some(ids) = &ids { - p["ids"] = ids.clone(); - } - p - }; - let mut applied = Map::new(); +/// A command a drawing tool runs and its params. +type Step<'a> = (&'a str, Value); + +/// The commands that apply `a`'s `fill`, `stroke`, `strokeWidth`, `dash`, `cap` and `join`, without +/// targets, and what each applies. +fn paint_steps(a: &Args) -> Result<(Vec<Step<'static>>, Map<String, Value>), String> { + let (mut steps, mut applied) = (vec![], Map::new()); if let Some(f) = a.get("fill") { - exec(b, "paint.setFill", with_ids(paint_params(f)?))?; + steps.push(("paint.setFill", paint_params(f)?)); applied.insert("fill".into(), f.clone()); } if let Some(s) = a.get("stroke") { - exec(b, "paint.setStroke", with_ids(paint_params(s)?))?; + steps.push(("paint.setStroke", paint_params(s)?)); applied.insert("stroke".into(), s.clone()); } // Weight, dash, cap and join go to one stroke.set. @@ -558,7 +557,18 @@ fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option<Value>) -> Result<Valu } } if !stroke.is_empty() { - exec(b, "stroke.set", with_ids(Value::Object(stroke)))?; + steps.push(("stroke.set", Value::Object(stroke))); + } + Ok((steps, applied)) +} + +fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option<Value>) -> Result<Value, String> { + let (steps, applied) = paint_steps(a)?; + for (cmd, mut p) in steps { + if let Some(ids) = &ids { + p["ids"] = ids.clone(); + } + exec(b, cmd, p)?; } Ok(Value::Object(applied)) } @@ -609,9 +619,18 @@ fn check_draw_options(b: &mut dyn Backend, a: &Args) -> Result<(), String> { Ok(()) } -/// Applies the checked options of [`check_draw_options`] to the new object `ids`, adding what was -/// applied to `made`. Paint and stroke options are [`apply_paint`]'s. -fn apply_draw_options(b: &mut dyn Backend, a: &Args, ids: Option<Value>, made: &mut Value) -> Result<(), String> { +/// Draws an object with `create` (a creating command and its params) and styles it with `a`'s +/// paint and draw options ([`check_draw_options`], checked first) as ONE `command.batch`, named +/// `label` (the undo step the creating command makes on its own): any step the engine refuses — a +/// colour it can't read, a swatch the document lacks — rolls the whole call back, so it leaves no +/// object and no undo step behind (#3545). The styling steps act on the new object, which the +/// creating command selects, and on its whole appearance (`item: null`) → `made` with the new +/// object's `id`, the `paint` applied and the other options applied. +fn draw(b: &mut dyn Backend, a: &Args, label: &str, create: Step<'_>, mut made: Value) -> Result<Value, String> { + check_draw_options(b, a)?; + let (paint, applied) = paint_steps(a)?; + let mut steps = vec![create]; + steps.extend(paint); let mut props = json!({}); for k in ["name", "opacity"] { if let Some(v) = a.get(k) { @@ -619,34 +638,40 @@ fn apply_draw_options(b: &mut dyn Backend, a: &Args, ids: Option<Value>, made: & made[k] = v.clone(); } } - if let Some(ids) = &ids { - props["ids"] = ids.clone(); - } if a.contains_key("name") || a.contains_key("opacity") { - exec(b, "object.setProps", props)?; + steps.push(("object.setProps", props)); } - let effects = a.get("effects").and_then(Value::as_array).filter(|e| !e.is_empty()); - if let Some(effects) = effects { - let mut applied = vec![]; - for e in effects { - // The whole object (item: null), as the Effect menu applies it. - let mut p = json!({"effect": e.get("effect"), "params": e.get("params").cloned().unwrap_or(json!({})), "item": null}); - if let Some(ids) = &ids { - p["ids"] = ids.clone(); + // The fill, stroke and effects go to the new object's whole appearance (item: null), as the + // Fill and Stroke proxies and the Effect menu apply them to a selected object. + let effects = a.get("effects").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + for e in effects { + steps.push(("effect.apply", json!({"effect": e.get("effect"), "params": e.get("params").cloned().unwrap_or(json!({}))}))); + } + let commands: Vec<Value> = steps + .into_iter() + .map(|(cmd, mut p)| { + if matches!(cmd, "paint.setFill" | "paint.setStroke" | "stroke.set" | "effect.apply") { + p["item"] = Value::Null; } - applied.push(exec(b, "effect.apply", p)?); - } - made["effects"] = Value::Array(applied); + json!({"command": cmd, "params": p}) + }) + .collect(); + // The engine names the failed step of its batch; the caller sent no batch, so the reply gives + // the step's own error. + let r = exec(b, "command.batch", json!({"label": label, "commands": commands})).map_err(|e| match e.split_once(" failed: ") { + Some((step, error)) if step.starts_with("batch step ") => error.to_string(), + _ => e, + })?; + let results = r.get("results").and_then(Value::as_array).ok_or("command.batch returned no results")?; + made["id"] = results.first().and_then(|r| r.get("id")).cloned().unwrap_or(Value::Null); + made["paint"] = Value::Object(applied); + if !effects.is_empty() { + made["effects"] = Value::Array(results.iter().skip(results.len().saturating_sub(effects.len())).cloned().collect()); } - Ok(()) -} - -fn created_id(r: &Value) -> Option<Value> { - r.get("id").filter(|v| v.is_u64()).map(|id| json!([id])) + Ok(made) } fn draw_path(b: &mut dyn Backend, a: &Args) -> Result<Value, String> { - check_draw_options(b, a)?; let mut p = json!({}); if let Some(d) = a.get("d").and_then(Value::as_str) { p["d"] = json!(d); @@ -667,33 +692,26 @@ fn draw_path(b: &mut dyn Backend, a: &Args) -> Result<Value, String> { p["anchors"] = Value::Array(anchors); p["closed"] = json!(a.get("closed").and_then(Value::as_bool).unwrap_or(false)); } - let r = exec(b, "path.create", p)?; - let paint = apply_paint(b, a, created_id(&r))?; - let mut made = json!({"id": r.get("id"), "paint": paint}); - apply_draw_options(b, a, created_id(&r), &mut made)?; - Ok(made) + draw(b, a, "Pen", ("path.create", p), json!({})) } fn draw_shape(b: &mut dyn Backend, a: &Args) -> Result<Value, String> { let shape = req_str(a, "shape")?; - let cmd = match shape { - "rectangle" | "rect" => "shape.rectangle", - "ellipse" | "circle" => "shape.ellipse", - "polygon" => "shape.polygon", - "star" => "shape.star", - "line" => "shape.line", + // Each command and the name of the undo step it makes. + let (cmd, label) = match shape { + "rectangle" | "rect" if a.get("radius").and_then(Value::as_f64).is_some_and(|r| r > 0.0) => ("shape.rectangle", "Rounded Rectangle"), + "rectangle" | "rect" => ("shape.rectangle", "Rectangle"), + "ellipse" | "circle" => ("shape.ellipse", "Ellipse"), + "polygon" => ("shape.polygon", "Polygon"), + "star" => ("shape.star", "Star"), + "line" => ("shape.line", "Line"), other => return Err(format!("unknown shape `{other}` (rectangle, ellipse, polygon, star, line)")), }; - check_draw_options(b, a)?; let mut p = a.clone(); for k in ["shape", "fill", "stroke", "strokeWidth", "dash", "cap", "join", "opacity", "name", "effects"] { p.remove(k); } - let r = exec(b, cmd, Value::Object(p))?; - let paint = apply_paint(b, a, created_id(&r))?; - let mut made = json!({"id": r.get("id"), "shape": shape, "paint": paint}); - apply_draw_options(b, a, created_id(&r), &mut made)?; - Ok(made) + draw(b, a, label, (cmd, Value::Object(p)), json!({"shape": shape})) } /// Select `ids` when given (tools that act on the selection). diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index fb94bd69e..d45ecc714 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -25,7 +25,9 @@ fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) { std::fs::write(base.join("in2/c.svg"), SVG).unwrap(); let in_dir = base.join("in"); let out_dir = base.join("out"); - let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap(); + let (read, write): (Vec<&Path>, Vec<&Path>) = + (read.then_some(in_dir.as_path()).into_iter().collect(), write.then_some(out_dir.as_path()).into_iter().collect()); + let roots = AutomationRoots::new(&read, &write).unwrap(); (Server::new(Box::new(Headless::with_document().with_automation_roots(roots))), base) } @@ -112,7 +114,13 @@ fn saves_exports_and_captures_go_only_into_the_write_root() { json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("outside"))}}), "outside the write root", ); - ok(&mut server, "run_command", json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("out"))}})); + // Data Recovery lists and restores what is in its folder too: write access alone isn't enough. + refused( + &mut server, + "run_command", + json!({"command": "prefs.set", "params": {"key": "recoveryFolder", "value": s(&base.join("out"))}}), + "outside the read root", + ); refused(&mut server, "run_command", json!({"command": "plugin.reload", "params": {"path": s(&base.join("outside"))}}), "outside the read root"); // The document saved inside the write root saves again in place. @@ -153,3 +161,12 @@ fn links_out_of_a_root_are_refused() { refused(&mut server, "export", json!({"path": s(&base.join("out/away/art.png"))}), "outside the write root"); assert!(!base.join("outside/art.png").exists()); } + +/// The Place tool keeps its queue until a click, which no scope confines: a queued file outside the +/// read root is refused when the queue is set (whichever tool is active when it is). +#[test] +fn a_place_queue_names_only_files_inside_the_read_root() { + let (mut server, base) = confined("place-queue", true, true); + let queue = |file: &str| json!({"key": "queue", "value": {"entries": [{"params": {"path": s(&base.join(file))}, "name": "x", "width": 10, "height": 10}]}}); + refused(&mut server, "run_command", json!({"command": "tool.setOption", "params": queue("outside/b.svg")}), "outside the read root"); +} diff --git a/crates/mcp/tests/protocol_props.rs b/crates/mcp/tests/protocol_props.rs index 9605a0ca7..2eb3db647 100644 --- a/crates/mcp/tests/protocol_props.rs +++ b/crates/mcp/tests/protocol_props.rs @@ -173,7 +173,7 @@ proptest! { let root = base.join("a/b/c/root"); std::fs::create_dir_all(&root).unwrap(); std::fs::create_dir_all(base.join("outside")).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(Some(root.as_path()), Some(root.as_path())).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[root.as_path()], &[root.as_path()]).unwrap(); let mut s = Server::new(Box::new(Headless::with_document().with_automation_roots(roots))); let path = format!("{}{}{tail}", root.display(), std::path::MAIN_SEPARATOR); let r = catch_quiet(|| call(&mut s, tool, json!({"path": path}))); diff --git a/crates/metafile/src/import.rs b/crates/metafile/src/import.rs index 047a59f5f..c56293022 100644 --- a/crates/metafile/src/import.rs +++ b/crates/metafile/src/import.rs @@ -788,7 +788,7 @@ impl Player { if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { return; } - let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; + let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default(), pixels: None }; let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(Box::new(im))); n.opacity = opacity.clamp(0.0, 1.0) as f32; self.push(n); diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 6f9ed1059..db55fc71a 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -33,6 +33,11 @@ const CLIPPING: &str = "clipping masks are not applied: the art they clip is wri const FLAT_GRADIENTS: &str = "gradients are written as one colour, the average of their stops"; const FLAT_PATTERNS: &str = "pattern fills are written as one colour"; const TOO_MANY_TILES: &str = "pattern fills past 250,000 tiles in all are written as one colour"; +const TEXT_OUTLINES: &str = "type is written as outlines: it can't be searched, selected or read aloud as text"; +const PATTERN_TILES: &str = "pattern fills are written as their tiles, one shape each"; +/// The warning for an image left out because it can't be decoded: the note raster exports give +/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). +const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; /// A stroke as a pen draws it. #[derive(Clone, Debug, PartialEq)] @@ -194,6 +199,7 @@ impl<'a> Scene<'a> { } NodeKind::Text(_) => { if let Some(o) = vectorcraft_effects::outline_text(n) { + self.warn(TEXT_OUTLINES); self.node(&o, true); } } @@ -313,6 +319,9 @@ impl<'a> Scene<'a> { return; } self.tiles_left -= tiles.len(); + if !tiles.is_empty() { + self.warn(PATTERN_TILES); + } let outer = self.opacity; self.opacity *= opacity.clamp(0.0, 1.0); self.ops.push(Op::Clip { path: bp.clone(), rule }); @@ -430,26 +439,27 @@ impl<'a> Scene<'a> { /// A placed image, decoded once per image. fn image(&mut self, im: &ImageObject) { - let Some(img) = self.decoded(&im.key) else { return }; + let Some(img) = self.decoded(im) else { return }; let (dw, dh) = (f64::from(img.width), f64::from(img.height)); let xf = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)) / dw, f64::from(im.height.max(1)) / dh); self.ops.push(Op::Image { image: img, xf, opacity: self.opacity.clamp(0.0, 1.0) }); } - /// Image `key`'s pixels (scaled down to what a metafile stores), `None` with a warning when it + /// Image `im`'s pixels (scaled down to what a metafile stores), `None` with a warning when it /// has none or can't be decoded. - fn decoded(&mut self, key: &str) -> Option<Arc<Rgba>> { + fn decoded(&mut self, im: &ImageObject) -> Option<Arc<Rgba>> { + let key = im.key.as_str(); if let Some(i) = self.images.get(key) { return i.clone(); } - let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()); + let blob = self.doc.image_blob(im).filter(|b| !b.bytes.is_empty()); let img = blob .and_then(|b| image::load_from_memory(&b.bytes).ok()) .map(|i| Rgba::fitted(i.to_rgba8())) .filter(|i| i.width > 0 && i.height > 0) .map(Arc::new); if img.is_none() { - self.warn(&format!("image '{key}' could not be decoded and was left out")); + self.warn(UNDECODED); } self.images.insert(key.to_string(), img.clone()); img diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 330de5e7b..122653b34 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -51,7 +51,10 @@ pub(crate) fn image_node(d: &mut Document, bytes: Vec<u8>, w: u32, h: u32, xf: A let blob = ImageBlob::new("image/png", bytes); let key = blob.content_key(); d.images.insert(key.clone(), blob); - Node::new(NodeId(0), NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))) + Node::new( + NodeId(0), + NodeKind::Image(Box::new(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ) } pub(crate) fn u32_at(b: &[u8], at: usize) -> u32 { @@ -253,9 +256,12 @@ fn emf_clips_gradients_and_images() { fn type_is_written_as_outlines() { let t = TextObject::point(Point::new(10.0, 50.0), "Hi", CharStyle { fill: red(), ..CharStyle::default() }); let d = doc_with(100.0, 100.0, vec![Node::new(NodeId(0), NodeKind::Text(Box::new(t)))]); - let k = kinds(&emf_records(&emf(&d).bytes)); + let out = emf(&d); + let k = kinds(&emf_records(&out.bytes)); assert!(k.contains(&62), "glyphs are filled paths"); assert!(!k.contains(&84) && !k.contains(&83), "no text records"); + // Said so (#3850). + assert!(out.warnings.iter().any(|w| w.contains("type is written as outlines")), "{:?}", out.warnings); } #[test] @@ -365,6 +371,8 @@ fn patterns_inside_pattern_tiles_share_one_tile_budget() { let (ops, warnings) = crate::scene::Scene::new(&d, d.artboards[0].rect, true).run(); assert!(ops.len() < 300_000, "{} operations", ops.len()); assert!(warnings.iter().any(|w| w.contains("one colour")), "{warnings:?}"); + // The tiles that were written are reported too (#3850). + assert!(warnings.iter().any(|w| w.contains("written as their tiles")), "{warnings:?}"); // A tile filled with its own pattern multiplies at every level down to the nesting limit. let square = path(shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 4.0)), pattern("self"), Paint::None, 0.0); diff --git a/crates/pathops/src/edit.rs b/crates/pathops/src/edit.rs index 173d91882..75a201753 100644 --- a/crates/pathops/src/edit.rs +++ b/crates/pathops/src/edit.rs @@ -1,8 +1,10 @@ //! Object → Path commands: Simplify, Smooth, Remove redundant points, Add Anchor Points, Average, //! Join, Split Into Grid. +use std::collections::{BTreeMap, BTreeSet}; + use kurbo::{CubicBez, ParamCurveArclen, Point, Rect, Vec2}; -use vectorcraft_geom::{Anchor, PathData, SubPath}; +use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath}; use crate::boolean::{Seg, segs_to_subpath}; use crate::fit::{end_tangent, fit_cubics, fit_single, fit_single_from, is_straight, sample, start_tangent}; @@ -29,7 +31,7 @@ pub fn simplify(path: &PathData, tolerance: f64) -> PathData { simplify_with(path, &SimplifyOptions { tolerance, ..Default::default() }) } -/// The Simplify slider's level for a tolerance of 1 pt, where the slider opens. +/// The Simplify slider's level for a tolerance of 1 pt, the middle of its scale. pub const SIMPLIFY_LEVEL: f64 = 50.0; /// The tolerance (points) of Simplify slider level `level`, from 0 (the fewest anchor points) to @@ -69,64 +71,180 @@ fn turn_deg(a: &Seg, b: &Seg) -> f64 { /// Least-squares Bézier refit of every subpath, keeping corners sharper than the threshold. pub fn simplify_with(path: &PathData, opts: &SimplifyOptions) -> PathData { - let tol = opts.tolerance.max(1e-6); let mut out = Vec::new(); for sp in &path.subpaths { - let mut segs = subpath_segs(sp); - let n = segs.len(); - if n == 0 { - out.push(sp.clone()); - continue; - } - let corner_at = |segs: &[Seg], i: usize| -> bool { - if !sp.closed && (i == 0 || i == n) { - return true; - } - turn_deg(&segs[(i + n - 1) % n], &segs[i % n]) > opts.corner_angle_deg - }; - let mut corners: Vec<usize> = (0..n).filter(|&i| corner_at(&segs, i)).collect(); - if sp.closed { - if let Some(&k) = corners.first() { - segs.rotate_left(k); - corners = corners.iter().map(|&c| c - k).collect(); - } else { - corners = vec![0]; - } - corners.push(n); - } else if corners.last() != Some(&n) { - corners.push(n); - } - let mut result: Vec<Seg> = Vec::new(); - for w in corners.windows(2) { - let run = &segs[w[0]..w[1]]; - if run.is_empty() { - continue; - } - let mut pts = Vec::new(); - for (k, s) in run.iter().enumerate() { - let len = s.c.arclen(1e-6); - let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256); - sample(&s.c, m, &mut pts, k == 0); - } - if opts.straight_lines { - let keep = rdp(&pts, tol); - result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1]))); - continue; - } - let t0 = start_tangent(&run[0].c); - let t1 = end_tangent(&run[run.len() - 1].c); - for c in fit_cubics(&pts, t0, t1, tol) { - let line = is_straight(&c, tol * 0.02); - result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); - } - } - if let Some(s) = segs_to_subpath(&result, sp.closed) { - out.push(if opts.straight_lines { s } else { no_worse(sp, s) }); + if let Some(s) = simplify_subpath(sp, opts) { + out.push(s); } } PathData::new(out) } +/// [`simplify_with`] for one subpath; `None` when the refit leaves nothing of it. +fn simplify_subpath(sp: &SubPath, opts: &SimplifyOptions) -> Option<SubPath> { + let mut segs = subpath_segs(sp); + let n = segs.len(); + if n == 0 { + return Some(sp.clone()); + } + if sp.closed { + // Start the loop at its first corner, so no fit runs across one. + let k = (0..n).find(|&i| turn_deg(&segs[(i + n - 1) % n], &segs[i]) > opts.corner_angle_deg).unwrap_or(0); + segs.rotate_left(k); + } + let result = simplify_run(&segs, opts); + let s = segs_to_subpath(&result, sp.closed)?; + Some(if opts.straight_lines { s } else { no_worse(sp, s) }) +} + +/// The refit of the consecutive segments `run`, split at its corners sharper than the threshold: +/// it starts and ends where `run` does, leaving the same way. +fn simplify_run(run: &[Seg], opts: &SimplifyOptions) -> Vec<Seg> { + let tol = opts.tolerance.max(1e-6); + let mut cuts: Vec<usize> = (1..run.len()).filter(|&i| turn_deg(&run[i - 1], &run[i]) > opts.corner_angle_deg).collect(); + cuts.insert(0, 0); + cuts.push(run.len()); + let mut result: Vec<Seg> = Vec::new(); + for w in cuts.windows(2) { + let piece = &run[w[0]..w[1]]; + if piece.is_empty() { + continue; + } + let mut pts = Vec::new(); + for (k, s) in piece.iter().enumerate() { + let len = s.c.arclen(1e-6); + let m = ((len / (tol * 0.5)).ceil() as usize).clamp(4, 256); + sample(&s.c, m, &mut pts, k == 0); + } + if opts.straight_lines { + let keep = rdp(&pts, tol); + result.extend(keep.windows(2).map(|p| Seg::line(p[0], p[1]))); + continue; + } + let t0 = start_tangent(&piece[0].c); + let t1 = end_tangent(&piece[piece.len() - 1].c); + for c in fit_cubics(&pts, t0, t1, tol) { + let line = is_straight(&c, tol * 0.02); + result.push(if line { Seg::line(c.p0, c.p3) } else { Seg { c, line } }); + } + } + result +} + +/// What [`simplify_part`] made of a path. +#[derive(Clone, Debug, PartialEq)] +pub struct SimplifiedPart { + pub path: PathData, + /// The segments of the simplified runs, as (subpath, segment) in `path`. + pub segments: BTreeSet<(usize, usize)>, + /// Where each anchor of the original path that is still there went: (subpath, anchor) in the + /// original → in `path`. Anchors inside a simplified run are gone; every other anchor is kept. + pub anchors: BTreeMap<(usize, usize), (usize, usize)>, +} + +/// Simplify only the `segments` (subpath, segment) of `path`: each run of consecutive segments +/// among them (a closed subpath's run may cross its start point) is refitted on its own between its +/// two end anchors, which stay where they are with their handles on the side away from the run. +/// The other segments and anchors stay exactly as they are. A subpath whose every segment is in +/// `segments` is simplified whole, as [`simplify_with`] does. A run whose refit would have more +/// segments than it had is left as it is (except with Convert to Straight Lines). +pub fn simplify_part(path: &PathData, segments: &BTreeSet<(usize, usize)>, opts: &SimplifyOptions) -> SimplifiedPart { + let (mut runs, mut anchors) = (BTreeSet::new(), BTreeMap::new()); + let mut subpaths = Vec::with_capacity(path.subpaths.len()); + for (si, sp) in path.subpaths.iter().enumerate() { + let n = sp.segment_count(); + let picked: Vec<bool> = (0..n).map(|i| segments.contains(&(si, i))).collect(); + let ni = subpaths.len(); + if !picked.contains(&true) { + anchors.extend((0..sp.anchors.len()).map(|ai| ((si, ai), (ni, ai)))); + subpaths.push(sp.clone()); + } else if !picked.contains(&false) { + if let Some(s) = simplify_subpath(sp, opts) { + runs.extend((0..s.segment_count()).map(|i| (ni, i))); + subpaths.push(s); + } + } else { + let (s, segs, kept) = simplify_runs(sp, &picked, opts); + runs.extend(segs.into_iter().map(|i| (ni, i))); + anchors.extend(kept.into_iter().map(|(old, new)| ((si, old), (ni, new)))); + subpaths.push(s); + } + } + SimplifiedPart { path: PathData::new(subpaths), segments: runs, anchors } +} + +/// [`simplify_part`] for a subpath with some but not all segments `picked`: → (the subpath, the +/// segments of its refitted runs, old → new index of each anchor kept). +fn simplify_runs(sp: &SubPath, picked: &[bool], opts: &SimplifyOptions) -> (SubPath, Vec<usize>, Vec<(usize, usize)>) { + let m = sp.anchors.len(); + let n = picked.len(); + // An anchor inside a run (both its segments picked) goes; a closed subpath's walk starts at the + // first anchor that stays, so that no run crosses the start of the walk. + let inside = |a: usize| (sp.closed && a == 0 && picked[n - 1] && picked[0]) || (a > 0 && a < n && picked[a - 1] && picked[a]); + let start = (0..m).find(|&a| !inside(a)).unwrap_or(0); + let mut anchors = vec![sp.anchors[start]]; + let mut kept = vec![(start, 0)]; + let mut runs = vec![]; + let mut k = 0; + while k < n { + let j = (start + k) % n; + if !picked[j] { + let next = (j + 1) % m; + if k + 1 < n || !sp.closed { + kept.push((next, anchors.len())); + anchors.push(sp.anchors[next]); + } + k += 1; + continue; + } + let len = (k..n).take_while(|&i| picked[(start + i) % n]).count(); + let run: Vec<Seg> = (k..k + len).map(|i| (start + i) % n).map(|i| Seg { c: sp.segment(i), line: sp.segment_is_line(i) }).collect(); + let end = (start + k + len) % m; + let fitted = simplify_run(&run, opts); + let keep_original = fitted.is_empty() || !opts.straight_lines && fitted.len() > run.len(); + let first = anchors.len() - 1; + if keep_original { + anchors.extend((k + 1..k + len).map(|i| sp.anchors[(start + i) % m])); + } else { + let a = anchors[first]; + anchors[first] = with_handle(a, true, if fitted[0].line { a.p } else { fitted[0].c.p1 }); + for w in fitted.windows(2) { + let p = w[1].c.p0; + let h_in = if w[0].line { p } else { w[0].c.p2 }; + let h_out = if w[1].line { p } else { w[1].c.p1 }; + anchors.push(Anchor::with_handles(p, h_in, h_out)); + } + } + // The run's segments, from its first anchor on: a run back to the walk's start ends with + // the closing segment, the last index. + runs.extend(first..first + if keep_original { run.len() } else { fitted.len() }); + let end_seg = fitted.last().filter(|_| !keep_original); + if sp.closed && k + len == n { + // The run ends where the walk started. + if let Some(s) = end_seg { + anchors[0] = with_handle(anchors[0], false, if s.line { anchors[0].p } else { s.c.p2 }); + } + } else { + let a = sp.anchors[end]; + kept.push((end, anchors.len())); + anchors.push(match end_seg { + Some(s) => with_handle(a, false, if s.line { a.p } else { s.c.p2 }), + None => a, + }); + } + k += len; + } + (SubPath::new(anchors, sp.closed), runs, kept) +} + +/// `a` with its out (`out`) or in handle at `h` and the other as it is: still smooth when it was +/// and its handles still line up, a corner otherwise. +fn with_handle(a: Anchor, out: bool, h: Point) -> Anchor { + let (h_in, h_out) = if out { (a.h_in, h) } else { (h, a.h_out) }; + let kind = if a.kind == AnchorKind::Smooth { Anchor::with_handles(a.p, h_in, h_out).kind } else { AnchorKind::Corner }; + Anchor { p: a.p, h_in, h_out, kind } +} + /// Ramer–Douglas–Peucker polyline reduction. fn rdp(pts: &[Point], tol: f64) -> Vec<Point> { if pts.len() < 3 { @@ -136,7 +254,17 @@ fn rdp(pts: &[Point], tol: f64) -> Vec<Point> { keep[0] = true; keep[pts.len() - 1] = true; let mut stack = vec![(0, pts.len() - 1)]; + // Distances measured at most: lopsided splits (a shallow zigzag) make it quadratic. Past it, + // the points of the parts left are kept as they are. + let mut budget = pts.len().saturating_mul(64).saturating_add(1 << 20); while let Some((a, b)) = stack.pop() { + budget = match budget.checked_sub(b - a) { + Some(left) => left, + None => { + keep[a..=b].fill(true); + continue; + } + }; let (pa, pb) = (pts[a], pts[b]); let chord = pb - pa; let len = chord.hypot(); @@ -200,6 +328,23 @@ pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData { // Drop zero-length segments. segs.retain(|s| !(s.c.p0.distance(s.c.p3) < 1e-9 && s.c.p1.distance(s.c.p0) < 1e-9 && s.c.p2.distance(s.c.p0) < 1e-9)); let min_segs = if sp.closed { 2 } else { 1 }; + // Joints are merged along the path in one pass (a merged segment is tried with the one + // before it, then the next); the search below then only finds what a closed path's wrap + // leaves. (Merging at the first joint of a dense line one at a time shifts the rest each time.) + let total = segs.len(); + let mut merged = 0; + let mut done: Vec<Seg> = Vec::with_capacity(total); + for s in segs.drain(..) { + done.push(s); + while done.len() >= 2 && total - merged > min_segs { + let n = done.len(); + let Some(m) = merge_pair(&done[n - 2], &done[n - 1], tol) else { break }; + done.truncate(n - 1); + done[n - 2] = m; + merged += 1; + } + } + segs = done; let mut changed = true; while changed && segs.len() > min_segs { changed = false; @@ -403,9 +548,12 @@ pub fn join(paths: &[PathData], tolerance: f64) -> PathData { return PathData::new(closed); } while open.len() > 1 { - // Find the closest pair of endpoints on different subpaths. + // Find the closest pair of endpoints on different subpaths. Past this many subpaths every + // pair at every merge is too much (cubic): the first one is joined to its nearest. + const SEARCH_ALL: usize = 200; + let first_only = open.len() > SEARCH_ALL; let mut best = (f64::INFINITY, 0, 0, false, false); - for i in 0..open.len() { + for i in 0..if first_only { 1 } else { open.len() } { for j in (i + 1)..open.len() { for ei in [false, true] { for ej in [false, true] { diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 29c9f2449..12fafac2b 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -12,18 +12,20 @@ mod fit; mod offset; mod pathfinder; mod planar; +mod trap; pub use boolean::{BoolOp, DEFAULT_PRECISION, area, boolean, boolean_n, normalize, try_boolean, try_normalize, unite_all}; pub use edit::{ - AverageAxis, SIMPLIFY_LEVEL, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, remove_redundant_points, - simplify, simplify_level_tolerance, simplify_with, smooth, split_into_grid, + AverageAxis, SIMPLIFY_LEVEL, SimplifiedPart, SimplifyOptions, add_anchor_points, auto_simplify_level, average, join, remove_anchor, + remove_redundant_points, simplify, simplify_level_tolerance, simplify_part, simplify_with, smooth, split_into_grid, }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; pub use planar::{ - BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint, - live_paint_with_gaps, shape_builder, + BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, + live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap, }; +pub use trap::{TRAP_HEIGHT_WIDTH_RANGE, TRAP_MAX_WIDTH, TRAP_MIN_WIDTH, trap_band}; /// Errors from fallible operations. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 548546d8d..39eaddff9 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -8,11 +8,30 @@ pub use kurbo::{Cap, Join}; use linesweeper::topology::{ContourIdx, Contours}; use crate::boolean::{Arrangement, DEFAULT_PRECISION, Tidy, all_contours_to_path, contours_to_path, fill_bezpath, normalize_bez}; +use crate::edit::remove_redundant_points; /// Flattening/fit tolerance handed to kurbo's stroker. const STROKE_TOL: f64 = 1e-3; +/// Drop collinear and coincident spine vertices before stroking (#3446). Uses `tol` only for +/// redundancy; does not simplify or refit the spine. +fn compact_stroke_spine(bp: &BezPath, tol: f64) -> BezPath { + if bp.elements().is_empty() { + return BezPath::new(); + } + let tol = if tol.is_finite() && tol > 0.0 { tol } else { STROKE_TOL }; + remove_redundant_points(&PathData::from_bezpath(bp), tol).to_bezpath() +} + fn stroke_bez(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath { + let spine = compact_stroke_spine(bp, STROKE_TOL); + let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0)); + close_all(&kurbo::stroke(spine.iter(), &style, &StrokeOpts::default(), STROKE_TOL)) +} + +/// Like [`stroke_bez`] but without [`compact_stroke_spine`] (regression baseline in unit tests). +#[cfg(test)] +fn stroke_bez_without_spine_compact(bp: &BezPath, width: f64, cap: Cap, join: Join, miter_limit: f64) -> BezPath { let style = Stroke::new(width).with_join(join).with_caps(cap).with_miter_limit(miter_limit.max(1.0)); close_all(&kurbo::stroke(bp.iter(), &style, &StrokeOpts::default(), STROKE_TOL)) } @@ -217,4 +236,56 @@ mod tests { assert_eq!(out.subpaths.len(), 1); assert!(after > before, "{before} → {after}"); } + + fn outline_max_deviation(a: &BezPath, b: &BezPath) -> f64 { + use kurbo::ParamCurve; + use vectorcraft_geom::Point; + let pa = PathData::from_bezpath(a); + let pb = PathData::from_bezpath(b); + let mut worst: f64 = 0.0; + for (src, dst) in [(a, &pb), (b, &pa)] { + for seg in src.segments() { + for i in 0..=12 { + let p = seg.eval(i as f64 / 12.0); + let pt = Point::new(p.x, p.y); + worst = worst.max(dst.nearest(pt).unwrap().4); + } + } + } + worst + } + + #[test] + fn compact_stroke_spine_drops_colinear_points_without_moving_curved_outlines() { + let width = 10.0; + let cap = Cap::Round; + let join = Join::Round; + let miter = 4.0; + // Dense colinear spine: compact outline should match the uncompacted one closely. + let dense: Vec<vectorcraft_geom::Point> = (0..=40).map(|i| vectorcraft_geom::Point::new(i as f64 * 2.5, 50.0)).collect(); + let dense_path = PathData::single(SubPath::polyline(&dense, false)); + let dense_bez = dense_path.to_bezpath(); + let compact = stroke_bez(&dense_bez, width, cap, join, miter); + let raw = stroke_bez_without_spine_compact(&dense_bez, width, cap, join, miter); + assert!(outline_max_deviation(&compact, &raw) <= 1e-3, "colinear compaction must not refit the stroke outline"); + let sparse = + PathData::single(SubPath::polyline(&[vectorcraft_geom::Point::new(0.0, 50.0), vectorcraft_geom::Point::new(100.0, 50.0)], false)); + let sparse_outline = outline_stroke(&sparse, width, cap, join, miter); + let compact_region = stroke_region(&[compact]); + assert!( + outline_max_deviation(&compact_region.to_bezpath(), &sparse_outline.to_bezpath()) <= 1e-3, + "compacted colinear spine should outline like the endpoints alone" + ); + // Sampled arc: only redundant vertices are removed; outline must track the raw stroke. + let arc: Vec<vectorcraft_geom::Point> = (0..=32) + .map(|i| { + let t = i as f64 / 32.0 * std::f64::consts::PI; + vectorcraft_geom::Point::new(50.0 + 50.0 * t.cos(), 50.0 + 8.0 * t.sin()) + }) + .collect(); + let arc_bez = PathData::single(SubPath::polyline(&arc, false)).to_bezpath(); + let arc_compact = stroke_bez(&arc_bez, width, cap, join, miter); + let arc_raw = stroke_bez_without_spine_compact(&arc_bez, width, cap, join, miter); + assert!(outline_max_deviation(&arc_compact, &arc_raw) <= 1e-3, "curved spine compaction must not refit the stroke outline"); + } } diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index aa63f0381..593c6215c 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -26,7 +26,7 @@ impl Shape { } } -/// The ten Pathfinder panel operations. +/// Pathfinder panel and effect operations. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum PathfinderOp { /// Shape mode: union of everything; result keeps the front-most key. @@ -50,6 +50,12 @@ pub enum PathfinderOp { Outline, /// Front-most minus everything behind; keeps the front-most key. MinusBack, + /// Effect only: split into faces like Divide; overlapping faces take the highest of each CMYK + /// channel from every shape covering them (Illustrator Effect → Pathfinder → Hard Mix). + HardMix, + /// Effect only: split into faces like Divide; overlapping faces blend fills in CMYK bottom to + /// top by a mixing rate (Illustrator Effect → Pathfinder → Soft Mix). + SoftMix, } /// One face of the planar arrangement of a set of shapes (Shape Builder / Live Paint face). @@ -172,6 +178,13 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> { .collect() } PathfinderOp::Outline => outline_edges(&arr, shapes), + PathfinderOp::HardMix | PathfinderOp::SoftMix => regions_of(&arr) + .into_iter() + .filter_map(|r| { + let key = shapes.get(r.top()?)?.key; + Some(Shape::new(r.path, FillRule::NonZero, key)) + }) + .collect(), } } @@ -338,6 +351,12 @@ impl FaceMerger { Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines, snap: std::cell::OnceCell::new() } } + /// Merge faces found with Gap Detection using the same temporary bridges as Live Paint. + /// Source indices stay those of the original inputs; bridges have no fill and are never art. + pub fn with_gap(shapes: &[Shape], gap: f64) -> Self { + Self::new(&crate::planar::gap_inputs(shapes, gap)) + } + /// The union of `faces`. pub fn merge(&self, faces: &[&Region]) -> PathData { self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces)) @@ -595,11 +614,20 @@ fn outline_edges(arr: &Arrangement, shapes: &[Shape]) -> Vec<Shape> { }) .collect(); ts.sort_by(f64::total_cmp); - let start_is_junction = ts.first().is_some_and(|&t| t < 1e-9); + // Junctions are compared where they are, not by parameter: a shape's own anchor and + // a crossing computed a hair from it are one junction, and one within `reach` of an + // end of the segment is that end. Else a sliver piece is left between them (#3870). + let at = |t: f64| kurbo::ParamCurve::eval(&c, t); + let start_is_junction = ts.first().is_some_and(|&t| at(t).distance(c.p0) <= reach); let mut cuts: Vec<f64> = vec![0.0]; - cuts.extend(ts.into_iter().filter(|&t| t > 1e-9 && t < 1.0 - 1e-9)); + for t in ts { + let p = at(t); + let last = at(cuts.last().copied().unwrap_or(0.0)); + if p.distance(last) > reach && p.distance(c.p3) > reach { + cuts.push(t); + } + } cuts.push(1.0); - cuts.dedup_by(|a, b| (*a - *b).abs() < 1e-9); for (k, w) in cuts.windows(2).enumerate() { let sub = c.subsegment(w[0]..w[1]); let seg = if line { Seg::line(sub.p0, sub.p3) } else { Seg { c: sub, line: false } }; @@ -918,9 +946,11 @@ mod tests { #[test] fn divide_open_paths_retains_the_existing_segment_cap() { let mut inputs = vec![Shape::new(rect(0.0, 0.0, 100.0, 100.0), FillRule::NonZero, 7)]; - inputs.extend((0..4093).map(|_| Shape::new(shapes::line(Point::new(-10.0, 50.0), Point::new(110.0, 50.0)), FillRule::NonZero, 99))); + // The square's four sides and one line past the cap. + let n = crate::SHAPE_BUILDER_MAX_SEGMENTS - 3; + inputs.extend((0..n).map(|_| Shape::new(shapes::line(Point::new(-10.0, 50.0), Point::new(110.0, 50.0)), FillRule::NonZero, 99))); let faces = pathfinder(PathfinderOp::Divide, &inputs); - assert_eq!(faces.len(), 1, "4097 segments fall back to filled areas"); + assert_eq!(faces.len(), 1, "a segment past the cap falls back to filled areas"); assert_eq!(faces[0].key, 7); assert!((crate::area(&faces[0].path, faces[0].rule) - 10000.0).abs() < 1e-6); } diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index f9f0940b5..3e38e6ca1 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -94,9 +94,65 @@ fn mid(segs: &[Seg]) -> Option<Point> { segs.get(segs.len() / 2).map(|s| s.c.eval(0.5)) } +/// Rectangles looked up by a point inside them, when there are too many to test one by one for +/// every point (the map asks once per edge and once per hole, so a scan of them all made it +/// quadratic in the input, #937). Rectangles narrow in x are sorted by their left edge, so the +/// ones that can hold a point are one short run of them; those wide in x but narrow in y (a hatch +/// of long horizontal lines) are sorted by their top edge the same way; the few wide both ways (a +/// long diagonal across the art) are scanned. "Narrow" is up to twice the side of a cell holding +/// one rectangle on average, so a run holds about the rectangles of one row or column of cells. +struct RectIndex<T> { + by_x: Vec<(kurbo::Rect, T)>, + max_width: f64, + by_y: Vec<(kurbo::Rect, T)>, + max_height: f64, + wide: Vec<(kurbo::Rect, T)>, +} + +impl<T> RectIndex<T> { + fn new(items: Vec<(kurbo::Rect, T)>) -> Self { + let n = items.len().max(1) as f64; + let all = items.iter().map(|(r, _)| *r).reduce(|a, b| a.union(b)); + let cell = all + .map(|a| { + let side = (a.area() / n).sqrt(); + if side > 0.0 { 2.0 * side } else { 2.0 * a.width().max(a.height()) / n } + }) + .filter(|c| c.is_finite()) + .unwrap_or(0.0) + .max(1e-9); + let (mut by_x, mut by_y, mut wide) = (Vec::new(), Vec::new(), Vec::new()); + for it in items { + if it.0.width() <= cell { + by_x.push(it); + } else if it.0.height() <= cell { + by_y.push(it); + } else { + // A NaN extent fails both tests above, so a non-finite rectangle is never in a sorted + // run along that axis; `contains` rejects it wherever it lands. + wide.push(it); + } + } + by_x.sort_by(|a, b| a.0.x0.total_cmp(&b.0.x0)); + by_y.sort_by(|a, b| a.0.y0.total_cmp(&b.0.y0)); + let max_width = by_x.iter().map(|(r, _)| r.width()).fold(0.0, f64::max); + let max_height = by_y.iter().map(|(r, _)| r.height()).fold(0.0, f64::max); + Self { by_x, max_width, by_y, max_height, wide } + } + + /// The items whose rectangle, grown by `tol`, holds `p`. + fn at(&self, p: Point, tol: f64) -> impl Iterator<Item = &T> { + let lo = self.by_x.partition_point(|(r, _)| r.x0 < p.x - tol - self.max_width); + let run_x = self.by_x.get(lo..).unwrap_or_default().iter().take_while(move |(r, _)| r.x0 <= p.x + tol); + let lo = self.by_y.partition_point(|(r, _)| r.y0 < p.y - tol - self.max_height); + let run_y = self.by_y.get(lo..).unwrap_or_default().iter().take_while(move |(r, _)| r.y0 <= p.y + tol); + run_x.chain(run_y).chain(&self.wide).filter(move |(r, _)| r.inflate(tol, tol).contains(p)).map(|(_, t)| t) + } +} + /// The inputs' segments (in the sweep's frame), to tell which inputs an edge lies on. struct Outlines { - segs: Vec<(kurbo::Rect, PathSeg, usize)>, + segs: RectIndex<(PathSeg, usize)>, tol: f64, } @@ -105,18 +161,18 @@ impl Outlines { // A zero-length segment (a point) is the source of no edge, even one running through it. let segs = inputs .iter() - .flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), s, *i))) + .flat_map(|(bp, i)| bp.segments().map(move |s| (s.bounding_box(), (s, *i)))) .filter(|(r, ..)| r.width() > 0.0 || r.height() > 0.0) .collect(); // The sweep moves points by a few `eps` at most. - Self { segs, tol: 16.0 * eps } + Self { segs: RectIndex::new(segs), tol: 16.0 * eps } } /// The front-most input whose outline passes through `p`. fn source_at(&self, p: Point) -> Option<usize> { use kurbo::ParamCurveNearest; let tol = self.tol; - self.segs.iter().filter(|(r, s, _)| r.inflate(tol, tol).contains(p) && s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, _, i)| *i).max() + self.segs.at(p, tol).filter(|(s, _)| s.nearest(p, 1e-9).distance_sq <= tol * tol).map(|(_, i)| *i).max() } } @@ -170,12 +226,24 @@ fn twin(h: usize) -> usize { /// The segments of half-edge `h`, in its direction. fn half_segs(edges: &[Edge], h: usize) -> Vec<Seg> { - let Some(e) = edges.get(h / 2) else { return Vec::new() }; - if h.is_multiple_of(2) { - e.segs.clone() - } else { - e.segs.iter().rev().map(|s| Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line }).collect() - } + half_seg_iter(edges, h).collect() +} + +/// The segments of half-edge `h` in its direction, without copying the edge first (an edge can +/// hold thousands of segments, and the angle it leaves a vertex at needs the first few). +fn half_seg_iter(edges: &[Edge], h: usize) -> impl Iterator<Item = Seg> + '_ { + let segs = edges.get(h / 2).map(|e| e.segs.as_slice()).unwrap_or_default(); + let forward = h.is_multiple_of(2); + (0..segs.len()).filter_map(move |k| { + let s = segs.get(if forward { k } else { segs.len() - 1 - k })?; + Some(if forward { *s } else { Seg { c: CubicBez::new(s.c.p3, s.c.p2, s.c.p1, s.c.p0), line: s.line } }) + }) +} + +/// Where half-edge `h` ends (the vertex at the other end of its edge). +fn far_end(edges: &[Edge], h: usize) -> Option<Point> { + let e = edges.get(h / 2)?; + if h.is_multiple_of(2) { e.segs.last().map(|s| s.c.p3) } else { e.segs.first().map(|s| s.c.p0) } } /// The vertex half-edge `h` leaves from. @@ -188,32 +256,33 @@ fn origin(edges: &[Edge], h: usize) -> Option<usize> { /// Edges that only meet at `v` cross a circle around it in the order they leave `v` in, however /// they start out (tangent curves, or the sweep's short straight stubs at a vertex). fn leaving_angle(edges: &[Edge], h: usize, v: Point, r: f64) -> f64 { - let segs = half_segs(edges, h); - let mut bp = BezPath::new(); - for (k, s) in segs.iter().enumerate() { - if k == 0 { - bp.move_to(s.c.p0); - } - bp.curve_to(s.c.p1, s.c.p2, s.c.p3); - } - let mut prev = segs.first().map_or(v, |s| s.c.p0); + let first = half_seg_iter(edges, h).next(); + let mut prev = first.map_or(v, |s| s.c.p0); let mut hit: Option<Point> = None; - kurbo::flatten(bp.iter(), (r * 1e-4).max(1e-12), |el| { - if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) { - if p.distance(v) >= r { - // The point on prev → p at distance r (prev is closer). - let (d, f) = (p - prev, prev - v); - let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r); - let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 }; - hit = Some(prev + d * t); + // One segment at a time, stopping at the first that gets r away: an edge can run on for + // thousands of segments (a traced outline between two crossings), all of which flattening + // the whole edge to this tolerance would visit, at every vertex. + for s in half_seg_iter(edges, h) { + let path = [kurbo::PathEl::MoveTo(s.c.p0), kurbo::PathEl::CurveTo(s.c.p1, s.c.p2, s.c.p3)]; + kurbo::flatten(path, (r * 1e-4).max(1e-12), |el| { + if let (None, kurbo::PathEl::LineTo(p)) = (hit, el) { + if p.distance(v) >= r { + // The point on prev → p at distance r (prev is closer). + let (d, f) = (p - prev, prev - v); + let (a, b, c) = (d.hypot2(), 2.0 * f.dot(d), f.hypot2() - r * r); + let t = if a > 0.0 { ((-b + (b * b - 4.0 * a * c).max(0.0).sqrt()) / (2.0 * a)).clamp(0.0, 1.0) } else { 1.0 }; + hit = Some(prev + d * t); + } + prev = p; } - prev = p; + }); + if hit.is_some() { + break; } - }); + } // An edge too short to reach r (it can't, r is at most half of every chord) leaves along its // tangent. - let far = - hit.unwrap_or_else(|| segs.first().map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3))); + let far = hit.unwrap_or_else(|| first.map_or(v, |s| [s.c.p1, s.c.p2, s.c.p3].into_iter().find(|p| p.distance(s.c.p0) > 0.0).unwrap_or(s.c.p3))); (far - v).atan2() } @@ -581,9 +650,11 @@ pub fn find_gaps(shapes: &[Shape], max_gap: f64) -> Vec<[Point; 2]> { } /// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed -/// paths enclose, outline edges): the map costs far more than the filled areas' arrangement, so past -/// this only the filled areas count. -pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096; +/// paths enclose, outline edges); past this only the filled areas count. The map costs about what +/// the filled areas' arrangement does (both near linear in the input; the cap was 4096 while +/// finding each edge's source scanned every segment, #937), so this only bounds the time a hover +/// can take on art far past what a drawing holds. +pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 65_536; /// The Shape Builder's arrangement: see [`shape_builder`]. #[derive(Clone, Debug, Default, PartialEq)] @@ -613,12 +684,44 @@ pub fn has_open_path(shapes: &[Shape]) -> bool { /// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas /// count and there are no pieces. pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement { + shape_builder_with_gap(shapes, edges, 0.0) +} + +/// Most distance (document units) a Shape Builder gap may span. +pub const SHAPE_BUILDER_MAX_GAP: f64 = 72.0; + +/// Original inputs and the shared detector's temporary, unfilled bridges. Keep the originals' +/// order and keys so only their split pieces can be returned as art. +pub(crate) fn gap_inputs(shapes: &[Shape], gap: f64) -> Vec<Shape> { + let mut closed = shapes.to_vec(); + let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); + if segments <= SHAPE_BUILDER_MAX_SEGMENTS && gap.is_finite() && gap > 0.0 && has_open_path(shapes) { + let gaps = find_gaps(shapes, gap.min(SHAPE_BUILDER_MAX_GAP)); + if !gaps.is_empty() { + let path = PathData::new(gaps.iter().map(|g| SubPath::polyline(g, false)).collect()); + closed.push(Shape::new(path, FillRule::NonZero, u64::MAX)); + } + } + closed +} + +/// [`shape_builder`] with Gap Detection: open paths whose ends come within `gap` (document units) +/// of another path are bridged for the regions they bound, so a region left open by a small gap is +/// found. A `gap` of 0 is [`shape_builder`]. The bridges are in no region's sources and in no piece +/// of `lines` or `edges`. +pub fn shape_builder_with_gap(shapes: &[Shape], edges: bool, gap: f64) -> BuilderArrangement { let has_open = has_open_path(shapes); let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS) .then(|| { let keyed: Vec<Shape> = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect(); - live_paint(&keyed) + if has_open && gap.is_finite() && gap > 0.0 { + let (faces, pieces) = live_paint(&gap_inputs(&keyed, gap)); + // Gap targets split original outlines too, but the bridges themselves aren't art. + (faces, pieces.into_iter().filter(|p| p.key < shapes.len() as u64).collect()) + } else { + live_paint(&keyed) + } }) .filter(|(faces, pieces)| !faces.is_empty() || !pieces.is_empty()); let Some((faces, pieces)) = planar else { @@ -717,6 +820,69 @@ pub fn cut_out(path: &PathData, piece: &SubPath, fill_closes: bool, tol: f64) -> Some(PathData::new(subpaths)) } +/// The run of `piece` (a Shape Builder edge or line piece) around the point of it nearest `at`, +/// from the nearest of `stops` before that point to the nearest after it, open. Stops are the +/// points within `tol` of the piece, such as the anchors of the path it lies on; an open piece's +/// own ends bound the run where no stop comes first. None when the piece has no segments, or when +/// it is closed and fewer than two different stops lie on it (the run would be all of it). +pub fn run_around(piece: &SubPath, stops: &[Point], at: Point, tol: f64) -> Option<SubPath> { + let n = piece.segment_count(); + if n == 0 { + return None; + } + let len = n as f64; + let (u, _) = nearest_u(piece, at)?; + // The stops by x, so that each segment measures only the stops within its bounds. + let mut by_x: Vec<Point> = stops.iter().copied().filter(|s| s.x.is_finite() && s.y.is_finite()).collect(); + by_x.sort_by(|a, b| a.x.total_cmp(&b.x)); + // The parameters of the stops within `tol` of segment `j` whose nearest point on the whole + // piece lies on it (a ring's segments repeat past either end, `len` apart). + let on = |j: isize| -> Vec<f64> { + use kurbo::ParamCurveNearest; + let seg = piece.segment(j.rem_euclid(n as isize) as usize); + let b = kurbo::Shape::bounding_box(&seg).inflate(tol, tol); + let near = by_x.get(by_x.partition_point(|s| s.x < b.x0)..).unwrap_or_default(); + near.iter() + .take_while(|s| s.x <= b.x1) + .filter(|s| s.y >= b.y0 && s.y <= b.y1) + .filter_map(|s| { + let m = seg.nearest(*s, 1e-9); + let d = m.distance_sq.sqrt(); + (d <= tol && nearest_u(piece, *s).is_some_and(|(_, nearest)| d <= nearest + 1e-9)).then_some(j as f64 + m.t) + }) + .collect() + }; + // From the segment under `at`, the walk goes back to the nearest stop at or before it and on + // to the nearest stop after it; an open piece's ends bound it. + let k0 = (u.floor().max(0.0) as isize).min(n as isize - 1); + let (first, last) = if piece.closed { (k0 - n as isize, k0 + n as isize) } else { (0, n as isize - 1) }; + let lo = (first..=k0).rev().find_map(|j| on(j).into_iter().filter(|&x| x <= u).max_by(f64::total_cmp)); + let lo = match lo { + Some(lo) => lo, + None if !piece.closed => 0.0, + None => return None, + }; + let after = u.max(lo + 1e-9); + let hi = (k0..=last).find_map(|j| on(j).into_iter().filter(|&x| x > after).min_by(f64::total_cmp)); + let hi = match hi { + Some(hi) => hi, + None if !piece.closed => len, + None => return None, + }; + // On a ring, a lone stop would make the run all of it. + if piece.closed && hi - lo >= len - 1e-9 { + return None; + } + // A run that passes the ring's start begins before its end and is unrolled past it. + let (lo, hi) = if lo < 0.0 { (lo + len, hi + len) } else { (lo, hi) }; + let run = span(piece, lo, hi)?; + // The run's ends go exactly on the stops they end at. + let stop_at = + |q: Point| stops.iter().copied().filter(|s| s.distance(q) <= tol).min_by(|a, b| a.distance(q).total_cmp(&b.distance(q))).unwrap_or(q); + let ends = [run.anchors.first()?.p, run.anchors.last()?.p].map(stop_at); + Some(snap_ends(run, ends, tol)) +} + /// The point of `sp` nearest `p`, as a parameter `segment + t`, with its distance. fn nearest_u(sp: &SubPath, p: Point) -> Option<(f64, f64)> { use kurbo::ParamCurveNearest; @@ -769,8 +935,10 @@ pub(crate) struct Map { edges: Vec<Edge>, around: Vec<Vec<usize>>, slot_of: Vec<usize>, - /// Bounded faces: their cycle of half-edges, path and area. + /// Bounded faces: their cycle of half-edges, path and area; and, by their bounds, which can + /// hold a point, so finding the face around one tests only those. faces: Vec<(Vec<usize>, BezPath, f64)>, + face_index: RectIndex<usize>, /// Outer boundaries of connected pieces, and the face each one is a hole in. holes: Vec<(Vec<usize>, BezPath)>, face_holes: Vec<Vec<usize>>, @@ -852,6 +1020,36 @@ pub(crate) fn map(shapes: &[Shape]) -> Option<Map> { } let close = |a: usize, b: usize| matches!((points.get(a), points.get(b)), (Some(p), Some(q)) if p.distance(*q) <= tol); let mut joined: Vec<usize> = (0..points.len()).collect(); + // Where two passes of one path cross another (an open path's way there and back, coincident + // outlines), the sweep can also join its two cuts, a rounding error apart, with a stub of that + // length: a speck of a piece, and a vertex twice over. Such stubs are no edges: their ends are + // one vertex. The sweep moves points by a few `eps` at most up and down its frame; along a path + // that runs nearly level in it (where the height a cut is found at pins its position down + // least) a cut can slide further, here up to a small fraction of the precision results are + // tidied to, below which no edge is drawn anyway. A vertex's cluster stays within those bounds + // as a whole: a run of short, nearly level segments (a finely flattened or traced path) is real + // geometry, not one stub after another, and must not collapse into one vertex. + let (stub_y, stub_x) = (4.0 * eps, (DEFAULT_PRECISION / 16.0).max(4.0 * eps)); + let mut extent: Vec<kurbo::Rect> = points.iter().map(|&p| kurbo::Rect::from_points(p, p)).collect(); + for (segs, [v0, v1]) in &pieces { + let Some(&p0) = points.get(*v0) else { continue }; + if !segs.iter().flat_map(|s| [s.c.p1, s.c.p2, s.c.p3]).all(|p| (p.y - p0.y).abs() <= stub_y && (p.x - p0.x).abs() <= stub_x) { + continue; + } + let (a, b) = (find(&mut joined, *v0), find(&mut joined, *v1)); + let (Some(&ra), Some(&rb)) = (extent.get(a), extent.get(b)) else { continue }; + let both = ra.union(rb); + if a == b || both.width() > stub_x || both.height() > stub_y { + continue; + } + let (root, child) = (a.min(b), a.max(b)); + if let Some(p) = joined.get_mut(child) { + *p = root; + } + if let Some(r) = extent.get_mut(root) { + *r = both; + } + } for (k, (_, [a, b])) in pieces.iter().enumerate() { let Some(&Some(m)) = mids.get(k) else { continue }; let (cx, cy) = cell_of(m); @@ -925,7 +1123,7 @@ pub(crate) fn map(shapes: &[Shape]) -> Option<Map> { for (v, list) in around.iter_mut().enumerate() { let Some(&at) = points.get(v) else { continue }; // Half the shortest chord of the edges here: every one of them gets that far from it. - let r = 0.5 * list.iter().filter_map(|&h| half_segs(&edges, h).last().map(|s| s.c.p3.distance(at))).fold(f64::INFINITY, f64::min); + let r = 0.5 * list.iter().filter_map(|&h| far_end(&edges, h).map(|p| p.distance(at))).fold(f64::INFINITY, f64::min); let mut keyed: Vec<(f64, usize)> = list.iter().map(|&h| (leaving_angle(&edges, h, at, r), h)).collect(); keyed.sort_by(|a, b| a.0.total_cmp(&b.0)); *list = keyed.into_iter().map(|(_, h)| h).collect(); @@ -986,14 +1184,15 @@ pub(crate) fn map(shapes: &[Shape]) -> Option<Map> { } // Each hole goes in the smallest face of another piece around it. + let face_index = RectIndex::new(faces.iter().enumerate().map(|(i, (_, bp, _))| (bp.bounding_box(), i)).collect()); let mut face_holes: Vec<Vec<usize>> = vec![Vec::new(); faces.len()]; for (k, (cycle, _)) in holes.iter().enumerate() { let Some(p) = cycle.first().and_then(|&h| origin(&edges, h)).and_then(|v| points.get(v)).copied() else { continue }; let own = piece_of(cycle); - let around_it = faces - .iter() - .enumerate() - .filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.bounding_box().contains(p) && bp.winding(p) != 0) + let around_it = face_index + .at(p, 0.0) + .filter_map(|&i| faces.get(i).map(|f| (i, f))) + .filter(|(_, (fc, bp, _))| piece_of(fc) != own && bp.winding(p) != 0) .min_by(|a, b| a.1.2.total_cmp(&b.1.2)) .map(|(i, _)| i); if let Some(list) = around_it.and_then(|i| face_holes.get_mut(i)) { @@ -1005,7 +1204,7 @@ pub(crate) fn map(shapes: &[Shape]) -> Option<Map> { let snap = 2.0 * eps; let mut anchors: Vec<Point> = shapes.iter().flat_map(|s| s.path.subpaths.iter().flat_map(|sp| sp.anchors.iter().map(|a| a.p))).collect(); anchors.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); - Some(Map { edges, around, slot_of, faces, holes, face_holes, tidy, anchors, snap }) + Some(Map { edges, around, slot_of, faces, face_index, holes, face_holes, tidy, anchors, snap }) } impl Map { @@ -1028,10 +1227,10 @@ impl Map { let q = SHEAR * p; // The smallest face around it: faces of a piece inside a hole lie within the face around. let (i, _) = self - .faces - .iter() - .enumerate() - .filter(|(_, (_, bp, _))| bp.bounding_box().contains(q) && bp.winding(q) != 0) + .face_index + .at(q, 0.0) + .filter_map(|&i| self.faces.get(i).map(|f| (i, f))) + .filter(|(_, (_, bp, _))| bp.winding(q) != 0) .min_by(|a, b| a.1.2.total_cmp(&b.1.2))?; *picked.get_mut(i)? = true; } @@ -1542,6 +1741,78 @@ mod tests { assert_eq!(lines.len(), 9); } + #[test] + fn shape_builder_merges_adjacent_gap_closed_faces_without_keeping_bridges() { + let outline = Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 4.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0), Point::new(0.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + ); + let shapes = [outline, line((50.0, 0.0), (50.0, 100.0), 1)]; + let arr = shape_builder_with_gap(&shapes, true, 6.0); + assert_eq!(arr.regions.len(), 2); + let faces: Vec<_> = arr.regions.iter().collect(); + let merged = crate::FaceMerger::with_gap(&shapes, 6.0).merge(&faces); + assert!((crate::area(&merged, FillRule::NonZero).abs() - 10000.0).abs() < 1e-6); + assert_eq!(merged.subpaths.len(), 1, "the shared edge disappears"); + assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key < 2), "bridges are not art"); + // A request over 72 pt cannot close a gap of 80 pt. + let large = [Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 80.0), Point::new(0.0, 200.0), Point::new(200.0, 200.0), Point::new(200.0, 0.0), Point::new(0.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + )]; + assert!(shape_builder_with_gap(&large, false, 1000.0).regions.is_empty()); + } + + #[test] + fn shape_builder_gap_detection_closes_small_gaps() { + // Three sides of a 100 x 100 square, the fourth open by 4: no region without Gap Detection. + let open = |gap: f64| { + let pts = [(0.0, gap), (0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)]; + let pts: Vec<Point> = pts.iter().map(|&(x, y)| Point::new(x, y)).collect(); + vec![Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0)] + }; + let s = open(4.0); + assert!(shape_builder(&s, false).regions.is_empty()); + assert!(shape_builder_with_gap(&s, false, 3.0).regions.is_empty(), "a gap longer than the length stays open"); + let arr = shape_builder_with_gap(&s, true, 6.0); + assert_eq!(arr.regions.len(), 1); + assert!((area(&arr.regions[0]) - 10000.0).abs() < 1e-6); + // The bridge is no art: the path comes back as it was, in its own pieces only. + assert!(arr.lines.iter().chain(&arr.edges).all(|p| p.key == 0)); + let len: f64 = arr.lines.iter().map(|p| p.path.to_bezpath().perimeter(1e-6)).sum(); + assert!((len - 396.0).abs() < 1e-6, "{len}"); + // Two paths whose ends miss each other: a U and a lid 3 above it. + let u = Shape::new( + PathData::single(SubPath::polyline( + &[Point::new(0.0, 0.0), Point::new(0.0, 100.0), Point::new(100.0, 100.0), Point::new(100.0, 0.0)], + false, + )), + FillRule::NonZero, + 0, + ); + let s = [u, line((0.0, -3.0), (100.0, -3.0), 1)]; + assert!(shape_builder_with_gap(&s, false, 2.0).regions.is_empty()); + let arr = shape_builder_with_gap(&s, false, 3.5); + assert_eq!(arr.regions.len(), 1); + assert!((area(&arr.regions[0]) - 10300.0).abs() < 1e-6); + // An end close to the side of another path bridges to the nearest point on it. + let s = [rect(0.0, 0.0, 100.0, 100.0, 0), line((50.0, 140.0), (50.0, 104.0), 1)]; + assert_eq!(shape_builder_with_gap(&s, false, 5.0).regions.len(), 1, "nothing is enclosed, nothing breaks"); + // Bad gaps are no gap. + let s = open(4.0); + for gap in [f64::NAN, f64::INFINITY, -3.0] { + assert!(shape_builder_with_gap(&s, false, gap).regions.is_empty()); + } + } + #[test] fn shape_builder_closed_only_or_degenerate_is_the_filled_arrangement() { let s = [rect(0.0, 0.0, 100.0, 100.0, 0), rect(50.0, 0.0, 150.0, 100.0, 1)]; @@ -1629,6 +1900,76 @@ mod tests { assert!(shape_builder(&s, false).lines.is_empty()); } + #[test] + fn a_traced_outline_of_many_segments_is_cut_by_lines() { + // #937: art traced to thousands of segments was past the old cap (4096), so lines stopped + // cutting it and the Shape Builder offered the whole shapes. A blob of 20 000 segments + // crossed by two long lines (far wider than any of its segments: the index of the + // outlines keeps those apart) splits into four regions, each line in four pieces. + let n = 20_000; + let blob: Vec<Point> = (0..n) + .map(|k| { + let t = k as f64 / n as f64 * std::f64::consts::TAU; + let r = 100.0 + 5.0 * (t * 37.0).sin(); + Point::new(r * t.cos(), r * t.sin()) + }) + .collect(); + let s = [ + Shape::new(PathData::single(SubPath::polyline(&blob, true)), FillRule::NonZero, 0), + line((-150.0, 0.0), (150.0, 3.0), 1), + line((0.0, -150.0), (3.0, 150.0), 2), + ]; + let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); + assert_eq!(regions.len(), 4, "{}", regions.len()); + assert!(regions.iter().all(|r| r.sources == [0])); + let whole = crate::area(&s[0].path, FillRule::NonZero); + assert!((regions.iter().map(area).sum::<f64>() - whole).abs() < 1e-6 * whole); + assert_eq!(lines.iter().filter(|l| l.key == 1).count(), 4, "cut twice by the blob and once by the other line"); + assert_eq!(lines.iter().filter(|l| l.key == 2).count(), 4); + } + + #[test] + fn a_hatch_of_parallel_lines_is_cut_line_by_line() { + // Hundreds of long horizontal lines over a square, all alike in x (the weak axis of an index + // by x alone), and one vertical line: every strip is cut in two. + let n: u32 = 300; + let mut s = vec![rect(0.0, 0.0, 100.0, 100.0, 0)]; + for k in 1..n { + let y = 100.0 * f64::from(k) / f64::from(n); + s.push(line((-10.0, y), (110.0, y), u64::from(k))); + } + s.push(line((50.0, -10.0), (50.0, 110.0), u64::from(n))); + let BuilderArrangement { regions, lines, .. } = shape_builder(&s, false); + assert_eq!(regions.len(), 2 * n as usize, "{}", regions.len()); + assert!(regions.iter().all(|r| r.sources == [0])); + assert!((regions.iter().map(area).sum::<f64>() - 10000.0).abs() < 1e-6); + // Each horizontal line: a loose end outside, two pieces inside, a loose end outside. + assert_eq!(lines.iter().filter(|l| l.key == 1).count(), 4); + // The vertical line: cut by the square's sides and every hatch line. + assert_eq!(lines.iter().filter(|l| l.key == u64::from(n)).count(), n as usize + 2); + } + + #[test] + fn rect_index_finds_every_rectangle_holding_a_point() { + // Narrow in x, narrow in y only, and wide both ways: each lookup matches a scan of them all. + let mut items = vec![(kurbo::Rect::new(0.0, 0.0, 100.0, 100.0), 0usize)]; + for k in 1..50 { + let a = f64::from(k as u32) * 2.0; + items.push((kurbo::Rect::new(a, 0.0, a + 1.0, 100.0), k)); + items.push((kurbo::Rect::new(0.0, a, 100.0, a + 1.0), 100 + k)); + } + items.push((kurbo::Rect::new(f64::NAN, 0.0, 1.0, 1.0), 999)); + let index = RectIndex::new(items.clone()); + for p in [Point::new(4.5, 6.5), Point::new(50.0, 50.0), Point::new(-1.0, 3.0), Point::new(99.5, 0.5)] { + let mut got: Vec<usize> = index.at(p, 0.1).copied().collect(); + got.sort_unstable(); + let mut want: Vec<usize> = items.iter().filter(|(r, _)| r.inflate(0.1, 0.1).contains(p)).map(|(_, k)| *k).collect(); + want.sort_unstable(); + assert_eq!(got, want, "{p:?}"); + } + assert_eq!(RectIndex::<usize>::new(Vec::new()).at(Point::ZERO, 1.0).count(), 0); + } + fn poly(pts: &[(f64, f64)], closed: bool) -> SubPath { SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), closed) } @@ -1637,6 +1978,102 @@ mod tests { (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p) } + /// Each piece cuts out of the input it is keyed by, losing just its own length (what erasing + /// it does), and none is a speck. + fn assert_pieces_cut_out(shapes: &[Shape], arr: &BuilderArrangement) { + for e in arr.edges.iter().chain(&arr.lines) { + let len = e.path.length(); + assert!(len > 1e-3, "a speck of a piece, keyed {}: {:?}", e.key, e.path); + let s = &shapes[e.key as usize]; + let cut = cut_out(&s.path, &e.path.subpaths[0], false, 0.05).unwrap_or_else(|| panic!("{:?} not on input {}", e.path, e.key)); + let lost = s.path.length() - cut.length(); + assert!((lost - len).abs() <= 1e-6 * s.path.length(), "input {} lost {lost} for a piece {len} long", e.key); + } + } + + #[test] + fn a_line_crossed_where_its_two_passes_part_is_cut_once() { + // The shrunk case of `pieces_cut_out_of_their_inputs`: the sweep cut the first line's way + // there and its way back by the third line a rounding error (1.2e-6) apart, joined by a + // stub. The line came back as a speck keyed by the third line and a piece running from + // the crossing out to (10, 70) and back to the other cut, which erasing took twice over. + let s = [line((10.0, 70.0), (80.0, 20.0), 0), line((10.0, 40.0), (70.0, 90.0), 0), line((20.0, 80.0), (20.0, 50.0), 0)]; + let arr = shape_builder(&s, true); + assert_pieces_cut_out(&s, &arr); + // The first line cut by both others, the other two once each. + let count = |k: u64| arr.lines.iter().filter(|l| l.key == k).count(); + assert_eq!((count(0), count(1), count(2)), (3, 2, 2), "{:?}", arr.lines); + let at = Point::new(20.0, 70.0 - 50.0 / 7.0); + for k in [0, 2] { + assert!(arr.lines.iter().filter(|l| l.key == k).all(|l| { + let (a, b) = ends(&l.path.subpaths[0]); + a.distance(b) > 1.0 && l.path.subpaths[0].anchors.len() == 2 + })); + assert_eq!( + arr.lines + .iter() + .filter(|l| l.key == k && [ends(&l.path.subpaths[0]).0, ends(&l.path.subpaths[0]).1].iter().any(|p| p.distance(at) < 1e-9)) + .count(), + 2 + ); + } + } + + #[test] + fn a_run_of_tiny_level_segments_is_not_contracted_as_stubs() { + // 2000 segments of 1e-4 (0.2 pt in all), nearly level: each one alone is as short as a + // stub the sweep leaves between two cuts, but chained they are real geometry, and the + // stub contraction must not pull them into one vertex. + let pts: Vec<Point> = (0..=2000).map(|k| Point::new(f64::from(k) * 1e-4, f64::from(k) * 1e-8)).collect(); + let run = Shape::new(PathData::single(SubPath::polyline(&pts, false)), FillRule::NonZero, 0); + let whole = run.path.length(); + let s = [run, line((0.1003, -1.0), (0.1003, 1.0), 0)]; + let arr = shape_builder(&s, false); + let pieces: Vec<&Shape> = arr.lines.iter().filter(|l| l.key == 0).collect(); + assert_eq!(pieces.len(), 2, "cut once by the line: {}", pieces.len()); + let total: f64 = pieces.iter().map(|l| l.path.length()).sum(); + // Where the line crosses, one segment may be taken for a stub (it is as short as one), + // moving the cut by less than a stub's reach; the rest of the run stays. + assert!((total - whole).abs() <= DEFAULT_PRECISION / 16.0, "{total} of {whole}"); + for l in &pieces { + let (a, b) = ends(&l.path.subpaths[0]); + assert!(a.distance(b) > 0.09, "a piece collapsed: {a:?} {b:?}"); + } + assert_eq!(arr.lines.iter().filter(|l| l.key == 1).count(), 2); + } + + #[test] + fn a_dense_hatch_at_exact_binary_steps_cuts_each_line_once_per_crossing() { + // Lines every 0.25 or 0.5 pt (exact in binary) cut a crossing line or a circle where two + // passes of each line met it a rounding error apart, adding a speck and a piece there. + let hatch = |n: i32, step: f64, f: &dyn Fn(f64) -> Shape| -> Vec<Shape> { (1..=n).map(|k| f(step * f64::from(k))).collect() }; + // Horizontal lines across a diagonal: each in two pieces, the diagonal in n + 1. + let mut s = vec![line((-3.0, -2.0), (33.0, 31.0), 0)]; + s.extend(hatch(119, 0.25, &|y| line((-5.0, y), (35.0, y), 0))); + let arr = shape_builder(&s, false); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.lines.iter().filter(|l| l.key == 0).count(), 120); + assert!((1..=119).all(|k| arr.lines.iter().filter(|l| l.key == k).count() == 2)); + assert_eq!(arr.lines.len(), 120 + 2 * 119); + // Sloping lines across a 45° one. + let mut s = vec![line((-3.0, -3.0), (33.0, 33.0), 0)]; + s.extend(hatch(59, 0.5, &|v| line((v - 40.0, -5.0), (v + 40.0, 35.0), 0))); + let arr = shape_builder(&s, false); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.lines.len(), 60 + 2 * 59, "{}", arr.lines.len()); + // Vertical lines across a circle: n + 1 strips, each line in three pieces, the circle's + // outline in two per line. + let mut s = vec![Shape::new(shapes::ellipse(Rect::new(0.0, 0.0, 30.0, 30.0)), FillRule::NonZero, 0)]; + s.extend(hatch(119, 0.25, &|x| line((x, -5.0), (x, 35.0), 0))); + let arr = shape_builder(&s, true); + assert_pieces_cut_out(&s, &arr); + assert_eq!(arr.regions.len(), 120); + assert!(arr.regions.iter().all(|r| r.sources == [0])); + assert!((1..=119).all(|k| arr.lines.iter().filter(|l| l.key == k).count() == 3)); + assert_eq!(arr.lines.len(), 3 * 119); + assert_eq!(arr.edges.len(), 2 * 119); + } + #[test] fn shape_builder_edges_are_the_outlines_cut_where_paths_meet() { // Two overlapping squares: each outline in two pieces, cut where the other crosses it. @@ -1690,6 +2127,60 @@ mod tests { assert!((cut.length() - 160.0).abs() < 1e-6, "{}", cut.length()); } + #[test] + fn run_around_stops_at_the_stops_either_side() { + // A lone square's edge is its whole outline; with its corners as stops, a point by any side + // gives that side. + let lone = shape_builder(&[rect(0.0, 0.0, 100.0, 100.0, 0)], true); + let edge = &lone.edges[0].path.subpaths[0]; + let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(Point::from); + let side = |at: (f64, f64)| { + run_around(edge, &corners, Point::from(at), 1e-3) + .map(|sp| (sp.closed, PathData::single(sp.clone()).length(), PathData::single(sp).bounds())) + }; + assert_eq!(side((50.0, 1.0)), Some((false, 100.0, Some(Rect::new(0.0, 0.0, 100.0, 0.0))))); + for at in [(0.5, 50.0), (99.0, 50.0), (50.0, 100.0)] { + let (closed, length, _) = side(at).expect("a side"); + assert!(!closed && (length - 100.0).abs() < 1e-6, "{at:?}: {length}"); + } + // Without two stops on it, the run would be all of the outline. + assert_eq!(run_around(edge, &corners[..1], Point::new(50.0, 0.0), 1e-3), None); + // An open piece's run ends at the piece's own ends where no stop comes first; (40, 5) lies + // off the line and does not stop the run. + let line = poly(&[(0.0, 0.0), (100.0, 0.0)], false); + let run = run_around(&line, &[Point::new(40.0, 0.0), Point::new(40.0, 5.0)], Point::new(70.0, 0.0), 1e-3).unwrap(); + assert_eq!(ends(&run), (Point::new(40.0, 0.0), Point::new(100.0, 0.0))); + let run = run_around(&line, &[Point::new(40.0, 0.0)], Point::new(10.0, 0.0), 1e-3).unwrap(); + assert_eq!(ends(&run), (Point::new(0.0, 0.0), Point::new(40.0, 0.0))); + // A stop counts only where it is nearest the piece: the folded line's last anchor lies + // 0.02 from its first side and does not stop the run there. + let folded = poly(&[(0.0, 0.0), (100.0, 0.0), (50.0, 0.02)], false); + let stops = folded.anchors.iter().map(|a| a.p).collect::<Vec<_>>(); + let run = run_around(&folded, &stops, Point::new(25.0, 0.0), 0.05).unwrap(); + assert_eq!(ends(&run), (Point::new(0.0, 0.0), Point::new(100.0, 0.0))); + } + + #[test] + fn run_around_a_ring_of_4000_sides_measures_only_the_stops_it_reaches() { + // Every anchor of the ring is a stop, so each run is one side. Measuring every stop along + // the whole ring would take 16 million nearest-point searches a call. + let n = 4000; + let pts: Vec<(f64, f64)> = (0..n) + .map(|k| { + let a = k as f64 * std::f64::consts::TAU / n as f64; + (500.0 * a.cos(), 500.0 * a.sin()) + }) + .collect(); + let ring = poly(&pts, true); + let stops: Vec<Point> = ring.anchors.iter().map(|a| a.p).collect(); + let start = std::time::Instant::now(); + for k in (0..n).step_by(200) { + let run = run_around(&ring, &stops, ring.segment(k).eval(0.5), 1e-3).expect("a side"); + assert_eq!(ends(&run), (stops[k], stops[(k + 1) % n]), "side {k}"); + } + assert!(start.elapsed().as_secs_f64() < 5.0, "20 runs took {:?}", start.elapsed()); + } + /// Proptest shrank a failure of `pieces_cut_out_of_their_inputs` to this input with linesweeper /// 0.5. The sweep cuts lines 0 and 1 at points of their crossing up to 1.4 `eps` apart, and /// `vertex_id` merges a point only into a vertex within `eps` of it. Line 0's loose end in the diff --git a/crates/pathops/src/trap.rs b/crates/pathops/src/trap.rs new file mode 100644 index 000000000..ab5c31571 --- /dev/null +++ b/crates/pathops/src/trap.rs @@ -0,0 +1,124 @@ +//! The geometry of a trap (Pathfinder › Trap): the thin strip where one colour is spread into the +//! area of an adjoining colour, so a slight misregistration on press leaves no paper gap between +//! them. See `vectorcraft_effects::trap` for which colour spreads and how the strip is painted. + +use kurbo::Affine; +use vectorcraft_geom::{FillRule, PathData}; + +use crate::boolean::{BoolOp, try_boolean, try_normalize}; +use crate::offset::{Cap, Join, outline_stroke}; + +/// Largest trap width, in points, as the public Trap options allow (0.01–5000 pt). +pub const TRAP_MAX_WIDTH: f64 = 5000.0; +/// Smallest trap width, in points. +pub const TRAP_MIN_WIDTH: f64 = 0.01; +/// Height/Width range (horizontal edges' trap as a fraction of vertical edges'). +pub const TRAP_HEIGHT_WIDTH_RANGE: (f64, f64) = (0.01, 10.0); + +/// Does every anchor and handle of `p` have finite coordinates? +fn finite(p: &PathData) -> bool { + p.subpaths.iter().flat_map(|sp| sp.anchors.iter()).all(|a| [a.p, a.h_in, a.h_out].iter().all(|q| q.x.is_finite() && q.y.is_finite())) +} + +/// The strip of `into` (with `into_rule`) within `width` of the edge of `spread` (with +/// `spread_rule`): where `spread`'s colour reaches when it grows by `width` across vertical edges +/// and by `width × height_width` across horizontal edges. Where the shapes overlap, the strip +/// also covers the overlap, so callers pass areas that don't overlap (the visible parts of two +/// objects). `precision` is the refit tolerance of the boolean that cuts the strip, in points. +/// +/// Empty when the shapes don't come within `width` of each other, when either is empty or has a +/// coordinate that isn't finite, or when `width` or `height_width` isn't a finite positive number. +/// `width` is capped at [`TRAP_MAX_WIDTH`] and `height_width` at [`TRAP_HEIGHT_WIDTH_RANGE`]. +pub fn trap_band( + spread: &PathData, + spread_rule: FillRule, + into: &PathData, + into_rule: FillRule, + width: f64, + height_width: f64, + precision: f64, +) -> PathData { + if !(width.is_finite() && width > 0.0 && height_width.is_finite() && height_width > 0.0) || spread.is_empty() || into.is_empty() { + return PathData::default(); + } + if !finite(spread) || !finite(into) { + return PathData::default(); + } + let w = width.clamp(TRAP_MIN_WIDTH, TRAP_MAX_WIDTH); + let r = height_width.clamp(TRAP_HEIGHT_WIDTH_RANGE.0, TRAP_HEIGHT_WIDTH_RANGE.1); + let precision = if precision.is_finite() && precision > 0.0 { precision.min(10.0) } else { crate::boolean::DEFAULT_PRECISION }; + // Quick reject: bounds farther apart than the trap reaches. + let (Some(sb), Some(ib)) = (spread.bounds(), into.bounds()) else { return PathData::default() }; + let reach = sb.inflate(w, w * r); + if reach.x1 < ib.x0 || ib.x1 < reach.x0 || reach.y1 < ib.y0 || ib.y1 < reach.y0 { + return PathData::default(); + } + // Grow uniformly in a space squashed vertically by `r`, then stretch back: horizontal edges' + // strip comes out `r` times as tall as vertical edges' is wide. + let Ok(outline) = try_normalize(spread, spread_rule) else { return PathData::default() }; + let squash = Affine::scale_non_uniform(1.0, 1.0 / r); + let ring = outline_stroke(&outline.transformed(squash), 2.0 * w, Cap::Butt, Join::Miter, 4.0); + if ring.is_empty() { + return PathData::default(); + } + let ring = ring.transformed(Affine::scale_non_uniform(1.0, r)); + try_boolean(&ring, FillRule::NonZero, into, into_rule, BoolOp::Intersect, precision).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use vectorcraft_geom::Rect; + use vectorcraft_geom::shapes::rectangle; + + use super::*; + + fn bounds(p: &PathData) -> Rect { + p.bounds().unwrap_or_default() + } + + fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 0.02 + } + + #[test] + fn abutting_rectangles_trap_along_their_shared_edge() { + let light = rectangle(Rect::new(0.0, 0.0, 50.0, 40.0)); + let dark = rectangle(Rect::new(50.0, 0.0, 100.0, 40.0)); + let band = trap_band(&light, FillRule::NonZero, &dark, FillRule::NonZero, 2.0, 1.0, 0.01); + let b = bounds(&band); + assert!(near(b.x0, 50.0) && near(b.x1, 52.0) && near(b.y0, 0.0) && near(b.y1, 40.0), "{b:?}"); + assert!(near(crate::area(&band, FillRule::NonZero), 80.0)); + } + + #[test] + fn height_width_scales_the_trap_on_horizontal_edges() { + let light = rectangle(Rect::new(0.0, 0.0, 40.0, 50.0)); + let dark = rectangle(Rect::new(0.0, 50.0, 40.0, 100.0)); + let band = trap_band(&light, FillRule::NonZero, &dark, FillRule::NonZero, 2.0, 2.0, 0.01); + let b = bounds(&band); + assert!(near(b.y0, 50.0) && near(b.y1, 54.0), "a horizontal edge's trap is twice the width: {b:?}"); + } + + #[test] + fn far_apart_shapes_and_bad_numbers_give_nothing() { + let a = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let b = rectangle(Rect::new(30.0, 0.0, 40.0, 10.0)); + assert!(trap_band(&a, FillRule::NonZero, &b, FillRule::NonZero, 2.0, 1.0, 0.01).is_empty()); + let c = rectangle(Rect::new(10.0, 0.0, 20.0, 10.0)); + for (w, r) in [(f64::NAN, 1.0), (f64::INFINITY, 1.0), (-1.0, 1.0), (0.0, 1.0), (1.0, f64::NAN), (1.0, 0.0)] { + assert!(trap_band(&a, FillRule::NonZero, &c, FillRule::NonZero, w, r, 0.01).is_empty(), "{w} {r}"); + } + let mut bad = a.clone(); + bad.subpaths[0].anchors[0].p.x = f64::NAN; + assert!(trap_band(&bad, FillRule::NonZero, &c, FillRule::NonZero, 1.0, 1.0, 0.01).is_empty()); + } + + #[test] + fn a_huge_width_is_capped_and_stays_inside_the_darker_shape() { + let a = rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)); + let c = rectangle(Rect::new(10.0, 0.0, 20.0, 10.0)); + let band = trap_band(&a, FillRule::NonZero, &c, FillRule::NonZero, 1e300, 1e300, f64::NAN); + let b = bounds(&band); + assert!(near(b.x0, 10.0) && near(b.x1, 20.0), "the whole darker square at most: {b:?}"); + } +} diff --git a/crates/pathops/tests/it/main.rs b/crates/pathops/tests/it/main.rs new file mode 100644 index 000000000..3f565e040 --- /dev/null +++ b/crates/pathops/tests/it/main.rs @@ -0,0 +1,12 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +#[path = "../common/mod.rs"] +mod common; + +mod ops; +mod pathfinder_pinned; +mod prop_pathops; +mod props; +mod regressions; diff --git a/crates/pathops/tests/ops.rs b/crates/pathops/tests/it/ops.rs similarity index 93% rename from crates/pathops/tests/ops.rs rename to crates/pathops/tests/it/ops.rs index 52d1af8c8..ff8e6aa76 100644 --- a/crates/pathops/tests/ops.rs +++ b/crates/pathops/tests/it/ops.rs @@ -1,5 +1,3 @@ -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::f64::consts::PI; use kurbo::{ParamCurve, Point, Rect, Shape as _}; @@ -219,6 +217,17 @@ fn pf_outline_edges() { assert!(close(len, 80.0, 1e-9)); } +/// #3870: a circle whose anchors sit on the square's edge it crosses gives the four edges of the +/// arrangement, meeting at those anchors, and no sliver between an anchor and the crossing +/// computed a hair from it. +#[test] +fn pf_outline_has_no_sliver_where_an_anchor_is_the_crossing() { + let s = vec![shape(rect(0.0, 0.0, 100.0, 100.0), 1), shape(circle(100.0, 100.0, 50.0), 2)]; + let r = pathfinder(PathfinderOp::Outline, &s); + assert_eq!(r.len(), 4, "{r:?}"); + assert!(r.iter().all(|s| s.path.length() > 1.0), "no sliver: {r:?}"); +} + #[test] fn pf_outline_dedups_shared_edges() { // Two squares sharing an edge exactly. @@ -294,6 +303,20 @@ fn outline_stroke_of_line() { assert!(close(ar(&o), 216.0, 1e-6)); } +#[test] +fn outline_stroke_of_straight_line_with_round_caps_is_compact() { + let l = PathData::single(SubPath::polyline(&[Point::new(0.0, 50.0), Point::new(100.0, 50.0)], false)); + let o = outline_stroke(&l, 10.0, Cap::Round, Join::Round, 4.0); + assert!(close(ar(&o), 100.0 * 10.0 + PI * 5.0 * 5.0, 1e-3), "{}", ar(&o)); + let anchors = o.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>(); + assert!(anchors <= 8, "round-cap expand should stay compact, got {anchors} anchors"); + let dense: Vec<Point> = (0..=20).map(|i| Point::new(i as f64 * 5.0, 50.0)).collect(); + let dense_path = PathData::single(SubPath::polyline(&dense, false)); + let dense_o = outline_stroke(&dense_path, 10.0, Cap::Round, Join::Round, 4.0); + let dense_anchors = dense_o.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>(); + assert_eq!(dense_anchors, anchors, "colinear spine points should not add outline anchors"); +} + #[test] fn outline_stroke_of_square_is_ring() { let o = outline_stroke(&rect(0.0, 0.0, 10.0, 10.0), 2.0, Cap::Butt, Join::Miter, 10.0); diff --git a/crates/pathops/tests/pathfinder_pinned.rs b/crates/pathops/tests/it/pathfinder_pinned.rs similarity index 98% rename from crates/pathops/tests/pathfinder_pinned.rs rename to crates/pathops/tests/it/pathfinder_pinned.rs index 5e9f27a3d..a6e987d27 100644 --- a/crates/pathops/tests/pathfinder_pinned.rs +++ b/crates/pathops/tests/it/pathfinder_pinned.rs @@ -1,7 +1,6 @@ //! The exact output of every Pathfinder operation, pinned so that work on the windings and the //! coverage tests can't change it: overlapping ellipses, rectangles and stars (even-odd and //! non-zero, repeated keys for Merge) at a few counts. -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use vectorcraft_geom::{FillRule, PathData, Point, Rect, SubPath, shapes}; use vectorcraft_pathops::{PathfinderOp, Shape, pathfinder}; diff --git a/crates/pathops/tests/prop_pathops.rs b/crates/pathops/tests/it/prop_pathops.rs similarity index 98% rename from crates/pathops/tests/prop_pathops.rs rename to crates/pathops/tests/it/prop_pathops.rs index 82feae9f9..995f86975 100644 --- a/crates/pathops/tests/prop_pathops.rs +++ b/crates/pathops/tests/it/prop_pathops.rs @@ -1,9 +1,7 @@ //! Path-operation properties on concave polygons, ellipses and random smooth curves (complements //! `props.rs`, which uses rectangles and circles). -// 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)] -mod common; +use crate::common; use proptest::prelude::*; use vectorcraft_geom::{FillRule, PathData, Point, Rect}; diff --git a/crates/pathops/tests/props.rs b/crates/pathops/tests/it/props.rs similarity index 96% rename from crates/pathops/tests/props.rs rename to crates/pathops/tests/it/props.rs index f09e96f0f..b5adcb7cc 100644 --- a/crates/pathops/tests/props.rs +++ b/crates/pathops/tests/it/props.rs @@ -1,6 +1,4 @@ -// 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)] -mod common; +use crate::common; use std::f64::consts::PI; diff --git a/crates/pathops/tests/regressions.rs b/crates/pathops/tests/it/regressions.rs similarity index 100% rename from crates/pathops/tests/regressions.rs rename to crates/pathops/tests/it/regressions.rs diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index a9c110aee..6dca09aa3 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -24,6 +24,7 @@ image = { workspace = true } jpeg-encoder = { workspace = true } craft-pdf = { workspace = true } log = { workspace = true } +chrono = { workspace = true } smallvec = "1" weezl = "0.1" serde = { workspace = true } diff --git a/crates/pdf/src/editing.rs b/crates/pdf/src/editing.rs index 979a2de1a..9b5600829 100644 --- a/crates/pdf/src/editing.rs +++ b/crates/pdf/src/editing.rs @@ -137,7 +137,18 @@ fn page_hash(pdf: &Pdf) -> String { for page in pdf.pages().iter() { let b = page.media_box(); h = fnv(h, format!("[{} {} {} {}]", b.x0, b.y0, b.x1, b.y1).as_bytes()); - h = fnv(h, page.page_stream().unwrap_or_default()); + // Decode into a temporary rather than through `Page::page_stream`, which keeps every + // page's copy for as long as the PDF lives, even when pages share one stream. + let contents = page.raw(); + if let Some(stream) = contents.get::<Stream<'_>>(b"Contents") { + h = fnv(h, stream.decoded().as_deref().unwrap_or_default()); + } else if let Some(array) = contents.get::<Array<'_>>(b"Contents") { + for stream in array.iter::<Stream<'_>>() { + if let Ok(data) = stream.decoded() { + h = fnv(fnv(h, &data), b" "); + } + } + } } format!("{h:016x}") } diff --git a/crates/pdf/src/encrypt.rs b/crates/pdf/src/encrypt.rs index 39a9ff48b..705ba0293 100644 --- a/crates/pdf/src/encrypt.rs +++ b/crates/pdf/src/encrypt.rs @@ -113,6 +113,13 @@ impl SecuritySettings { standard.label() ))); } + // Revision 2 has one printing bit: it can't say "low resolution only". + if revision(c) == 2 && self.printing == Printing::Low && !self.permissions_password.is_empty() { + return Err(PdfError::BadSetting(format!( + "{} can't limit printing to low resolution: choose PDF 1.4 or later, or allow high-resolution printing", + c.label() + ))); + } if self.open_password == self.permissions_password { return Err(PdfError::BadSetting("the open and permissions passwords must differ".into())); } @@ -146,6 +153,12 @@ pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> { if s.protected() && s.plaintext_metadata && revision(set.compatibility) <= 3 { out.push(format!("{} encryption covers the metadata too: plaintext metadata needs PDF 1.5 or later", set.compatibility.label())); } + if s.protected() && set.compatibility == Compatibility::Pdf13 { + out.push(format!( + "{} encrypts with a 40-bit key, which can be searched in minutes: choose PDF 1.6 or later for stronger protection", + set.compatibility.label() + )); + } out } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 77be57d01..1854db945 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -38,7 +38,7 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> { /// Like [`export`], also returning warnings: options accepted but not applied yet (see /// [`crate::PdfSettings::warnings`]) and features approximated or dropped. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> { - write::guard(|| leaving_out_undecodable(|left_out| export_with_report_inner(doc, opts, left_out))) + write::guard(|| leaving_out_undecodable(doc, |left_out| export_with_report_inner(doc, opts, left_out))) } /// Run `write` (a whole PDF written by a [`Writer`]), and run it again each time it fails on an @@ -46,12 +46,21 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep /// adds the image's key to the set `write` is given, and the exporters leave out the images it /// holds. The writer reports such an image (a PNG, GIF or WebP kept as it is, whose header reads) /// only when it finishes the file. Each repeated run leaves out at least one more image. -pub(crate) fn leaving_out_undecodable<T>(mut write: impl FnMut(&mut HashSet<String>) -> Result<T, PdfError>) -> Result<T, PdfError> { +pub(crate) fn leaving_out_undecodable<T>(doc: &Document, mut write: impl FnMut(&mut HashSet<String>) -> Result<T, PdfError>) -> Result<T, PdfError> { + // The most runs repeated: each is a whole export, and a document of many images that don't + // decode would make them quadratic. Past it, one last run leaves out every image of `doc`, so + // the export still finishes (the images left out are reported). + const MAX_RETRIES: usize = 16; let mut left_out = HashSet::new(); + let mut retries = 0; loop { let known = left_out.len(); match write(&mut left_out) { - Err(_) if left_out.len() > known => {} + Err(_) if left_out.len() > known && retries < MAX_RETRIES => retries += 1, + Err(_) if left_out.len() > known && retries == MAX_RETRIES => { + left_out.extend(doc.images.keys().cloned()); + retries += 1; + } done => return done, } } @@ -73,6 +82,7 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha let now = vectorcraft_doc::metadata::output_time(); let created_at = opts.created.or(now); crate::pdfx::require_date(set.standard, created_at)?; + let display_doc_title = opts.display_doc_title || opts.title.as_deref().is_some_and(|title| !title.trim().is_empty()); let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?; // Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it). let mut warnings = set.warnings(); @@ -132,13 +142,15 @@ fn export_with_report_inner(doc: &Document, opts: &PdfOptions, left_out: &mut Ha vectorcraft_doc::cancel::check().map_err(|_| PdfError::Cancelled)?; let (layers, overprinted, invisible) = (ex.layers, ex.overprinted, ex.invisible); w.absorb(ex); - let (bytes, more) = w.finish(left_out)?; + let (bytes, more) = w.finish(left_out, display_doc_title)?; let bytes = crate::forms::finish(bytes, doc, set, layers, overprinted, invisible)?; // The writer gives the file its creation date as its modification date too. let bytes = match opts.modified.filter(|m| Some(*m) != created_at) { Some(modified) => crate::post::modification_date(bytes, modified)?, None => bytes, }; + // The writer's metadata has no copyright: it is added to the XMP packet. + let bytes = crate::post::rights(bytes, &doc.metadata)?; warnings.extend(more); let bytes = crate::post::user_units(bytes, &units)?; let bytes = crate::post::finish(bytes, opts, native.is_some(), &mut warnings)?; @@ -291,6 +303,9 @@ impl Writer { no_device_cs: out.tagged, cmyk_profile, configuration, + // SVG glyphs, which krilla can't draw, are drawn as art where they sit (`glyph_text`): + // as text they draw nothing, rather than their fonts' plain outlines over the art. + render_svg_glyph_fn: |_, _, _, _, _| Some(()), ..Default::default() }; @@ -423,7 +438,7 @@ impl Writer { /// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK /// alternates. When the writer couldn't decode one of the images, its key is added to /// `left_out` ([`leaving_out_undecodable`]) and the writer's error is returned. - pub(crate) fn finish(mut self, left_out: &mut HashSet<String>) -> Result<(Vec<u8>, Vec<String>), PdfError> { + pub(crate) fn finish(mut self, left_out: &mut HashSet<String>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> { let images = std::mem::take(&mut self.images); let mut bytes = self.pdf.finish().map_err(|e| { if let write::WriteError::Serialization(k) = &e @@ -438,7 +453,7 @@ impl Writer { } let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only(); let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes }; - let (bytes, more) = self.out.write_catalog(bytes)?; + let (bytes, more) = self.out.write_catalog(bytes, display_doc_title)?; self.warnings.extend(more); self.warnings.extend(self.rgb_in_cmyk.warning()); Ok((crate::pdfx::finish(bytes, self.out.standard, self.pdf13)?, self.warnings)) @@ -567,6 +582,9 @@ pub(crate) struct Exporter<'a> { lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, /// Images may ask viewers to smooth them (not in PDF/A). interpolate: bool, + /// Images may have 16 bits a sample (PDF 1.5 and later); in earlier files they are written at + /// 8 bits ([`embed`]). + sixteen_bit: bool, /// How images are resampled and compressed. compression: &'a crate::CompressionSettings, /// Layers whose Print option is off are written too. @@ -597,11 +615,14 @@ pub(crate) struct Exporter<'a> { pub overprint: bool, /// An overprinting fill or stroke was drawn so. pub overprinted: bool, - /// Characters with neither fill nor stroke were written as text in forms marked invisible. + /// Characters with neither fill nor stroke, or whose fill is drawn as art (outlines filled + /// with an image or pattern), were written as text in forms marked invisible. pub invisible: bool, /// Inline graphics being drawn inside inline graphics (text in a symbol shown inline in /// text…): deeper ones are left out. inline_depth: u32, + /// Tiling patterns being drawn inside tiling patterns ([`Self::tiling`]). + pattern_depth: u32, /// Keys of the images left out because the writer couldn't decode them /// ([`leaving_out_undecodable`]). pub left_out: HashSet<String>, @@ -610,6 +631,9 @@ pub(crate) struct Exporter<'a> { /// How deep inline graphics nest before they are left out. const MAX_INLINE_DEPTH: u32 = 4; +/// How deep tiling patterns nest: deeper pattern fills are written as their tiles. +const MAX_PATTERN_DEPTH: u32 = 4; + /// The warning for an image left out because it can't be decoded: the note raster exports give /// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`). const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture"; @@ -627,6 +651,8 @@ impl<'a> Exporter<'a> { knockout: doc.page_knockout, lab_spots: vec![], interpolate: set.standard != Standard::PdfA2b, + // PDF 1.3 and 1.4 files are written with the PDF 1.4 settings ([`Writer::new`]). + sixteen_bit: !matches!(set.compatibility, Compatibility::Pdf13 | Compatibility::Pdf14), compression: &set.compression, non_printing: false, intent: vectorcraft_color::cms::active().settings().intent, @@ -643,6 +669,7 @@ impl<'a> Exporter<'a> { overprinted: false, invisible: false, inline_depth: 0, + pattern_depth: 0, left_out: HashSet::new(), } } @@ -668,6 +695,8 @@ struct GlyphLine { /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). const MAX_FIELD_PX: f64 = 512.0; +/// Most pixels along a side of a linear or radial gradient's image. +const MAX_GRADIENT_PX: f64 = 4096.0; /// The largest magnitude a coordinate, length or transform entry may have in the content stream it /// is drawn into (for glyph spacing, in thousandths of an em). Art that would go beyond it is left @@ -1226,7 +1255,7 @@ impl Exporter<'_> { } self.warn_raster(&fl.effects, || "fills".into()); self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { - if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) { + if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds, true) { return; } let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return }; @@ -1268,20 +1297,22 @@ impl Exporter<'_> { } } - /// Is `p` a paint PDF has no paint for, which [`Self::area`] draws as art: a pattern swatch or - /// a freeform gradient? - fn area_paint(&self, p: &Paint) -> bool { + /// Is `p` a paint PDF has no paint for when it paints a fill (`fill`) or a stroke, which + /// [`Self::area`] draws as art: a pattern swatch or a gradient drawn as pixels + /// ([`vectorcraft_color::GradientPaint::draws_as_pixels`])? + fn area_paint(&self, p: &Paint, fill: bool) -> bool { match p { Paint::Pattern { pattern, .. } => self.doc.pattern(pattern).is_some(), - Paint::Gradient(g) => g.gradient.kind == GradientKind::Freeform, + Paint::Gradient(g) => g.draws_as_pixels(fill), _ => false, } } /// Paint the area `clip` (filled by `rule`; bounds `area`) with `p` at `transparency` (opacity, - /// blend mode) when it is an [area paint](Self::area_paint): a pattern's tile instances - /// covering it, or an image of a freeform gradient placed on `bounds` (the painted object's - /// box, as on the canvas), clipped to it. False (nothing drawn) for other paints. + /// blend mode) when it is an [area paint](Self::area_paint) of a fill (`fill`) or a stroke: a + /// pattern's [tiling pattern](Self::tiling) (else its tile instances covering it), or an image + /// of a gradient drawn as pixels placed on `bounds` (the painted object's box, as on the + /// canvas), clipped to it. False (nothing drawn) for other paints. #[allow(clippy::too_many_arguments)] fn area( &mut self, @@ -1292,31 +1323,46 @@ impl Exporter<'_> { transparency: (f32, BlendMode), bounds: Rect, area: Rect, + fill: bool, ) -> bool { - if !self.area_paint(p) { + if !self.area_paint(p, fill) { return false; } let (opacity, mode) = transparency; + let tiling = match p { + Paint::Pattern { pattern, xf } => self.tiling(s, pattern, *xf), + _ => None, + }; let mut pushes = 0; if mode != BlendMode::Normal { s.push_blend_mode(blend(mode)); pushes += 1; } - s.push_clip_path(clip, &rule); - pushes += 1; - if opacity < 1.0 { - s.push_opacity(norm(opacity)); + if let Some(paint) = tiling { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: norm(opacity), rule })); + s.draw_path(clip); + s.set_fill(None); + } else { + s.push_clip_path(clip, &rule); pushes += 1; - } - let doc = self.doc; - match p { - Paint::Pattern { pattern, xf } => { - for inst in doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default() { - self.node(s, &inst, area, true); + if opacity < 1.0 { + s.push_opacity(norm(opacity)); + pushes += 1; + } + let doc = self.doc; + match p { + Paint::Pattern { pattern, xf } => { + if let Some(def) = doc.pattern(pattern) { + self.warn("pattern fills nested more than 4 patterns deep, or with tiles too large for PDF, are written as their tiles"); + for inst in def.instances_in(*xf, area) { + self.node(s, &inst, area, true); + } + } } + Paint::Gradient(g) => self.field_image(s, g, bounds, area, fill), + _ => {} } - Paint::Gradient(g) => self.field_image(s, g, bounds, area), - _ => {} } for _ in 0..pushes { s.pop(); @@ -1324,21 +1370,71 @@ impl Exporter<'_> { true } - /// Draw freeform gradient `g`, placed on `bounds`, as an image of its colour field over `area`, - /// sampled at the document's raster effects resolution (at most [`MAX_FIELD_PX`] a side). - fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect) { + /// Pattern swatch `name` placed by `at` (pattern space → the current space) as a PDF tiling + /// pattern (ISO 32000-1, 8.7.3.1): one period of the tiling ([`vectorcraft_doc::PatternDef::period`], + /// what the canvas repeats too) with every tile instance reaching into it, in paint order, so + /// brick and hex tilings and overlapping tiles repeat as they are; clipped to the period when + /// art reaches out of it. `None` for a pattern nested deeper than [`MAX_PATTERN_DEPTH`], a period + /// or placement beyond [`MAX_COORD`] or a placement that flattens the tiles. + fn tiling(&mut self, s: &mut Surface, name: &str, at: Affine) -> Option<krilla::paint::Paint> { + let doc = self.doc; + let def = doc.pattern(name)?; + let (pw, ph) = def.period(); + if self.pattern_depth >= MAX_PATTERN_DEPTH || !in_range(&[pw, ph]) || !in_range(&at.as_coeffs()) || at.determinant().abs() <= 1e-12 { + return None; + } + let cell = Rect::new(0.0, 0.0, pw, ph); + let tiles = def.instances_in(Affine::IDENTITY, cell); + let mut sb = s.stream_builder(); + let mut cs = sb.surface(); + let reaches_out = tiles.iter().any(|t| t.visual_bounds().is_some_and(|b| cell.intersect(b) != b)); + let clip = reaches_out.then(|| to_path(&cs, &cell.to_path(0.1))).flatten(); + if let Some(c) = &clip { + cs.push_clip_path(c, &krilla::paint::FillRule::NonZero); + } + // Pattern art is a picture of its own: it takes no part in a knockout group around the object. + let knockout = std::mem::take(&mut self.knockout); + self.pattern_depth += 1; + for t in &tiles { + self.node(&mut cs, t, cell, true); + } + self.pattern_depth -= 1; + self.knockout = knockout; + if clip.is_some() { + cs.pop(); + } + cs.finish(); + Some(krilla::paint::Pattern { stream: sb.finish(), transform: xf(at), width: pw as f32, height: ph as f32 }.into()) + } + + /// Draw gradient `g`, placed on `bounds`, as an image over `area`, sampled at the document's + /// raster effects resolution where it is drawn (`area` as the current transform sizes it, so + /// scaled type gets its pixels too): a freeform gradient's colour field (at most + /// [`MAX_FIELD_PX`] a side), or a linear or radial gradient (at most [`MAX_GRADIENT_PX`] a side), + /// with dither's noise when it paints a fill (`fill`). + fn field_image(&mut self, s: &mut Surface, g: &vectorcraft_color::GradientPaint, bounds: Rect, area: Rect, fill: bool) { use vectorcraft_color::freeform::{painted_box, spread_scale}; let Some(b) = painted_box(bounds) else { return }; let area = area.abs(); - let long = area.width().max(area.height()); + // The area's sides in points on the page. + let [m0, m1, m2, m3, _, _] = ctm(s).as_coeffs(); + let (w, h) = (area.width() * m0.hypot(m1), area.height() * m2.hypot(m3)); + let long = w.max(h); if !(long > 1e-9 && long.is_finite()) { return; } - let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_FIELD_PX / long); - let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_FIELD_PX) as u16; - let (cols, rows) = (along(area.width()), along(area.height())); - let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); - let rgba: Vec<u8> = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect(); + let freeform = g.gradient.kind == GradientKind::Freeform; + let max = if freeform { MAX_FIELD_PX } else { MAX_GRADIENT_PX }; + let k = (self.doc.raster_effects_ppi / 72.0).min(max / long); + let along = |len: f64| (len * k).ceil().clamp(1.0, max) as u16; + let (cols, rows) = (along(w), along(h)); + let rgba: Vec<u8> = if freeform { + let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); + field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() + } else { + self.warn("linear and radial gradients drawn as pixels are exported as images"); + g.pixels(b, area, cols, rows, fill, &|c| c.to_rgb()).flatten().collect() + }; let pixels = if self.convert { self.out.pixels() } else { None }; let (Some(img), Some(size)) = ( crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels), @@ -1358,13 +1454,14 @@ impl Exporter<'_> { if !stroke::is_plain(st) && self.brush_art(s, bp, st, page) { return; } - if self.area_paint(&st.paint) { + if self.area_paint(&st.paint, false) && st.path_gradient().is_none() { // The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as - // Outline Stroke makes it, painted with the pattern's tiles or the freeform's image. + // Outline Stroke makes it, painted with the pattern's tiles or the gradient's image (a + // gradient along or across the stroke is written as slices below). let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath(); if let Some(clip) = to_path(s, ®ion) { let area = region.bounding_box(); - self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area); + self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area, false); } return; } @@ -1531,11 +1628,13 @@ impl Exporter<'_> { // pushed for each fill and stroke of the characters, inside its form when the fill or // stroke overprints. let text_space = xf(t.xf); - let plain = (!self.outline_text).then(|| t.plain_text()); + // Ruby can't be real text in one run (it isn't characters of the base text): the type's + // characters go out as outlines where ruby is present, ruby included. + let plain = (!self.outline_text && t.ruby.is_empty()).then(|| t.plain_text()); let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t); for (i, run) in t.runs.iter().enumerate() { let mut bp = BezPath::new(); - for g in layout.glyphs.iter().filter(|g| g.run == i) { + for g in layout.glyphs.iter().filter(|g| g.run == i).chain(layout.ruby.iter().filter(|g| g.run == i)) { bp.extend(g.outline.iter()); } // Underline and strikethrough bars are paths, with real text too (#847). @@ -1545,12 +1644,15 @@ impl Exporter<'_> { } bp.extend(run_bars.iter()); let fill = &run.style.fill; + // Colour bitmap glyphs (emoji) real text doesn't write: all of them, or those it leaves. + let mut images: Option<Vec<usize>> = None; self.overprinting(s, run.style.overprint_fill, fill, |ex, s| { s.push_transform(&text_space); // The outlines left to fill as paths: all of them, or those real text leaves. - let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) { + let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill, true)) { Some(text) => { - let mut left = ex.glyph_text(s, &layout, i, text, fill); + let (mut left, unwritten) = ex.glyph_text(s, &layout, i, text, fill, true); + images = Some(unwritten); left.extend(run_bars.iter()); Cow::Owned(left) } @@ -1558,7 +1660,7 @@ impl Exporter<'_> { }; let opaque = (1.0, BlendMode::Normal); if let Some(path) = to_path(s, &outlines) - && !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box()) + && !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box(), true) && let Some(paint) = ex.paint(s, fill, layout.bounds) { s.set_stroke(None); @@ -1567,15 +1669,29 @@ impl Exporter<'_> { } s.pop(); }); - // Characters with neither fill nor stroke (a scanned page's recognized text) are still - // text: written invisible (rendering mode 3), so they can still be selected and searched. - if let Some(text) = plain.as_deref().filter(|_| fill.is_none() && !run.style.has_stroke()) { + // They paint themselves, whatever the fill. + let images = images.unwrap_or_else(|| layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == i).map(|(gi, _)| gi).collect()); + s.push_transform(&text_space); + for g in images.iter().filter_map(|&gi| layout.glyphs.get(gi)) { + self.glyph_image(s, g); + // COLR glyphs: their layers, as art. + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(s, &art, page, true); + } + } + s.pop(); + // Characters with neither fill nor stroke (a scanned page's recognized text), or whose + // fill is drawn as art (a dithered gradient's image, a pattern: outlines, not text), + // are still text: written invisible (rendering mode 3), so they can still be selected + // and searched. + let unpainted = fill.is_none() && !run.style.has_stroke(); + if let Some(text) = plain.as_deref().filter(|_| unpainted || self.area_paint(fill, true)) { self.invisible = true; crate::forms::form(s, Mark::Invisible, |s| { s.push_transform(&text_space); // The paint only lets the writer write the text; the glyphs it can't write - // as text draw nothing. - self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0))); + // as text draw nothing, and colour bitmap glyphs are drawn already. + self.glyph_text(s, &layout, i, text, &Paint::solid(Color::gray(0.0)), false); s.pop(); }); } @@ -1618,7 +1734,7 @@ impl Exporter<'_> { for item in items { match item { AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { - if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box()) + if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box(), true) && let Some(paint) = ex.paint(s, &fl.paint, tb) { s.set_stroke(None); @@ -1640,16 +1756,22 @@ impl Exporter<'_> { /// text space; `text` is the plain text, whose characters the glyphs stand for, which makes /// the text selectable and searchable) → the outlines of the glyphs left to draw as paths: /// those of fonts that can't be embedded, and missing glyphs. Glyphs sharing a font, a frame - /// (size, scale, rotation) and a baseline are written together. - fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint) -> BezPath { - let mut rest = BezPath::new(); + /// (size, scale, rotation) and a baseline are written together. Colour glyphs (bitmap emoji, + /// COLR, SVG), with `images`, are written as text too, which draws their images or layers; the + /// indices of those it can't write or draw (SVG glyphs) are returned with the outlines. + fn glyph_text(&mut self, s: &mut Surface, layout: &TextLayout, run: usize, text: &str, fill: &Paint, images: bool) -> (BezPath, Vec<usize>) { + let (mut rest, mut unwritten) = (BezPath::new(), vec![]); let mut line: Option<GlyphLine> = None; - for g in layout.glyphs.iter().filter(|g| g.run == run) { + for (gi, g) in layout.glyphs.iter().enumerate().filter(|(_, g)| g.run == run) { + let coloured = g.image.is_some() || g.colour.is_some(); + if coloured && !images { + continue; + } let range = g.byte..g.byte + g.len; let ch = text.get(range.clone()).and_then(|t| t.chars().next()); // Spaces are written too (text extraction needs them); control characters and soft // hyphens draw nothing. - let blank = g.outline.elements().is_empty(); + let blank = g.outline.elements().is_empty() && !coloured; if blank && !ch.is_some_and(|c| c.is_whitespace() && !c.is_control()) { continue; } @@ -1661,8 +1783,20 @@ impl Exporter<'_> { let det = g.xf.determinant(); let Some((font, upem)) = font.filter(|(_, upem)| det.is_finite() && det.abs() > 1e-9 && (1e-3..=1e5).contains(&(upem * sy))) else { rest.extend(g.outline.iter()); + if coloured { + unwritten.push(gi); + } continue; }; + // SVG glyphs are written as text that draws nothing (see the serialize settings): their + // art is drawn, or else their outline. + if is_svg_glyph(g) { + if coloured { + unwritten.push(gi); + } else { + rest.extend(g.outline.iter()); + } + } let size = upem * sy; let frame = g.xf * Affine::scale(1.0 / sy); // Its pen position along the frame's baseline, when it continues the line. @@ -1690,7 +1824,28 @@ impl Exporter<'_> { if let Some(l) = line { self.draw_glyph_line(s, l, text, fill, layout.bounds); } - rest + (rest, unwritten) + } + + /// Draw colour bitmap glyph `g`'s image (on `s` in text space), its PNG written once. + fn glyph_image(&mut self, s: &mut Surface, g: &vectorcraft_text::PositionedGlyph) { + let Some((image, to_text)) = g.placed_image() else { return }; + if !writable(s, to_text, image.rect()) { + return; + } + let cache = format!("\u{0}glyph\u{0}{}", image.blob.content_key()); + let img = match self.images.get(&cache) { + Some(img) => img.clone(), + None => { + let img = embed(&image.blob, self.interpolate, self.sixteen_bit); + self.images.insert(cache, img.clone()); + img + } + }; + let (Some(img), Some(size)) = (img, Size::from_wh(image.width as f32, image.height as f32)) else { return }; + s.push_transform(&xf(to_text)); + s.draw_image(img, size); + s.pop(); } /// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where @@ -1760,11 +1915,12 @@ impl Exporter<'_> { font } - /// Image `key` placed `size` points wide and high, resampled and compressed as the Compression + /// Image `im` placed `size` points wide and high, resampled and compressed as the Compression /// settings say ([`crate::images::recode`], [`crate::images::recode_cmyk`] for CMYK images); /// cached by key (and size when images are resampled). `None`, with [`UNDECODED`] among the /// warnings, for an image that is missing, can't be decoded or is in [`Self::left_out`]. - fn load_image(&mut self, key: &str, size: (f64, f64)) -> Option<Image> { + fn load_image(&mut self, im: &vectorcraft_doc::ImageObject, size: (f64, f64)) -> Option<Image> { + let key = im.key.as_str(); let c = self.compression; let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None); let cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; @@ -1774,7 +1930,7 @@ impl Exporter<'_> { let interpolate = self.interpolate; let doc = self.doc; let out = self.out.clone(); - let img = doc.images.get(key).filter(|_| !self.left_out.contains(key)).and_then(|blob| { + let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { let cmyk_image = blob.is_cmyk(); if !cmyk_image && self.convert && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk { self.rgb_in_cmyk.images.insert(key.to_string()); @@ -1787,7 +1943,7 @@ impl Exporter<'_> { if let Some(w) = r.warning { self.warn(w); } - r.image.or_else(|| embed(blob, interpolate)) + r.image.or_else(|| embed(blob, interpolate, self.sixteen_bit)) }); if img.is_none() { self.warn(UNDECODED); @@ -1815,8 +1971,8 @@ impl Exporter<'_> { return i.clone(); } let doc = self.doc; - let img = doc.images.get(&im.key).filter(|b| b.is_cmyk()).and_then(|blob| { - let alpha = crate::images::mask_alpha(doc.images.get(&mi.key)?, im.width, im.height, m.clip)?; + let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { + let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; let r = crate::images::recode_cmyk(blob, size, self.compression, self.interpolate, self.out.cmyk_pixels(), Some(alpha)); if let (Some(w), Some(_)) = (r.warning, &r.image) { self.warn(w); @@ -1833,7 +1989,7 @@ impl Exporter<'_> { if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { return; } - let Some(img) = img.or_else(|| self.load_image(&im.key, placed_size(im))) else { return }; + let Some(img) = img.or_else(|| self.load_image(im, placed_size(im))) else { return }; let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; s.push_transform(&xf(im.xf)); s.draw_image(img, size); @@ -1847,11 +2003,41 @@ fn placed_size(im: &vectorcraft_doc::ImageObject) -> (f64, f64) { (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)) } +/// Does PNG `bytes` have 16 bits a sample: the bit depth of its IHDR chunk, which follows the +/// signature? +fn png_16_bit(bytes: &[u8]) -> bool { + bytes.get(12..16) == Some(b"IHDR".as_slice()) && bytes.get(24) == Some(&16) +} + +/// PNG `bytes` at 8 bits a sample: decoded, and encoded again as gray or RGB, with alpha when the +/// source has it, and with the source's ICC profile. `None` when it can't be decoded. +fn png_8_bit(bytes: &[u8]) -> Option<Vec<u8>> { + use image::{ColorType, DynamicImage, ImageDecoder, ImageEncoder}; + let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(bytes)).ok()?; + let icc = decoder.icc_profile().ok().flatten(); + let img = DynamicImage::from_decoder(decoder).ok()?; + let img = match img.color() { + ColorType::L8 | ColorType::L16 => DynamicImage::ImageLuma8(img.into_luma8()), + ColorType::La8 | ColorType::La16 => DynamicImage::ImageLumaA8(img.into_luma_alpha8()), + ColorType::Rgb8 | ColorType::Rgb16 => DynamicImage::ImageRgb8(img.into_rgb8()), + _ => DynamicImage::ImageRgba8(img.into_rgba8()), + }; + let mut png = vec![]; + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + encoder.set_icc_profile(icc).ok()?; + } + img.write_with_encoder(encoder).ok()?; + Some(png) +} + /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats decoded. -fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option<Image> { +/// other formats decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG of 16 bits a +/// sample is written at 8 bits ([`png_8_bit`]). +fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool) -> Option<Image> { let bytes = blob.bytes.as_ref().clone(); let direct = match blob.mime.as_str() { + "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), "image/gif" => Image::from_gif(bytes.clone().into(), interpolate).ok(), @@ -1865,6 +2051,11 @@ fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option<Image> }) } +/// Is `g` drawn from its font's SVG document, which the PDF writer can't draw as a font's glyph? +fn is_svg_glyph(g: &vectorcraft_text::PositionedGlyph) -> bool { + vectorcraft_text::FontDb::global().is_svg_glyph(g.font_id, g.gid) +} + #[cfg(test)] mod tests { use super::*; @@ -1889,4 +2080,22 @@ mod tests { s.finish(); page.finish(); } + + #[test] + fn a_16_bit_png_at_8_bits_keeps_its_color_type_and_icc_profile() { + use image::{ImageDecoder, ImageEncoder}; + let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::GRAY).unwrap().to_vec(); + let gray = image::DynamicImage::ImageLuma16(image::ImageBuffer::from_fn(4, 2, |x, y| image::Luma([x as u16 * 20000 + y as u16]))); + let mut png = vec![]; + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + encoder.set_icc_profile(icc.clone()).unwrap(); + gray.write_with_encoder(encoder).unwrap(); + assert!(png_16_bit(&png)); + let out = png_8_bit(&png).unwrap(); + assert!(!png_16_bit(&out)); + let mut decoder = image::codecs::png::PngDecoder::new(std::io::Cursor::new(out.as_slice())).unwrap(); + assert_eq!(decoder.icc_profile().unwrap(), Some(icc)); + assert_eq!(decoder.color_type(), image::ColorType::L8); + assert_eq!(image::DynamicImage::from_decoder(decoder).unwrap().to_luma8(), gray.to_luma8()); + } } diff --git a/crates/pdf/src/file_info.rs b/crates/pdf/src/file_info.rs index 4e858701c..983a7dcea 100644 --- a/crates/pdf/src/file_info.rs +++ b/crates/pdf/src/file_info.rs @@ -16,9 +16,9 @@ const MAX_NESTING: usize = 256; const RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; const XML: &str = "http://www.w3.org/XML/1998/namespace"; -const DC: &str = "http://purl.org/dc/elements/1.1/"; +pub(crate) const DC: &str = "http://purl.org/dc/elements/1.1/"; const XMP: &str = "http://ns.adobe.com/xap/1.0/"; // brand-ok: the XMP namespace the files use -const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use +pub(crate) const XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; // brand-ok: the XMP namespace the files use const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; // brand-ok: the XMP namespace the files use /// The File Info fields a PDF gives, each `None` when the file gives none (or only blank text). @@ -234,14 +234,8 @@ fn pdf_date(d: DateTime) -> Option<i64> { /// Whether `day` is a day of `month` (1 to 12) in `year` (proleptic Gregorian). fn in_month(year: i64, month: i64, day: i64) -> bool { - let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let last = match month { - 2 if leap => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - }; - (1..=last).contains(&day) + let ymd = i32::try_from(year).ok().zip(u32::try_from(month).ok()).zip(u32::try_from(day).ok()); + ymd.is_some_and(|((y, m), d)| chrono::NaiveDate::from_ymd_opt(y, m, d).is_some()) } /// An XMP date (ISO 16684-1: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, then optionally `Thh:mm`, `:ss` with @@ -282,13 +276,16 @@ fn xmp_date(s: &str) -> Option<i64> { } /// `[year, month, day, hour, minute, second]` at a UTC offset of `offset` minutes → Unix seconds -/// (days-from-civil, proleptic Gregorian: the inverse of [`vectorcraft_doc::metadata::civil`]). +/// (the inverse of [`vectorcraft_doc::metadata::civil`]). The date has already passed [`in_month`], +/// so it is a real day; an impossible one reads as the epoch rather than panicking. fn unix([year, month, day, hour, minute, second]: [i64; 6], offset: i64) -> i64 { - let y = if month <= 2 { year - 1 } else { year }; - let era = y.div_euclid(400); - let yoe = y - era * 400; - let doy = (153 * ((month + 9) % 12) + 2) / 5 + day - 1; - let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; - let days = era * 146_097 + doe - 719_468; + let days = i32::try_from(year) + .ok() + .zip(u32::try_from(month).ok()) + .zip(u32::try_from(day).ok()) + .and_then(|((y, m), d)| chrono::NaiveDate::from_ymd_opt(y, m, d)) + .zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1)) + .map(|(date, epoch)| (date - epoch).num_days()) + .unwrap_or(0); days * 86_400 + hour * 3600 + minute * 60 + second - offset * 60 } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index 9bbb6257b..1c0937324 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -389,8 +389,12 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p id: i as u32 + 1, name: format!("Artboard {}", i + 1), rect: ab, + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); let page_layer = |b: &mut Builder<'_>, art: Vec<Arc<Node>>, shown: bool| { let name = if shown { format!("Page {}", number + 1) } else { format!("Page {} (unsorted)", number + 1) }; @@ -479,6 +483,10 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p let list = b.missing_fonts.join(", "); b.warn(&format!("fonts that aren't available show in the fallback font until they are: {list}")); } + // Before anything walks the tree by recursion (`fix_next_id` does): nested clips can be deeper + // than the stack goes. + let mut depth_notes = vec![]; + doc.limit_depth_noting(&mut depth_notes); doc.fix_next_id(); doc.reserve_ids(b.next); @@ -490,7 +498,7 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p } } } - doc.limit_depth_noting(&mut warnings); + warnings.extend(depth_notes); let user_unit = picked.first().and_then(|&n| pages.get(n)).map_or(1.0, |page| f64::from(page.user_unit())); Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text, user_unit }) } @@ -978,12 +986,20 @@ fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> Stro kurbo::Join::Bevel => LineJoin::Bevel, }; st.miter_limit = p.miter_limit as f64; - let dash = Dash { pattern: p.dash_array.iter().map(|v| *v as f64 * scale).collect(), offset: p.dash_offset as f64 * scale, align_corners: false }; + // (Every stroke keeps its own copy: a dash array has a few entries, and one with thousands is cut.) + let dash = Dash { + pattern: p.dash_array.iter().take(MAX_DASH_ENTRIES).map(|v| *v as f64 * scale).collect(), + offset: p.dash_offset as f64 * scale, + align_corners: false, + }; // An invalid dash array (a negative value, or all zeros) strokes solid. st.dash = dash.is_dashed().then_some(dash); st } +/// The most entries of a dash array kept (an even count keeps it a pattern of dashes and gaps). +const MAX_DASH_ENTRIES: usize = 64; + pub(crate) fn round3(v: f32) -> f32 { (v * 1000.0).round() / 1000.0 } @@ -1565,7 +1581,7 @@ impl<'p> Builder<'p> { let xf = transform * Affine::scale_non_uniform(f64::from(w) / f64::from(mw.max(1)), f64::from(h) / f64::from(mh.max(1))); let art = Node::new( self.id(), - NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { key: k, width: mw, height: mh, xf, link: None, placement: Default::default(), pixels: None })), ); Some(OpacityMask::new(art, true)) } @@ -1585,8 +1601,10 @@ impl<'p> Builder<'p> { (k, w, h) }; let id = self.id(); - let mut n = - Node::new(id, NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default() }))); + let mut n = Node::new( + id, + NodeKind::Image(Box::new(ImageObject { key: k, width: w, height: h, xf, link: None, placement: Default::default(), pixels: None })), + ); n.extras_mut().mask = mask.map(Box::new); self.push_node(n); } @@ -1626,7 +1644,7 @@ impl<'p> Builder<'p> { return n.clone(); } let data = o.font_data(); - let name = self.fonts.get(&key).map(|(n, _)| n.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); + let name = self.fonts.get(&key).map(|f| f.name.clone()).or_else(|| data.as_ref().and_then(|d| d.postscript_name.clone())); let (weight, italic) = data.as_ref().map_or((None, false), |d| (d.weight, d.is_italic)); // A subset's six-letter tag. let ps = name.as_deref().map(|n| match n.split_once('+') { @@ -1708,13 +1726,12 @@ impl<'p> Builder<'p> { return false; } let width = o.advance_width().filter(|w| w.is_finite()); - // Set vertically (WMode 1): no horizontal advance; its em box is a full em wide. + // Set vertically (WMode 1): no horizontal advance. let upright = width.is_some_and(|w| w.abs() <= 1.0); - let top = m * kurbo::Point::new(f64::from(width.filter(|w| *w > 1.0).unwrap_or(1000.0)) * 0.5, 880.0); let mut info = self.font_name(key, o); // A font the file doesn't carry is drawn with a stand-in: its glyphs can't tell which // style of the installed family it is, so the name decides. - let embedded = self.fonts.get(&key).is_none_or(|(_, e)| *e); + let embedded = self.fonts.get(&key).is_none_or(|f| f.embedded); if let Some(face) = &info.face && embedded { @@ -1770,8 +1787,17 @@ impl<'p> Builder<'p> { stroke, }; let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0); - let place = |line: &mut TextLine| { - if upright { line.push_upright(&look, at, opacity, Upright { top }, &text) } else { line.push(&look, at, opacity, advance, &text) } + // A glyph set vertically is drawn from its origin, which the file puts the position + // vector's vx (from the font's `W2`, else half its width from `W` or `DW`) to the left of + // the column's centre (ISO 32000-1 §9.7.4.3); vertical type centres each glyph on the + // column. Half an em only for a full-width glyph (#722). + let top = upright.then(|| { + let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(glyph)); + m * kurbo::Point::new(vx, 880.0) + }); + let place = |line: &mut TextLine| match top { + Some(top) => line.push_upright(&look, at, opacity, Upright { top }, &text), + None => line.push(&look, at, opacity, advance, &text), }; self.flush_glyphs(); // A stroke over the glyph just filled (fill and stroke rendering). diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index 2bdafbadd..ac5b1580e 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -362,7 +362,8 @@ impl<'p> Colors<'p> { let alt = alt_model(&it.next::<Object<'_>>()?); let tint = Function::new(&it.next::<Object<'_>>()?)?; let key = debug_text(&tint)?; - if names.is_empty() || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) { + // (PDF's limit is 32 colorants; each gets a vector of that length below.) + if names.is_empty() || names.len() > MAX_COLORANTS || self.spaces.iter().any(|s| s.key == key && s.device_n == device_n && s.names == names) { return None; } let colorants = names @@ -521,3 +522,6 @@ fn alt_color(alt: Alt, v: &[f32]) -> Option<Color> { Alt::Lab => Color::lab(round3(c(0)?.clamp(0.0, 100.0)), round3(c(1)?.clamp(-128.0, 127.0)), round3(c(2)?.clamp(-128.0, 127.0))), }) } + +/// The most colorants of a DeviceN space read. +const MAX_COLORANTS: usize = 64; diff --git a/crates/pdf/src/import_lines.rs b/crates/pdf/src/import_lines.rs index 15294ad3d..d29b354e7 100644 --- a/crates/pdf/src/import_lines.rs +++ b/crates/pdf/src/import_lines.rs @@ -14,10 +14,10 @@ use std::sync::Arc; use kurbo::{Point, Rect, Shape}; use vectorcraft_color::BlendMode; -use vectorcraft_doc::{AppearanceItem, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{AppearanceItem, CharStyle, Justify, Node, NodeId, NodeKind, TextKind, TextObject, TextRun}; use vectorcraft_geom::shapes; -use crate::import_text::LineFacts; +use crate::import_text::{LineFacts, median}; /// Rebuild the type among `nodes` (and in their groups) from what its lines tell (`lines`). pub(crate) fn rebuild(nodes: &mut Vec<Arc<Node>>, lines: &HashMap<NodeId, LineFacts>) { @@ -148,65 +148,108 @@ fn block(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>) -> (usize, Opt (count, (space > -size * 0.01).then_some(Block { leading, space: space.max(0.0), ys })) } +/// How far a line's characters' tracking may stray (thousandths of an em) and still be one +/// value: placing each glyph where the file put it (#722) tracks characters a little apart where +/// the file's widths are rounded, or it moved a glyph a hair (`TJ`). +const EVEN_TRACKING: f64 = 10.0; + +/// A line's spacing: the tracking after its letters, and how much more its spaces take (word +/// spacing, `Tw`; `None` when it has none). `None` when its letters are tracked apart (set by +/// hand), or its spaces more than `slack` apart. +fn line_spacing(t: &TextObject, slack: f64) -> Option<(f64, Option<f64>)> { + // The tracking of the runs with characters that are spaces (`true`) or that aren't. + let tracking = |space: bool| -> Vec<f64> { + t.runs.iter().filter(|r| r.text.chars().any(|c| c.is_whitespace() == space)).map(|r| r.style.tracking).collect() + }; + let letters = tracking(false); + let one = median(letters.clone())?; + letters.iter().all(|x| (x - one).abs() <= EVEN_TRACKING).then_some(())?; + let spaces = tracking(true); + let word = match median(spaces.clone()) { + Some(s) => { + spaces.iter().all(|x| (x - s).abs() <= slack).then_some(())?; + Some(s - one) + } + None => None, + }; + Some((one, word)) +} + +/// The one word spacing (thousandths of an em past the letters' tracking) of lines spaced +/// `words` (`None`: no spaces): 0 for lines without. +fn one_word_spacing(words: &[Option<f64>]) -> Option<f64> { + let w: Vec<f64> = words.iter().flatten().copied().collect(); + let Some(one) = median(w.clone()) else { return Some(0.0) }; + w.iter().all(|x| (x - one).abs() <= EVEN_TRACKING).then_some(if one.abs() <= EVEN_TRACKING { 0.0 } else { one }) +} + /// Lines `nodes` of block `b` as one area type object, aligned left or justified, when laying it /// out breaks its lines where the file does. fn area_type(nodes: &[Arc<Node>], lines: &HashMap<NodeId, LineFacts>, b: &Block) -> Option<TextObject> { let texts: Vec<(&TextObject, &LineFacts)> = nodes.iter().map(|n| line(n, lines)).collect::<Option<_>>()?; let (first, _) = texts.first()?; let size = first.first_style().size; - // Each line's tracking (a line with spaces tracked apart was set by hand). - let trackings: Vec<f64> = texts - .iter() - .map(|(t, _)| { - let mut all = t.runs.iter().map(|r| r.style.tracking); - let one = all.next()?; - all.all(|x| x == one).then_some(one) - }) - .collect::<Option<_>>()?; - let median = |mut v: Vec<f64>| { - v.sort_by(f64::total_cmp); - v.get(v.len() / 2).copied() - }; + // Each line's tracking and word spacing. A justified line spaces its words out (word spacing, + // `Tw`), which justification does again (#722); word spacing the same on every line is the + // type's own. + // A wrapped line's spaces may each be tracked apart, within a fiftieth of an em of each other. + let spacings: Vec<(f64, Option<f64>)> = + texts.iter().map(|(t, f)| line_spacing(t, if f.wraps { 20.0 } else { EVEN_TRACKING })).collect::<Option<_>>()?; + let trackings: Vec<f64> = spacings.iter().map(|(t, _)| *t).collect(); + let words: Vec<Option<f64>> = spacings.iter().map(|(_, w)| *w).collect(); let wrapped: Vec<f64> = texts.iter().filter(|(_, f)| f.wraps).map(|(_, f)| f.length).collect(); let longest = |v: &[f64]| v.iter().copied().fold(0.0, f64::max); let mut tries = vec![]; - // Wrapped lines all as long: justified (a paragraph's last line aligned left), tracked as the - // lines that end paragraphs are (the others are spaced out). + // Wrapped lines all as long: justified (a paragraph's last line aligned left), tracked and + // word spaced as the lines that end paragraphs are (the others are spaced out). let full = longest(&wrapped); - if wrapped.len() >= 2 && wrapped.iter().all(|l| (full - l).abs() <= size * 0.02) { - let ends: Vec<f64> = texts.iter().zip(&trackings).filter(|((_, f), _)| !f.wraps).map(|(_, t)| *t).collect(); - let tracking = median(ends).or_else(|| trackings.iter().copied().reduce(f64::min))?; - tries.push((Justify::JustifyLeft, full, tracking)); + let ends: Vec<usize> = texts.iter().enumerate().filter(|(_, (_, f))| !f.wraps).map(|(i, _)| i).collect(); + let end_words: Vec<Option<f64>> = ends.iter().filter_map(|i| words.get(*i).copied()).collect(); + if wrapped.len() >= 2 + && wrapped.iter().all(|l| (full - l).abs() <= size * 0.02) + && let Some(word) = one_word_spacing(&end_words) + { + let tracking = + median(ends.iter().filter_map(|i| trackings.get(*i).copied()).collect()).or_else(|| trackings.iter().copied().reduce(f64::min))?; + tries.push((Justify::JustifyLeft, full, tracking, word)); } - let all: Vec<f64> = texts.iter().map(|(_, f)| f.length).collect(); - tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?)); - tries.into_iter().find_map(|(justify, width, tracking)| laid_out(&texts, b, justify, width, tracking)) + // Every line spaced alike: aligned left. + if let Some(word) = one_word_spacing(&words) { + let all: Vec<f64> = texts.iter().map(|(_, f)| f.length).collect(); + tries.push((Justify::Left, longest(&all) + size * 0.02, median(trackings)?, word)); + } + tries.into_iter().find_map(|(justify, width, tracking, word)| laid_out(&texts, b, justify, width, tracking, word)) } /// The area type of lines `texts` (block `b`): `width` wide, aligned `justify`, its characters -/// tracked `tracking`; `None` when it doesn't lay out as the lines did. -fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, width: f64, tracking: f64) -> Option<TextObject> { +/// tracked `tracking` and its spaces `word` more; `None` when it doesn't lay out as the lines did. +fn laid_out(texts: &[(&TextObject, &LineFacts)], b: &Block, justify: Justify, width: f64, tracking: f64, word: f64) -> Option<TextObject> { let (first, _) = texts.first()?; let size = first.first_style().size; // Leading as set, unless it is Auto's (120% of the size). let explicit = (b.leading - size * 1.2).abs() > size * 1e-3 || texts.iter().any(|(t, _)| t.runs.iter().any(|r| (r.style.size - size).abs() > 1e-9)); let mut runs: Vec<TextRun> = vec![]; + let mut push = |text: &str, style: CharStyle| match runs.last_mut() { + Some(last) if last.style == style => last.text.push_str(text), + _ => runs.push(TextRun { text: text.to_string(), style, inline: None }), + }; for (k, (t, f)) in texts.iter().enumerate() { + let mut style = CharStyle::default(); for r in &t.runs { - let mut style = r.style.clone(); - style.tracking = tracking; + style = r.style.clone(); + style.tracking = if r.text.trim().is_empty() { tracking + word } else { tracking }; style.leading = explicit.then_some((b.leading * 1000.0).round() / 1000.0); - match runs.last_mut() { - Some(last) if last.style == style => last.text.push_str(&r.text), - _ => runs.push(TextRun { text: r.text.clone(), style, inline: None }), - } + push(&r.text, style.clone()); } // A wrapped line's space, or the end of a paragraph. - if k + 1 < texts.len() - && let Some(last) = runs.last_mut() - { - last.text.push(if f.wraps { ' ' } else { '\n' }); + if k + 1 < texts.len() { + if f.wraps { + style.tracking = tracking + word; + push(" ", style); + } else { + push("\n", style); + } } } let mut t = (*first).clone(); diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 95ca8932e..694cc35c5 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -20,7 +20,7 @@ use craft_pdf::interpret::CacheKey; use craft_pdf::syntax::Pdf; use craft_pdf::syntax::content::TypedIter; use craft_pdf::syntax::content::ops::TypedInstruction; -use craft_pdf::syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, ObjectIdentifier, dict_or_stream}; +use craft_pdf::syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream}; use craft_pdf::syntax::page::{Page, Resources}; use crate::PdfError; @@ -28,6 +28,9 @@ use crate::import_color::ink_names; /// The interpreter's limit on nested form XObjects. const MAX_DEPTH: u32 = 50; + +/// The most graphics states saved at once (PDF's own limit for nesting is 28; files nest far deeper). +const MAX_STATES: usize = 65_536; /// The most content operators walked per page (a guard against forms drawn within each other /// many times over): the art past it can't be told apart by group. A drawing of a million paths /// takes a few million; the walk reads about 15 million a second, twice as fast as the @@ -464,6 +467,8 @@ struct Walker<'w, 'p> { /// The saved graphics states' overprint parameters and inks, the current one last, pushed and /// popped as the interpreter does its states. states: Vec<State>, + /// The `q`s past [`MAX_STATES`] whose `Q` has not come. + unkept: usize, /// The text shown since the last painted path. text: TextOverprint, /// The inks of the text shown since the last painted path: of its fills, of its strokes. @@ -513,12 +518,20 @@ impl Walker<'_, '_> { } fn save(&mut self) { + // Past this many nested states the new ones are not kept (a stream of `q` is cheap to write + // and holds a state each); their `Q`s are counted off first. + if self.states.len() >= MAX_STATES { + self.unkept = self.unkept.saturating_add(1); + return; + } self.states.push(self.state()); } /// The interpreter never pops its first state. fn restore(&mut self) { - if self.states.len() > 1 { + if self.unkept > 0 { + self.unkept -= 1; + } else if self.states.len() > 1 { self.states.pop(); } } @@ -617,7 +630,10 @@ impl Walker<'_, '_> { if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref()) && let Some(name) = f.get::<Name<'_>>(b"BaseFont") { - self.fonts.entry(f.cache_key()).or_insert_with(|| (String::from_utf8_lossy(name.as_ref()).into_owned(), embeds_glyphs(&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) } + }); } } } @@ -841,9 +857,153 @@ impl Walker<'_, '_> { } } -/// The fonts of a document by cache key: base font name, and whether the file carries the glyphs -/// ([`embeds_glyphs`]). -pub(crate) type FontNames = HashMap<u128, (String, bool)>; +/// The fonts of a document by cache key. +pub(crate) type FontNames = HashMap<u128, FontNote>; + +/// What a document says of one of its fonts. +#[derive(Debug)] +pub(crate) struct FontNote { + /// Its base (PostScript) font name. + pub name: String, + /// The file carries its glyphs ([`embeds_glyphs`]). + pub embedded: bool, + /// A composite font written vertically: its descendant's metrics. + pub vertical: Option<Vertical>, +} + +/// The most CIDs a vertical font's widths are read for (the most a CID-keyed font has). +const MAX_CIDS: u32 = 65_536; + +/// A vertical composite font's metrics (ISO 32000-1 §9.7.4.3), in glyph space (1000 units an em): +/// where each glyph's position vector puts the column's centre. +#[derive(Debug, Default)] +pub(crate) struct Vertical { + /// The default width (`DW`, w0). + default: f64, + /// Widths by CID (`W`). + widths: HashMap<u32, f64>, + /// The position vector's x by CID (`W2`'s vx). + vx: HashMap<u32, f64>, + /// The CID of each glyph: `None` when they can't be told (a CID-keyed CFF font's charset + /// decides, or a font the file doesn't carry is drawn with a stand-in's glyphs). + cids: Option<Cids>, +} + +/// How glyph ids map back to CIDs. +#[derive(Debug)] +enum Cids { + /// The same (`CIDToGIDMap` `/Identity`, or none). + Identity, + /// The first CID of each glyph a `CIDToGIDMap` stream maps. + Map(HashMap<u32, u32>), +} + +impl Vertical { + /// How far right of glyph `gid`'s origin (glyph space) the column's centre is: the position + /// vector's vx from `W2`, else half the glyph's width (w0 from `W`, or `DW`). + pub fn centre(&self, gid: u32) -> f64 { + let cid = match &self.cids { + Some(Cids::Identity) => Some(gid), + Some(Cids::Map(m)) => m.get(&gid).copied(), + None => None, + }; + let w0 = |cid: Option<u32>| cid.and_then(|c| self.widths.get(&c)).copied().unwrap_or(self.default) * 0.5; + cid.and_then(|c| self.vx.get(&c)).copied().unwrap_or_else(|| w0(cid)) + } +} + +/// The vertical metrics of font `f` when it is a composite font written vertically (its CMap's +/// writing mode is 1), from its descendant CIDFont. `embedded`: the file carries its glyphs. +pub(crate) fn vertical_metrics(f: &Dict<'_>, embedded: bool) -> Option<Vertical> { + if f.get::<Name<'_>>(b"Subtype")?.as_ref() != b"Type0" { + return None; + } + let vertical = match f.get::<Name<'_>>(b"Encoding") { + Some(n) => n.as_ref().ends_with(b"-V"), + None => f.get::<Stream<'_>>(b"Encoding").is_some_and(|s| s.dict().get::<i32>(b"WMode") == Some(1)), + }; + if !vertical { + return None; + } + let d = f.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next())?; + let finite = |v: f64| v.is_finite() && v.abs() < 1e5; + let default = d.get::<f64>(b"DW").filter(|w| finite(*w)).unwrap_or(1000.0); + let mut widths = HashMap::new(); + if let Some(w) = d.get::<Array<'_>>(b"W") { + cid_ranges(&w, 1, |cid, v| { + if let [w0] = v { + widths.insert(cid, *w0); + } + }); + } + let mut vx = HashMap::new(); + if let Some(w2) = d.get::<Array<'_>>(b"W2") { + cid_ranges(&w2, 3, |cid, v| { + if let [_, x, _] = v { + vx.insert(cid, *x); + } + }); + } + widths.retain(|_, w| finite(*w)); + vx.retain(|_, x| finite(*x)); + let true_type = d.get::<Name<'_>>(b"Subtype").is_some_and(|s| s.as_ref() == b"CIDFontType2"); + let cids = if !embedded || !true_type { + None + } else if let Some(s) = d.get::<Stream<'_>>(b"CIDToGIDMap") { + // Two bytes a CID, the glyph it maps to. + let data = s.decoded().ok()?; + let mut m = HashMap::new(); + for (cid, g) in (0..MAX_CIDS).zip(data.as_chunks::<2>().0) { + m.entry(u32::from(u16::from_be_bytes(*g))).or_insert(cid); + } + Some(Cids::Map(m)) + } else { + Some(Cids::Identity) + }; + Some(Vertical { default, widths, vx, cids }) +} + +/// Each CID a `W` or `W2` array (`per` numbers a CID) gives values for, and its values: `c [v…]` +/// gives CIDs from c on their values in turn, `c_first c_last v…` the same values to each of the +/// range. At most [`MAX_CIDS`] CIDs are read. +fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) { + let mut it = a.flex_iter(); + let mut read = 0; + loop { + if let Some((first, list)) = it.next::<(u32, Array<'_>)>() { + let values: Vec<f64> = list.iter::<f64>().collect(); + for (cid, v) in (first..=u32::MAX).zip(values.chunks_exact(per)) { + read += 1; + if read > MAX_CIDS { + return; + } + each(cid, v); + } + } else if per == 1 + && let Some((first, last, w)) = it.next::<(u32, u32, f64)>() + { + for cid in first..=last.min(first.saturating_add(MAX_CIDS)) { + read += 1; + if read > MAX_CIDS { + return; + } + each(cid, &[w]); + } + } else if per == 3 + && let Some((first, last, w, x, y)) = it.next::<(u32, u32, f64, f64, f64)>() + { + for cid in first..=last.min(first.saturating_add(MAX_CIDS)) { + read += 1; + if read > MAX_CIDS { + return; + } + each(cid, &[w, x, y]); + } + } else { + return; + } + } +} /// 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 @@ -881,6 +1041,7 @@ fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: & out: Scan::default(), hidden_form: false, states: vec![State::default()], + unkept: 0, text: TextOverprint::None, text_inks: [RunInk::None; 2], ink_index: HashMap::new(), diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index a4954a57a..85423148f 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -85,7 +85,7 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, ink let stops = ts.iter().map(|&t| stop_at(t, (r0 + (r1 - r0) * t) / r1)).collect(); (GradientKind::Radial, geom, stops) }; - let mut g = GradientPaint::new(Gradient { kind, stops }); + let mut g = GradientPaint::new(Gradient::new(kind, stops)); g.geom = Some(geom); Some((g, *extend)) } diff --git a/crates/pdf/src/import_text.rs b/crates/pdf/src/import_text.rs index f0dfaf4c8..4eca5d11a 100644 --- a/crates/pdf/src/import_text.rs +++ b/crates/pdf/src/import_text.rs @@ -1,13 +1,15 @@ //! Text as editable point type: glyphs drawn one after another on the same baseline make one //! point text object of their Unicode text, a run per font, size and paint (a gap wider than a -//! fifth of the size reads as a space). Fonts are named from the file's base font name (its +//! fifth of the smaller size beside it reads as a space; a glyph the file raised or lowered off the +//! baseline, text rise, is baseline shift). Each glyph stays where the file drew it: the kerning +//! and tracking the type takes put it there (#722). Fonts are named from the file's base font name (its //! subset prefix dropped, the family matched against the fonts available); a font that isn't //! available keeps its name, and the text shows in the fallback font until it is. //! //! Glyphs each turned a little further along a curve (type set on a path: apps write each glyph //! with its own placement) make one type-on-a-path object, its path through the glyphs' baseline. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; use vectorcraft_color::Paint; @@ -117,6 +119,33 @@ impl Look { } } +/// A gap between adjacent glyphs on a line. +#[derive(Clone, Copy, Debug)] +struct Gap { + /// Byte offset in the line's text of the glyph after the gap. + at: usize, + /// From the end of the glyph before to the start of the next (points). + width: f64, + /// The smaller of the two glyphs' sizes (points): a word gap beside a much larger glyph is + /// narrow for the large size (#3623). + em: f64, + /// Along the line, not turned along a curve. + on_line: bool, +} + +/// The spacing a line's letters share (letter-spaced type), in ems: the gap a third of the way up +/// from the narrowest, of three or more along the line. Never below zero, and none at all past +/// half an em: glyphs set wider apart than that are words or columns, not tracked letters. +fn common_gap(gaps: &[Gap]) -> f64 { + let mut widths: Vec<f64> = gaps.iter().filter(|g| g.on_line).map(|g| g.width / g.em).filter(|w| w.is_finite()).collect(); + if widths.len() < 3 { + return 0.0; + } + let pivot = widths.len() / 3; + let (_, common, _) = widths.select_nth_unstable_by(pivot, f64::total_cmp); + if *common > 0.5 { 0.0 } else { common.max(0.0) } +} + /// A line of type being gathered: runs of glyphs on one baseline. pub(crate) struct TextLine { /// The first glyph's placement. @@ -130,9 +159,20 @@ pub(crate) struct TextLine { visible: Point, /// The last glyph's baseline direction. last_dir: Vec2, + /// The last glyph's size (points). + last_size: f64, /// Each glyph's baseline origin, then where the last one ends. baseline: Vec<Point>, - runs: Vec<(Look, String)>, + /// Runs of glyphs drawn alike: their look, how far the file raised them off the line's + /// baseline (points, up; text rise, `Ts`), their text. + runs: Vec<(Look, f64, String)>, + /// The bytes of text the horizontal glyphs added to `runs` so far (counted as they come: a + /// sum over the runs for each glyph is quadratic when every glyph is a run). + text_bytes: usize, + /// Where the file put each character of the runs' text, in order: the first character of each + /// glyph has its glyph's [`Slot`]; a space read from a gap and the other characters of a + /// glyph of several have none. + slots: Vec<Option<Slot>>, /// The first glyph was set vertically ([`Upright`]). upright: Option<Upright>, /// The glyphs make a column (vertical type), each below the last. @@ -140,16 +180,29 @@ pub(crate) struct TextLine { /// Each glyph's ink box as drawn (document space), in drawing order; glyphs without ink are /// left out. pub ink: Vec<Rect>, - /// The spaces read from gaps between glyphs: their byte offset in the text, and the gap from - /// the end of the glyph before to the start of the next (points). - gaps: Vec<(usize, f64)>, - /// Each glyph's advance in the file's font (points), with [`Self::baseline`]. - advances: Vec<f64>, + /// The gaps between adjacent glyphs that may read as spaces ([`finish`](Self::finish) + /// decides). + gaps: Vec<Gap>, + /// The spaces the file draws between two glyphs that aren't spaces: their byte offset in the + /// text before the spaces read from gaps go in, and the gaps on both sides of it plus its + /// advance (points). + drawn_spaces: Vec<(usize, f64)>, + /// A space the file drew after a glyph that isn't one, until the next glyph comes: its byte + /// offset, and the gap before it plus its advance (points). + drawn_space: Option<(usize, f64)>, /// The strokes of its glyphs' outlines drawn between them, one per glyph with ink so far /// (written right after the line). pub held: Vec<Node>, } +/// Where the file put a glyph: the glyph's index in the line, and its advance in the file's font +/// (points) when it shows one character (`None` for a ligature's several). +#[derive(Clone, Copy, Debug)] +struct Slot { + glyph: usize, + advance: Option<f64>, +} + /// What a finished line of type tells beyond its text object (see [`crate::import_lines`]). #[derive(Clone, Debug, Default)] pub(crate) struct LineFacts { @@ -174,13 +227,17 @@ impl TextLine { last: at.origin, visible: at.origin, last_dir: at.dir, + last_size: at.size, baseline: vec![], runs: vec![], + text_bytes: 0, + slots: vec![], upright: None, column: false, ink: vec![], gaps: vec![], - advances: vec![], + drawn_spaces: vec![], + drawn_space: None, held: vec![], } } @@ -196,8 +253,8 @@ impl TextLine { pub fn push_upright(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, text: &str) -> bool { let down = -self.at.up(); let size = self.at.size.max(at.size); - let Some((last, run)) = self.runs.last_mut() else { - self.runs.push((look.clone(), text.to_string())); + let Some((last, _, run)) = self.runs.last_mut() else { + self.runs.push((look.clone(), 0.0, text.to_string())); (self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin); return true; }; @@ -225,7 +282,7 @@ impl TextLine { if last.takes(look) { run.push_str(text); } else { - self.runs.push((look.clone(), text.to_string())); + self.runs.push((look.clone(), 0.0, text.to_string())); } self.next = at.origin + down * at.size; self.last = at.origin; @@ -239,17 +296,33 @@ impl TextLine { if self.column || self.upright.is_some() { return false; } - let bytes: usize = self.runs.iter().map(|(_, t)| t.len()).sum(); - if let Some((last, run)) = self.runs.last_mut() { + let bytes = self.text_bytes; + let glyph = self.baseline.len(); + let curved = self.curved(); + let mut shift = 0.0; + if let Some((last, last_shift, run)) = self.runs.last_mut() { let size = self.at.size.max(at.size); let gap = (at.origin - self.next).dot(self.last_dir); + // How far the glyph sits above the line's baseline. The file raises a superscript or + // lowers a subscript (text rise, `Ts`) with the glyphs turned exactly as the rest of + // the line (`level`): it stays on the line, shifted off its baseline. Up to half an em + // when it follows on from the glyph before (no wider gap than a space reads from): + // further apart, it is type of its own (the next cell of a table's row, its baseline a + // little higher). + let above = (at.origin - self.at.origin).dot(self.at.up()); + let level = at.dir.dot(self.at.dir) > 1.0 - 1e-9 && !curved; let on_line = opacity == self.opacity && at.dir.dot(self.at.dir) > 0.9995 && (at.slant - self.at.slant).abs() < 1e-3 - && (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15 + && (above.abs() < size * 0.15 || (level && above.abs() < size * 0.5 && gap <= size * 0.2)) // Proportional widths close CJK marks up by up to half an em. && gap > -size * 0.6 && gap < size * 3.0; + // Within a hair of the baseline (rounding in the file) is on it; a glyph turned a + // little (type on a path) sits on the curve, not shifted off it. + if on_line && level && above.abs() > size * 0.01 { + shift = above; + } // Or on a curve: turned a little from the glyph before it, and starting about where // that one ends. let on_curve = !on_line @@ -261,29 +334,38 @@ impl TextLine { if !on_line && !on_curve { return false; } - // A gap wider than a fifth of an em reads as a space. - if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') { - run.push(' '); - if on_line { - self.gaps.push((bytes, gap)); + if !run.ends_with(' ') && !text.starts_with(' ') { + self.gaps.push(Gap { at: bytes, width: gap, em: self.last_size.min(at.size), on_line }); + } + // A space the file draws between two glyphs spans the gaps on both sides of it. + let drawn = self.drawn_space.take(); + if on_line { + if text == " " && !run.ends_with(' ') { + self.drawn_space = Some((bytes, gap + advance)); + } else if let Some((b, before)) = drawn.filter(|_| !text.starts_with(' ')) { + self.drawn_spaces.push((b, before + gap)); } } - if last.takes(look) { + if last.takes(look) && (*last_shift - shift).abs() < 1e-3 { run.push_str(text); } else { - self.runs.push((look.clone(), text.to_string())); + self.runs.push((look.clone(), shift, text.to_string())); } } else { - self.runs.push((look.clone(), text.to_string())); + self.runs.push((look.clone(), 0.0, text.to_string())); } + self.text_bytes = self.text_bytes.saturating_add(text.len()); + let one = text.chars().count() == 1; + self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) })); + self.slots.extend(text.chars().skip(1).map(|_| None)); self.next = at.origin + at.dir * advance; if !text.trim().is_empty() { self.visible = self.next; } self.last = at.origin; self.last_dir = at.dir; + self.last_size = at.size; self.baseline.push(at.origin); - self.advances.push(advance); true } @@ -320,7 +402,7 @@ impl TextLine { /// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't /// that glyph. pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool { - let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else { + let Some((look, ..)) = self.runs.last_mut().filter(|(l, ..)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else { return false; }; (look.stroke, look.overprint_stroke) = (Some(stroke), overprint); @@ -332,18 +414,92 @@ impl TextLine { let mut facts = LineFacts { ink: std::mem::take(&mut self.ink), ..LineFacts::default() }; // Turned along a curve: type on a path through the glyphs. let path = if self.curved() { self.baseline_path() } else { None }; - facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(_, t)| t.ends_with(' ')); - if let Some((_, last)) = self.runs.last_mut() { + facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(.., t)| t.ends_with(' ')); + if let Some((.., last)) = self.runs.last_mut() { last.truncate(last.trim_end().len()); } - self.runs.retain(|(_, t)| !t.is_empty()); - if self.runs.iter().all(|(_, t)| t.trim().is_empty()) { + self.runs.retain(|(.., t)| !t.is_empty()); + if self.runs.iter().all(|(.., t)| t.trim().is_empty()) { return None; } - let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style(), inline: None }); + // The spaces trimmed off the end have no place either. + let chars: usize = self.runs.iter().map(|(.., t)| t.chars().count()).sum(); + self.slots.truncate(chars); + // The baseline most of the characters sit on is the line's: the others are shifted off it + // (a line starting with a raised footnote mark keeps the baseline of its text). + let mut on: Vec<(f64, usize)> = vec![]; + for (_, shift, text) in &self.runs { + let n = text.chars().count(); + match on.iter_mut().find(|(s, _)| (s - shift).abs() < 1e-3) { + Some((_, count)) => *count += n, + None => on.push((*shift, n)), + } + } + // A tie keeps the first glyph's ("m²" sits on the m's baseline). + let base = on.iter().fold(None::<(f64, usize)>, |best, &(s, n)| if best.is_none_or(|(_, b)| n > b) { Some((s, n)) } else { best }); + let base = base.map_or(0.0, |(s, _)| s); + if path.is_some() { + // Type on a path sits on its curve: no glyph is shifted off it. + for (_, shift, _) in &mut self.runs { + *shift = 0.0; + } + } else if base != 0.0 { + // Up the slant the type leans by (a sheared text matrix), as the file raises it. + self.at.origin += (self.at.up() + self.at.dir * self.at.slant) * base; + for (_, shift, _) in &mut self.runs { + *shift -= base; + } + } + let mut runs = self.runs.into_iter().map(|(look, shift, text)| { + let style = CharStyle { baseline_shift: round(shift), ..look.style() }; + TextRun { text, style, inline: None } + }); let first = runs.next()?; let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style); t.runs.extend(runs); + // A gap wider than a fifth of an em, past the spacing the line's letters share, reads as a + // space (one set at exactly a fifth still counts, whatever the rounding). Letter spacing is + // read only in text left in the file's order. + let space = |gap: &Gap, common: f64| gap.width / gap.em - common > 0.199; + let directed = vectorcraft_text::logical_order(&t.plain_text()).is_some(); + let mut common = if directed { 0.0 } else { common_gap(&self.gaps) }; + // Evenly spaced glyphs alone could be letters set apart or words of one letter each: the + // shared spacing is letter spacing only on a line that also shows where its words break, + // with spaces it draws or with gaps wider than the rest. + if self.drawn_spaces.is_empty() && !self.gaps.iter().any(|gap| space(gap, common)) { + common = 0.0; + } + let mut spaces = vec![]; + for gap in &self.gaps { + if !space(gap, common) { + continue; + } + // After the earlier spaces, at the end of the run before it (the run it follows). + let at = gap.at + spaces.len(); + let mut offset = 0; + let Some(i) = t.runs.iter().position(|run| { + offset += run.text.len(); + at <= offset + }) else { + break; + }; + // The characters before it, in the runs before and in its own. + let earlier: usize = t.runs.iter().take(i).map(|r| r.text.chars().count()).sum(); + let Some(run) = t.runs.get_mut(i) else { break }; + let at = at - (offset - run.text.len()); + let Some(head) = run.text.get(..at) else { continue }; + // A space read from a gap has no slot of its own. + let char_at = earlier + head.chars().count(); + if char_at <= self.slots.len() { + self.slots.insert(char_at, None); + } + run.text.insert(at, ' '); + spaces.push((gap.at, gap.width, gap.on_line)); + } + // Each by its offset in the text with the spaces in. + let mut gaps: Vec<(usize, f64)> = spaces.iter().enumerate().filter(|(_, s)| s.2).map(|(i, s)| (s.0 + i, s.1)).collect(); + gaps.extend(self.drawn_spaces.iter().map(|&(b, width)| (b + spaces.iter().filter(|s| s.0 < b).count(), width))); + gaps.sort_by_key(|&(b, _)| b); if self.upright.is_none() { to_logical(&mut t); } @@ -351,19 +507,20 @@ impl TextLine { // The glyphs' tops lean along the baseline (+x), up being -y in the type's own space. t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(angle) * Affine::skew(-self.at.slant, 0.0); let db = vectorcraft_text::FontDb::global(); - // Glyphs spaced each their own way (character tightening, optical kerning) keep their - // place: each character its own tracking. - let own = (self.upright.is_none() && t.para.direction.is_none()) - .then(|| own_spacing(&t, &self.baseline, &self.advances, path.as_ref(), self.at.dir, self.at.size)) - .flatten(); - if let Some(own) = &own { - t.runs = tracked_each(std::mem::take(&mut t.runs), own, self.at.size); - } + // Each glyph where the file put it (#722): the kerning (the font's, or none) and tracking + // (one value for the line, or each character its own) that place them there. + let placed = (self.upright.is_none() && t.para.direction.is_none()) + .then(|| place_each(&t, &self.slots, &self.baseline, path.as_ref(), self.at, self.at.size)) + .flatten() + .map(|(runs, laid)| { + t.runs = runs; + laid.bounds + }); if let Some(path) = path { // Evenly spaced type keeps its length along the path: the path runs through the // glyphs' origins, one curve each, to the end of the last glyph, and tracking makes up // the difference from the advances. - if own.is_none() { + if placed.is_none() { let length: f64 = path.segments().take(self.baseline.len()).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).sum(); let laid = vectorcraft_text::layout(db, &t); let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum(); @@ -397,16 +554,21 @@ impl TextLine { if !t.vertical { facts.length = length; } + // Placed glyph by glyph: laid out so already. + if let Some(bounds) = placed { + t.cached_bounds = Some(bounds); + return Some((t, self.opacity, facts)); + } let laid = vectorcraft_text::layout(db, &t); let natural: f64 = laid.glyphs.iter().map(|g| g.advance).sum(); let chars: usize = t.runs.iter().map(|r| r.text.chars().count()).sum(); let size = self.at.size; let mut bounds = laid.bounds; - if own.is_none() && chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 { - // The spaces read from gaps keep their own place only in text left in the file's order. + if chars > 1 && length.is_finite() && (length - natural).abs() > size * 0.01 { + // The spaces read from gaps or drawn between glyphs keep their own place only in text + // left in the file's order. let total: usize = t.runs.iter().map(|r| r.text.len()).sum(); - let gaps: Vec<(usize, f64)> = - if t.para.direction.is_none() { self.gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] }; + let gaps: Vec<(usize, f64)> = if t.para.direction.is_none() { gaps.into_iter().filter(|(b, _)| *b < total).collect() } else { vec![] }; let (tracking, spaces) = spacing(&laid, &gaps, length - natural, chars, size); t.runs = tracked(std::mem::take(&mut t.runs), tracking, &spaces); // Tracked: laid out again for its bounds. @@ -417,79 +579,311 @@ impl TextLine { } } -/// The space each glyph of `t` (laid out untracked) leaves after its advance to put the next one -/// where the file did (points; the last glyph's is the one before it), when that varies by more -/// than a fiftieth of an em: one tracking value can't place them. Glyph origins are `baseline`, -/// measured along `path` (type on a path: one curve per glyph) or along `dir`. None when the -/// glyphs can't be matched to the characters one to one (spaces, ligatures), or when the font -/// laid out isn't the file's (its advances differ from `advances`): spacing measured against a -/// stand-in's widths would keep the stand-in's differences once the font is installed. -fn own_spacing(t: &TextObject, baseline: &[Point], advances: &[f64], path: Option<&BezPath>, dir: Vec2, size: f64) -> Option<Vec<f64>> { - let text: String = t.runs.iter().map(|r| r.text.as_str()).collect(); - let chars = text.chars().count(); - if chars < 3 || text.contains(' ') || baseline.len() != chars || advances.len() != chars { - return None; +/// Where along the line (points) layout `laid` puts each character at a byte offset of `chars` +/// (in order), and its advance: `None` for one inside another's cluster. Text in the file's order +/// (left to right). +fn pens(laid: &TextLayout, chars: &[usize]) -> Vec<Option<(f64, f64)>> { + let mut at = vec![None; chars.len()]; + let mut pen = 0.0; + for g in &laid.glyphs { + if let Some(slot) = chars.binary_search(&g.byte).ok().and_then(|i| at.get_mut(i)) { + slot.get_or_insert((pen, g.advance)); + } + pen += g.advance; } - let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - let advs: Vec<f64> = laid.glyphs.iter().map(|g| g.advance).collect(); - if advs.len() != chars || advs.iter().zip(advances).any(|(a, b)| (a - b).abs() > size * 0.01) { - return None; - } - let dist: Vec<f64> = match path { - Some(bp) => bp.segments().take(chars - 1).map(|seg| kurbo::ParamCurveArclen::arclen(&seg, 1e-3)).collect(), - None => baseline.windows(2).map(|w| (w[1] - w[0]).dot(dir)).collect(), - }; - if dist.len() != chars - 1 { - return None; - } - let mut extra: Vec<f64> = dist.iter().zip(&advs).map(|(d, a)| d - a).collect(); - let mean = extra.iter().sum::<f64>() / extra.len() as f64; - if extra.iter().all(|e| (e - mean).abs() <= size * 0.02) { - return None; - } - extra.push(*extra.last()?); - Some(extra) + at } -/// `runs` with each character's tracking from `own` (points, one per character), characters with -/// the same tracking kept in one run. -fn tracked_each(runs: Vec<TextRun>, own: &[f64], size: f64) -> Vec<TextRun> { - let em = |pts: f64| (pts / size * 1000.0).clamp(-1000.0, 1000.0).round(); - let mut out: Vec<TextRun> = vec![]; +/// `t`'s runs with kerning `kern` and each character's tracking from `own` (points, one per +/// character, in thousandths of each run's em), characters alike kept in one run. +fn restyled(t: &TextObject, kern: Option<f64>, own: &[f64]) -> Vec<TextRun> { + let mut out: Vec<TextRun> = Vec::with_capacity(t.runs.len()); + // The run of `t` the last character came from. + let mut from = None; let mut k = 0; - for r in runs { + for (i, r) in t.runs.iter().enumerate() { + let size = r.style.size.max(1e-3); for c in r.text.chars() { - let style = CharStyle { tracking: own.get(k).map_or(0.0, |e| em(*e)), ..r.style.clone() }; + let tracking = own.get(k).map_or(0.0, |e| (e / size * 1000.0).clamp(-1000.0, 10000.0)); k += 1; + // Hundredths of a thousandth of an em: no drift along a line, no noise in the panels. + let tracking = (tracking * 100.0).round() / 100.0; match out.last_mut() { - Some(last) if last.style == style && last.inline == r.inline => last.text.push(c), - _ => out.push(TextRun { text: c.to_string(), style, inline: r.inline.clone() }), + Some(last) if from == Some(i) && last.style.tracking == tracking => last.text.push(c), + last => { + let style = CharStyle { tracking, kerning: kern, ..r.style.clone() }; + match last { + Some(last) if last.style == style && last.inline == r.inline => last.text.push(c), + _ => out.push(TextRun { text: c.to_string(), style, inline: r.inline.clone() }), + } + } } + from = Some(i); } } out } +/// The runs of `t` that put its glyphs where the file did (#722), and their layout: with the +/// font's kerning or none, whichever the file's spacing follows, and tracking that is the same +/// after every character when the file spaced them evenly, else each character's own (word +/// spacing, `TJ` adjustments, character tightening, spaces read from gaps), measured from the +/// file's widths (not the font's advances, which may be a hair off them) and kept on course where +/// the differences add up ([`on_course`]). +/// +/// The file put the glyph of each character with a [`Slot`] at `baseline` (one origin per glyph), +/// measured along `path` (type on a path: one curve per glyph) or along `line`'s baseline from its +/// origin, where a character shifted off it by its baseline shift draws that shift times `line`'s +/// slant further along (the type's skew leans it so, as the file's text rise did). `None` when the +/// font laid out isn't the file's (its advances differ from the file's): spacing measured against +/// a stand-in's widths would keep the stand-in's differences once the font is installed; or when +/// the glyphs can't be placed so (the layout joins characters the file drew apart). +fn place_each( + t: &TextObject, + slots: &[Option<Slot>], + baseline: &[Point], + path: Option<&BezPath>, + line: Placement, + size: f64, +) -> Option<(Vec<TextRun>, TextLayout)> { + let chars: Vec<usize> = t + .runs + .iter() + .scan(0, |off, r| { + let base = *off; + *off += r.text.len(); + Some(r.text.char_indices().map(move |(i, _)| base + i).collect::<Vec<_>>()) + }) + .flatten() + .collect(); + if chars.len() < 2 || chars.len() != slots.len() || t.runs.iter().any(|r| r.inline.is_some()) { + return None; + } + // How far along the line the file put each glyph. + let along: Vec<f64> = match path { + Some(bp) => std::iter::once(0.0) + .chain(bp.segments().scan(0.0, |d, seg| { + *d += kurbo::ParamCurveArclen::arclen(&seg, 1e-3); + Some(*d) + })) + .take(baseline.len()) + .collect(), + None => baseline.iter().map(|p| (*p - line.origin).dot(line.dir)).collect(), + }; + // Each character's baseline shift, which the type's slant draws `slant` times as far along. + let shifts: Vec<f64> = t.runs.iter().flat_map(|r| r.text.chars().map(move |_| r.style.baseline_shift)).collect(); + let slant = if path.is_some() { 0.0 } else { line.slant }; + // Each character's target: where the file put it, if it put it anywhere. + let target: Vec<Option<(f64, Option<f64>)>> = + slots.iter().zip(&shifts).map(|(s, shift)| s.and_then(|s| Some((*along.get(s.glyph)? - slant * shift, s.advance)))).collect(); + if target.first().is_none_or(Option::is_none) || target.iter().flatten().any(|(d, _)| !d.is_finite()) { + return None; + } + let db = vectorcraft_text::FontDb::global(); + // The spacing each character takes after it, laid out with `pens`: whatever puts the next one + // the file placed where it did. Characters between two the file placed (a space read from a + // gap, the rest of a ligature) take the line's spacing, the last of them the rest. + let spacing = |pens: &[Option<(f64, f64)>]| -> Option<Vec<f64>> { + // How far each character the file placed is from where the layout puts it. + let placed: Vec<(usize, f64)> = + target.iter().zip(pens).enumerate().filter_map(|(i, (t, p))| Some((i, t.as_ref()?.0 - p.as_ref()?.0))).collect(); + let steps: Vec<f64> = placed.windows(2).filter(|w| w[1].0 == w[0].0 + 1).map(|w| w[1].1 - w[0].1).collect(); + let line = median(steps).unwrap_or(0.0); + let mut own = vec![line; chars.len()]; + for w in placed.windows(2) { + let ((a, da), (b, db)) = (w[0], w[1]); + *own.get_mut(b.checked_sub(1)?)? = db - da - line * (b - a - 1) as f64; + } + // After the last character the file placed, spacing changes nothing: the line's. + let last = placed.last()?.0; + let tail = if last > 0 { own.get(last - 1).copied().unwrap_or(line) } else { line }; + for e in own.get_mut(last..).into_iter().flatten() { + *e = tail; + } + Some(own) + }; + // Evenly spaced: one tracking value for the line. (A file rounds its widths to thousandths of + // an em: a hair either way is even.) + let even = |own: &[f64]| { + let line = median(own.to_vec())?; + own.iter().all(|e| (e - line).abs() <= size * 0.002).then(|| vec![line; own.len()]) + }; + let changes = |own: &[f64]| own.windows(2).filter(|w| (w[1] - w[0]).abs() > size * 1e-4).count(); + // The type as it is: untracked, with the font's kerning. + let auto = vectorcraft_text::layout(db, t); + // Each glyph's own advance (the font's, unkerned) tells whether the layout's font is the + // file's, whether the font kerned any of the line's pairs, and where the glyphs go unkerned + // (the same glyphs, each on its own advance: no layout needed to tell). + let mut faces: Vec<std::sync::Arc<vectorcraft_text::FontFace>> = vec![]; + let mut unkerned_pens = vec![None; chars.len()]; + let mut pen = 0.0; + let mut kerned_any = false; + for g in &auto.glyphs { + let face = match faces.iter().find(|f| f.id() == g.font_id) { + Some(f) => f.clone(), + None => { + let f = db.face_by_id(g.font_id)?; + faces.push(f.clone()); + f + } + }; + let style = &t.runs.get(g.run)?.style; + let own = face.advance(g.gid) * style.size / face.units_per_em().max(1e-9) * style.h_scale / 100.0; + kerned_any |= (own - g.advance).abs() > size * 1e-6; + if let Some(slot) = chars.binary_search(&g.byte).ok().and_then(|i| unkerned_pens.get_mut(i)) { + slot.get_or_insert((pen, own)); + } + pen += own; + } + let same_font = unkerned_pens.iter().zip(&target).all(|(n, t)| match t { + Some((_, Some(adv))) => n.is_some_and(|(_, a): (f64, f64)| (a - adv).abs() <= size * 0.01), + _ => true, + }); + if !same_font { + return None; + } + // Where the glyphs go on the file's own widths: each character's pen moved by how much wider + // the file says the characters before it are than the font's advances (a file gives widths to + // the thousandth of an em, so a font of 2048 units an em is a hair off), where the file gives + // a character's width and the layout sets it as a glyph of its own. Spacing measured from them + // is the file's (its `Tc`, `Tw`, `TJ`): measured from the installed font's advances, each + // character would also take its own hair of tracking (a run each), and keep it once a font + // rounded otherwise is installed. + let end = pen; + let mut wider = 0.0; + let mut drift = Vec::with_capacity(chars.len()); + for (i, t) in target.iter().enumerate() { + drift.push(wider); + let (Some(Some((start, _))), Some((_, Some(width)))) = (unkerned_pens.get(i), t) else { continue }; + let next = match unkerned_pens.get(i + 1) { + Some(n) => n.map(|(p, _)| p), + None => Some(end), + }; + if let Some(next) = next { + wider += width - (next - start); + } + } + let by_file = + |pens: &[Option<(f64, f64)>]| -> Vec<Option<(f64, f64)>> { pens.iter().zip(&drift).map(|(p, d)| p.map(|(x, a)| (x + d, a))).collect() }; + // The font's kerning when the file followed it (or there is none to follow), else none. + let kerned_pens = pens(&auto, &chars); + let kerned = spacing(&by_file(&kerned_pens))?; + let (kern, own) = match even(&kerned) { + Some(own) => (None, own), + // Without kerning the line lays out the same. + None if !kerned_any => (None, kerned), + None => { + let unkerned = spacing(&by_file(&unkerned_pens))?; + match even(&unkerned) { + Some(own) => (Some(0.0), own), + None if changes(&unkerned) < changes(&kerned) => (Some(0.0), unkerned), + None => (None, kerned), + } + } + }; + let own = on_course(own, if kern.is_some() { &unkerned_pens } else { &kerned_pens }, &target, size); + let runs = restyled(t, kern, &own); + // Laid out so, are they where the file put them? (A ligature the layout forms where the file + // drew two glyphs moves those after it.) + let mut probe = t.clone(); + probe.runs = runs; + let check = vectorcraft_text::layout(db, &probe); + let got = pens(&check, &chars); + let ok = got.iter().zip(&target).all(|(g, t)| match t { + Some((d, _)) => g.is_some_and(|(p, _)| (p - d).abs() <= size * 0.02), + None => true, + }); + ok.then_some((probe.runs, check)) +} + +/// `own` (each character's spacing, points, measured from the file's widths) kept on course: laid +/// out from `pens` (where each character goes untracked), the glyphs land within a hundredth of an +/// em of `target` (where the file put them). The font's advances may each be a little off the +/// file's widths (by up to the 1% that still counts as the same font), and a line spaced evenly to +/// a hair takes the same value after every character: small differences that add up along a line. +/// Off course, each stretch of the line takes a share of the difference after every character +/// (one run), as long a stretch as one share keeps on course. +fn on_course(mut own: Vec<f64>, pens: &[Option<(f64, f64)>], target: &[Option<(f64, Option<f64>)>], size: f64) -> Vec<f64> { + let tolerance = size * 0.01; + // How much further along than `own` lays it out each glyph the file placed must go. + let mut at = 0.0; + let mut behind = vec![]; + for (i, ((p, t), e)) in pens.iter().zip(target).zip(&own).enumerate() { + if let (Some((p, _)), Some((t, _))) = (p, t) { + behind.push((i, t - p - at)); + } + at += e; + } + if behind.iter().all(|(_, d)| d.abs() <= tolerance) { + return own; + } + // The character the stretch starts at, how far the shares before it moved it, the last glyph + // the stretch keeps on course, and the shares (lowest, highest) that keep each since on it. + let (mut from, mut moved, mut last) = (0, 0.0, 0); + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); + for &(i, d) in &behind { + if i <= from { + continue; + } + let course = |from: usize, moved: f64| { + let n = (i - from) as f64; + ((d - moved - tolerance) / n, (d - moved + tolerance) / n) + }; + let (l, h) = course(from, moved); + if l > hi || h < lo { + // No share keeps this glyph on course too: the stretch ends at the last one it does. + let share = (lo + hi) / 2.0; + for e in own.get_mut(from..last).into_iter().flatten() { + *e += share; + } + moved += share * (last - from) as f64; + from = last; + (lo, hi) = course(from, moved); + } else { + (lo, hi) = (lo.max(l), hi.min(h)); + } + last = i; + } + // The last stretch takes its share after the characters past the last glyph the file placed + // too (it changes nothing there). + if lo.is_finite() && hi.is_finite() { + let share = (lo + hi) / 2.0; + for e in own.get_mut(from..).into_iter().flatten() { + *e += share; + } + } + own +} + +/// The middle value of `v` (the upper one of an even count). +pub(crate) fn median(mut v: Vec<f64>) -> Option<f64> { + v.sort_by(f64::total_cmp); + v.get(v.len() / 2).copied() +} + /// The tracking (thousandths of an em) that sets a line of `chars` characters, laid out untracked /// as `laid`, `extra` points longer, as the file set it: the same after every character, except -/// the spaces read from gaps (`gaps`: byte offset, gap in points) where that would put the glyphs -/// after them more than a tenth of an em off (a barcode's digits, set in groups apart). Those take -/// their own tracking, given with their byte offsets. +/// the spaces read from gaps or drawn between glyphs (`gaps`: byte offset, gap in points) where +/// that would put the glyphs after them more than a tenth of an em off (a barcode's digits, set in +/// groups apart). Those take their own tracking, given with their byte offsets. fn spacing(laid: &TextLayout, gaps: &[(usize, f64)], extra: f64, chars: usize, size: f64) -> (f64, Vec<(usize, f64)>) { let pairs = chars.saturating_sub(1).max(1) as f64; + // The advance of the first glyph laid out at each byte offset. + let space: HashMap<usize, f64> = laid.glyphs.iter().rev().map(|g| (g.byte, g.advance)).collect(); // The space and the character before it take `need` (the gap less the space) between them; // tracking gives them twice its share. - let space = |b: usize| laid.glyphs.iter().find(|g| g.byte == b).map(|g| g.advance); - let needs: Vec<(usize, f64)> = gaps.iter().filter_map(|&(b, gap)| Some((b, gap - space(b)?))).collect(); + let needs: Vec<(usize, f64)> = gaps.iter().filter_map(|&(b, gap)| Some((b, gap - space.get(&b)?))).collect(); let mut own: Vec<(usize, f64)> = vec![]; + let mut taken: HashSet<usize> = HashSet::new(); let mut tracking = extra / pairs; // Each space taken apart changes the others' share: until no more are. loop { let more: Vec<(usize, f64)> = - needs.iter().filter(|(b, need)| !own.iter().any(|(o, _)| o == b) && (need - 2.0 * tracking).abs() > size * 0.1).copied().collect(); + needs.iter().filter(|(b, need)| !taken.contains(b) && (need - 2.0 * tracking).abs() > size * 0.1).copied().collect(); if more.is_empty() { break; } + taken.extend(more.iter().map(|(b, _)| *b)); own.extend(more); let rest = pairs - 2.0 * own.len() as f64; tracking = if rest >= 1.0 { (extra - own.iter().map(|(_, need)| need).sum::<f64>()) / rest } else { 0.0 }; diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index b855fa242..bfb04ab82 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -5,7 +5,8 @@ //! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes //! as their brush art), opacity and blend modes (transparency groups), //! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and -//! freeform gradients as images (clipped to what they paint), embedded images (resampled and +//! freeform and Perceptual gradients and dithered gradient fills as images at the raster +//! effects resolution (clipped to what they paint), embedded images (resampled and //! compressed as the Compression settings say) and text as outlined glyph paths, or as real //! text in embedded subset fonts (Advanced). Colours are converted, tagged with ICC profiles and //! given an output intent as the Output settings say. Hidden objects, @@ -93,6 +94,8 @@ pub struct PdfOptions { pub artboards: Option<Vec<usize>>, /// Document title for the metadata; `None` = the document's title. pub title: Option<String>, + /// Ask PDF viewers to display the document title instead of the file name. + pub display_doc_title: bool, /// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date. pub created: Option<i64>, /// Modification date as Unix seconds (UTC); `None` = the creation date. @@ -304,6 +307,8 @@ mod tests_stroke; #[cfg(test)] mod tests_strokeout; #[cfg(test)] +mod tests_text_placement; +#[cfg(test)] mod tests_textstroke; #[cfg(test)] mod tests_webview; diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 710bbe82f..55ab42ced 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -160,6 +160,8 @@ fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Optio let name = o.destination.trim(); let fallback = if cmyk_only { crate::pdfx::cmyk_destination(set, source) } else { String::new() }; let name = match (name.is_empty(), mode) { + // Document CMYK: the CMYK profile in effect, in an RGB document too (#2950). + (false, _) if name.eq_ignore_ascii_case(crate::DOCUMENT_CMYK) => source.settings().cmyk.as_str(), (false, _) => name, (true, _) if cmyk_only => fallback.as_str(), (true, ColorMode::Cmyk) => source.settings().cmyk.as_str(), @@ -348,14 +350,17 @@ impl ColorOut { /// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification /// ([`crate::pdfx`]) → (the file, warnings). - pub(crate) fn write_catalog(&self, pdf: Vec<u8>) -> Result<(Vec<u8>, Vec<String>), PdfError> { - let Some(cat) = &self.catalog else { return Ok((pdf, vec![])) }; + pub(crate) fn write_catalog(&self, pdf: Vec<u8>, display_doc_title: bool) -> Result<(Vec<u8>, Vec<String>), PdfError> { + let cat = self.catalog.as_ref(); + if cat.is_none() && !display_doc_title { + return Ok((pdf, vec![])); + } let xref = Xref::read(&pdf).ok_or_else(|| PdfError::Write("the written PDF has no cross-reference table".into()))?; let mut patch = Patch::new(&xref); let mut warnings = vec![]; let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n)); let Some(root) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) }; - if let Some(OutputIntent { id, condition, registry, info, profile }) = &cat.intent { + if let Some(OutputIntent { id, condition, registry, info, profile }) = cat.and_then(|c| c.intent.as_ref()) { if crate::lab_spot::find(pdf.get(root.0..root.1).unwrap_or_default(), b"/OutputIntents", 0).is_some() { warnings.push("the file has an output intent already: the one asked for is left out".into()); } else { @@ -386,9 +391,12 @@ impl ColorOut { patch.replace(root.0, root.0, format!("/OutputIntents[{oi} 0 R]").into_bytes()); } } + if display_doc_title && crate::lab_spot::find(pdf.get(root.0..root.1).unwrap_or_default(), b"/ViewerPreferences", 0).is_none() { + patch.replace(root.0, root.0, b"/ViewerPreferences<</DisplayDocTitle true>>".to_vec()); + } let info = xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n)); let mut entries = String::new(); - if let Some(trapped) = cat.trapped + if let Some(trapped) = cat.and_then(|c| c.trapped) && info.is_none_or(|(s, e)| crate::lab_spot::find(pdf.get(s..e).unwrap_or_default(), b"/Trapped", 0).is_none()) { entries.push_str(if trapped { "/Trapped/True" } else { "/Trapped/False" }); @@ -402,7 +410,7 @@ impl ColorOut { patch.trailer_entry(format!("/Info {n} 0 R").as_bytes()); } } - crate::pdfx::xmp(&pdf, &xref, root, &mut patch, self.standard, cat.trapped == Some(true))?; + crate::pdfx::xmp(&pdf, &xref, root, &mut patch, self.standard, cat.and_then(|c| c.trapped) == Some(true))?; Ok((patch.apply(&pdf, &xref)?, warnings)) } } diff --git a/crates/pdf/src/post.rs b/crates/pdf/src/post.rs index 4196696d1..eda3b9a5a 100644 --- a/crates/pdf/src/post.rs +++ b/crates/pdf/src/post.rs @@ -3,11 +3,13 @@ //! file, [`crate::linearize`]) and encryption ([`crate::encrypt`]), which a linearised file goes //! through between its two steps so that it stays linearised. Also here: what makes a file say it //! is PDF 1.3 ([`declare_pdf13`]), for PDF 1.3 and PDF/X-1a and PDF/X-3 files, the file's -//! modification date ([`modification_date`]) and the pages' user units ([`user_units`]), which the -//! export writes in that order before the thumbnails. +//! modification date ([`modification_date`]), its copyright ([`rights`]) and the pages' user units +//! ([`user_units`]), which the export writes in that order before the thumbnails. use std::borrow::Cow; +use vectorcraft_doc::{CopyrightStatus, DocMetadata}; + use crate::encrypt::Obj; use crate::linearize::{self, Parsed}; use crate::patch::Patch; @@ -121,6 +123,78 @@ pub(crate) fn modification_date(pdf: Vec<u8>, modified: i64) -> Result<Vec<u8>, patch.apply(&pdf, &xref) } +/// `pdf` with File Info's copyright (`info`) added to the catalog's XMP metadata, after the +/// writer's properties: the notice as `dc:rights`, the URL as `xmpRights:WebStatement` and the +/// status as `xmpRights:Marked` (`True` for a copyrighted work, `False` for a public-domain one, +/// none when it is unknown), as [`crate::file_info`] reads them. Unchanged when `info` gives none of +/// them or the file has no XMP metadata. +pub(crate) fn rights(pdf: Vec<u8>, info: &DocMetadata) -> Result<Vec<u8>, PdfError> { + let props = rights_properties(info); + if props.is_empty() { + return Ok(pdf); + } + let failed = |why: &str| PdfError::Write(format!("can't write the file's copyright: {why}")); + let Parsed { xref, objects, root, .. } = linearize::parse(&pdf).map_err(failed)?; + let metadata = match objects.get(&root).and_then(|c| c.value.get(b"Metadata")) { + Some(&Obj::Ref(n, _)) => objects.get(&n), + _ => None, + }; + let Some(o) = metadata else { return Ok(pdf) }; + let Some(&Obj::Int { value, start, end }) = o.value.get(b"Length") else { return Err(failed("its metadata has no direct length")) }; + let xmp = pdf.get(o.value_span.1..o.endobj).unwrap_or_default(); + let close = crate::lab_spot::rfind(xmp, b"</rdf:Description>").ok_or_else(|| failed("its metadata has no description"))?; + let at = o.value_span.1 + close; + let mut patch = Patch::new(&xref); + patch.replace(start, end, (value + props.len() as i64).to_string().into_bytes()); + patch.replace(at, at, props.into_bytes()); + patch.apply(&pdf, &xref) +} + +/// The XMP properties [`rights`] adds, each declaring its namespace; empty when `info` has no +/// copyright notice or URL and its status is unknown. +fn rights_properties(info: &DocMetadata) -> String { + use crate::file_info::{DC, XMP_RIGHTS}; + let mut out = String::new(); + let notice = info.copyright_notice.trim(); + if !notice.is_empty() { + out.push_str(&format!( + "<dc:rights xmlns:dc=\"{DC}\"><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:rights>", + xml_text(notice) + )); + } + let url = info.copyright_url.trim(); + if !url.is_empty() { + out.push_str(&format!("<xmpRights:WebStatement xmlns:xmpRights=\"{XMP_RIGHTS}\">{}</xmpRights:WebStatement>", xml_text(url))); + } + let marked = match info.copyright_status { + CopyrightStatus::Copyrighted => Some("True"), + CopyrightStatus::PublicDomain => Some("False"), + CopyrightStatus::Unknown => None, + }; + if let Some(m) = marked { + out.push_str(&format!("<xmpRights:Marked xmlns:xmpRights=\"{XMP_RIGHTS}\">{m}</xmpRights:Marked>")); + } + out +} + +/// `s` as XML character data: `&`, `<`, `>` and `"` escaped, a carriage return written as the +/// character reference ` `, and the characters XML 1.0 doesn't allow left out. +fn xml_text(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\r' => out.push_str(" "), + '\t' | '\n' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'.. => out.push(c), + _ => {} + } + } + out +} + /// Linearise `pdf`, encrypting it on the way as `set` says. fn linearised(pdf: &[u8], set: &PdfSettings) -> Result<Vec<u8>, PdfError> { let (prepared, plan) = linearize::prepare(pdf)?; diff --git a/crates/pdf/src/presets.rs b/crates/pdf/src/presets.rs index cf7ae0fe3..df592804b 100644 --- a/crates/pdf/src/presets.rs +++ b/crates/pdf/src/presets.rs @@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize}; use crate::{ - BleedSettings, Compatibility, CompressionSettings, Downsample, ImageCodec, ImageCompression, JpegQuality, MonoCodec, MonoCompression, - PdfSettings, Standard, + BleedSettings, ColorConversion, Compatibility, CompressionSettings, DOCUMENT_CMYK, Downsample, ImageCodec, ImageCompression, JpegQuality, + MonoCodec, MonoCompression, OutputSettings, PdfSettings, Standard, }; /// The built-in preset every PDF export starts from. @@ -62,10 +62,18 @@ pub fn builtin_presets() -> Vec<PdfPreset> { PdfSettings { preserve_editing: true, ..Default::default() }, ), preset("High Quality Print", "For desktop printers and proofs: images stay at print resolution.", print(ImageCodec::Auto, false)), + // Converted to Document CMYK, the numbers of colours already in it kept: RGB colours and + // images come out CMYK, in an RGB document too, as a press needs (#2950). A printer's published settings for + // the [Press Quality] preset list the same Output: Convert to Destination (Preserve + // Numbers), Document CMYK, no profiles included + // (https://postmediasolutions.com/hubfs/Website/Client%20Resources/PostmediaPrintPDFSpecSheet.pdf). preset( "Press Quality", - "For commercial printing: high-resolution images compressed losslessly, the document's bleed.", - print(ImageCodec::Zip, true), + "For commercial printing: high-resolution images compressed losslessly, the document's bleed, and colours converted to the CMYK profile in effect (Document CMYK), keeping the numbers of colours already in it.", + PdfSettings { + output: OutputSettings { conversion: ColorConversion::PreserveNumbers, destination: DOCUMENT_CMYK.into(), ..Default::default() }, + ..print(ImageCodec::Zip, true) + }, ), preset( "Smallest File Size", diff --git a/crates/pdf/src/print/mod.rs b/crates/pdf/src/print/mod.rs index 2bd8f01b0..56c8a2525 100644 --- a/crates/pdf/src/print/mod.rs +++ b/crates/pdf/src/print/mod.rs @@ -221,6 +221,10 @@ impl<'a> Job<'a> { if printed.is_empty() { return Err(PdfError::BadSetting("output.inks: no ink is set to print".into())); } + // Counted before the sheets are made: layouts times inks can be far more than the limit. + if layouts.len().saturating_mul(printed.len()).saturating_mul(set.copies as usize) > MAX_PAGES { + return Err(PdfError::BadSetting(format!("the job would print more than {MAX_PAGES} pages"))); + } let sheets: Vec<(usize, Option<usize>)> = (0..layouts.len()).flat_map(|l| printed.iter().map(move |i| (l, *i))).collect(); if sheets.len().saturating_mul(set.copies as usize) > MAX_PAGES { return Err(PdfError::BadSetting(format!("the job would print more than {MAX_PAGES} pages"))); @@ -392,7 +396,7 @@ pub fn plan<'a>(doc: &'a Document, opts: &PrintOptions) -> Result<PrintPlan<'a>, /// Print `doc` to a PDF as `opts` say. pub fn print(doc: &Document, opts: &PrintOptions) -> Result<PrintReport, PdfError> { - craft_pdf::write::guard(|| leaving_out_undecodable(|left_out| print_inner(doc, opts, left_out))) + craft_pdf::write::guard(|| leaving_out_undecodable(doc, |left_out| print_inner(doc, opts, left_out))) } fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet<String>) -> Result<PrintReport, PdfError> { @@ -426,7 +430,7 @@ fn print_inner(doc: &Document, opts: &PrintOptions, left_out: &mut HashSet<Strin for ex in exporters { w.absorb(ex); } - let (bytes, more) = w.finish(left_out)?; + let (bytes, more) = w.finish(left_out, false)?; let bytes = crate::forms::finish(bytes, doc, &pdf, false, overprinted, invisible)?; let mut warnings = plan.warnings; for m in more { diff --git a/crates/pdf/src/print/settings.rs b/crates/pdf/src/print/settings.rs index 822ab8ba9..27fb557c1 100644 --- a/crates/pdf/src/print/settings.rs +++ b/crates/pdf/src/print/settings.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use vectorcraft_color::cms::{self, Intent, ProfileKind}; -use crate::settings::{choice, within}; +use crate::settings::{Choice, choice, within}; use crate::{BleedSettings, MarkSettings, PdfError}; choice! { @@ -19,20 +19,41 @@ choice! { } default All } -choice! { - /// The paper (portrait sizes; [`Media::Custom`] is [`PrintSettings::width`] × - /// [`PrintSettings::height`]). - Media { - Letter = "letter", "Letter"; - Legal = "legal", "Legal"; - Tabloid = "tabloid", "Tabloid"; - A3 = "a3", "A3"; - A4 = "a4", "A4"; - A5 = "a5", "A5"; - B4 = "b4", "B4"; - B5 = "b5", "B5"; - Custom = "custom", "Custom"; - } default Letter +/// The paper (portrait sizes; [`Media::Custom`] is [`PrintSettings::width`] × +/// [`PrintSettings::height`]). +/// +/// `B4` and `B5` keep their ISO 216 dimensions (250 × 353 mm and 176 × 250 mm) so settings saved +/// before the Japanese papers existed keep their paper, and their dialogs say "(ISO)". Japanese +/// printing uses the JIS sizes, which are separate choices: `b4 (jis)` 257 × 364 mm, `b5 (jis)` +/// 182 × 257 mm and `b6 (jis)` 128 × 182 mm. The other Japanese papers are はがき +/// (`japanese postcard`, 100 × 148 mm), 往復はがき (`japanese double postcard`, 148 × 200 mm), +/// 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判 (`yonroku-ban`, 127 × 188 mm). +/// +/// Written out rather than through the `choice!` macro because a paper also comes from a person: +/// [`Media::from_name`] accepts a JSON id or a dialog label, any case and spacing. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Media { + #[default] + Letter, + Legal, + Tabloid, + A3, + A4, + A5, + B4, + B5, + A6, + B4Jis, + B5Jis, + B6Jis, + JapanesePostcard, + JapaneseDoublePostcard, + JapaneseEnvelope3, + JapaneseEnvelope4, + Kaku2, + Kaku3, + YonrokuBan, + Custom, } impl Media { @@ -46,11 +67,133 @@ impl Media { Self::A3 => mm(297.0, 420.0), Self::A4 => mm(210.0, 297.0), Self::A5 => mm(148.0, 210.0), + Self::A6 => mm(105.0, 148.0), + // ISO 216. Self::B4 => mm(250.0, 353.0), Self::B5 => mm(176.0, 250.0), + // JIS B (JIS X 6310): the Japanese B sizes print a little larger than ISO's. + Self::B4Jis => mm(257.0, 364.0), + Self::B5Jis => mm(182.0, 257.0), + Self::B6Jis => mm(128.0, 182.0), + // はがき, 往復はがき, 封筒 長形3号/長形4号, 角形2号/角形3号, 四六判. + Self::JapanesePostcard => mm(100.0, 148.0), + Self::JapaneseDoublePostcard => mm(148.0, 200.0), + Self::JapaneseEnvelope3 => mm(120.0, 235.0), + Self::JapaneseEnvelope4 => mm(90.0, 225.0), + Self::Kaku2 => mm(240.0, 332.0), + Self::Kaku3 => mm(216.0, 277.0), + Self::YonrokuBan => mm(127.0, 188.0), Self::Custom => None, } } + + /// The paper an id or a dialog label names, ignoring case and anything but letters and digits: + /// `"b5 (jis)"`, `"B5 (JIS)"` and `"b5jis"` all mean [`Media::B5Jis`], and `"b5"` stays the ISO + /// size that settings and presets already use. `None` for a name that is no paper. + pub fn from_name(name: &str) -> Option<Self> { + let norm = |s: &str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect::<String>(); + let n = norm(name); + if n.is_empty() { + return None; + } + let at = |names: &'static [&'static str]| names.iter().position(|name| norm(name) == n); + at(Self::IDS).or_else(|| at(Self::LABELS)).and_then(|i| Self::ALL.get(i).copied()) + } +} + +impl Choice for Media { + const ALL: &'static [Self] = &[ + Self::Letter, + Self::Legal, + Self::Tabloid, + Self::A3, + Self::A4, + Self::A5, + Self::B4, + Self::B5, + Self::A6, + Self::B4Jis, + Self::B5Jis, + Self::B6Jis, + Self::JapanesePostcard, + Self::JapaneseDoublePostcard, + Self::JapaneseEnvelope3, + Self::JapaneseEnvelope4, + Self::Kaku2, + Self::Kaku3, + Self::YonrokuBan, + Self::Custom, + ]; + const IDS: &'static [&'static str] = &[ + "letter", + "legal", + "tabloid", + "a3", + "a4", + "a5", + "b4", + "b5", + "a6", + "b4 (jis)", + "b5 (jis)", + "b6 (jis)", + "japanese postcard", + "japanese double postcard", + "japanese envelope #3", + "japanese envelope #4", + "kaku #2", + "kaku #3", + "yonroku-ban", + "custom", + ]; + const LABELS: &'static [&'static str] = &[ + "Letter", + "Legal", + "Tabloid", + "A3", + "A4", + "A5", + "B4 (ISO)", + "B5 (ISO)", + "A6", + "B4 (JIS)", + "B5 (JIS)", + "B6 (JIS)", + "Japanese Postcard", + "Japanese Double Postcard", + "Japanese Envelope #3", + "Japanese Envelope #4", + "Kaku #2", + "Kaku #3", + "Yonroku-ban", + "Custom", + ]; +} + +impl Media { + /// The label shown in the Print dialogs. + pub fn label(self) -> &'static str { + Self::ALL.iter().position(|m| *m == self).and_then(|i| Self::LABELS.get(i)).copied().unwrap_or_default() + } + + /// The JSON id. + pub fn id(self) -> &'static str { + Self::ALL.iter().position(|m| *m == self).and_then(|i| Self::IDS.get(i)).copied().unwrap_or_default() + } +} + +impl Serialize for Media { + fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { + serializer.serialize_str(self.id()) + } +} + +impl<'de> Deserialize<'de> for Media { + fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { + use serde::de::Error as _; + let name = String::deserialize(deserializer)?; + Self::from_name(&name).ok_or_else(|| D::Error::custom(format!("unknown paper `{name}` ({})", Self::IDS.join("|")))) + } } choice! { diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 329e8c7fd..17f37f90b 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -347,12 +347,17 @@ impl BleedSettings { } } +/// The Output destination that names the CMYK profile of the colour settings in effect, in an RGB +/// document as in a CMYK one (Illustrator's "Document CMYK"; #2950). +pub const DOCUMENT_CMYK: &str = "Document CMYK"; + /// The Output section. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct OutputSettings { pub conversion: ColorConversion, - /// Destination profile name (empty: the document's profile). + /// Destination profile name (empty: the document's profile; [`DOCUMENT_CMYK`]: the CMYK + /// profile in effect, whatever the document's colour mode). pub destination: String, pub profiles: ProfileInclusion, /// PDF/X output intent profile name. diff --git a/crates/pdf/src/streams.rs b/crates/pdf/src/streams.rs index 7ef4e6834..25f0c1973 100644 --- a/crates/pdf/src/streams.rs +++ b/crates/pdf/src/streams.rs @@ -271,7 +271,12 @@ fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool { /// here). pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { let find = |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at); - let mut from = 0; + // A walk back through a dictionary stops at `dicts_from`, the end of the last keyword walked + // back from, so the scan reads each byte a bounded number of times (#3586): before, every + // keyword walked back up to 64 KiB through all the dictionaries before it. + let (mut from, mut dicts_from) = (0, 0); + // The first `endstream` at or after the last place it was looked for (`None`: there is none). + let mut endstream: Option<Option<usize>> = None; while let Some(at) = find(from, b"stream") { from = at + 6; if at >= 3 && pdf.get(at - 3..at) == Some(b"end") { @@ -282,8 +287,10 @@ pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { if close < 2 || pdf.get(close - 2..close) != Some(b">>") { continue; } + let floor = close.saturating_sub(1 << 16).max(dicts_from); + dicts_from = from; let (mut depth, mut open, mut i) = (0usize, None, close); - while i >= 2 && close - i < 1 << 16 { + while i >= floor + 2 { match pdf.get(i - 2..i) { Some(b">>") => (depth, i) = (depth + 1, i - 2), Some(b"<<") => { @@ -308,12 +315,16 @@ pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { let start = from + usize::from(pdf.get(from) == Some(&b'\r')) + usize::from(pdf.get(from + usize::from(pdf.get(from) == Some(&b'\r'))) == Some(&b'\n')); - let end = dict - .get::<usize>(b"Length") - .and_then(|n| start.checked_add(n)) - .filter(|e| *e <= pdf.len()) - .or_else(|| find(start, b"endstream")) - .unwrap_or(pdf.len()); + let length = dict.get::<usize>(b"Length").and_then(|n| start.checked_add(n)).filter(|e| *e <= pdf.len()); + let end = match length { + Some(end) => end, + None => { + if endstream.is_none_or(|e| e.is_some_and(|e| e < start)) { + endstream = Some(find(start, b"endstream")); + } + endstream.flatten().unwrap_or(pdf.len()) + } + }; if matches!(decode(pdf.get(start..end).unwrap_or_default(), &dict, false), Err(TooBig)) { return true; } @@ -463,5 +474,22 @@ mod tests { ] { assert!(!unsafe_file_streams(&fine)); } + // A bad stream after others is still found: a walk back stops at the previous keyword, not before its own `<<`. + let fine = stream("/Type /XRef /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 5 >>", b"x"); + let bad = stream("/Type /XRef /Filter /FlateDecode /DecodeParms 9 0 R", b"x"); + assert!(unsafe_file_streams(&[fine.as_slice(), b"<< >>stream x endstream", &bad].concat())); + } + + #[test] + fn a_file_dense_with_stream_keywords_is_scanned_in_linear_time() { + // #3586: each `stream` keyword walked back up to 64 KiB, through every dictionary before + // it, so 1 MiB of keywords took minutes. Now the whole scan takes milliseconds. + for unit in [&b"<</A <</B 1>> >>stream"[..], b">>stream", b"<</Type/XRef>>stream"] { + let pdf = unit.repeat((1 << 20) / unit.len()); + let t = std::time::Instant::now(); + assert!(!unsafe_file_streams(&pdf)); + let took = t.elapsed(); + assert!(took < std::time::Duration::from_secs(5), "{} took {took:?}", String::from_utf8_lossy(unit)); + } } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 8b99458fb..6fa4da12a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -79,15 +79,23 @@ fn one_page_per_artboard_with_sizes() { id: 2, name: "B".into(), rect: Rect::new(300.0, 0.0, 400.0, 400.0), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); d.artboards.push(Artboard { id: 3, name: "C".into(), rect: Rect::new(0.0, 500.0, 612.0, 1292.0), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); let bytes = export(&d, &PdfOptions::default()).unwrap(); let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap(); @@ -102,8 +110,12 @@ fn artboard_range_and_bad_index() { id: 2, name: "B".into(), rect: Rect::new(300.0, 0.0, 400.0, 400.0), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); let bytes = export(&d, &PdfOptions { artboards: Some(vec![1]), ..Default::default() }).unwrap(); let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap(); @@ -373,6 +385,7 @@ fn art_reaching_beyond_a_billion_points_is_left_out() { xf: Affine::new([5.5e8, 0.0, 0.0, 10.0, far, 60.0]), link: None, placement: Default::default(), + pixels: None, }; for (what, n) in [ ("path", rect_node(Rect::new(far, 60.0, 90.0, 90.0), Color::BLACK)), @@ -503,6 +516,30 @@ fn text_exported_as_outlines() { assert!(b.x0 >= 9.0 && b.x1 < 120.0 && b.y1 <= 56.0 && b.y0 > 25.0, "{b:?}"); } +/// Colour bitmap glyphs (emoji) are images in the PDF: in their font's Type 3 glyphs with real +/// text, else drawn where the glyphs are. +#[test] +fn colour_bitmap_glyphs_export_as_images() { + use vectorcraft_text::test_fonts::{COLOUR_CHAR, colour_bitmap_family, colour_bitmap_font}; + for sbix in [true, false] { + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(sbix).unwrap()); + let mut d = doc(300.0, 100.0); + let st = CharStyle { font_family: colour_bitmap_family(sbix).into(), size: 24.0, ..CharStyle::default() }; + add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &format!("{COLOUR_CHAR}F"), st))))); + let text = uncompressed(&d).replace(' ', ""); + assert!(text.contains("/Type3") && text.contains("/Subtype/Image"), "an image in a Type 3 glyph (sbix {sbix})"); + let mut outlined = PdfOptions::uncompressed(); + outlined.settings.advanced.outline_text = true; + let r = import_with_report(&export(&d, &outlined).unwrap(), &ImportOptions::default()).unwrap(); + let l = leaves(&r.document); + let images: Vec<_> = l.iter().filter(|n| matches!(n.kind, NodeKind::Image(_))).collect(); + assert_eq!(images.len(), 1, "outlined, the glyph is an image (sbix {sbix})"); + let b = images[0].geometric_bounds().unwrap(); + assert!((b.width() - 24.0).abs() < 0.1 && (b.height() - 24.0).abs() < 0.1, "one em: {b:?}"); + assert!(l.iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "the other glyph stays an outline"); + } +} + fn tiny_png() -> Vec<u8> { let mut img = image::RgbaImage::new(4, 2); for (x, _, p) in img.enumerate_pixels_mut() { @@ -524,6 +561,7 @@ fn image_roundtrip() { xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), link: None, placement: Default::default(), + pixels: None, }; add(&mut d, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); let out = roundtrip(&d); @@ -565,7 +603,7 @@ fn an_image_whose_pixels_cant_be_decoded_is_left_out_with_one_note() { for (i, (key, blob)) in blobs.into_iter().enumerate() { d.images.insert(key.into(), blob); let xf = Affine::translate((20.0, 20.0 + 50.0 * i as f64)) * Affine::scale(10.0); - let im = ImageObject { key: key.into(), width: 4, height: 2, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: 4, height: 2, xf, link: None, placement: Default::default(), pixels: None }; add(&mut d, Node::new(NodeId(0), NodeKind::Image(Box::new(im)))); } add(&mut d, rect_node(Rect::new(100.0, 100.0, 150.0, 150.0), Color::BLACK)); @@ -598,6 +636,7 @@ fn a_group_transform_effect_exports_its_image_copies() { xf: Affine::translate((20.0, 30.0)) * Affine::scale(5.0), link: None, placement: Default::default(), + pixels: None, }; let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))); let mut square = rect_node(Rect::new(20.0, 60.0, 30.0, 70.0), Color::BLACK); @@ -723,8 +762,12 @@ fn import_max_pages_and_multi_page() { id: 2, name: "B".into(), rect: Rect::new(300.0, 0.0, 400.0, 400.0), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK)); add(&mut d, rect_node(Rect::new(310.0, 10.0, 350.0, 50.0), Color::BLACK)); @@ -870,15 +913,17 @@ fn empty_and_populated_file_info_preserve_key_presence() { for key in ["/Title", "/Author", "/Keywords", "<dc:title>", "<dc:creator>", "<pdf:Keywords>"] { assert!(!s.contains(key), "unexpected empty metadata {key}"); } + assert!(!s.contains("/DisplayDocTitle true"), "the default export does not request displaying its metadata title"); d.title = "Synthetic title".into(); d.metadata.author = " Test author ".into(); d.metadata.keywords = vec!["test keyword".into()]; - let bytes = export(&d, &PdfOptions { created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap(); + let bytes = export(&d, &PdfOptions { display_doc_title: true, created: Some(1_700_000_000), ..PdfOptions::uncompressed() }).unwrap(); let s = String::from_utf8_lossy(&bytes); for key in ["/Title", "/Author", "/Keywords", "/CreationDate", "<dc:title>", "<dc:creator>", "<pdf:Keywords>"] { assert!(s.contains(key), "missing populated metadata {key}"); } assert!(s.contains("Synthetic title") && s.contains("Test author") && s.contains("test keyword")); + assert!(s.contains("/ViewerPreferences<</DisplayDocTitle true>>"), "viewers should show the document title (#3646)"); } /// A function or colour space built on itself is refused before the interpreter builds it (by @@ -952,3 +997,117 @@ fn dictionary_values_the_reader_cannot_parse_are_skipped() { assert!(e.as_deref().is_some_and(|e| e.contains("built on one another without end")), "{junk}: {e:?}"); } } + +/// Illustrator's documentation describes dither as the gradient converted to pixels with subtle +/// noise, at the Document Raster Effects resolution +/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-dither-in-gradients.html): +/// a dithered gradient fill is drawn as an image at that resolution, clipped to the shape; a +/// stroke (dither applies to fills only) and an undithered fill stay shadings. +#[test] +fn dithered_gradient_fills_export_as_images_and_strokes_as_shadings() { + let grad = |dither| Paint::Gradient(Box::new(GradientPaint::new(Gradient { dither, ..Default::default() }))); + let shape = shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)); + let mut d = doc(200.0, 100.0); + d.raster_effects_ppi = 144.0; + add(&mut d, Node::path(NodeId(0), shape.clone(), Appearance::basic(grad(true), grad(true), 4.0))); + let pdf = uncompressed(&d).replace(' ', ""); + assert!(pdf.contains("/Subtype/Image"), "the fill is an image"); + assert!(pdf.contains("/Width200") && pdf.contains("/Height100"), "two pixels a point at 144 ppi"); + assert!(pdf.contains("/ShadingType2"), "the stroke keeps its shading"); + let mut plain = doc(200.0, 100.0); + add(&mut plain, Node::path(NodeId(0), shape, Appearance::basic(grad(false), Paint::None, 0.0))); + let pdf = uncompressed(&plain).replace(' ', ""); + assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType2")); +} + +/// Illustrator's documentation: perceptual gradients aren't supported in PDF exports, and are +/// rasterised when saved that way +/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html). +/// A Perceptual fill and stroke are drawn as images at the raster effects resolution, clipped to +/// what they paint; a Classic gradient stays a shading, and so does a gradient along a stroke +/// (slices through stops on the perceptual curve, as the canvas draws it). +#[test] +fn perceptual_gradients_export_as_images_and_classic_ones_as_shadings() { + let grad = |method| Paint::Gradient(Box::new(GradientPaint::new(Gradient { method, ..Default::default() }))); + let p = grad(vectorcraft_color::GradientMethod::Perceptual); + let shape = shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)); + let mut d = doc(200.0, 100.0); + d.raster_effects_ppi = 144.0; + add(&mut d, Node::path(NodeId(0), shape.clone(), Appearance::basic(p.clone(), p.clone(), 4.0))); + let pdf = uncompressed(&d).replace(' ', ""); + assert_eq!(pdf.matches("/Subtype/Image").count(), 2, "the fill and the stroke are images"); + assert!(pdf.contains("/Width200") && pdf.contains("/Height100"), "two pixels a point at 144 ppi"); + assert!(!pdf.contains("/ShadingType2"), "no shading"); + let mut along = Appearance::basic(Paint::None, p, 4.0); + if let Some(AppearanceItem::Stroke(st)) = along.items.get_mut(1) { + st.gradient_mode = vectorcraft_doc::StrokeGradientMode::Along; + } + let mut a = doc(200.0, 100.0); + add(&mut a, Node::path(NodeId(0), shape.clone(), along)); + let pdf = uncompressed(&a).replace(' ', ""); + assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType2"), "along the stroke: slices"); + let c = grad(vectorcraft_color::GradientMethod::Classic); + let mut plain = doc(200.0, 100.0); + add(&mut plain, Node::path(NodeId(0), shape, Appearance::basic(c.clone(), c, 4.0))); + let pdf = uncompressed(&plain).replace(' ', ""); + assert!(!pdf.contains("/Subtype/Image") && pdf.contains("/ShadingType2")); +} + +/// A dithered Perceptual gradient: the fill and the stroke are images (Perceptual), and dither +/// adds its noise to the fill's pixels only. +#[test] +fn dithered_perceptual_gradients_export_as_images_with_noise_on_the_fill() { + let grad = |dither| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient { + dither, + method: vectorcraft_color::GradientMethod::Perceptual, + ..Default::default() + }))) + }; + let shape = shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)); + let export_with = |dither, stroke: bool| { + let mut d = doc(200.0, 100.0); + let (fill, stroke) = if stroke { (Paint::None, grad(dither)) } else { (grad(dither), Paint::None) }; + add(&mut d, Node::path(NodeId(0), shape.clone(), Appearance::basic(fill, stroke, 4.0))); + uncompressed(&d).replace(' ', "") + }; + for stroke in [false, true] { + let pdf = export_with(true, stroke); + assert_eq!(pdf.matches("/Subtype/Image").count(), 1, "stroke {stroke}: an image"); + assert!(!pdf.contains("/ShadingType"), "stroke {stroke}: no shading"); + } + assert_ne!(export_with(true, false), export_with(false, false), "the fill's pixels carry the noise"); + assert_eq!(export_with(true, true), export_with(false, true), "a stroke has no dither"); +} + +/// COLR glyphs in the PDF: as real text, in their font's Type 3 glyphs (the foreground layer in +/// the fill); outlined, as vector art, a sweep glyph as an image. +#[test] +fn colr_glyphs_export_as_vector_art() { + use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_family, colr_font}; + for v1 in [false, true] { + vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap()); + } + let page = |v1: bool, ch: char, outline_text: bool| { + let mut d = doc(300.0, 100.0); + let fill = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + let st = CharStyle { font_family: colr_family(v1).into(), size: 24.0, fill, ..CharStyle::default() }; + add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 50.0), &ch.to_string(), st))))); + let mut o = PdfOptions::uncompressed(); + o.settings.advanced.outline_text = outline_text; + import_with_report(&export(&d, &o).unwrap(), &ImportOptions::default()).unwrap().document + }; + let fills = |d: &Document| -> Vec<Paint> { leaves(d).iter().map(|n| n.appearance.fill_paint()).collect() }; + let near = + |p: &Paint, rgb: [f32; 3]| matches!(p, Paint::Solid { color, .. } if color.to_rgb().iter().zip(rgb).all(|(a, b)| (a - b).abs() < 0.02)); + for outline_text in [false, true] { + let v0 = fills(&page(false, COLOUR_CHAR, outline_text)); + assert!(v0.iter().any(|p| near(p, [1.0, 0.0, 0.0])), "the red layer (outlined {outline_text}): {v0:?}"); + assert!(v0.iter().any(|p| near(p, [0.0, 0.0, 1.0])), "the foreground layer in the fill (outlined {outline_text}): {v0:?}"); + } + let v1 = page(true, COLOUR_CHAR, true); + assert!(!leaves(&v1).iter().any(|n| matches!(n.kind, NodeKind::Image(_))), "vector art"); + assert!(fills(&v1).iter().any(|p| matches!(p, Paint::Gradient(_))), "with its gradients"); + let sweep = page(true, COLR_SWEEP_CHAR, true); + assert!(leaves(&sweep).iter().any(|n| matches!(n.kind, NodeKind::Image(_))), "a sweep glyph is an image"); +} diff --git a/crates/pdf/src/tests_cmykimages.rs b/crates/pdf/src/tests_cmykimages.rs index 65f93d3dc..e7c08f5aa 100644 --- a/crates/pdf/src/tests_cmykimages.rs +++ b/crates/pdf/src/tests_cmykimages.rs @@ -306,7 +306,15 @@ fn all_images(d: &Document) -> Vec<&ImageBlob> { fn image_doc(blob: ImageBlob, w: u32, h: u32) -> Document { let mut d = Document::new_with_mode(100.0, 100.0, ColorMode::Cmyk); d.images.insert("img".into(), blob); - let image = ImageObject { key: "img".into(), width: w, height: h, xf: Affine::scale(40.0 / w as f64), link: None, placement: Default::default() }; + let image = ImageObject { + key: "img".into(), + width: w, + height: h, + xf: Affine::scale(40.0 / w as f64), + link: None, + placement: Default::default(), + pixels: None, + }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); @@ -383,6 +391,7 @@ fn export_masked(mask: Vec<u8>, clip: bool, invert: bool) -> (Vec<u8>, Vec<Optio xf: Affine::scale(20.0), link: None, placement: Default::default(), + pixels: None, })) }; let mut mask = vectorcraft_doc::OpacityMask::new(Node::new(d.alloc_id(), image("alpha")), clip); @@ -429,3 +438,47 @@ fn a_cmyk_image_masked_by_a_grey_image_of_its_grid_writes_it_as_its_smask() { assert_eq!(smasks, [None]); assert!(String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "a luminosity soft mask"); } + +/// #2950: Press Quality converts to the document's profile with its numbers kept, so in a CMYK +/// document an RGB image comes out CMYK, as the press needs; with no conversion (the default +/// preset) it stays RGB, as before. +#[test] +fn press_quality_writes_an_rgb_image_in_a_cmyk_document_as_cmyk() { + let rgb = image::RgbImage::from_fn(4, 4, |x, y| image::Rgb([(x * 60) as u8, (y * 60) as u8, 128])); + let blob = ImageBlob { mime: "image/png".into(), bytes: Arc::new(png(rgb)), proxy: None }; + let d = image_doc(blob, 4, 4); + let press = builtin_presets().into_iter().find(|p| p.name == "Press Quality").unwrap().settings; + assert_eq!(press.output.conversion, ColorConversion::PreserveNumbers); + let options = PdfOptions { settings: press, created: Some(1_700_000_000), ..Default::default() }; + let w = written(&export_with_report(&d, &options).unwrap().bytes); + assert_eq!(w.len(), 1, "{w:?}"); + assert_eq!((w[0].space.as_str(), w[0].icc_n), ("DeviceCMYK", None), "Press Quality"); + let w = written(&export_with_report(&d, &opts(json!({}))).unwrap().bytes); + assert_eq!(w[0].space, "DeviceRGB", "no conversion"); +} + +/// #3012 review: in an RGB document Press Quality converts to Document CMYK too, the CMYK +/// profile in effect: a CMYK image keeps its inks (not converted to RGB), and an RGB image comes +/// out CMYK. +#[test] +fn press_quality_writes_cmyk_in_an_rgb_document_too() { + let press = || PdfOptions { settings: builtin_preset("Press Quality").unwrap().settings, created: Some(1_700_000_000), ..Default::default() }; + assert_eq!(press().settings.output.destination, DOCUMENT_CMYK); + let ink = [0u8, 102, 255, 0]; + let tiff = ImageBlob::cmyk_tiff(&Inks::new(4, 4, flat(ink, 16)).unwrap()).unwrap(); + let rgb = image::RgbImage::from_fn(4, 4, |x, y| image::Rgb([(x * 60) as u8, (y * 60) as u8, 128])); + let png_blob = ImageBlob { mime: "image/png".into(), bytes: Arc::new(png(rgb)), proxy: None }; + for (what, blob) in [("CMYK image", tiff), ("RGB image", png_blob)] { + let mut d = image_doc(blob, 4, 4); + d.color_mode = ColorMode::Rgb; + let out = export_with_report(&d, &press()).unwrap(); + let w = written(&out.bytes); + assert_eq!(w.len(), 1, "{what}: {w:?}"); + assert_eq!(w[0].space, "DeviceCMYK", "{what} in an RGB document"); + // Preserve Numbers: the CMYK image comes back with the inks it had. + if what == "CMYK image" { + let back = import(&out.bytes).unwrap(); + assert_eq!(all_images(&back)[0].cmyk().unwrap().data, flat(ink, 16), "{what} inks"); + } + } +} diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs index 2954f91c3..ab70e4521 100644 --- a/crates/pdf/src/tests_compression.rs +++ b/crates/pdf/src/tests_compression.rs @@ -1,6 +1,7 @@ //! Image compression and downsampling: images above the threshold resolution are resampled to the //! target, compressed with JPEG or ZIP as their kind (colour, greyscale, monochrome) asks, and the -//! codecs the writer lacks are written as ZIP with a warning. +//! codecs the writer lacks are written as ZIP with a warning. In files before PDF 1.5, 16-bit +//! images are written at 8 bits. use std::io::Cursor; @@ -29,7 +30,15 @@ fn doc(bytes: Vec<u8>, mime: &str) -> Document { let xf = Affine::scale(72.0 / 600.0); let n = Node::new( d.alloc_id(), - NodeKind::Image(Box::new(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None, placement: Default::default() })), + NodeKind::Image(Box::new(ImageObject { + key: "img".into(), + width: 600, + height: 600, + xf, + link: None, + placement: Default::default(), + pixels: None, + })), ); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); @@ -81,6 +90,59 @@ fn jpeg_zip_and_automatic_compression() { assert!(!text.contains("/DCTDecode") && text.contains("/SMask")); } +#[test] +fn sixteen_bit_images_are_written_at_8_bits_before_pdf_1_5() { + let mut clear = colour().to_rgba8(); + clear.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + let clear = DynamicImage::from(clear); + // 16-bit PNGs of RGB, gray and RGBA pixels, and the 8-bit pixels they hold. + let images = [ + ("RGB", DynamicImage::ImageRgb16(colour().to_rgb16()), colour()), + ("gray", DynamicImage::ImageLuma16(colour().grayscale().to_luma16()), colour().grayscale()), + ("RGBA", DynamicImage::ImageRgba16(clear.to_rgba16()), clear), + ]; + for (kind, deep, pixels) in images { + let d = doc(encoded(deep, ImageFormat::Png), "image/png"); + // PDF/X-1a and PDF/X-3 files are PDF 1.3. A PDF 1.3 file has no soft masks: the app + // flattens an image with transparency before it is written. + let mut cases = vec![(json!({"compatibility": "1.4"}), 8), (json!({"compatibility": "1.5"}), 16), (json!({"compatibility": "1.7"}), 16)]; + if kind != "RGBA" { + cases.extend([(json!({"compatibility": "1.3"}), 8), (json!({"standard": "pdfX3", "compatibility": "1.3"}), 8)]); + } + if kind == "gray" { + cases.push((json!({"standard": "pdfX1a", "compatibility": "1.3"}), 8)); + } + for (case, depth) in cases { + let mut settings: PdfSettings = serde_json::from_value(case.clone()).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(&d, &PdfOptions { settings, created: Some(0), ..Default::default() }) + .unwrap_or_else(|e| panic!("{kind}, {case}: {e}")); + let text = String::from_utf8_lossy(&r.bytes); + let other = if depth == 8 { 16 } else { 8 }; + assert!( + text.contains(&format!("/BitsPerComponent {depth}")) && !text.contains(&format!("/BitsPerComponent {other}")), + "{kind}, {case}: {depth} bits" + ); + assert!(!r.warnings.iter().any(|w| w.contains("decoded")), "{kind}, {case}: {:?}", r.warnings); + if depth == 8 { + // The image reads back with the 8-bit pixels (within 1 for the PDF/X-3 file's RGB + // image, which is tagged with the sRGB profile). + let back = import(&r.bytes).unwrap(); + let got = image::load_from_memory(&back.images.values().next().unwrap().bytes).unwrap().to_rgba8(); + let close = got.dimensions() == (600, 600) && got.as_raw().iter().zip(pixels.to_rgba8().as_raw()).all(|(a, b)| a.abs_diff(*b) <= 1); + assert!(back.images.len() == 1 && close, "{kind}, {case}: the pixels"); + } + } + } + // A 16-bit PNG whose pixel data can't be decoded is left out with the note. + let mut cut = encoded(DynamicImage::ImageRgb16(colour().to_rgb16()), ImageFormat::Png); + let at = cut.windows(4).position(|w| w == b"IDAT").unwrap(); + cut.truncate(at + 10); + let settings = serde_json::from_value(json!({"compatibility": "1.4"})).unwrap(); + let r = export_with_report(&doc(cut, "image/png"), &PdfOptions { settings, ..Default::default() }).unwrap(); + assert_eq!(r.warnings, ["an image couldn't be decoded and is missing from the picture"]); +} + #[test] fn codecs_the_writer_lacks_are_zip_with_a_warning() { let png = doc(encoded(colour(), ImageFormat::Png), "image/png"); diff --git a/crates/pdf/src/tests_file_info.rs b/crates/pdf/src/tests_file_info.rs index f860724d0..7ce2f42f4 100644 --- a/crates/pdf/src/tests_file_info.rs +++ b/crates/pdf/src/tests_file_info.rs @@ -207,3 +207,44 @@ fn a_written_pdf_gives_its_file_info_back() { assert_eq!(f.keywords, Some(vec!["fair".to_string(), "print".into()])); assert_eq!((f.created, f.modified), (Some(CREATED), Some(CREATED))); } + +#[test] +fn a_written_pdf_gives_its_copyright_back() { + use vectorcraft_doc::CopyrightStatus; + let notice = "(c) 2026 Ada & <Babbage>\r\nAll rights \"reserved\""; + let url = "https://example.com/rights?a=1&b=2"; + for standard in [Standard::None, Standard::PdfA2b, Standard::PdfX1a, Standard::PdfX4] { + for (status, marked) in + [(CopyrightStatus::Copyrighted, Some(true)), (CopyrightStatus::PublicDomain, Some(false)), (CopyrightStatus::Unknown, None)] + { + let mut d = Document::new(100.0, 100.0); + d.metadata.author = "Ada Lovelace".into(); + (d.metadata.copyright_status, d.metadata.copyright_notice, d.metadata.copyright_url) = (status, notice.into(), url.into()); + let mut o = PdfOptions { created: Some(CREATED), ..PdfOptions::uncompressed() }; + (o.settings.standard, o.settings.compatibility) = (standard, standard.version()); + let bytes = export(&d, &o).unwrap(); + let f = file_info(&bytes, None).unwrap(); + let what = format!("{standard:?} {status:?}"); + assert_eq!(f.copyright_notice.as_deref(), Some(notice), "{what}"); + assert_eq!(f.copyright_url.as_deref(), Some(url), "{what}"); + assert_eq!(f.copyrighted, marked, "{what}"); + // The writer's own properties stay readable beside them. + assert_eq!((f.author.as_deref(), f.created), (Some("Ada Lovelace"), Some(CREATED)), "{what}"); + let t = String::from_utf8_lossy(&bytes); + assert_eq!(t.contains("<pdfxid:GTS_PDFXVersion"), standard == Standard::PdfX4, "{what}: the PDF/X-4 identification"); + assert_eq!(import(&bytes).unwrap().artboards.len(), 1, "{what}: the file reads back"); + } + } + // A status alone is written; a document without any copyright gets none of the properties. + let mut d = Document::new(100.0, 100.0); + d.metadata.copyright_status = CopyrightStatus::PublicDomain; + let f = file_info(&export(&d, &PdfOptions::uncompressed()).unwrap(), None).unwrap(); + assert_eq!((f.copyrighted, f.copyright_notice, f.copyright_url), (Some(false), None, None)); + let bytes = export(&Document::new(100.0, 100.0), &PdfOptions::uncompressed()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("xmpRights") && !String::from_utf8_lossy(&bytes).contains("dc:rights")); + // Characters XML doesn't allow are left out, so the packet still reads. + let mut d = Document::new(100.0, 100.0); + (d.metadata.author, d.metadata.copyright_notice) = ("Ada".into(), "(c)\u{1} Ada\u{FFFF}".into()); + let f = file_info(&export(&d, &PdfOptions::uncompressed()).unwrap(), None).unwrap(); + assert_eq!((f.author.as_deref(), f.copyright_notice.as_deref()), (Some("Ada"), Some("(c) Ada"))); +} diff --git a/crates/pdf/src/tests_import_color.rs b/crates/pdf/src/tests_import_color.rs index 3a561d723..8b98f7703 100644 --- a/crates/pdf/src/tests_import_color.rs +++ b/crates/pdf/src/tests_import_color.rs @@ -105,7 +105,7 @@ fn gray_imports_as_gray_and_rgb_documents_stay_rgb() { fn shading_stops_keep_cmyk_and_spot_tints() { let mut d = Document::new_with_mode(100.0, 50.0, ColorMode::Cmyk); d.swatches.push(spot("Ink A", Color::cmyk(0.0, 0.5, 1.0, 0.0))); - let grad = |stops: Vec<GradientStop>| Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let grad = |stops: Vec<GradientStop>| Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); add(&mut d, grad(vec![GradientStop::new(0.0, Color::cmyk(1.0, 0.0, 0.0, 0.0)), GradientStop::new(1.0, Color::cmyk(0.0, 0.0, 1.0, 0.2))])); let ink = d.global_color("Ink A").unwrap(); let linked = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink A".into()), tint, ..GradientStop::new(offset, ink.tinted(tint)) }; diff --git a/crates/pdf/src/tests_import_fidelity.rs b/crates/pdf/src/tests_import_fidelity.rs index 446e84ee8..9fd45432a 100644 --- a/crates/pdf/src/tests_import_fidelity.rs +++ b/crates/pdf/src/tests_import_fidelity.rs @@ -309,6 +309,19 @@ fn text_becomes_point_type() { assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some("<Text Outlines>"))); } +#[test] +fn character_spacing_does_not_become_spaces() { + for size in [12.0, 13.0, 14.0, 15.0] { + for tracking in [150.0, 180.0, 200.0, 220.0, 250.0] { + let spacing = size * tracking / 1000.0; + let content = format!("BT /F1 {size} Tf {spacing} Tc 10 40 Td (OPEN DAILY) Tj ET"); + let t = texts(&open(&one_page(&content, HELVETICA, &[]))); + assert_eq!(t.len(), 1, "size {size}, tracking {tracking}: {t:?}"); + assert_eq!(t[0].plain_text(), "OPEN DAILY", "size {size}, tracking {tracking}"); + } + } +} + /// Invisible text (rendering mode 3: a scanned page's recognized text, laid over the scan) is /// type with neither fill nor stroke, and the visible text keeps its paint. On a line with /// stroked text it is a run of its own, not stroked. As outlines it would draw nothing: it is left @@ -371,7 +384,8 @@ fn fonts_are_noted_past_font_resources_the_reader_cannot_parse() { 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(); - assert_eq!(fonts.into_values().collect::<Vec<_>>(), [("Helvetica".to_string(), false)], "{junk}"); + let noted: Vec<_> = fonts.into_values().map(|f| (f.name, f.embedded)).collect(); + assert_eq!(noted, [("Helvetica".to_string(), false)], "{junk}"); } } @@ -418,7 +432,7 @@ fn gradient_stop_opacity_round_trips() { let mut d = Document::new(100.0, 100.0); let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; stops[1].opacity = 0.2; - let g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); let n = Node::path( NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), diff --git a/crates/pdf/src/tests_import_lines.rs b/crates/pdf/src/tests_import_lines.rs index 898dcaa23..4e81666c0 100644 --- a/crates/pdf/src/tests_import_lines.rs +++ b/crates/pdf/src/tests_import_lines.rs @@ -205,6 +205,27 @@ fn words_set_ems_apart_keep_their_place() { assert!((at(6) - f64::from(x2)).abs() < 0.05, "the third word starts where the file has it: {} vs {x2}", at(6)); } +#[test] +fn words_set_ems_apart_beside_the_spaces_the_file_draws_keep_their_place() { + // The same words, the file drawing each space (2 pt wide) after a word, before the next one, + // or alone with a gap on each side. + let (x1, x2) = (48.0, 84.0); + let drawings: [&[(f32, f32, f32, &str)]; 3] = [ + &[(20.0, 50.0, 10.0, "ab "), (x1, 50.0, 10.0, "cd "), (x2, 50.0, 10.0, "ef")], + &[(20.0, 50.0, 10.0, "ab"), (x1 - 2.0, 50.0, 10.0, " cd"), (x2 - 2.0, 50.0, 10.0, " ef")], + &[(20.0, 50.0, 10.0, "ab"), (36.0, 50.0, 10.0, " "), (x1, 50.0, 10.0, "cd"), (70.0, 50.0, 10.0, " "), (x2, 50.0, 10.0, "ef")], + ]; + for (k, lines) in drawings.iter().enumerate() { + let t = texts(&import(&source_sans(lines)).unwrap()); + assert_eq!(t.len(), 1, "drawing {k}"); + assert_eq!(t[0].plain_text(), "ab cd ef", "drawing {k}"); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]); + let at = |byte: usize| (t[0].xf * laid.glyphs.iter().find(|g| g.byte == byte).unwrap().origin).x; + assert!((at(3) - f64::from(x1)).abs() < 0.05, "drawing {k}: the second word starts where the file has it: {} vs {x1}", at(3)); + assert!((at(6) - f64::from(x2)).abs() < 0.05, "drawing {k}: the third word starts where the file has it: {} vs {x2}", at(6)); + } +} + /// A document of area type `text` (Source Sans 3, 10 pt) `width` wide at (20, 30). fn area_doc(text: &str, justify: Justify, width: f64, space_before: f64) -> Document { let style = CharStyle { size: 10.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; @@ -247,6 +268,33 @@ fn lines_wrapped_in_a_frame_come_back_as_area_type() { } } +#[test] +fn justified_lines_come_back_as_area_type_only_with_their_word_gaps_alike() { + // "aa bb cc", "aa bb cc", "aa" with the file's spaces drawn, the wrapped lines 44 pt wide: "cc" + // ends at 64, and "bb" starts where both gaps are 4.87 pt wider than a space, or elsewhere. + let lines = |bb: f32| { + [ + (20.0, 50.0, 10.0, "aa "), + (bb, 50.0, 10.0, "bb "), + (54.88, 50.0, 10.0, "cc "), + (20.0, 62.0, 10.0, "aa "), + (bb, 62.0, 10.0, "bb "), + (54.88, 62.0, 10.0, "cc "), + (20.0, 74.0, 10.0, "aa"), + ] + }; + let t = texts(&import(&source_sans(&lines(36.95))).unwrap()); + assert_eq!(t.len(), 1, "one object: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>()); + assert_eq!(t[0].plain_text(), "aa bb cc aa bb cc aa"); + assert_eq!(t[0].para.justify, Justify::JustifyLeft); + // Gaps 1 and 8.74 pt wider than a space were set by hand: the lines keep their words in place. + let t = texts(&import(&source_sans(&lines(33.08))).unwrap()); + assert_eq!(t.len(), 3, "{:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>()); + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]); + let bb = (t[0].xf * laid.glyphs.iter().find(|g| g.byte == 3).unwrap().origin).x; + assert!((bb - 33.08).abs() < 0.05, "the second word starts where the file has it: {bb} vs 33.08"); +} + #[test] fn lines_that_were_not_wrapped_stay_point_type() { // A list: each line its own, none ending in a space. diff --git a/crates/pdf/src/tests_live_text.rs b/crates/pdf/src/tests_live_text.rs index b61dcb847..19b198253 100644 --- a/crates/pdf/src/tests_live_text.rs +++ b/crates/pdf/src/tests_live_text.rs @@ -101,6 +101,90 @@ fn letter_spacing_in_the_pdf_becomes_tracking() { assert!((ink_width(&t[0]) - expected).abs() < 0.3, "{} vs {expected}", ink_width(&t[0])); } +#[test] +fn common_tracking_does_not_turn_gaps_into_spaces() { + let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + for size in [12.0, 13.0, 14.0, 15.0] { + for tracking in [150.0, 180.0, 200.0, 220.0, 250.0] { + let mut x = 20.0; + let mut pieces = vec![]; + for (word_index, word) in ["OPEN", "DAILY", "TYPE"].iter().enumerate() { + for character in word.chars() { + pieces.push((x, character.to_string())); + x += font.advance(font.glyph_for(character)) as f32 / font.units_per_em() as f32 * size + size * tracking / 1000.0; + } + if word_index < 2 { + x += font.advance(font.glyph_for(' ')) as f32 / font.units_per_em() as f32 * size + size * tracking / 1000.0; + } + } + let lines: Vec<_> = pieces.iter().map(|(x, text)| (*x, 60.0, size, text.as_str())).collect(); + let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1, "size {size}, tracking {tracking}: {t:?}"); + assert_eq!(t[0].plain_text(), "OPEN DAILY TYPE", "size {size}, tracking {tracking}"); + assert!((t[0].first_style().tracking - f64::from(tracking)).abs() <= 10.0, "size {size}, tracking {tracking}: {:?}", t[0].runs); + } + } +} + +/// Words of one letter each, placed one by one with ordinary word spaces between them, are words: +/// even gaps alone don't read as letter spacing. +#[test] +fn evenly_spaced_one_letter_words_keep_their_spaces() { + let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + let size = 12.0_f32; + for gap in [0.25_f32, 0.3, 0.35] { + let mut x = 20.0; + let mut lines = vec![]; + for text in ["A", "B", "C", "D"] { + lines.push((x, 60.0, size, text)); + x += font.advance(font.glyph_for(text.chars().next().unwrap())) as f32 / font.units_per_em() as f32 * size + gap * size; + } + let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1, "gap {gap} em: {t:?}"); + assert_eq!(t[0].plain_text(), "A B C D", "gap {gap} em"); + } +} + +/// Glyphs set more than half an em apart, each on its own, are words or columns: every gap stays a +/// space, however evenly they are spaced. +#[test] +fn evenly_wide_gaps_stay_spaces() { + let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + let size = 12.0_f32; + let one = font.advance(font.glyph_for('1')) as f32 / font.units_per_em() as f32 * size; + for gap in [0.55_f32, 0.75, 1.0] { + let lines: Vec<_> = + ["1", "2", "3", "4"].iter().enumerate().map(|(i, text)| (20.0 + i as f32 * (one + gap * size), 60.0, size, *text)).collect(); + let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1, "gap {gap} em: {t:?}"); + assert_eq!(t[0].plain_text(), "1 2 3 4", "gap {gap} em"); + } +} + +/// A word set beside a much larger one, apart by a gap and no space glyph (as a browser prints a +/// margin between two sizes), reads as two words: the gap is measured against the smaller size +/// (#3623). Set close, the two sizes stay one word. +#[test] +fn a_gap_between_two_sizes_is_measured_against_the_smaller() { + let font = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap(); + let advance = |c: char, size: f32| font.advance(font.glyph_for(c)) as f32 / font.units_per_em() as f32 * size; + // 3.4 pt: 0.17 em of the 20 pt "4", 0.43 em of the 8 pt "hours". + for (gap, want) in [(3.4_f32, "4 hours"), (0.4, "4hours")] { + let lines = [(20.0, 60.0, 20.0, "4"), (20.0 + advance('4', 20.0) + gap, 60.0, 8.0, "hours")]; + let d = import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document; + let t = texts(&d); + assert_eq!(t.len(), 1, "gap {gap} pt: {t:?}"); + assert_eq!(t[0].plain_text(), want, "gap {gap} pt"); + } + // The small size first. + let lines = [(20.0, 60.0, 8.0, "ca"), (20.0 + advance('c', 8.0) + advance('a', 8.0) + 3.4, 60.0, 20.0, "4")]; + let t = texts(&import_with_report(&text_pdf(&lines, false), &ImportOptions::default()).unwrap().document); + assert_eq!(t.iter().map(TextObject::plain_text).collect::<Vec<_>>(), ["ca 4"]); +} + /// Embedded font data is untrusted: anything that isn't a CID-keyed CFF is no table, never a panic. #[test] fn cid_tables_ignore_fonts_that_are_not_cid_keyed_cff() { diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests_output.rs index 95b8439f2..26d65fd19 100644 --- a/crates/pdf/src/tests_output.rs +++ b/crates/pdf/src/tests_output.rs @@ -197,7 +197,8 @@ fn png(rgb: [u8; 3]) -> Vec<u8> { fn image_doc(bytes: Vec<u8>) -> Document { let mut d = Document::new(100.0, 100.0); d.images.insert("img".into(), ImageBlob::new("image/png", bytes)); - let image = ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default() }; + let image = + ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default(), pixels: None }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))); let l = d.layers[0].id; d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/pdf/src/tests_pdfx.rs b/crates/pdf/src/tests_pdfx.rs index 400662b76..dbc221e0d 100644 --- a/crates/pdf/src/tests_pdfx.rs +++ b/crates/pdf/src/tests_pdfx.rs @@ -125,6 +125,7 @@ fn pdfx_1a_files_have_no_rgb() { xf: Affine::translate((150.0, 50.0)) * Affine::scale(5.0), link: None, placement: Default::default(), + pixels: None, }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); diff --git a/crates/pdf/src/tests_print.rs b/crates/pdf/src/tests_print.rs index 5c57fa42b..d969dcacc 100644 --- a/crates/pdf/src/tests_print.rs +++ b/crates/pdf/src/tests_print.rs @@ -23,7 +23,17 @@ fn doc(w: f64, h: f64, shapes_: &[(Rect, Paint)]) -> Document { } fn artboard(id: u32, x: f64) -> Artboard { - Artboard { id, name: format!("Artboard {id}"), rect: Rect::new(x, 0.0, x + 100.0, 100.0), show_center_mark: false, show_cross_hairs: false } + Artboard { + id, + name: format!("Artboard {id}"), + rect: Rect::new(x, 0.0, x + 100.0, 100.0), + locked: false, + show_center_mark: false, + show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, + } } fn rgb(r: f32, g: f32, b: f32) -> Paint { @@ -94,6 +104,75 @@ fn defaults_and_json() { assert_eq!(Media::A4.size().map(|(w, h)| (w.round(), h.round())), Some((595.0, 842.0))); } +#[test] +fn japanese_paper_sizes() { + // The Japanese papers: JIS B4, B5 and B6 are the sizes Japanese printing uses (JIS X 6310), + // a little larger than ISO's; the others are はがき, 往復はがき, 封筒 and 四六判. + let near = |m: Media, w: f64, h: f64| { + let (mw, mh) = m.size().unwrap(); + assert!((mw - w).abs() < 0.01 && (mh - h).abs() < 0.01, "{m:?} is {mw} × {mh}, not {w} × {h}"); + }; + near(Media::B4Jis, 728.50, 1031.81); + near(Media::B5Jis, 515.91, 728.50); + near(Media::B6Jis, 362.83, 515.91); + near(Media::A6, 297.64, 419.53); + near(Media::JapanesePostcard, 283.46, 419.53); + near(Media::JapaneseDoublePostcard, 419.53, 566.93); + near(Media::JapaneseEnvelope3, 340.16, 666.14); + near(Media::JapaneseEnvelope4, 255.12, 637.80); + near(Media::Kaku2, 680.31, 941.10); + near(Media::Kaku3, 612.28, 785.20); + near(Media::YonrokuBan, 360.0, 532.91); + // The ISO B sizes keep the dimensions settings and presets saved before the Japanese papers + // existed: they are not the JIS sizes. + near(Media::B4, 708.66, 1000.63); + near(Media::B5, 498.90, 708.66); + + // A paper is named by its JSON id or its dialog label, any case and spacing. + for (name, want) in [ + ("b4 (jis)", Media::B4Jis), + ("B4 (JIS)", Media::B4Jis), + ("b4jis", Media::B4Jis), + ("b5", Media::B5), + ("B5 (ISO)", Media::B5), + ("b5 (jis)", Media::B5Jis), + ("japanese postcard", Media::JapanesePostcard), + ("Japanese Postcard", Media::JapanesePostcard), + ("Japanese Envelope #3", Media::JapaneseEnvelope3), + ("kaku #2", Media::Kaku2), + ("yonroku-ban", Media::YonrokuBan), + ] { + assert_eq!(Media::from_name(name), Some(want), "{name}"); + } + assert_eq!(Media::from_name("b7"), None); + assert_eq!(Media::from_name(" "), None); + for m in Media::ALL { + assert_eq!(Media::from_name(m.id()), Some(*m), "id {:?}", m.id()); + assert_eq!(Media::from_name(m.label()), Some(*m), "label {:?}", m.label()); + assert_eq!(serde_json::to_value(m).unwrap(), json!(m.id())); + assert_eq!(serde_json::from_value::<Media>(json!(m.id())).unwrap(), *m); + } + assert!(serde_json::from_value::<Media>(json!("b7")).is_err(), "an unknown paper is refused"); + + // Every paper prints as a page of its own size, the added ones included. + let d = doc(50.0, 50.0, &[(Rect::new(0.0, 0.0, 50.0, 50.0), rgb(0.0, 0.0, 0.0))]); + for (media, w, h) in [ + ("japanese postcard", 283.46, 419.53), + ("japanese double postcard", 419.53, 566.93), + ("B5 (JIS)", 515.91, 728.50), + ("kaku #2", 680.31, 941.10), + ("yonroku-ban", 360.0, 532.91), + // The ISO B sizes keep their page size. + ("b5", 498.90, 708.66), + ] { + let r = job(&d, json!({"media": media, "autoRotate": false})); + assert_eq!(r.pages, 1, "{media}"); + let p = pages(&r.bytes); + assert_eq!(p.len(), 1, "{media}"); + assert!((p[0].0.0 - w).abs() < 0.01 && (p[0].0.1 - h).abs() < 0.01, "{media}: {:?}, not {w} × {h}", p[0].0); + } +} + #[test] fn fit_to_letter_scales_the_artboard_into_the_paper() { // Twice Letter, landscape, a red square in its top-left quarter. diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index d88e064f7..b85ec9d20 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -528,6 +528,33 @@ fn glyphs_spaced_each_their_own_way_keep_their_place() { }); } +/// Words set apart by spaces tracked past an em (#2384) come back with each glyph in its place: +/// the file draws each space and leaves the gap after it. Evenly tracked words keep one run. +#[test] +fn words_set_apart_by_spaces_tracked_past_an_em_keep_their_place() { + let runs = [("Hooks", 0.0), (" ", 1800.0), ("and", 0.0), (" ", 2400.0), ("leads", 0.0)]; + let mut t = TextObject::point(Point::new(20.0, 100.0), "", style(12.0)); + t.runs = runs.iter().map(|(s, tr)| TextRun { text: s.to_string(), style: CharStyle { tracking: *tr, ..style(12.0) }, inline: None }).collect(); + let d = doc(vec![t]); + let want = origins(&d); + let back = import_as(&pdf(&d, false).bytes, TextAs::default()); + assert_eq!(texts(&back), ["Hooks and leads"]); + let got = origins(&back); + assert_eq!(got.len(), want.len()); + for (g, w) in got.iter().zip(&want) { + assert!((g.x - w.x).abs() < 0.1 && (g.y - w.y).abs() < 0.1, "{got:?}\nvs {want:?}"); + } + // Evenly tracked, the spaces too: one run. + let even = doc(vec![TextObject::point(Point::new(20.0, 100.0), "Hooks and leads", CharStyle { tracking: 150.0, ..style(12.0) })]); + let back = import_as(&pdf(&even, false).bytes, TextAs::default()); + assert_eq!(texts(&back), ["Hooks and leads"]); + back.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + assert_eq!(t.runs.len(), 1, "{:?}", t.runs.iter().map(|r| r.style.tracking).collect::<Vec<_>>()); + } + }); +} + /// Evenly tracked type on a path comes back with its tracking: each glyph where it was along the /// curve, not closed up to the font's advances. #[test] @@ -546,3 +573,25 @@ fn evenly_tracked_type_on_a_path_keeps_its_length() { assert!(g.distance(*w) < 0.3, "{got:?}\nvs {want:?}"); } } + +/// Type filled with a dithered gradient is an image clipped to its outlines, sampled at the raster +/// effects resolution on the page however the type is scaled, with its characters under it as +/// invisible text (rendering mode 3), so they can still be selected and searched. +#[test] +fn dithered_type_keeps_its_resolution_when_scaled_and_stays_searchable() { + let fill = Paint::Gradient(Box::new(GradientPaint::new(Gradient { dither: true, ..Default::default() }))); + let big = TextObject::point(Point::new(20.0, 80.0), "Dither", CharStyle { fill: fill.clone(), ..style(40.0) }); + let mut scaled = TextObject::point(Point::new(5.0, 20.0), "Dither", CharStyle { fill, ..style(10.0) }); + scaled.xf = Affine::scale(4.0) * scaled.xf; + let width = |bytes: &[u8]| -> u32 { + let s = String::from_utf8_lossy(bytes); + s.split("/Width ").nth(1).and_then(|w| w.split(|c: char| !c.is_ascii_digit()).next()).and_then(|n| n.parse().ok()).unwrap() + }; + let (big, scaled) = (pdf(&doc(vec![big]), false).bytes, pdf(&doc(vec![scaled]), false).bytes); + let (wb, ws) = (width(&big), width(&scaled)); + // At 72 ppi a pixel a point: 40 pt "Dither" is about 100 points wide. + assert!(wb >= 80 && wb.abs_diff(ws) <= 2, "40 pt type {wb} pixels wide, 10 pt type scaled 4 times {ws}"); + let ops = String::from_utf8_lossy(&scaled).split_whitespace().collect::<Vec<_>>().join(" "); + assert!(ops.contains("3 Tr"), "the characters as invisible text"); + assert_eq!(texts(&import_as(&scaled, TextAs::Text)), ["Dither"]); +} diff --git a/crates/pdf/src/tests_text_placement.rs b/crates/pdf/src/tests_text_placement.rs new file mode 100644 index 000000000..fc0057a90 --- /dev/null +++ b/crates/pdf/src/tests_text_placement.rs @@ -0,0 +1,659 @@ +//! #722: type from a PDF lands where the file draws it. Each file here is written from scratch +//! from the PDF specification (ISO 32000-1 §9, "Text": the text state, text-positioning and +//! text-showing operators, Type 0, Type 3 and TrueType fonts, vertical writing), with Source Sans 3 +//! from this repo's assets embedded as its font program. What the file draws (its glyphs opened as +//! outlines) and what the type it opens as draws must match contour by contour. + +use std::sync::Arc; + +use kurbo::{BezPath, PathEl, Point, Rect, Shape}; +use vectorcraft_doc::{Document, NodeKind, TextKind, TextObject}; +use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; +use vectorcraft_text::{FontDb, FontFace}; + +use crate::tests_import_fidelity::stream; +use crate::*; + +const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + +fn sans() -> Arc<FontFace> { + FontDb::global().find_postscript("SourceSans3-Regular").expect("Source Sans 3 is built in") +} + +/// Source Sans 3's advance of `c`, in thousandths of an em (as a PDF's widths give it). +fn width(c: char) -> f64 { + let face = sans(); + (face.advance(face.glyph_for(c)) * 1000.0 / face.units_per_em()).round() +} + +/// The font program of Source Sans 3 Regular as a stream (ASCII hex). +fn font_file() -> String { + let hex: String = SOURCE_SANS.iter().map(|b| format!("{b:02X}")).collect::<String>() + ">"; + stream(&format!("/Length1 {} /Filter /ASCIIHexDecode", SOURCE_SANS.len()), &hex) +} + +/// A font descriptor for the font program in object `file`. +fn descriptor(file: usize) -> String { + format!( + "<< /Type /FontDescriptor /FontName /SourceSans3-Regular /Flags 32 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1024 /Descent -400 /CapHeight 660 /StemV 80 /FontFile2 {file} 0 R >>" + ) +} + +/// A simple TrueType font (WinAnsi, Source Sans 3's widths) described by object `desc`. +fn simple(desc: usize) -> String { + let widths: Vec<String> = (32u8..=126).map(|c| width(c as char).to_string()).collect(); + format!( + "<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /FirstChar 32 /LastChar 126 /Widths [{}] /FontDescriptor {desc} 0 R /Encoding /WinAnsiEncoding >>", + widths.join(" ") + ) +} + +/// The characters the Type 0 fonts map: printable ASCII, and symbols that stand upright in +/// vertical type and are narrower than an em. +fn cid_chars() -> impl Iterator<Item = char> { + (32u8..=126).map(char::from).chain("§±×".chars()) +} + +/// `text` as a string of Source Sans 3's glyph ids, two bytes each (Identity-H or Identity-V, +/// CIDToGIDMap Identity). +fn gids(text: &str) -> String { + let face = sans(); + let hex: String = text.chars().map(|c| format!("{:04X}", face.glyph_for(c))).collect(); + format!("<{hex}>") +} + +/// A ToUnicode map of Source Sans 3's glyph ids for [`cid_chars`]. +fn to_unicode() -> String { + let face = sans(); + let rows: Vec<String> = cid_chars().map(|c| format!("<{:04X}> <{:04X}>", face.glyph_for(c), c as u32)).collect(); + let data = format!( + "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange {} beginbfchar\n{}\nendbfchar endcmap CMapName currentdict /CMap defineresource pop end end", + rows.len(), + rows.join("\n") + ); + stream("", &data) +} + +/// A CID-keyed TrueType font (the descendant of the Type 0 fonts) of Source Sans 3 with its +/// widths (/W), described by object `desc`. +fn cid_font(desc: usize) -> String { + let face = sans(); + let w: Vec<String> = cid_chars().map(|c| format!("{} [{}]", face.glyph_for(c), width(c))).collect(); + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /SourceSans3-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {desc} 0 R /CIDToGIDMap /Identity /DW 1000 /W [{}] >>", + w.join(" ") + ) +} + +/// [`cid_font`] with vertical metrics (/W2: each glyph's position vector half its width across, +/// 880 up) and no /W (every width the default, an em). +fn cid_font_w2(desc: usize) -> String { + let face = sans(); + let w2: Vec<String> = cid_chars().map(|c| format!("{} [-1000 {} 880]", face.glyph_for(c), width(c) / 2.0)).collect(); + format!( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /SourceSans3-Regular /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {desc} 0 R /CIDToGIDMap /Identity /DW 1000 /DW2 [880 -1000] /W2 [{}] >>", + w2.join(" ") + ) +} + +/// A Type 3 font (object `first`, its glyph procedures the two after it) whose glyph space is +/// 1/`upm` of text space (FontMatrix [1/upm 0 0 1/upm 0 0]): `a` is half an em wide and draws a box +/// 0.4 em wide, `b` an em wide with a box of 0.9 em; both boxes are 0.7 em tall on the baseline. +fn type3(first: usize, upm: f64) -> Vec<String> { + let s = 1.0 / upm; + let (h, w) = (upm * 0.5, upm); + let a = stream("", &format!("{h} 0 d0 0 0 {} {} re f", h * 0.8, upm * 0.7)); + let b = stream("", &format!("{w} 0 d0 0 0 {} {} re f", w * 0.9, upm * 0.7)); + let font = format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 {w} {w}] /FontMatrix [{s} 0 0 {s} 0 0] /CharProcs << /a {} 0 R /b {} 0 R >> /Encoding << /Type /Encoding /Differences [97 /a /b] >> /FirstChar 97 /LastChar 98 /Widths [{h} {w}] /Resources << >> >>", + first + 1, + first + 2 + ); + vec![font, a, b] +} + +/// A 300 × 200 pt page drawing `content`, plus `entries` in the page dictionary, with fonts: +/// - `/F1`: Source Sans 3, embedded, a simple TrueType font; +/// - `/F4`: Source Sans 3 as a Type 0 font, Identity-H (two-byte glyph ids, see [`gids`]); +/// - `/F5`: the same, Identity-V (vertical writing); +/// - `/F6`: the same with vertical metrics (/W2) and no widths (/W), Identity-V; +/// - `/T3`, `/T30`: Type 3 fonts of 1000 and of 100 units an em; +/// - `/HV`: Helvetica, not embedded. +fn page(content: &str, entries: &str) -> Vec<u8> { + sized_page(300.0, 200.0, content, entries) +} + +/// [`page`], `w` × `h` pt. +fn sized_page(w: f64, h: f64, content: &str, entries: &str) -> Vec<u8> { + let n = first_extra(1); + let type0 = |enc: &str, cid: usize| { + format!( + "<< /Type /Font /Subtype /Type0 /BaseFont /SourceSans3-Regular /Encoding /{enc} /DescendantFonts [{cid} 0 R] /ToUnicode {} 0 R >>", + n + 4 + ) + }; + let mut objs = vec![ + font_file(), // n + descriptor(n), // n + 1 + simple(n + 1), // n + 2: F1 + cid_font(n + 1), // n + 3 + to_unicode(), // n + 4 + type0("Identity-H", n + 3), // n + 5: F4 + type0("Identity-V", n + 3), // n + 6: F5 + cid_font_w2(n + 1), // n + 7 + type0("Identity-V", n + 7), // n + 8: F6 + ]; + let t3 = n + objs.len(); + objs.extend(type3(t3, 1000.0)); + objs.extend(type3(t3 + 3, 100.0)); + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let resources = format!( + "/Font << /F1 {} 0 R /F4 {} 0 R /F5 {} 0 R /F6 {} 0 R /T3 {t3} 0 R /T30 {} 0 R /HV << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> >>", + n + 2, + n + 5, + n + 6, + n + 8, + t3 + 3 + ); + pdf_with(&[PdfPage { resources, entries: entries.into(), ..PdfPage::new(w, h, content) }], &refs, None) +} + +fn open_as(bytes: &[u8], text_as: TextAs) -> Document { + import_with_report(bytes, &ImportOptions { text_as, ..Default::default() }).unwrap().document +} + +fn texts(d: &Document) -> Vec<TextObject> { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + v.push((**t).clone()); + } + }); + v +} + +fn plain(d: &Document) -> Vec<String> { + texts(d).iter().map(TextObject::plain_text).collect() +} + +/// The largest difference between two boxes' edges. +fn off(a: Rect, b: Rect) -> f64 { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())) +} + +/// The bounds of each contour of `bp` (each subpath). +fn contours_of(bp: &BezPath, out: &mut Vec<Rect>) { + let mut cur = BezPath::new(); + for el in bp.elements() { + if matches!(el, PathEl::MoveTo(_)) && cur.elements().len() > 1 { + out.push(std::mem::take(&mut cur).bounding_box()); + } + if matches!(el, PathEl::MoveTo(_)) { + cur = BezPath::new(); + } + cur.push(*el); + } + if cur.elements().len() > 1 { + out.push(cur.bounding_box()); + } +} + +/// The contours `d` draws: its paths' and its type's glyphs', in document space. +fn contours(d: &Document) -> Vec<Rect> { + let mut out = vec![]; + d.walk(|n| match &n.kind { + NodeKind::Path { path, .. } => contours_of(&path.to_bezpath(), &mut out), + NodeKind::Text(t) => { + for g in vectorcraft_text::layout(FontDb::global(), t).glyphs { + contours_of(&(t.xf * g.outline), &mut out); + } + } + _ => {} + }); + out +} + +/// How far (points) the glyphs of the type `bytes` opens as are from where the file draws them: +/// the furthest any contour the file draws is from the nearest one the type draws, or the other +/// way round. +fn displacement(bytes: &[u8]) -> f64 { + let drawn = contours(&open_as(bytes, TextAs::Outlines)); + let typed = contours(&open_as(bytes, TextAs::Text)); + assert!(!drawn.is_empty() && !typed.is_empty(), "the file draws glyphs"); + let worst = |a: &[Rect], b: &[Rect]| a.iter().map(|r| b.iter().map(|s| off(*r, *s)).fold(f64::INFINITY, f64::min)).fold(0.0, f64::max); + worst(&drawn, &typed).max(worst(&typed, &drawn)) +} + +/// The type `content` opens as lands where the file draws it, as `want` (its objects' text). +#[track_caller] +fn lands(content: &str, entries: &str, want: &[&str]) { + let bytes = page(content, entries); + assert_eq!(plain(&open_as(&bytes, TextAs::Text)), want, "{content}"); + let d = displacement(&bytes); + assert!(d < 0.02, "{content}: glyphs {d:.3} pt from where the file draws them"); +} + +/// Three lines wrapped in a frame (each wrapped line ends in a space). +const PARAGRAPH: &str = "(The quick brown fox ) Tj T* (jumps over the lazy ) Tj T* (dog and runs away.) Tj"; +const PARAGRAPH_TEXT: &str = "The quick brown fox jumps over the lazy dog and runs away."; + +/// The text-positioning operators and the text matrix under any CTM: lines start where the file +/// starts them, the glyphs of each where it draws them. +#[test] +fn text_positioning_and_the_ctm_put_type_where_the_file_draws_it() { + let lines = ["Line one", "Line two", "Line three"]; + for (content, entries, want) in [ + ("BT /F1 24 Tf 20 50 Td (Hello World) Tj ET", "", &["Hello World"][..]), + ("BT /F1 1 Tf 24 0 0 24 20 50 Tm (Hello World) Tj ET", "", &["Hello World"]), + ("q 2 0 0 2 10 10 cm BT /F1 12 Tf 10 20 Td (Hello World) Tj ET Q", "", &["Hello World"]), + // A y-down CTM with a text matrix that turns the type upright again. + ("q 1 0 0 -1 0 200 cm BT /F1 12 Tf 1 0 0 -1 20 50 Tm (Hello World) Tj ET Q", "", &["Hello World"]), + ("BT /F1 12 Tf 14 TL 20 150 Td (Line one) Tj T* (Line two) Tj T* (Line three) Tj ET", "", &lines), + ("BT /F1 12 Tf 20 150 Td (Line one) Tj 0 -16 TD (Line two) Tj T* (Line three) Tj ET", "", &lines), + // ' moves to the next line; " also sets word and character spacing. + ("BT /F1 12 Tf 14 TL 20 150 Td (Line one) Tj (Line two) ' 2 1 (Line three) \" ET", "", &lines), + ("BT /F1 18 Tf 0.866 0.5 -0.5 0.866 40 40 Tm (Turned line) Tj ET", "", &["Turned line"]), + ("BT /F1 1 Tf 30 0 0 15 20 50 Tm (Squashed) Tj ET", "", &["Squashed"]), + ("q 1 0 0 0.5 0 0 cm BT /F1 20 Tf 20 100 Td (Squashed) Tj ET Q", "", &["Squashed"]), + ("BT /F1 24 Tf 60 80 Td (Cropped) Tj ET", "/CropBox [40 30 260 180]", &["Cropped"]), + ("BT /F1 24 Tf 20 50 Td (Turned page) Tj ET", "/Rotate 90", &["Turned page"]), + ] { + lands(content, entries, want); + } +} + +/// Horizontal scaling, character and word spacing, and `TJ`'s adjustments: each glyph keeps the +/// place the file gave it (they used to be evened out over the line, and laid out with the font's +/// kerning where the file had none, so the glyphs between the ends slid). +#[test] +fn spacing_and_scaling_keep_each_glyph_where_the_file_put_it() { + for (content, want) in [ + ("BT /F1 20 Tf 50 Tz 20 50 Td (Narrow type) Tj ET", "Narrow type"), + ("BT /F1 20 Tf 150 Tz 20 50 Td (Wide type) Tj ET", "Wide type"), + ("BT /F1 16 Tf 2 Tc 20 50 Td (Spaced out) Tj ET", "Spaced out"), + ("BT /F1 16 Tf -0.8 Tc 20 50 Td (Tight type here) Tj ET", "Tight type here"), + ("BT /F1 16 Tf 6 Tw 20 50 Td (Word space here) Tj ET", "Word space here"), + ("BT /F1 16 Tf 1.5 Tc 80 Tz 20 50 Td (Spaced and narrow) Tj ET", "Spaced and narrow"), + ("BT /F1 16 Tf 20 50 Td [(A) 120 (V) -80 (A) 300 (Tower)] TJ ET", "AVATower"), + ("BT /F1 16 Tf 20 50 Td (AVATAR WAVE) Tj ET", "AVATAR WAVE"), + ] { + lands(content, "", &[want]); + } + // Two-byte codes of a Type 0 font alike (word spacing applies only to single-byte code 32, + // ISO 32000-1 §9.3.3: none here). + for content in ["BT /F4 24 Tf 70 Tz 3 Ts 20 50 Td <> Tj ET", "BT /F4 24 Tf 9 Tw 2 Tc 20 50 Td <> Tj ET"] { + lands(&content.replace("<>", &gids("Hello World")), "", &["Hello World"]); + } +} + +/// Word spacing stays on the spaces, as tracking of their own; evenly spaced letters keep one +/// tracking value for the line, and a file that didn't kern opens with kerning off. +#[test] +fn word_spacing_is_tracking_on_the_spaces_and_unkerned_type_opens_unkerned() { + let d = open_as(&page("BT /F1 16 Tf 6 Tw 20 50 Td (Word space here) Tj ET", ""), TextAs::Text); + let t = texts(&d); + let letters: Vec<f64> = t[0].runs.iter().filter(|r| r.text != " ").map(|r| r.style.tracking).collect(); + let spaces: Vec<f64> = t[0].runs.iter().filter(|r| r.text == " ").map(|r| r.style.tracking).collect(); + assert_eq!(spaces.len(), 2, "{:?}", t[0].runs); + for s in &spaces { + assert!((s - 375.0).abs() < 0.5, "6 pt at 16 pt is 375/1000 em: {spaces:?}"); + } + assert!(letters.iter().all(|l| l.abs() < 0.5), "the letters as the font spaces them: {letters:?}"); + // "AV", "VA", "WA" kern in Source Sans 3; this file set them on their widths alone. + let d = open_as(&page("BT /F1 16 Tf 20 50 Td (AVATAR WAVE) Tj ET", ""), TextAs::Text); + let t = texts(&d); + assert_eq!(t[0].runs.len(), 1, "{:?}", t[0].runs); + assert_eq!(t[0].runs[0].style.kerning, Some(0.0), "kerning off, as the file set it"); + assert!(t[0].runs[0].style.tracking.abs() < 0.5); +} + +/// Type the font's kerning spaced (as editors write it) keeps the font's kerning. +#[test] +fn kerned_type_keeps_the_fonts_kerning() { + let bytes = crate::tests_live_text::text_pdf(&[(20.0, 50.0, 24.0, "AVATAR WAVE")], false); + let d = open_as(&bytes, TextAs::Text); + let t = texts(&d); + assert_eq!(t.len(), 1); + assert!(t[0].runs.iter().all(|r| r.style.kerning.is_none()), "{:?}", t[0].runs); + assert!(displacement(&bytes) < 0.02); +} + +/// Text rise (`Ts`): a superscript or subscript stays in its line as baseline shift (it used to be +/// pulled onto the baseline within 0.15 em of it, or split off as an object of its own), and a line +/// that starts raised keeps the baseline of the rest of its text. +#[test] +fn raised_and_lowered_characters_stay_in_their_line_shifted_off_its_baseline() { + for (content, want) in [ + ("BT /F1 12 Tf 20 50 Td 5 Ts (Raised line) Tj ET", "Raised line"), + ("BT /F1 12 Tf 20 50 Td (E = mc) Tj 5 Ts (2) Tj 0 Ts ( here) Tj ET", "E = mc2 here"), + ("BT /F1 12 Tf 20 50 Td (H) Tj -3 Ts (2) Tj 0 Ts (O water) Tj ET", "H2O water"), + ("BT /F1 12 Tf 20 50 Td (H) Tj 1.5 Ts (2) Tj 0 Ts (O water) Tj ET", "H2O water"), + ("BT /F1 12 Tf 20 50 Td (CO) Tj -1.2 Ts /F1 8 Tf (2) Tj 0 Ts /F1 12 Tf ( gas) Tj ET", "CO2 gas"), + ("BT /F1 12 Tf 20 50 Td 1.5 Ts (1) Tj 0 Ts (Footnote text) Tj ET", "1Footnote text"), + ] { + lands(content, "", &[want]); + } + // Under a sheared text matrix (slanted type) text rise moves a glyph along the slant, as the + // type's slant draws its baseline shift: the shifted glyphs land where the file draws them. + for (content, want) in [ + ("BT /F1 12 Tf 1 0 0.2 1 20 50 Tm (E = mc) Tj 5 Ts (2) Tj ET", "E = mc2"), + ("BT /F1 12 Tf 1 0 0.2 1 20 50 Tm (E = mc) Tj 5 Ts (2) Tj 0 Ts ( here) Tj ET", "E = mc2 here"), + ("BT /F1 12 Tf 1 0 -0.3 1 20 50 Tm (H) Tj -3 Ts (2) Tj 0 Ts (O water) Tj ET", "H2O water"), + ("BT /F1 12 Tf 1 0 0.2 1 20 50 Tm 1.5 Ts (1) Tj 0 Ts (Footnote text) Tj ET", "1Footnote text"), + ("BT /F1 12 Tf 1 0 0.2 1 20 50 Tm 4 Ts (Raised) Tj ET", "Raised"), + ] { + lands(content, "", &[want]); + } + let t = texts(&open_as(&page("BT /F1 12 Tf 20 50 Td 1.5 Ts (1) Tj 0 Ts (Footnote text) Tj ET", ""), TextAs::Text)); + let origin = t[0].xf * Point::ZERO; + assert!((origin.y - 150.0).abs() < 1e-6, "on the text's baseline, not the mark's: {origin:?}"); + assert_eq!(t[0].runs[0].text, "1"); + assert!((t[0].runs[0].style.baseline_shift - 1.5).abs() < 1e-6, "{:?}", t[0].runs[0].style.baseline_shift); + assert!(t[0].runs[1..].iter().all(|r| r.style.baseline_shift == 0.0)); +} + +/// Lines wrapped in a frame (area type, as an editor writes it: each line placed with `T*` or `Td`) +/// come back as one area type object whose glyphs are where the lines' were: aligned left, or +/// justified with word spacing (`Tw`) on each wrapped line. +#[test] +fn text_boxes_open_where_their_lines_were() { + for content in [ + format!("BT /F1 12 Tf 14 TL 20 150 Td {PARAGRAPH} ET"), + format!("BT /F1 12 Tf 14 TL 4 Ts 80 Tz 20 150 Td {PARAGRAPH} ET"), + format!("q 1.5 0 0 1.5 5 5 cm BT /F1 8 Tf 10 TL 10 90 Td {PARAGRAPH} ET Q"), + format!("BT /F1 12 Tf 14 TL 0.866 0.5 -0.5 0.866 40 40 Tm {PARAGRAPH} ET"), + ] { + let t = texts(&open_as(&page(&content, ""), TextAs::Text)); + assert_eq!(t.len(), 1, "{content}: one text box"); + assert!(matches!(t[0].kind, TextKind::Area { .. }), "{content}"); + lands(&content, "", &[PARAGRAPH_TEXT]); + } + // Justified: every wrapped line spaced out to the frame's width with word spacing. + let lines = ["The quick brown fox ", "jumps over the lazy ", "dog and runs away."]; + let (size, frame) = (12.0, 110.0); + let mut content = format!("BT /F1 {size} Tf 20 170 Td "); + for (i, l) in lines.iter().enumerate() { + let natural = l.trim_end().chars().map(width).sum::<f64>() * size / 1000.0; + let spaces = l.trim_end().matches(' ').count() as f64; + let tw = if i + 1 < lines.len() { (frame - natural) / spaces } else { 0.0 }; + if i > 0 { + content += "0 -14.4 Td "; + } + content += &format!("{tw:.4} Tw ({l}) Tj "); + } + content += "ET"; + let t = texts(&open_as(&page(&content, ""), TextAs::Text)); + assert_eq!(t.len(), 1, "one text box: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>()); + assert_eq!(t[0].para.justify, vectorcraft_doc::Justify::JustifyLeft); + lands(&content, "", &[PARAGRAPH_TEXT]); +} + +/// Vertical writing (Identity-V): the file puts each glyph's origin half its width (/W) left of +/// the column (ISO 32000-1 §9.7.4.3); glyphs narrower than an em stay centred on it. +#[test] +fn vertical_glyphs_narrower_than_an_em_stay_on_their_column() { + lands(&format!("BT /F5 24 Tf 150 180 Td {} Tj ET", gids("§±×")), "", &["§±×"]); +} + +/// Type 3 glyphs (drawn by their procedures, kept as outlines) are scaled by the font's matrix: +/// the same boxes at 1000 and at 100 units an em. +#[test] +fn type3_glyphs_follow_the_font_matrix() { + for font in ["T3", "T30"] { + let d = open_as(&page(&format!("BT /{font} 20 Tf 20 50 Td (ab) Tj ET"), ""), TextAs::Text); + assert!(texts(&d).is_empty(), "Type 3 text keeps its outlines"); + let mut boxes = contours(&d); + boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + // `a` at x 20 (0.4 em = 8 pt wide), `b` after its half-em advance; 0.7 em = 14 pt tall, + // standing on the baseline at y 150 (200 - 50). + let want = [Rect::new(20.0, 136.0, 28.0, 150.0), Rect::new(30.0, 136.0, 48.0, 150.0)]; + assert_eq!(boxes.len(), 2, "{font}: {boxes:?}"); + for (b, w) in boxes.iter().zip(want) { + assert!(off(*b, w) < 1e-3, "{font}: {b:?} vs {w:?}"); + } + } +} + +/// A font that isn't embedded or installed is set in a stand-in: the line still starts where +/// the file starts it, on its baseline, and is as long as the file's. +#[test] +fn type_in_a_stand_in_font_still_starts_where_the_file_starts_it() { + let t = texts(&open_as(&page("BT /HV 12 Tf 6 Tw 20 50 Td 5 Ts (Raised word line) Tj ET", ""), TextAs::Text)); + assert_eq!(t.len(), 1); + assert_eq!(t[0].plain_text(), "Raised word line"); + let origin = t[0].xf * Point::ZERO; + assert!((origin.x - 20.0).abs() < 1e-6 && (origin.y - 145.0).abs() < 1e-6, "{origin:?}"); +} + +/// Type on a gentle arc: each glyph turned a little from the one before, the first few within a +/// hair of the first's direction. They sit on the curve; none is shifted off it as well (which +/// moved them off the curve by the shift twice over). +#[test] +fn type_on_a_gentle_arc_sits_on_its_curve_unshifted() { + let text = "Gentle arc of type here"; + let (size, r) = (12.0, 450.0); + let (cx, cy) = (150.0, -300.0); + let mut phi = 100f64.to_radians(); + let mut content = format!("BT /F1 {size} Tf "); + for c in text.chars() { + let a = phi - std::f64::consts::FRAC_PI_2; + let (x, y) = (cx + r * phi.cos(), cy + r * phi.sin()); + let esc = if c == ' ' { " ".to_string() } else { c.to_string() }; + content += &format!("{:.6} {:.6} {:.6} {:.6} {x:.4} {y:.4} Tm ({esc}) Tj ", a.cos(), a.sin(), -a.sin(), a.cos()); + phi -= width(c) * size / 1000.0 / r; + } + content += "ET"; + let t = texts(&open_as(&page(&content, ""), TextAs::Text)); + assert_eq!(t.len(), 1, "{:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>()); + assert_eq!(t[0].plain_text(), text); + assert!(matches!(t[0].kind, TextKind::OnPath { .. }), "type on a path"); + assert!(t[0].runs.iter().all(|r| r.style.baseline_shift == 0.0), "{:?}", t[0].runs); + let d = displacement(&page(&content, "")); + assert!(d < 0.05, "glyphs {d:.3} pt off the curve"); +} + +/// A line with as many raised characters as level ones keeps the baseline of its first glyph: +/// "m²" sits on the m's baseline, the 2 shifted up. +#[test] +fn a_tie_keeps_the_first_glyphs_baseline() { + let content = "BT /F1 12 Tf 20 50 Td (m) Tj 5 Ts (2) Tj ET"; + lands(content, "", &["m2"]); + let t = texts(&open_as(&page(content, ""), TextAs::Text)); + let origin = t[0].xf * Point::ZERO; + assert!((origin.y - 150.0).abs() < 1e-6, "on the m's baseline: {origin:?}"); + assert_eq!(t[0].runs.len(), 2, "{:?}", t[0].runs); + assert_eq!(t[0].runs[0].style.baseline_shift, 0.0); + assert!((t[0].runs[1].style.baseline_shift - 5.0).abs() < 1e-6, "{:?}", t[0].runs[1].style); +} + +/// The cells of a table's row whose baselines differ a little (a third of an em) stay objects of +/// their own: only a character that follows on from the one before is raised in its line. +#[test] +fn table_cells_a_little_apart_stay_apart() { + for content in [ + "BT /F1 12 Tf 20 50 Td (Cell one) Tj 60 3.6 Td (Cell two) Tj ET", + "BT /F1 12 Tf 20 50 Td (Cell one) Tj 60 -3.6 Td (Cell two) Tj ET", + "BT /F1 12 Tf 20 50 Td (Cell one) Tj 3.6 Ts 60 0 Td (Cell two) Tj ET", + ] { + lands(content, "", &["Cell one", "Cell two"]); + } +} + +/// Vertical writing with the font's own vertical metrics (/W2): each glyph's position vector puts +/// the column's centre where the font says (ISO 32000-1 §9.7.4.3), not half the default width. +#[test] +fn vertical_type_takes_the_fonts_position_vectors() { + lands(&format!("BT /F6 24 Tf 150 180 Td {} Tj ET", gids("§±×")), "", &["§±×"]); +} + +/// A vertical font's metrics as the file gives them: /W2 first, then half the width from /W, then +/// half /DW; glyphs mapped back to their CIDs through a /CIDToGIDMap stream; a CFF font's glyphs +/// (whose charset maps them) take /DW's. +#[test] +fn vertical_font_metrics_are_read_from_w2_w_and_dw() { + use craft_pdf::syntax::Pdf; + use craft_pdf::syntax::object::{Dict, ObjectIdentifier}; + let n = first_extra(1); + // CID → glyph: 10 → 5, 20 → 6, 30 → 7, 31 → 9, 40 → 8 (others 0). + let mut map = [0u16; 41]; + for (cid, gid) in [(10, 5), (20, 6), (30, 7), (31, 9), (40, 8)] { + map[cid] = gid; + } + let hex: String = map.iter().map(|g| format!("{g:04X}")).collect::<String>() + ">"; + let descendant = |subtype: &str, gid_map: &str| { + format!( + "<< /Type /Font /Subtype /{subtype} /BaseFont /Test /DW 900 /W [10 20 600 30 [400 500]] /W2 [10 [-1000 222 880] 40 41 -1000 333 880] {gid_map} >>" + ) + }; + let type0 = |enc: &str, d: usize| format!("<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding /{enc} /DescendantFonts [{d} 0 R] >>"); + let objs = [ + stream("/Filter /ASCIIHexDecode", &hex), // n + descendant("CIDFontType2", &format!("/CIDToGIDMap {n} 0 R")), // n + 1 + type0("Identity-V", n + 1), // n + 2 + descendant("CIDFontType2", ""), // n + 3 + type0("Identity-V", n + 3), // n + 4 + descendant("CIDFontType0", ""), // n + 5 + type0("Identity-V", n + 5), // n + 6 + type0("Identity-H", n + 3), // n + 7 + ]; + let refs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let bytes = pdf_with(&[PdfPage::new(100.0, 100.0, "")], &refs, None); + let pdf = Pdf::new(Arc::new(bytes)).unwrap(); + let font = |k: usize| pdf.xref().get::<Dict<'_>>(ObjectIdentifier::new((n + k) as i32, 0)).unwrap(); + let mapped = crate::import_scan::vertical_metrics(&font(2), true).expect("vertical"); + for (gid, want) in [(5, 222.0), (6, 300.0), (7, 200.0), (9, 250.0), (8, 333.0), (100, 450.0)] { + assert_eq!(mapped.centre(gid), want, "glyph {gid}"); + } + // CIDToGIDMap /Identity: glyph ids are CIDs. + let identity = crate::import_scan::vertical_metrics(&font(4), true).expect("vertical"); + for (gid, want) in [(10, 222.0), (15, 300.0), (31, 250.0), (41, 333.0), (42, 450.0)] { + assert_eq!(identity.centre(gid), want, "glyph {gid}"); + } + // A font the file doesn't carry, or a CFF one: its glyphs can't be told by CID. + let stand_in = crate::import_scan::vertical_metrics(&font(4), false).expect("vertical"); + let cff = crate::import_scan::vertical_metrics(&font(6), true).expect("vertical"); + assert_eq!((stand_in.centre(10), cff.centre(10)), (450.0, 450.0)); + assert!(crate::import_scan::vertical_metrics(&font(7), true).is_none(), "horizontal"); +} + +/// Area type whose lines the file spaced a hair unevenly (`TJ` adjustments of a few thousandths +/// of an em) still comes back as one text box. +#[test] +fn text_boxes_with_a_hair_of_uneven_spacing_still_open_as_one() { + let content = + "BT /F1 12 Tf 14 TL 20 150 Td [(The qu) -4 (ick brown fox )] TJ T* [(jumps o) 5 (ver the) -3 ( lazy )] TJ T* (dog and runs away.) Tj ET"; + let t = texts(&open_as(&page(content, ""), TextAs::Text)); + assert_eq!(t.len(), 1, "one text box: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>()); + assert!(matches!(t[0].kind, TextKind::Area { .. })); + assert_eq!(t[0].plain_text(), PARAGRAPH_TEXT); + assert!(displacement(&page(content, "")) < 0.1); +} + +/// Word spacing the same on every line of a text box, its last line too, is the type's: it comes +/// back as one text box, aligned left, its spaces tracked apart. +#[test] +fn text_boxes_word_spaced_throughout_open_as_one() { + let content = format!("BT /F1 12 Tf 14 TL 2 Tw 20 150 Td {PARAGRAPH} ET"); + let t = texts(&open_as(&page(&content, ""), TextAs::Text)); + assert_eq!(t.len(), 1, "one text box: {:?}", t.iter().map(TextObject::plain_text).collect::<Vec<_>>()); + assert!(matches!(t[0].kind, TextKind::Area { .. })); + assert_eq!(t[0].para.justify, vectorcraft_doc::Justify::Left); + let spaces: Vec<f64> = t[0].runs.iter().filter(|r| r.text.trim().is_empty()).map(|r| r.style.tracking).collect(); + assert!(!spaces.is_empty() && spaces.iter().all(|s| (s - 2.0 / 12.0 * 1000.0).abs() < 1.0), "{:?}", t[0].runs); + lands(&content, "", &[PARAGRAPH_TEXT]); +} + +/// A font the file names but doesn't carry, drawn with the installed one of that name, whose +/// advances (2048 units an em) its widths give only to the thousandth of an em: the spacing the +/// file set is measured from its widths, so letters a hair off the font's widths still take one +/// tracking value, not each character its own (a run each). Inter is built in, so this doesn't +/// depend on the fonts installed. +#[test] +fn widths_rounded_off_the_installed_fonts_advances_keep_one_tracking() { + let inter = FontDb::global().find_postscript("Inter-Regular").expect("Inter is built in"); + let upm = inter.units_per_em(); + assert!(upm != 1000.0, "advances finer than the file's widths: {upm}"); + let widths: Vec<String> = (32u8..=126).map(|c| (inter.advance(inter.glyph_for(c as char)) * 1000.0 / upm).round().to_string()).collect(); + let font = format!( + "<< /Type /Font /Subtype /TrueType /BaseFont /Inter-Regular /FirstChar 32 /LastChar 126 /Widths [{}] /Encoding /WinAnsiEncoding >>", + widths.join(" ") + ); + let resources = format!("/Font << /IN {} 0 R >>", first_extra(1)); + let content = "BT /IN 12 Tf 20 50 Td [(Next) -1000 (line here)] TJ ET"; + let bytes = pdf_with(&[PdfPage { resources, ..PdfPage::new(300.0, 200.0, content) }], &[&font], None); + let t = texts(&open_as(&bytes, TextAs::Text)); + assert_eq!(t.len(), 1); + let runs: Vec<&str> = t[0].runs.iter().map(|r| r.text.as_str()).collect(); + assert_eq!(runs, ["Next", " ", "line here"], "{:?}", t[0].runs); + assert!(t[0].runs[0].style.tracking.abs() < 0.5 && t[0].runs[2].style.tracking.abs() < 0.5, "{:?}", t[0].runs); + assert!(t[0].runs[1].style.tracking > 500.0, "the em's gap on the space: {:?}", t[0].runs); +} + +/// A long line whose file widths are each a little off the font's advances (0.8% wider or +/// narrower, within the 1% that still counts as the same font) opens with its glyphs where the +/// file draws them, in one run: the differences add up along the line (with only the spacing the +/// file set, none, the glyphs would end points away from the file's), so the line takes their +/// share after every character. A line the font kerns (laid out without its kerning, as the file +/// sets it) and one it doesn't (laid out as the type is). Source Sans 3 is embedded: what the file +/// draws and what the type draws are the same outlines, so their contours tell where each glyph is. +#[test] +fn a_long_line_of_widths_a_little_off_the_fonts_advances_lands_in_one_run() { + let n = first_extra(1); + let resources = format!("/Font << /F1 {} 0 R >>", n + 2); + for scale in [1.008, 0.992] { + let widths: Vec<String> = (32u8..=126).map(|c| (width(c as char) * scale).round().to_string()).collect(); + let font = format!( + "<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /FirstChar 32 /LastChar 126 /Widths [{}] /FontDescriptor {} 0 R /Encoding /WinAnsiEncoding >>", + widths.join(" "), + n + 1 + ); + for line in [ + "Gather the quick brown fox over the lazy dogs while the waves roll in", + "minimum minimum minimum minimum minimum minimum minimum minimum", + ] { + let content = format!("BT /F1 12 Tf 10 50 Td ({line}) Tj ET"); + let page = PdfPage { resources: resources.clone(), ..PdfPage::new(600.0, 200.0, &content) }; + let bytes = pdf_with(&[page], &[&font_file(), &descriptor(n), &font], None); + let t = texts(&open_as(&bytes, TextAs::Text)); + assert_eq!(t.iter().map(TextObject::plain_text).collect::<Vec<_>>(), [line]); + assert_eq!(t[0].runs.len(), 1, "{scale} {line:?}: {:?}", t[0].runs); + let d = displacement(&bytes); + assert!(d < 12.0 * 0.02, "{scale} {line:?}: glyphs {d:.3} pt from where the file draws them"); + } + } +} + +/// A text-heavy page: `columns` columns of `rows` lines of 10 pt type, alternately plain lines and +/// paragraphs wrapped in a frame (left aligned, and justified with word spacing). +fn text_heavy(columns: usize, rows: usize) -> Vec<u8> { + const WORDS: [&str; 12] = ["Gather", "the", "quick", "brown", "fox", "over", "lazy", "dogs", "while", "AVATAR", "waves", "kerning"]; + let mut content = String::new(); + for c in 0..columns { + let x = 20.0 + c as f64 * 200.0; + content += &format!("BT /F1 10 Tf 12 TL {x} {} Td ", 40.0 + rows as f64 * 12.0); + for r in 0..rows { + let words: Vec<&str> = (0..6).map(|k| WORDS[(c * 7 + r * 5 + k) % WORDS.len()]).collect(); + let wrapped = c % 2 == 1 && r % 6 != 5; + let tw = if c % 4 == 3 && wrapped { 1.5 } else { 0.0 }; + content += &format!("{tw} Tw ({}{}) Tj T* ", words.join(" "), if wrapped { " " } else { "" }); + } + content += "ET "; + } + sized_page(40.0 + columns as f64 * 200.0, 80.0 + rows as f64 * 12.0, &content, "") +} + +/// How long a text-heavy file takes to open as type (run by hand: `-- --ignored --nocapture`). +#[test] +#[ignore] +fn perf_text_heavy_open() { + let bytes = text_heavy(20, 200); + for _ in 0..2 { + let start = std::time::Instant::now(); + let d = open_as(&bytes, TextAs::Text); + let took = start.elapsed(); + let n = texts(&d).len(); + let start = std::time::Instant::now(); + let _ = open_as(&bytes, TextAs::Outlines); + println!("4000 lines: {n} text objects in {took:?} (as outlines: {:?})", start.elapsed()); + } +} diff --git a/crates/pdf/src/tests_webview.rs b/crates/pdf/src/tests_webview.rs index dcee6baa7..680845722 100644 --- a/crates/pdf/src/tests_webview.rs +++ b/crates/pdf/src/tests_webview.rs @@ -25,8 +25,12 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document { id: i as u32 + 1, name: format!("A{i}"), rect: Rect::new(x, 0.0, x + 100.0, 100.0), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); } let mut png = vec![]; @@ -47,7 +51,7 @@ fn doc(n: usize, image: bool, on_first: bool) -> Document { .unwrap(); if image && (i > 0 || on_first) { let xf = Affine::translate((x + 60.0, 60.0)) * Affine::scale(4.0); - let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default() }; + let img = ImageObject { key: "img".into(), width: 8, height: 8, xf, link: None, placement: Default::default(), pixels: None }; let id = d.alloc_id(); d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(img)))).unwrap(); } diff --git a/crates/pdf/tests/editing_memory.rs b/crates/pdf/tests/editing_memory.rs new file mode 100644 index 000000000..ee8e5d916 --- /dev/null +++ b/crates/pdf/tests/editing_memory.rs @@ -0,0 +1,79 @@ +//! Opening a PDF whose pages share one large content stream, with an editing file naming a page +//! hash, keeps a working set that doesn't grow with the page count. One test in its own process, +//! so the allocator's peak is this test's. +// 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)] +// A counting allocator forwards to the system one. +#![allow(unsafe_code)] + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::sync::atomic::{AtomicUsize, Ordering::Relaxed}; + +use vectorcraft_pdf::{EDITING_FILE, editing_with}; + +static LIVE: AtomicUsize = AtomicUsize::new(0); +static PEAK: AtomicUsize = AtomicUsize::new(0); + +struct Counting; + +fn grew(n: usize) { + PEAK.fetch_max(LIVE.fetch_add(n, Relaxed) + n, Relaxed); +} + +// SAFETY: every call is forwarded to the system allocator unchanged. +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, l: Layout) -> *mut u8 { + grew(l.size()); + unsafe { System.alloc(l) } + } + unsafe fn dealloc(&self, p: *mut u8, l: Layout) { + LIVE.fetch_sub(l.size(), Relaxed); + unsafe { System.dealloc(p, l) } + } + unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 { + LIVE.fetch_sub(l.size(), Relaxed); + grew(new); + unsafe { System.realloc(p, l, new) } + } +} + +#[global_allocator] +static ALLOC: Counting = Counting; + +fn raw_pdf(objects: &[String]) -> Vec<u8> { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objects.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for at in offsets { + out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + out +} + +#[test] +fn pages_sharing_one_stream_are_hashed_without_keeping_a_copy_each() { + const PAGES: usize = 12; + const STREAM: usize = 4 << 20; + let mut objects = vec![ + "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 3 0 R >> >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {PAGES} >>", (0..PAGES).map(|i| format!("{} 0 R ", i + 7)).collect::<String>()), + "<< /Names [(a) 4 0 R] >>".to_string(), + format!("<< /Type /Filespec /UF ({EDITING_FILE}) /Desc (VectorCraft editing data; page hash 0000000000000000) /EF << /F 5 0 R >> >>"), + "<< /Length 5 >>\nstream\nhello\nendstream".to_string(), + format!("<< /Length {STREAM} >>\nstream\n{}\nendstream", "q ".repeat(STREAM / 2)), + ]; + objects.extend((0..PAGES).map(|_| "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] /Contents 6 0 R >>".to_string())); + let bytes = raw_pdf(&objects); + let base = LIVE.load(Relaxed); + PEAK.store(base, Relaxed); + let e = editing_with(&bytes, None).unwrap(); + let extra = PEAK.load(Relaxed) - base; + assert_eq!(e.data, b"hello"); + assert!(extra < 4 * STREAM, "peak {extra} bytes above the input: each page kept its own decoded copy"); +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index ecd189364..061ef506c 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -6,7 +6,8 @@ //! - **no host imports**: no WASI, file system, network, clock or randomness. A module that //! imports anything is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is -//! also enforced (native builds), so infinite loops end with an error; +//! also enforced (native builds), so infinite loops end with an error. A filter may ask for a +//! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it; //! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits; //! - a **fresh instance per run**, so no state leaks between calls. //! @@ -33,8 +34,8 @@ mod runtime; pub mod wat; pub use dialog::ParamUi; -pub use manifest::{Kind, Manifest, Param, ParamSpec}; -pub use runtime::{ABI_VERSION, Limits, Plugin}; +pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; +pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; /// What the host puts before a plug-in's id to name what it adds: its live effect /// (`plugin.<id>`, [`effect`]) or its file format ([`export`]). diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index 609e20a84..6d47c5511 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -45,6 +45,27 @@ impl Kind { } } +/// How much one run of a plug-in may compute. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Budget { + /// The usual budget ([`crate::Limits::default`]). + #[default] + Light, + /// A filter's larger budget for heavy but legitimate work (routing, packing, simulation): + /// [`crate::HEAVY_FUEL`] instructions and [`crate::HEAVY_WALL_TIME_MS`]. The + /// user allows it once, when the plug-in is installed, and the app runs it in the background + /// with Cancel. + Heavy, +} + +impl Budget { + /// The usual budget, which a manifest written back leaves out (`skip_serializing_if`). + fn is_light(&self) -> bool { + *self == Budget::Light + } +} + /// One declared parameter. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -191,6 +212,9 @@ pub struct Manifest { /// files get. Only exporters declare them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extensions: Vec<String>, + /// How much a run may compute: only a filter may be [`Budget::Heavy`]. + #[serde(default, skip_serializing_if = "Budget::is_light")] + pub budget: Budget, } impl Manifest { @@ -216,9 +240,12 @@ impl Manifest { author: String::new(), params: Vec::new(), extensions: Vec::new(), + budget: Budget::Light, } } + /// Checks what the module declared: its id, name and text lengths, its extensions, a budget its + /// kind may have (only a filter may be heavy), and each parameter and its dialog layout. fn validate(&self) -> Result<()> { if !valid_id(&self.id) { return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id))); @@ -231,6 +258,15 @@ impl Manifest { return Err(Error::Manifest("`version`, `description` or `author` is too long".into())); } self.validate_extensions()?; + match (self.budget, self.kind) { + (Budget::Light, _) | (Budget::Heavy, Kind::Filter) => {} + (Budget::Heavy, Kind::Effect) => { + return Err(Error::Manifest("a live effect runs while the document is drawn, so it can't be `heavy`".into())); + } + (Budget::Heavy, Kind::Exporter) => { + return Err(Error::Manifest("an exporter always gets an export's budget: leave `budget` out".into())); + } + } for (i, (k, p)) in self.params.iter().enumerate() { if !valid_key(k) { return Err(Error::Manifest(format!("invalid parameter name {k:?}"))); @@ -501,6 +537,28 @@ mod tests { } } + /// A filter may ask for a heavy budget; a live effect runs while the document is drawn and an + /// exporter has an export's budget, so they can't. Light, the default, isn't written back. + #[test] + fn only_a_filter_asks_for_a_heavy_budget() { + let heavy = Manifest::parse(br#"{"id":"org.example.route","name":"Route","kind":"filter","budget":"heavy"}"#).unwrap(); + assert_eq!(heavy.budget, Budget::Heavy); + assert_eq!(serde_json::to_value(&heavy).unwrap()["budget"], json!("heavy")); + for light in [r#"{"id":"a","name":"x","kind":"filter"}"#, r#"{"id":"a","name":"x","kind":"effect","budget":"light"}"#] { + let m = Manifest::parse(light.as_bytes()).unwrap(); + assert_eq!(m.budget, Budget::Light); + assert!(serde_json::to_value(&m).unwrap().get("budget").is_none(), "{light}"); + } + for bad in [ + r#"{"id":"a","name":"x","kind":"effect","budget":"heavy"}"#, + r#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"],"budget":"heavy"}"#, + r#"{"id":"a","name":"x","kind":"filter","budget":"huge"}"#, + r#"{"id":"a","name":"x","kind":"filter","budget":true}"#, + ] { + assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}"); + } + } + #[test] fn strings_and_number_lists_resolve_like_the_other_types() { let m = Manifest::parse( diff --git a/crates/plugins/src/objects.rs b/crates/plugins/src/objects.rs index c98dc5595..db743af15 100644 --- a/crates/plugins/src/objects.rs +++ b/crates/plugins/src/objects.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Point, Rect}; use crate::{Error, Result}; @@ -26,6 +26,12 @@ pub const MAX_ANCHORS: usize = 4_000_000; pub const MAX_ITEMS: usize = 256; /// Thickest stroke a plug-in may set, in points. pub const MAX_STROKE_WIDTH: f64 = 1000.0; +/// The range of a stroke's miter limit (as the Stroke panel's). +pub const MITER_LIMIT: std::ops::RangeInclusive<f64> = 1.0..=500.0; +/// Longest effect id a plug-in may return, in bytes. +pub const MAX_EFFECT_ID: usize = 128; +/// Largest data a plug-in may keep with a document (`documentData`), in bytes of JSON. +pub const MAX_DOCUMENT_DATA: usize = 1 << 20; /// One object returned by a plug-in. `None` fields keep the object's value (new objects take the /// defaults: a path, non-zero rule, no fills or strokes, full opacity). @@ -43,14 +49,51 @@ pub struct OutObject { pub fills: Option<Vec<Paint>>, pub strokes: Option<Vec<OutStroke>>, pub opacity: Option<f32>, + /// The object's effects, in order, replacing its own (not its fills' or strokes'). The host + /// admits them before they reach the document ([`AdmitEffects`]). + pub effects: Option<Vec<Effect>>, } -/// One stroke of an [`OutObject`]. +/// One stroke of an [`OutObject`]. `None` fields keep the stroke's value (a new stroke's +/// default: 1 pt, butt cap, miter join, miter limit 10). #[derive(Clone, Debug, PartialEq)] pub struct OutStroke { pub paint: Paint, - /// `None`: keep the stroke's weight (1 pt for a new stroke). pub width: Option<f64>, + pub join: Option<LineJoin>, + pub cap: Option<LineCap>, + pub miter_limit: Option<f64>, +} + +impl OutStroke { + /// A stroke of `paint` that keeps everything else. + pub fn paint(paint: Paint) -> Self { + Self { paint, width: None, join: None, cap: None, miter_limit: None } + } +} + +/// How the host admits the effect records a filter returns for object `n` (written with the +/// output, its effects still its own): the records to give it, or why they are refused. The +/// plug-in crate checks their form; which effects exist and what parameters they take is the +/// host's to say. +pub type AdmitEffects<'a> = &'a dyn Fn(&[Effect], &Node) -> std::result::Result<Vec<Effect>, String>; + +/// The name plug-ins use for line join `j`, as SVG's `stroke-linejoin`. +fn join_name(j: LineJoin) -> &'static str { + match j { + LineJoin::Miter => "miter", + LineJoin::Round => "round", + LineJoin::Bevel => "bevel", + } +} + +/// The name plug-ins use for line cap `c`, as SVG's `stroke-linecap`. +fn cap_name(c: LineCap) -> &'static str { + match c { + LineCap::Butt => "butt", + LineCap::Round => "round", + LineCap::Square => "square", + } } fn rule_name(r: FillRule) -> &'static str { @@ -84,25 +127,23 @@ pub fn geometry(n: &Node) -> Option<(PathData, FillRule, bool)> { } /// The fills and strokes of appearance `ap` as plug-ins see them, each in paint order: a fill is -/// its paint, a stroke `{paint, width}`. Filters and live effects are given the same. +/// its paint, a stroke `{paint, width, join, cap, miterLimit}`. Filters and live effects are given +/// the same. pub(crate) fn paint_lists(ap: &Appearance) -> (Vec<Value>, Vec<Value>) { let fills = ap.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect(); - let strokes = ap - .items - .iter() - .filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(json!({"paint": paint_json(&s.paint), "width": s.width})) } else { None }) - .collect(); + let stroke = |s: &StrokeLayer| json!({"paint": paint_json(&s.paint), "width": s.width, "join": join_name(s.join), "cap": cap_name(s.cap), "miterLimit": s.miter_limit}); + let strokes = ap.items.iter().filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(stroke(s)) } else { None }).collect(); (fills, strokes) } /// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path, -/// fillRule, fills, strokes, opacity, bounds}`. +/// fillRule, fills, strokes, effects, opacity, bounds}`. pub fn encode(n: &Node) -> Option<Value> { let (path, rule, compound) = geometry(n)?; let (fills, strokes) = paint_lists(&n.appearance); let mut o = json!({ "id": n.id.0, "type": if compound { "compound" } else { "path" }, "path": path, "fillRule": rule_name(rule), - "fills": fills, "strokes": strokes, "opacity": n.opacity, "bounds": rect_json(path.bounds()), + "fills": fills, "strokes": strokes, "effects": n.appearance.effects, "opacity": n.opacity, "bounds": rect_json(path.bounds()), }); if let Some(name) = &n.name { o["name"] = json!(name); @@ -227,6 +268,9 @@ fn opt<'a>(o: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a Value o.get(key).filter(|v| !v.is_null()) } +/// Object `v` of a plug-in's output (`at` names it in errors), its anchors added to `anchors` and +/// held to [`MAX_ANCHORS`]: each key it has checked, numbers clamped into range, as [`decode`] reads +/// them. fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject> { let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?; let mut out = OutObject::default(); @@ -268,18 +312,10 @@ fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject> out.fills = Some(fills.iter().enumerate().map(|(i, f)| decode_paint(f, &format!("{at}.fills[{i}]"))).collect::<Result<_>>()?); } if let Some(strokes) = list("strokes")? { - let mut v = Vec::with_capacity(strokes.len()); - for (i, s) in strokes.iter().enumerate() { - let at = format!("{at}.strokes[{i}]"); - let s = s.as_object().ok_or_else(|| out_err(&at, "a stroke must be {\"paint\", \"width\"}"))?; - let paint = decode_paint(s.get("paint").unwrap_or(&Value::Null), &at)?; - let width = match opt(s, "width") { - None => None, - Some(w) => Some(w.as_f64().ok_or_else(|| out_err(&at, "`width` must be a number"))?.clamp(0.0, MAX_STROKE_WIDTH)), - }; - v.push(OutStroke { paint, width }); - } - out.strokes = Some(v); + out.strokes = Some(strokes.iter().enumerate().map(|(i, s)| decode_stroke(s, &format!("{at}.strokes[{i}]"))).collect::<Result<_>>()?); + } + if let Some(effects) = list("effects")? { + out.effects = Some(effects.iter().enumerate().map(|(i, e)| decode_effect(e, &format!("{at}.effects[{i}]"))).collect::<Result<_>>()?); } if let Some(op) = opt(o, "opacity") { out.opacity = Some(unit(op.as_f64().ok_or_else(|| out_err(at, "`opacity` must be a number"))? as f32)); @@ -287,11 +323,97 @@ fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject> Ok(out) } -/// Parses a plug-in's output: `{"objects": [...]}`, or `{"error": "message"}` when the plug-in -/// declined (the message reaches the user). +/// A stroke from plug-in output: `{paint, width?, join?, cap?, miterLimit?}`, its weight clamped +/// to 0–[`MAX_STROKE_WIDTH`] and its miter limit into [`MITER_LIMIT`]. +fn decode_stroke(v: &Value, at: &str) -> Result<OutStroke> { + let s = v.as_object().ok_or_else(|| out_err(at, "a stroke must be {\"paint\", \"width\"?, \"join\"?, \"cap\"?, \"miterLimit\"?}"))?; + let number = |key: &str, range: std::ops::RangeInclusive<f64>| -> Result<Option<f64>> { + let Some(v) = opt(s, key) else { return Ok(None) }; + let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| out_err(at, format!("`{key}` must be a number")))?; + Ok(Some(x.clamp(*range.start(), *range.end()))) + }; + let join = match opt(s, "join").map(|j| j.as_str()) { + None => None, + Some(Some("miter")) => Some(LineJoin::Miter), + Some(Some("round")) => Some(LineJoin::Round), + Some(Some("bevel")) => Some(LineJoin::Bevel), + Some(_) => return Err(out_err(at, "`join` must be \"miter\", \"round\" or \"bevel\"")), + }; + let cap = match opt(s, "cap").map(|c| c.as_str()) { + None => None, + Some(Some("butt")) => Some(LineCap::Butt), + Some(Some("round")) => Some(LineCap::Round), + Some(Some("square")) => Some(LineCap::Square), + Some(_) => return Err(out_err(at, "`cap` must be \"butt\", \"round\" or \"square\"")), + }; + Ok(OutStroke { + paint: decode_paint(s.get("paint").unwrap_or(&Value::Null), at)?, + width: number("width", 0.0..=MAX_STROKE_WIDTH)?, + join, + cap, + miter_limit: number("miterLimit", MITER_LIMIT)?, + }) +} + +/// An effect record from plug-in output: `{id, params?, visible?}`, in form only (the host says +/// whether the effect exists and takes the parameters). `params` stays as given, so a record +/// handed back unchanged equals the object's own. +fn decode_effect(v: &Value, at: &str) -> Result<Effect> { + let e = v.as_object().ok_or_else(|| out_err(at, "an effect must be {\"id\", \"params\"?, \"visible\"?}"))?; + let id = e.get("id").and_then(Value::as_str).filter(|id| (1..=MAX_EFFECT_ID).contains(&id.len()) && !id.chars().any(char::is_control)); + let id = id.ok_or_else(|| out_err(at, format!("`id` must be an effect id of 1-{MAX_EFFECT_ID} bytes")))?; + let params = match e.get("params") { + None => Value::Null, + Some(p @ (Value::Null | Value::Object(_))) => p.clone(), + Some(_) => return Err(out_err(at, "`params` must be an object")), + }; + let visible = match e.get("visible") { + None | Some(Value::Null) => true, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(out_err(at, "`visible` must be true or false")), + }; + Ok(Effect { id: id.to_string(), params, visible }) +} + +/// What a filter returned: its objects, and what it keeps with the document. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Output { + pub objects: Vec<OutObject>, + /// `documentData`, as `Document::set_plugin_data` takes it: `None` when the output has none + /// (what the plug-in keeps stays as it is), `Some(None)` for `null` (it forgets it), else what + /// it keeps from now on, at most [`MAX_DOCUMENT_DATA`] bytes of JSON. + pub document_data: Option<Option<Value>>, +} + +/// Parses a filter's output: `{"objects": [...], "documentData"?: …}`, or `{"error": "message"}` +/// when the plug-in declined (the message reaches the user). +pub fn decode_output(bytes: &[u8]) -> Result<Output> { + let mut v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; + let objects = decode_objects(&v)?; + let document_data = match v.get_mut("documentData").map(Value::take) { + None => None, + Some(Value::Null) => Some(None), + Some(data) => { + let size = serde_json::to_vec(&data).map_or(usize::MAX, |b| b.len()); + if size > MAX_DOCUMENT_DATA { + return Err(Error::Output(format!("`documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA})"))); + } + Some(Some(data)) + } + }; + Ok(Output { objects, document_data }) +} + +/// Parses a plug-in's objects: `{"objects": [...]}`, or `{"error": "message"}` when the plug-in +/// declined (the message reaches the user). A live effect's other keys don't matter. pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> { let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?; - if let Some(e) = declined(&v) { + decode_objects(&v) +} + +/// The objects of output `v`, or its decline. +fn decode_objects(v: &Value) -> Result<Vec<OutObject>> { + if let Some(e) = declined(v) { return Err(e); } let objects = v.get("objects").and_then(Value::as_array).ok_or_else(|| Error::Output("expected {\"objects\": [...]}".into()))?; @@ -343,7 +465,7 @@ fn unlinked(mut p: Paint) -> Paint { /// `p` as it may enter `doc`: unchanged from `was` (the paint in the same slot before), or /// without swatch links; a pattern must exist in the document. -fn admit(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result<Paint> { +fn admit_paint(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result<Paint> { if let Paint::Pattern { pattern, .. } = &p && doc.pattern(pattern).is_none() { @@ -352,30 +474,27 @@ fn admit(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result<Pain Ok(if was == Some(&p) { p } else { unlinked(p) }) } -/// Sets the paints of `ap`'s fills (`fill`) or strokes to `new`, in paint order: extra items are -/// removed (from the top), missing ones added above the last item of their kind (fills at the -/// bottom, strokes on top when the object has none). -fn set_items(ap: &mut Appearance, fill: bool, new: Vec<(Paint, Option<f64>)>) { +/// Sets the paints of `ap`'s fills (`fill`) or strokes to `new` (a fill keeps everything but its +/// paint), in paint order: extra items are removed (from the top), missing ones added above the +/// last item of their kind (fills at the bottom, strokes on top when the object has none). +fn set_items(ap: &mut Appearance, fill: bool, new: Vec<OutStroke>) { let idx: Vec<usize> = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect(); for &i in idx.iter().skip(new.len()).rev() { ap.remove_item(i); } let kept = new.len().min(idx.len()); let mut at = idx.get(..kept).and_then(<[usize]>::last).map_or(if fill { 0 } else { ap.items.len() }, |i| i + 1); - for (k, (paint, width)) in new.into_iter().enumerate() { + for (k, o) in new.into_iter().enumerate() { match idx.get(k).and_then(|&i| ap.items.get_mut(i)) { - Some(AppearanceItem::Fill(f)) => f.paint = paint, - Some(AppearanceItem::Stroke(s)) => { - s.paint = paint; - if let Some(w) = width { - s.width = w; - } - } + Some(AppearanceItem::Fill(f)) => f.paint = o.paint, + Some(AppearanceItem::Stroke(s)) => style(s, o), None => { let item = if fill { - AppearanceItem::Fill(FillLayer::new(paint)) + AppearanceItem::Fill(FillLayer::new(o.paint)) } else { - AppearanceItem::Stroke(StrokeLayer::new(paint, width.unwrap_or(1.0))) + let mut s = StrokeLayer::new(Paint::None, 1.0); + style(&mut s, o); + AppearanceItem::Stroke(s) }; ap.insert_item(at, item); at += 1; @@ -384,6 +503,15 @@ fn set_items(ap: &mut Appearance, fill: bool, new: Vec<(Paint, Option<f64>)>) { } } +/// Gives stroke `s` the paint of `o` and each of its weight, join, cap and miter limit it has. +fn style(s: &mut StrokeLayer, o: OutStroke) { + s.paint = o.paint; + s.width = o.width.unwrap_or(s.width); + s.join = o.join.unwrap_or(s.join); + s.cap = o.cap.unwrap_or(s.cap); + s.miter_limit = o.miter_limit.unwrap_or(s.miter_limit); +} + /// A compound path's members: one path per subpath of `path`, keeping the ids of `old` members. fn members(doc: &mut Document, old: &[Arc<Node>], path: PathData) -> Vec<Arc<Node>> { path.subpaths @@ -396,14 +524,16 @@ fn members(doc: &mut Document, old: &[Arc<Node>], path: PathData) -> Vec<Arc<Nod .collect() } -/// Writes `o` into `n` (an input object, or a fresh node for a new one). -fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()> { +/// Writes `o` into `n` (an input object, or a fresh node for a new one); its effect records once +/// `admit` lets them in. +fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str, admit: AdmitEffects) -> Result<()> { let (cur_path, cur_rule, cur_compound) = geometry(n).unwrap_or((PathData::default(), FillRule::NonZero, false)); let compound = o.compound.unwrap_or(cur_compound); let rule = o.rule.unwrap_or(cur_rule); match (&mut n.kind, compound) { (NodeKind::Path { path, rule: r, live, .. }, false) => { - if let Some(p) = o.path { + // A path handed back as it was stays the live shape it was. + if let Some(p) = o.path.filter(|p| p != path) { *path = p; *live = None; } @@ -421,16 +551,16 @@ fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()> (_, true) => n.kind = NodeKind::Compound { children: members(doc, &[], o.path.unwrap_or(cur_path)), rule }, (_, false) => n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false }, } - for (fill, new) in [ - (true, o.fills.map(|f| f.into_iter().map(|p| (p, None)).collect::<Vec<_>>())), - (false, o.strokes.map(|s| s.into_iter().map(|s| (s.paint, s.width)).collect())), - ] { + for (fill, new) in [(true, o.fills.map(|f| f.into_iter().map(OutStroke::paint).collect::<Vec<_>>())), (false, o.strokes)] { let Some(new) = new else { continue }; let was = paints(&n.appearance, fill); let admitted = new .into_iter() .enumerate() - .map(|(i, (p, w))| Ok((admit(doc, p, was.get(i).copied(), &format!("{at}.{}[{i}]", if fill { "fills" } else { "strokes" }))?, w))) + .map(|(i, s)| { + let at = format!("{at}.{}[{i}]", if fill { "fills" } else { "strokes" }); + Ok(OutStroke { paint: admit_paint(doc, s.paint.clone(), was.get(i).copied(), &at)?, ..s }) + }) .collect::<Result<Vec<_>>>()?; set_items(&mut n.appearance, fill, admitted); } @@ -440,6 +570,9 @@ fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()> if let Some(name) = o.name { n.name = Some(name).filter(|s| !s.trim().is_empty()); } + if let Some(effects) = o.effects { + n.appearance.effects = admit(&effects, n).map_err(|why| out_err(&format!("{at}.effects"), why))?; + } Ok(()) } @@ -539,8 +672,9 @@ impl Plan { /// without one are new and go just above the object listed before them, or, listed before any /// input object, just below the first one listed after them (at the first input object's place /// when the output keeps none; on top of `parent` when there were no inputs). Input objects missing -/// from the output are deleted. Returns the resulting objects in output order (to select). -pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>, parent: Option<NodeId>) -> Result<Vec<NodeId>> { +/// from the output are deleted. An object's effect records replace its own once `admit` lets them +/// in. Returns the resulting objects in output order (to select). +pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>, parent: Option<NodeId>, admit: AdmitEffects) -> Result<Vec<NodeId>> { let wanted: HashSet<NodeId> = inputs.iter().copied().collect(); let mut originals: HashMap<NodeId, Node> = HashMap::new(); doc.walk(|n| { @@ -561,7 +695,7 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>, let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" }; out_err(&at, format!("{} {why}", id.0)) })?; - write(doc, &mut n, o, &at)?; + write(doc, &mut n, o, &at, admit)?; if !run.is_empty() { let nodes = std::mem::take(&mut run); match prev { @@ -578,7 +712,7 @@ pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>, return Err(out_err(&at, "a new object needs a `path`")); } let mut n = Node::path(doc.alloc_id(), PathData::default(), Appearance::default()); - write(doc, &mut n, o, &at)?; + write(doc, &mut n, o, &at, admit)?; result.push(n.id); run.push(n); } diff --git a/crates/plugins/src/objects/tests.rs b/crates/plugins/src/objects/tests.rs index 80f16ff11..c937120f8 100644 --- a/crates/plugins/src/objects/tests.rs +++ b/crates/plugins/src/objects/tests.rs @@ -27,6 +27,11 @@ fn names(d: &Document, layer: NodeId) -> Vec<String> { d.children(Some(layer)).unwrap().iter().map(|n| n.name.clone().unwrap_or_else(|| "?".into())).collect() } +/// Admits every effect record as it is (the host's checks are the engine's). +fn keep(fx: &[Effect], _: &Node) -> std::result::Result<Vec<Effect>, String> { + Ok(fx.to_vec()) +} + fn decode_json(v: Value) -> Result<Vec<OutObject>> { decode(v.to_string().as_bytes()) } @@ -41,6 +46,8 @@ fn targets_are_selected_paths_in_paint_order() { assert_eq!(filter_targets(&d, &[layer]), [ids[0], ids[2]]); } +/// Objects as plug-ins are given them (type, name, fill rule, fills, strokes, bounds); handed back +/// unchanged, they change nothing, swatch links included. #[test] fn objects_round_trip_through_the_json_model() { let (mut d, ids, _) = doc(); @@ -56,10 +63,37 @@ fn objects_round_trip_through_the_json_model() { // Echoed back unchanged, nothing changes (swatch links included). let before = d.clone(); let out = decode(&serde_json::to_vec(&input).unwrap()).unwrap(); - assert_eq!(apply_output(&mut d, &ids, out, None).unwrap(), ids); + assert_eq!(apply_output(&mut d, &ids, out, None, &keep).unwrap(), ids); assert_eq!(d, before); } +/// A path handed back as it was stays the live shape it was (a rectangle keeps its corner +/// widgets); a path that changed is a plain path. +#[test] +fn an_unchanged_path_stays_a_live_shape() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + let live = vectorcraft_doc::LiveShape::Rectangle { + w: 10.0, + h: 10.0, + radii: [0.0; 4], + radii_relative: false, + kinds: Default::default(), + xf: vectorcraft_geom::Affine::IDENTITY, + }; + if let Some(NodeKind::Path { live: l, .. }) = d.node_mut(a).map(|n| &mut n.kind) { + *l = Some(Box::new(live)); + } + let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap(); + let before = d.clone(); + apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &keep).unwrap(); + assert_eq!(d, before, "still a live rectangle"); + apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "path": square(1.0)}]})).unwrap(), None, &keep).unwrap(); + assert!(matches!(&d.node(a).unwrap().kind, NodeKind::Path { live: None, .. }), "moved: a plain path"); +} + +/// An output updates the input objects it lists, deletes the ones it leaves out, and adds new +/// objects where it lists them. #[test] fn outputs_update_add_and_delete_in_place() { let (mut d, ids, layer) = doc(); @@ -67,7 +101,7 @@ fn outputs_update_add_and_delete_in_place() { let new = |name: &str| json!({"name": name, "path": square(5.0), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}}]}); // A recoloured, B dropped, C kept; new objects around them. let out = json!({"objects": [new("n1"), {"id": a, "fills": [null, {"type": "solid", "color": {"model": "rgb", "r": 5, "g": 0, "b": 0}}]}, new("n2"), {"id": c}, new("n3")]}); - let sel = apply_output(&mut d, &ids, decode_json(out).unwrap(), None).unwrap(); + let sel = apply_output(&mut d, &ids, decode_json(out).unwrap(), None, &keep).unwrap(); assert_eq!(names(&d, layer), ["n1", "A", "n2", "C", "n3", "?", "?"]); assert_eq!(sel.len(), 5); assert!(d.node(NodeId(b)).is_none()); @@ -79,18 +113,22 @@ fn outputs_update_add_and_delete_in_place() { assert!(n1.appearance.stroke().is_none()); } +/// New objects take the place of the input objects they replace, or go on top of the given +/// container when there were none. #[test] fn new_objects_take_the_place_of_replaced_inputs_or_go_on_top() { let (mut d, ids, layer) = doc(); let out = json!({"objects": [{"name": "X", "path": square(0.0)}]}); - apply_output(&mut d, &ids[1..], decode_json(out.clone()).unwrap(), None).unwrap(); + apply_output(&mut d, &ids[1..], decode_json(out.clone()).unwrap(), None, &keep).unwrap(); assert_eq!(names(&d, layer), ["A", "X", "?", "?"], "B and C replaced by X at B's place"); // No inputs (a generator): on top of the given container. let (mut d, _, layer) = doc(); - apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer)).unwrap(); + apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &keep).unwrap(); assert_eq!(names(&d, layer).last().map(String::as_str), Some("X")); } +/// An output turns a path into a compound path and back and sets its fill rule, strokes (weights +/// clamped), opacity and name. #[test] fn geometry_type_and_strokes_change() { let (mut d, ids, _) = doc(); @@ -98,7 +136,7 @@ fn geometry_type_and_strokes_change() { let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect()); let blue = json!({"type": "solid", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}}); let out = json!({"objects": [{"id": a.0, "type": "compound", "path": two, "fillRule": "evenodd", "strokes": [{"paint": blue, "width": 4000}], "opacity": 0.5, "name": ""}]}); - apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap(); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap(); let n = d.node(a).unwrap(); let NodeKind::Compound { children, rule } = &n.kind else { panic!("not a compound: {:?}", n.kind) }; assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd)); @@ -107,29 +145,32 @@ fn geometry_type_and_strokes_change() { assert_eq!(geometry(n).unwrap().0, two); // And back to a single path; strokes removed. let out = json!({"objects": [{"id": a.0, "type": "path", "strokes": []}]}); - apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap(); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &keep).unwrap(); let n = d.node(a).unwrap(); assert!(matches!(&n.kind, NodeKind::Path { path, .. } if *path == two)); assert!(n.appearance.stroke().is_none()); } +/// A paint handed back as it was keeps its swatch link and any other is unlinked; a pattern paint +/// must name a pattern the document has. #[test] fn changed_paints_lose_swatch_links_and_patterns_must_exist() { let (mut d, ids, _) = doc(); let a = ids[0]; d.node_mut(a).unwrap().appearance.set_fill(Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 }); let linked = json!({"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}, "swatch": "Red", "tint": 0.5}); - apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [linked]}]})).unwrap(), None).unwrap(); + apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [linked]}]})).unwrap(), None, &keep).unwrap(); assert!(matches!(d.node(a).unwrap().appearance.fill_paint(), Paint::Solid { swatch: Some(_), .. }), "unchanged: kept"); let other = json!({"type": "solid", "color": {"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}, "swatch": "Red", "tint": 0.5}); - apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [other]}]})).unwrap(), None).unwrap(); + apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [other]}]})).unwrap(), None, &keep).unwrap(); assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 1.0, 0.0))); let pattern = json!({"objects": [{"id": a.0, "fills": [{"type": "pattern", "pattern": "Dots"}]}]}); - assert!(apply_output(&mut d, &[a], decode_json(pattern.clone()).unwrap(), None).is_err()); + assert!(apply_output(&mut d, &[a], decode_json(pattern.clone()).unwrap(), None, &keep).is_err()); d.patterns.push(serde_json::from_value(json!({"name": "Dots", "tile": {"x0": 0, "y0": 0, "x1": 10, "y1": 10}, "art": []})).unwrap()); - assert!(apply_output(&mut d, &[a], decode_json(pattern).unwrap(), None).is_ok()); + assert!(apply_output(&mut d, &[a], decode_json(pattern).unwrap(), None, &keep).is_ok()); } +/// Output the host can't read as objects is refused, whatever is wrong with it. #[test] fn bad_outputs_are_refused() { let (d, ids, _) = doc(); @@ -153,8 +194,100 @@ fn bad_outputs_are_refused() { json!({"error": "nothing to do"}), ] { let mut d = d.clone(); - let r = decode_json(bad.clone()).and_then(|out| apply_output(&mut d, &ids, out, None)); + let r = decode_json(bad.clone()).and_then(|out| apply_output(&mut d, &ids, out, None, &keep)); assert!(r.is_err(), "{bad}"); } assert!(decode(b"\xff\xfe").is_err()); } + +/// Strokes come with their join, cap and miter limit and objects with their effect records; both go +/// back the same way, the records only as the host admits them. +#[test] +fn stroke_styles_and_effect_records_round_trip() { + let (mut d, ids, _) = doc(); + let a = ids[0]; + let roughen = Effect { id: "distort.roughen".into(), params: json!({"size": 5.0}), visible: true }; + d.node_mut(a).unwrap().appearance.effects.push(roughen.clone()); + let input: Value = serde_json::from_slice(&encode_input(&d, &[a])).unwrap(); + let (stroke, effects) = (&input["objects"][0]["strokes"][0], &input["objects"][0]["effects"]); + assert_eq!((&stroke["join"], &stroke["cap"], &stroke["miterLimit"]), (&json!("miter"), &json!("butt"), &json!(10.0))); + assert_eq!(*effects, json!([{"id": "distort.roughen", "params": {"size": 5.0}, "visible": true}])); + // Handed back unchanged, nothing changes; the host sees the records and what the object had. + let seen = std::cell::RefCell::new(vec![]); + let admit = |fx: &[Effect], n: &Node| -> std::result::Result<Vec<Effect>, String> { + seen.borrow_mut().push((fx.to_vec(), n.appearance.effects.clone())); + Ok(fx.to_vec()) + }; + let before = d.clone(); + apply_output(&mut d, &[a], decode(&serde_json::to_vec(&input).unwrap()).unwrap(), None, &admit).unwrap(); + assert_eq!(d, before); + assert_eq!(*seen.borrow(), [(vec![roughen.clone()], vec![roughen.clone()])]); + // A round join, a square cap, a miter limit past the range; the effects replaced. + let out = json!({"objects": [{"id": a.0, "strokes": [{"paint": null, "join": "round", "cap": "square", "miterLimit": 900}], + "effects": [{"id": "x.y", "params": {"k": 1}, "visible": false}, {"id": "z"}]}]}); + apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &admit).unwrap(); + let n = d.node(a).unwrap(); + let st = n.appearance.stroke().unwrap(); + assert_eq!((st.join, st.cap, st.miter_limit, st.width), (LineJoin::Round, LineCap::Square, *MITER_LIMIT.end(), 1.0), "the weight kept"); + let z = Effect { id: "z".into(), params: Value::Null, visible: true }; + assert_eq!(n.appearance.effects, [Effect { id: "x.y".into(), params: json!({"k": 1}), visible: false }, z]); + // A new object's stroke takes what it is given; the rest is a new stroke's default. + let out = json!({"objects": [{"path": square(0.0), "strokes": [{"paint": null, "cap": "round"}]}]}); + let layer = d.layers[0].id; + let new = apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer), &admit).unwrap(); + let st = d.node(new[0]).unwrap().appearance.stroke().unwrap().clone(); + assert_eq!((st.cap, st.join, st.width, st.miter_limit), (LineCap::Round, LineJoin::Miter, 1.0, 10.0)); + // What the host refuses changes nothing. + let before = d.clone(); + let refuse = |_: &[Effect], _: &Node| -> std::result::Result<Vec<Effect>, String> { Err("not an effect".into()) }; + let out = json!({"objects": [{"id": a.0, "effects": [{"id": "x.y"}]}]}); + let e = apply_output(&mut d, &[a], decode_json(out).unwrap(), None, &refuse).unwrap_err(); + assert_eq!((e.to_string().contains("objects[0].effects: not an effect"), &d), (true, &before), "{e}"); +} + +/// Stroke styles and effect records in a form the host doesn't read are refused. +#[test] +fn bad_stroke_styles_and_effect_records_are_refused() { + let a = doc().1[0].0; + for bad in [json!({"join": "pointy"}), json!({"cap": 3}), json!({"miterLimit": "x"}), json!({"width": "wide"})] { + let mut stroke = json!({"paint": null}); + stroke.as_object_mut().unwrap().extend(bad.as_object().unwrap().clone()); + assert!(decode_json(json!({"objects": [{"id": a, "strokes": [stroke]}]})).is_err(), "{bad}"); + } + let long = "x".repeat(MAX_EFFECT_ID + 1); + for bad in [ + json!(3), + json!([3]), + json!([{}]), + json!([{"id": ""}]), + json!([{"id": 5}]), + json!([{"id": long}]), + json!([{"id": "a\nb"}]), + json!([{"id": "a", "params": [1]}]), + json!([{"id": "a", "visible": "yes"}]), + ] { + assert!(decode_json(json!({"objects": [{"id": a, "effects": bad}]})).is_err(), "{bad}"); + } +} + +/// A filter's `documentData`: absent keeps what the plug-in keeps, `null` forgets it, any other JSON +/// of at most [`MAX_DOCUMENT_DATA`] bytes is kept, and more refuses the whole output. +#[test] +fn document_data_is_kept_forgotten_or_refused() { + let out = |data: Option<Value>| { + let mut v = json!({"objects": []}); + if let Some(data) = data { + v["documentData"] = data; + } + decode_output(v.to_string().as_bytes()).map(|o| o.document_data) + }; + assert_eq!(out(None).unwrap(), None); + assert_eq!(out(Some(Value::Null)).unwrap(), Some(None)); + assert_eq!(out(Some(json!({"pens": 2}))).unwrap(), Some(Some(json!({"pens": 2})))); + // A string's quotes count: this one is the limit exactly, and one more byte refuses it. + let at = json!("x".repeat(MAX_DOCUMENT_DATA - 2)); + assert_eq!(out(Some(at.clone())).unwrap(), Some(Some(at))); + assert!(matches!(out(Some(json!("x".repeat(MAX_DOCUMENT_DATA - 1)))), Err(Error::Output(_)))); + // A decline is still a decline. + assert!(matches!(decode_output(br#"{"error": "No pens.", "documentData": 1}"#), Err(Error::Failed(_)))); +} diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index fd165102c..caf63eb54 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -92,7 +92,8 @@ pub struct FolderReport { } /// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is -/// reported and skipped; the others still load. +/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]), +/// which installs only once the user agrees. #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> { load_folder_where(dir, |_| Ok(())) @@ -114,6 +115,10 @@ pub fn load_folder_where(dir: &std::path::Path, allow: impl Fn(&std::path::Path) continue; } match load_file(&f, Limits::default()) { + // A heavy plug-in installs once the user agrees, which loading a folder doesn't ask. + Ok(p) if p.manifest().budget == crate::Budget::Heavy => { + report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into())); + } Ok(p) => report.loaded.push(install(p).id().to_string()), Err(e) => report.failed.push((f.display().to_string(), e.to_string())), } diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 311d296ce..b70ebe84b 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -14,6 +14,20 @@ pub const ABI_VERSION: i32 = 1; /// Fuel handed out per slice; between slices the host checks the deadline. const FUEL_SLICE: u64 = 20_000_000; +/// Instructions a heavy filter's run may use besides [`Limits::fuel_per_byte`] for each byte of +/// input ([`crate::Budget::Heavy`]): 20 times a light one's. Its output's size can't add to it: +/// the host learns that only once the run has ended. +pub const HEAVY_FUEL: u64 = 1_000_000_000; + +/// Wall-clock budget of a heavy filter's run, in milliseconds (native builds). +pub const HEAVY_WALL_TIME_MS: u64 = 300_000; + +/// A heavy run's budget as people read it: (minutes it may take, times the instructions a light +/// run gets). +pub fn heavy_terms() -> (u64, u64) { + (HEAVY_WALL_TIME_MS / 60_000, HEAVY_FUEL / Limits::default().fuel_base.max(1)) +} + /// Resource limits for running plug-ins. #[derive(Clone, Debug, PartialEq)] pub struct Limits { @@ -156,6 +170,12 @@ impl Plugin { e => e, })?; p.manifest = Manifest::parse(&text)?; + // A heavy filter's runs get the larger budget, which the host lets the user allow or + // refuse before installing it. + if p.manifest.budget == crate::Budget::Heavy { + p.limits.fuel_base = p.limits.fuel_base.max(HEAVY_FUEL); + p.limits.wall_time_ms = p.limits.wall_time_ms.max(HEAVY_WALL_TIME_MS); + } // The other exports must exist with the right types. live.func::<i32, i32>("vc_alloc")?; live.func::<RunArgs, i64>("vc_run")?; diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 5118d9cd6..0201e1a50 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -36,11 +36,31 @@ pub const ECHO: &str = "(i64.or (i64.shl (i64.extend_i32_u (local.get $il)) (i64 /// A plug-in that always returns `output` (whatever it is given). pub fn returning(manifest: &str, output: &str) -> String { - let run = format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len()); + then_returning(manifest, "", output) +} + +/// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then +/// returns `output`. +fn then_returning(manifest: &str, first: &str, output: &str) -> String { + let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len()); let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output)); module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1) } +/// A plug-in that counts `steps` down before it returns `output`. Each step costs a few +/// instructions, so a test picks work that a light budget can't finish and a heavy one can, or +/// that runs until it is cancelled. +pub fn spinning(manifest: &str, steps: u64, output: &str) -> String { + let spin = format!( + "(local $n i64) (local.set $n (i64.const {steps})) + (block $done (loop $step + (br_if $done (i64.le_s (local.get $n) (i64.const 0))) + (local.set $n (i64.sub (local.get $n) (i64.const 1))) + (br $step)))" + ); + then_returning(manifest, &spin, output) +} + /// What [`when_given`] looks through. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Given { diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 5ddd8efb3..1ca9df87a 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; -use vectorcraft_plugins::{Error, Kind, Limits, Plugin, registry, wat as tpl}; +use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, heavy_terms, registry, wat as tpl}; /// The example Desaturate plug-in, built from `crates/plugins/example`. const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm"); @@ -179,6 +179,20 @@ fn cancelling_stops_a_run_between_two_slices_of_its_budget() { assert_eq!(e, Error::Limit("instruction budget".into())); } +/// A filter that asks for a heavy budget gets 20 times the instructions and five times the time of +/// a light one, so it finishes work a light one can't: here 100 million steps of a loop. The +/// budget is its own: what it returns can't add to it, since the host learns that only at the end. +#[test] +fn a_heavy_filter_finishes_what_a_light_one_cannot() { + let heavy = r#"{"id":"test.heavy","name":"Heavy","version":"1","kind":"filter","budget":"heavy"}"#; + let (light, heavy) = (build(&tpl::spinning(MANIFEST, 100_000_000, INPUT)), build(&tpl::spinning(heavy, 100_000_000, INPUT))); + assert_eq!(run_err(&light, Limits::default()).0, Error::Limit("instruction budget".into())); + let p = Plugin::load(&heavy, Limits::default()).unwrap(); + assert_eq!((p.limits().fuel_base, p.limits().wall_time_ms), (HEAVY_FUEL, HEAVY_WALL_TIME_MS)); + assert_eq!(heavy_terms(), (5, 20)); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); +} + #[test] fn an_exporter_returns_its_file_with_an_export_s_budget() { use vectorcraft_plugins::export::{self, Target}; @@ -267,3 +281,18 @@ fn folder_loading_skips_bad_files() { assert!(registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module"); std::fs::remove_dir_all(&dir).ok(); } + +/// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped +/// and reported. +#[test] +fn folder_loading_skips_heavy_filters() { + let dir = std::env::temp_dir().join(format!("vectorcraft-plugins-heavy-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let manifest = r#"{"id":"test.folder.heavy","name":"Heavy","kind":"filter","budget":"heavy"}"#; + std::fs::write(dir.join("heavy.wasm"), build(&tpl::spinning(manifest, 1, INPUT))).unwrap(); + let r = registry::load_folder(&dir).unwrap(); + assert!(r.loaded.is_empty()); + assert!(r.failed.len() == 1 && r.failed[0].1.contains("heavy budget"), "{:?}", r.failed); + assert!(registry::get("test.folder.heavy").is_none()); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 72dc14de0..e62e68418 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -168,6 +168,54 @@ mod tests { assert_eq!(again.pixels, img.pixels); } + /// 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] + fn object_opacity_fades_a_brushed_stroke_once() { + for (dashed, fill) in [(false, false), (true, false), (false, true), (true, true)] { + let node = |d: &mut Document, opacity: f32| { + let id = d.alloc_id(); + let fill = if fill { Paint::solid(Color::rgb(0.9, 0.1, 0.1)) } else { Paint::None }; + let mut ap = Appearance::basic(fill, Paint::solid(Color::BLACK), 1.0); + let st = ap.stroke_mut().unwrap(); + st.brush = Some("10 pt. Oval".into()); + if dashed { + st.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 4.0], ..Default::default() }); + } + let mut n = Node::path(id, shapes::rectangle(Rect::new(30.0, 30.0, 170.0, 70.0)), ap); + n.opacity = opacity; + n + }; + let mut faded = Document::new(200.0, 100.0); + let n = node(&mut faded, 0.5); + let l = faded.layers[0].id; + faded.insert(Some(l), 0, n).unwrap(); + let mut grouped = Document::new(200.0, 100.0); + let n = node(&mut grouped, 1.0); + let mut g = Node::group(grouped.alloc_id(), vec![std::sync::Arc::new(n)]); + g.opacity = 0.5; + let l = grouped.layers[0].id; + grouped.insert(Some(l), 0, g).unwrap(); + let (a, b) = (render(&mut Renderer::new(), &faded), render(&mut Renderer::new(), &grouped)); + assert!( + dark( + render(&mut Renderer::new(), &{ + let mut d = Document::new(200.0, 100.0); + let n = node(&mut d, 1.0); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + }) + .pixel(30, 50) + ), + "the brush paints the edge" + ); + let off = + a.pixels.chunks(4).zip(b.pixels.chunks(4)).filter(|(x, y)| x.iter().zip(*y).any(|(p, q)| (*p as i32 - *q as i32).abs() > 2)).count(); + assert_eq!(off, 0, "dashed {dashed}, fill {fill}: {off} pixels differ from the object faded as a whole"); + } + } + #[test] fn unknown_brush_falls_back_to_plain_stroke_and_scatter_renders() { let mut r = Renderer::new(); diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs new file mode 100644 index 000000000..8af4c07d9 --- /dev/null +++ b/crates/render/src/changed.rs @@ -0,0 +1,120 @@ +//! What draws differently after an edit, so a view can draw again only there. + +use std::sync::Arc; + +use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes}; +use vectorcraft_geom::{Affine, Rect}; + +/// What may draw differently in a document than in its version before an edit ([`changed`]). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Changed { + Nothing, + /// This area of the document, outside of which every pixel draws as before. + Area(Rect), + Everything, +} + +/// What may draw differently in `new` than in `old`, the version before an edit: the bounds of +/// everything the objects that changed paint, before and after the edit ([`crate::painted_bounds`]), +/// or everything when more than the art in the layers changed, or while a pattern is edited (its +/// art also draws in the copies round it). Objects that an edit doesn't touch +/// keep their identity, so this costs what changed, not the size of the document. +pub fn changed(old: &Document, new: &Document) -> Changed { + if new.pattern_edit.is_some() || !old.same_outside_layers(new) { + return Changed::Everything; + } + let mut diff = Diff { old: old.symbol_boxes(), new: new.symbol_boxes(), area: None }; + diff.children(&old.layers, &new.layers); + diff.area.map_or(Changed::Nothing, Changed::Area) +} + +/// The pixels of a `w`×`h` render through `view` that drawing `area` again covers, as x0, y0, x1, +/// y1: its box in the view, grown by a pixel for the anti-aliased edges. None when it covers none. +pub fn view_pixels(area: Rect, view: Affine, w: u32, h: u32) -> Option<[u32; 4]> { + let r = view.transform_rect_bbox(area).inflate(1.0, 1.0); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|c| c.is_finite()) { + return None; + } + let (x0, y0) = (r.x0.floor().max(0.0), r.y0.floor().max(0.0)); + let (x1, y1) = (r.x1.ceil().min(f64::from(w)), r.y1.ceil().min(f64::from(h))); + (x1 > x0 && y1 > y0).then_some([x0 as u32, y0 as u32, x1 as u32, y1 as u32]) +} + +/// [`changed`] in progress: the symbols' boxes of both versions, and the area so far. +struct Diff<'a> { + old: SymbolBoxes<'a>, + new: SymbolBoxes<'a>, + area: Option<Rect>, +} + +impl Diff<'_> { + /// Members in the same order compare one by one. Members added, removed or restacked count + /// before and after, except those that kept their place unchanged. + fn children(&mut self, old: &[Arc<Node>], new: &[Arc<Node>]) { + if old.len() == new.len() && old.iter().zip(new).all(|(a, b)| a.id == b.id) { + for (a, b) in old.iter().zip(new) { + self.node(a, b); + } + return; + } + for i in 0..old.len().max(new.len()) { + match (old.get(i), new.get(i)) { + (Some(a), Some(b)) if Arc::ptr_eq(a, b) => {} + (a, b) => { + self.add_old(a.map(|a| &**a)); + self.add_new(b.map(|b| &**b)); + } + } + } + } + + /// A group or layer that only its members changed in, without a clipping mask or effects of + /// its own that draw from all of them, changed where they did. Any other object that changed + /// counts whole, before and after. + fn node(&mut self, old: &Arc<Node>, new: &Arc<Node>) { + if Arc::ptr_eq(old, new) { + return; + } + if let (Some(a), Some(b)) = (members(old), members(new)) + && same_but_members(old, new) + { + self.children(a, b); + } else { + self.add_old(Some(old)); + self.add_new(Some(new)); + } + } + + fn add_old(&mut self, n: Option<&Node>) { + self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.old, n))); + } + + fn add_new(&mut self, n: Option<&Node>) { + self.add(n.and_then(|n| crate::brush_fx::cull_bounds(&self.new, n))); + } + + fn add(&mut self, r: Option<Rect>) { + self.area = vectorcraft_geom::union_opt(self.area, r); + } +} + +/// The members of a group or layer whose members draw each on their own: no clipping mask, and no +/// effects of its own. +fn members(n: &Node) -> Option<&[Arc<Node>]> { + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !crate::fx::has_object_fx(n) => Some(children), + _ => None, + } +} + +/// Are `a` and `b` the same but for their members? +fn same_but_members(a: &Node, b: &Node) -> bool { + let strip = |n: &Node| { + let mut n = n.clone(); + if let Some(children) = n.children_mut() { + children.clear(); + } + n + }; + strip(a) == strip(b) +} diff --git a/crates/render/src/dither.rs b/crates/render/src/dither.rs new file mode 100644 index 000000000..9d214616b --- /dev/null +++ b/crates/render/src/dither.rs @@ -0,0 +1,147 @@ +//! Dithered gradient fills in the renderer. +//! +//! A dithered linear or radial gradient is drawn as pixels: the gradient sampled on a grid over +//! the painted box with a cell per device pixel or finer (in quarter-octave steps, so a pan reuses +//! the grid and a zoom rarely misses), each colour channel given fine noise of up to a level +//! ([`vectorcraft_color::gradient::dither_noise`]) before it is rounded, and drawn as an image +//! paint that the fill clips. Past [`MAX_CELLS`] a side (a box larger than that on screen), cells +//! grow beyond a pixel and are mixed bilinearly, so the gradient stays smooth. Only the cells the +//! render target can show are sampled ([`window`]), so a large box mostly off-screen costs what +//! its visible part does. + +use std::cell::RefCell; +use std::hash::{Hash, Hasher}; +use std::ops::Range; +use std::sync::Arc; + +use vectorcraft_color::GradientPaint; +use vectorcraft_color::freeform::painted_box; +use vectorcraft_geom::{Affine, Rect}; +use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; +use vello_cpu::{Pixmap, RenderContext}; + +use crate::ink::Ink; +use crate::lru::{self, FrameCache}; + +/// Most cells along a side of a dithered gradient's grid (64 MiB for a square grid). +pub(crate) const MAX_CELLS: f64 = 4096.0; + +/// Cells along a side of the part of a grid that is sampled snap out to multiples of this +/// ([`window`]), so a pan within them keeps the sampled part. +const TILE: f64 = 256.0; + +thread_local! { + /// Grids by [`key`], within [`lru::DITHER_BUDGET`], least recently used first out. + static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::DITHER_BUDGET, usize::MAX)); +} + +/// Bytes of the grids this thread keeps, after `f` runs on an empty cache. +#[cfg(test)] +pub(crate) fn grid_bytes_after(f: impl FnOnce()) -> usize { + GRIDS.with(|c| c.borrow_mut().clear()); + f(); + GRIDS.with(|c| c.borrow().bytes()) +} + +/// Cells along a side `px` device pixels long: at least one per pixel, rounded up to a +/// quarter-octave step, 1 to [`MAX_CELLS`]. +fn cells(px: f64) -> u16 { + let steps = (4.0 * px.max(1.0).log2()).ceil(); + 2f64.powf(steps / 4.0).ceil().clamp(1.0, MAX_CELLS) as u16 +} + +/// Cache key of gradient `g` placed on box `b` as a `cols` × `rows` grid, cells `win` of it, +/// painted with `ink` (and the active colour settings, which turn colours into display RGB or +/// inks). +fn key(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: &[Range<u16>; 2], ink: Ink) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + let gr = &g.gradient; + (gr.kind as u8, gr.dither, gr.method as u8).hash(&mut h); + for s in &gr.stops { + (s.offset.to_bits(), s.opacity.to_bits(), s.midpoint.to_bits()).hash(&mut h); + s.color.key().hash(&mut h); + } + let geom = g.resolve(b); + (geom.start.x.to_bits(), geom.start.y.to_bits(), geom.end.x.to_bits(), geom.end.y.to_bits(), geom.aspect.to_bits()).hash(&mut h); + geom.focal.map(|f| (f.x.to_bits(), f.y.to_bits())).hash(&mut h); + (b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h); + win.hash(&mut h); + (Arc::as_ptr(&vectorcraft_color::cms::active()) as usize, ink).hash(&mut h); + h.finish() +} + +/// The cells of a `cols` × `rows` grid over box `b` that a `w` × `h` render target, which +/// transform `xf` maps the box onto, can show: (columns, rows), a cell more each way for the +/// bilinear mix, out to whole [`TILE`]s. The whole grid where `xf` has no inverse; None where +/// no cell shows. +fn window(xf: Affine, w: u16, h: u16, b: Rect, cols: u16, rows: u16) -> Option<[Range<u16>; 2]> { + let seen = xf.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(w), f64::from(h))); + let span = |lo: f64, hi: f64, start: f64, len: f64, n: u16| { + let cell = len / f64::from(n); + let (first, last) = (((lo - start) / cell).floor() - 1.0, ((hi - start) / cell).ceil() + 1.0); + if !(first.is_finite() && last.is_finite()) { + return Some(0..n); + } + let a = ((first / TILE).floor() * TILE).clamp(0.0, f64::from(n)); + let z = ((last / TILE).ceil() * TILE).clamp(0.0, f64::from(n)); + (a < z).then_some(a as u16..z as u16) + }; + Some([span(seen.x0, seen.x1, b.x0, b.width(), cols)?, span(seen.y0, seen.y1, b.y0, b.height(), rows)?]) +} + +/// Cells `win` (columns, rows) of `g` placed on `b` as a `cols` × `rows` grid, as a +/// premultiplied pixmap, colours as `ink` paints them. +pub(crate) fn rasterize(g: &GradientPaint, b: Rect, cols: u16, rows: u16, win: [Range<u16>; 2], ink: Ink) -> Pixmap { + let (w, h) = (win[0].len() as u16, win[1].len() as u16); + let data = g + .pixels_within(b, b, cols, rows, win, true, &|c| ink.rgb(c)) + .map(|[r, gr, bl, a]| { + let pm = |v: u8| ((u16::from(v) * u16::from(a) + 127) / 255) as u8; + vello_cpu::color::PremulRgba8 { r: pm(r), g: pm(gr), b: pm(bl), a } + }) + .collect(); + Pixmap::from_parts(data, w, h) +} + +/// Set dithered gradient `g` on box `bounds` (in the paint's space, which the context's current +/// transform maps to pixels) as the context paint. Returns false for an empty box or one off the +/// render target. +pub(crate) fn set_dithered_paint(ctx: &mut RenderContext, g: &GradientPaint, bounds: Rect, ink: Ink) -> bool { + let Some(b) = painted_box(bounds) else { return false }; + // Device pixels per unit along the box's sides. + let [m0, m1, m2, m3, _, _] = ctx.transform().as_coeffs(); + let (sx, sy) = (m0.hypot(m1), m2.hypot(m3)); + let (wpx, hpx) = (b.width() * sx, b.height() * sy); + if !(wpx.is_finite() && hpx.is_finite()) { + return false; + } + let (cols, rows) = (cells(wpx), cells(hpx)); + let Some(win) = window(*ctx.transform(), ctx.width(), ctx.height(), b, cols, rows) else { return false }; + let (first_col, first_row) = (win[0].start, win[1].start); + let k = key(g, b, cols, rows, &win, ink); + let pm = GRIDS + .with(|c| { + let mut c = c.borrow_mut(); + let t = c.tick(); + c.get(&k, t).cloned() + }) + .unwrap_or_else(|| { + let pm = Arc::new(rasterize(g, b, cols, rows, win, ink)); + GRIDS.with(|c| { + let mut c = c.borrow_mut(); + let t = c.tick(); + c.insert(k, pm.clone(), usize::from(pm.width()) * usize::from(pm.height()) * 4, t); + }); + pm + }); + // A cell per pixel or finer: each pixel takes one cell, its noise intact. Coarser (a box past + // the grid's largest size on screen): mixed bilinearly, so no cell shows as a block. + let fine = f64::from(cols) >= wpx && f64::from(rows) >= hpx; + let quality = if fine { ImageQuality::Low } else { ImageQuality::Medium }; + let sampler = ImageSampler { x_extend: Extend::Pad, y_extend: Extend::Pad, quality, alpha: 1.0 }; + ctx.set_paint(vello_cpu::Image { image: vello_cpu::ImageSource::Pixmap(pm), sampler }); + let (cw, ch) = (b.width() / f64::from(cols), b.height() / f64::from(rows)); + let origin = (b.x0 + f64::from(first_col) * cw, b.y0 + f64::from(first_row) * ch); + ctx.set_paint_transform(Affine::translate(origin) * Affine::scale_non_uniform(cw, ch)); + true +} diff --git a/crates/render/src/encode/quantize.rs b/crates/render/src/encode/quantize.rs index 9c5061f1d..ec04c9f46 100644 --- a/crates/render/src/encode/quantize.rs +++ b/crates/render/src/encode/quantize.rs @@ -197,7 +197,9 @@ pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, } let weights = o.reduction.weights(); let dither = if exact { Dither::None } else { o.dither }; - let mut indices = map(&colors, width as usize, &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); + // (A width past the pixels there are would size the error rows from a number nothing checked.) + let mut indices = + map(&colors, (width as usize).min(colors.len()), &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i])); // Drop the entries no pixel uses (fixed palettes), keeping the order. let mut used = [false; 256]; for (i, &ix) in indices.iter().enumerate() { diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs index ae74160c9..03f2fcc1c 100644 --- a/crates/render/src/encode/tests.rs +++ b/crates/render/src/encode/tests.rs @@ -264,6 +264,7 @@ fn art_bounds_take_in_the_effects_of_groups_type_and_images() { xf: vectorcraft_geom::Affine::translate((20.0, 50.0)), link: None, placement: Default::default(), + pixels: None, }; let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); let cut_off: Vec<_> = [("group", group), ("type", text), ("image", image)] diff --git a/crates/render/src/encode/tga.rs b/crates/render/src/encode/tga.rs index 884276eec..abac01dba 100644 --- a/crates/render/src/encode/tga.rs +++ b/crates/render/src/encode/tga.rs @@ -34,11 +34,11 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &TgaOptions) -> Result<Ve return Err(format!("{width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution")); }; let (w, h) = (usize::from(w16), usize::from(h16)); - if w == 0 || h == 0 || w * h * 4 != rgba.len() { + if w == 0 || h == 0 || (w * h).checked_mul(4) != Some(rgba.len()) { return Err("Targa encoding failed: the pixel buffer doesn't match the image size".into()); } let px = rgba.as_chunks::<4>().0; - let mut out = Vec::with_capacity(18 + w * h * usize::from(o.depth / 8) + 26); + let mut out = Vec::with_capacity((w * h).saturating_mul(usize::from(o.depth / 8)).saturating_add(18 + 26)); // No image id or colour map, uncompressed true colour, origin (0, 0). out.extend([0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]); out.extend(w16.to_le_bytes()); diff --git a/crates/render/src/freeform.rs b/crates/render/src/freeform.rs index ece0a0fa4..05a145156 100644 --- a/crates/render/src/freeform.rs +++ b/crates/render/src/freeform.rs @@ -11,7 +11,7 @@ use std::hash::{Hash, Hasher}; use std::sync::Arc; use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; -use vectorcraft_color::{Color, Freeform, GradientPaint}; +use vectorcraft_color::{Freeform, GradientPaint}; use vectorcraft_geom::{Affine, Rect}; use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; @@ -24,22 +24,13 @@ thread_local! { static GRIDS: RefCell<FrameCache<u64, Arc<Pixmap>>> = RefCell::new(FrameCache::new(lru::FREEFORM_BUDGET, usize::MAX)); } -fn hash_color(c: &Color, h: &mut impl Hasher) { - match *c { - Color::Rgb { r, g, b } => (0u8, r.to_bits(), g.to_bits(), b.to_bits()).hash(h), - Color::Cmyk { c, m, y, k } => (1u8, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()).hash(h), - Color::Gray { k } => (2u8, k.to_bits()).hash(h), - Color::Lab { l, a, b } => (3u8, l.to_bits(), a.to_bits(), b.to_bits()).hash(h), - } -} - /// Cache key of the grid of `f` on box `b` at `cols` × `rows` painted with `ink` (and the active /// colour settings, which turn colours into display RGB or inks). fn key(f: &Freeform, b: Rect, cols: u16, rows: u16, ink: Ink) -> u64 { let mut h = std::collections::hash_map::DefaultHasher::new(); for p in &f.points { (p.at.x.to_bits(), p.at.y.to_bits(), p.opacity.to_bits(), p.spread.to_bits()).hash(&mut h); - hash_color(&p.color, &mut h); + p.color.key().hash(&mut h); } f.lines.hash(&mut h); (b.x0.to_bits(), b.y0.to_bits(), b.x1.to_bits(), b.y1.to_bits(), cols, rows).hash(&mut h); diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index cc95cb9f6..cb314ff61 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -81,12 +81,12 @@ pub(crate) fn has_fx(n: &Node) -> bool { /// symbol instances, live objects; Crop Marks and colour adjustments on anything), is it type with /// a geometry effect on a fill or stroke of its own or an image with a fill or stroke of its own /// that paints a shape ([`effects::has_item_geometry`]), is it a path whose Transform effect copies -/// it with a gradient ([`effects::has_gradient_copies`]), or is it a group or layer with an -/// appearance of its own (see [`Renderer::draw_object_fx`])? +/// it with a gradient or changes its fills and strokes ([`effects::has_transform_art`]), or is it a +/// group or layer with an appearance of its own (see [`Renderer::draw_object_fx`])? pub(crate) fn has_object_fx(n: &Node) -> bool { (effects::needs_outline(n) && (visible(&n.appearance.effects) || n.projection().is_some())) || effects::has_item_geometry(n) - || effects::has_gradient_copies(n) + || effects::has_transform_art(n) || effects::has_container_appearance(n) || effects::has_crop_marks(n) || effects::has_adjustment(n) @@ -316,7 +316,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> { }; match &n.kind { _ if effects::has_container_appearance(n) => { - let art = effects::evaluate_container(n); + let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); if art.clips() { art.visual_bounds_with(symbols) } else { union(art) } } @@ -350,7 +350,7 @@ fn shape_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> { NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, }; - let reshaped = effects::reshape(n, symbol.as_deref()); + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); let b = match &reshaped { Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, painted_reach(symbols, c))) @@ -430,7 +430,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio _ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)), // The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach. _ if effects::has_container_appearance(n) => { - let art = effects::evaluate_container(n); + let art = effects::evaluate_container(Some(symbols.document()), n); let art = art.as_ref().unwrap_or(n); let b = if art.clips() { art.visual_bounds_with(symbols) @@ -453,7 +453,7 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio NodeKind::SymbolInstance { symbol, .. } => symbols.document().symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), _ => None, }; - let reshaped = effects::reshape(n, symbol.as_deref()); + let reshaped = effects::reshape(Some(symbols.document()), n, symbol.as_deref()); let b = match &reshaped { Some(r) if matches!(n.kind, NodeKind::Text(_)) || (matches!(n.kind, NodeKind::Image(_)) && !r.clips()) => { r.children().into_iter().flatten().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, inner(c))) @@ -478,12 +478,26 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio } _ => { let b = n.visual_bounds_with(symbols)?; + let b = match &n.kind { + NodeKind::Text(t) => vectorcraft_geom::union_opt(Some(b), glyphs_box(t, n.appearance.outset())).unwrap_or(b), + _ => b, + }; let a = live_content_reach(symbols, n); Some(b.inflate(a, a)) } } } +/// The box of type `t`'s glyphs as laid out (its layout bounds, else laid out now), grown by its +/// characters' strokes, miter spikes included, and by `outset`: on a path they stand above it and +/// run past its ends, in an area they can hang past its frame, where its own box +/// ([`Node::visual_bounds_with`]) doesn't. +fn glyphs_box(t: &vectorcraft_doc::TextObject, outset: f64) -> Option<Rect> { + let b = t.cached_bounds.unwrap_or_else(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds); + let o = outset.max(t.char_stroke_reach()); + (b.width() > 0.0 || b.height() > 0.0).then(|| t.xf.transform_rect_bbox(b).inflate(o, o)) +} + /// How far the content of live object `n` (a blend, envelope or repeat) can paint past its visual /// bounds: for each object in it, how far the effects in a symbol instance's art paint past that /// art ([`art_effects_reach`]) and how far brush art can paint ([`crate::brush_fx::pad`]), on the @@ -643,7 +657,7 @@ pub(crate) struct PixelEntry { /// For one cropped to the view, or made at a lower resolution, the view and the rect it was /// made for: it draws only there, so dragging other art over the object keeps it (a wide blur, /// or any blur zoomed in, reaches past the pixel budget). - view: Option<ViewKey>, + view: Option<ViewCrop>, made: Made, /// What the content read when the raster was made, with the channels the effects ran on and, /// under De-Interlace, the raster resolution. @@ -672,13 +686,29 @@ fn linear_key(view: Affine) -> [u64; 4] { [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] } -/// A view's six coefficients (its pan included) and the device-pixel rect a frame draws in, as a -/// cache key: a raster cropped to that rect, or made at a lower resolution than the view's, draws -/// only in that view of that rect ([`PixelEntry::view`], [`ShadowEntry::view`]). -type ViewKey = ([u64; 6], [u64; 4]); +/// The pan of the view a raster was cropped for, or made at a lower resolution than the view's, and +/// the device-pixel rect of the frame it was made in ([`PixelEntry::view`], [`ShadowEntry::view`]). +#[derive(Clone, Copy, Debug)] +struct ViewCrop { + pan: Vec2, + screen: Rect, +} -fn view_key(view: Affine, screen: Rect) -> ViewKey { - (view.as_coeffs().map(f64::to_bits), [screen.x0, screen.y0, screen.x1, screen.y1].map(f64::to_bits)) +impl ViewCrop { + fn of(view: Affine, screen: Rect) -> Self { + Self { pan: view.translation(), screen } + } + + /// Does the raster draw in a frame through `view` (of the same zoom and rotation) onto + /// `screen`? When the view is panned by whole pixels and the frame's rect, panned back, lies + /// within the one the raster was made in: the same pixels in the same places, as when a frame + /// is drawn again only in a part of it. + fn serves(&self, view: Affine, screen: Rect) -> bool { + let d = view.translation() - self.pan; + let whole = Vec2::new(d.x.round(), d.y.round()); + let s = screen - whole; + (d - whole).hypot() < 1e-6 && s.x0 >= self.screen.x0 && s.y0 >= self.screen.y0 && s.x1 <= self.screen.x1 && s.y1 <= self.screen.y1 + } } /// Most pixels a raster of [`Renderer::pixel_content`] or [`Renderer::with_filters`] holds while @@ -768,7 +798,7 @@ pub(crate) struct ShadowEntry { /// `None` for a raster of the whole silhouette's reach: it draws after a pan too. For one /// cropped to the view, or made over such a raster, the view and the rect it was made for: it /// draws only there ([`PixelEntry::view`]). - view: Option<ViewKey>, + view: Option<ViewCrop>, made: Made, /// What the silhouette read when the raster was made. inputs: Inputs, @@ -1084,9 +1114,9 @@ impl Renderer { // What the silhouette reads: as the raster kept for `n` found it, else found now. let reads = self.shadows.get(&key).filter(|e| e.node.is(n)).map_or_else(|| Reads::of(n), |e| e.inputs.reads); let inputs = self.raster_inputs(f, n, reads); - let in_view = view_key(f.view, screen); + let in_view = ViewCrop::of(f.view, screen); let exact = |e: &ShadowEntry| { - e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v == in_view) + e.linear == linear && e.node.is(n) && e.inputs == inputs && (interacting || !e.moved) && e.view.is_none_or(|v| v.serves(f.view, screen)) }; if interacting && !self.shadows.get(&key).is_some_and(exact) { self.move_shadow(f, n, key, fx, reach + Vec2::new(dx, dy), owner, &inputs, paint); @@ -1453,14 +1483,14 @@ impl Renderer { let art = match effects::crop_marks_art(a) .or_else(|| effects::revolve_art(a)) .or_else(|| effects::solid_art(a)) - .or_else(|| effects::reshape(a, symbol.as_ref())) + .or_else(|| effects::reshape(Some(doc), a, symbol.as_ref())) { Some(r) => r, - None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()), + None if container => effects::evaluate_container(Some(doc), a).unwrap_or_else(|| (**a).clone()), None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(), - None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), + None => effects::outline_art(Some(doc), a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; - let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art }; + let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art }; let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); @@ -1784,7 +1814,7 @@ impl Renderer { // The cached raster: of the whole content at full size at this zoom, else of this view. let linear = linear_key(f.view); let whole = fits && k >= 1.0; - let in_view = (!whole).then(|| view_key(f.view, screen)); + let in_view = (!whole).then(|| ViewCrop::of(f.view, screen)); let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink)); let stamp = self.stamp; let channels = match f.ink { @@ -1803,8 +1833,12 @@ impl Renderer { self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) }); let interacting = f.opts.interacting; + let in_place = |e: &PixelEntry| match e.view { + None => whole, + Some(v) => !whole && v.serves(f.view, screen), + }; let exact = |e: &PixelEntry| { - e.linear == linear && e.view == in_view && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) + e.linear == linear && in_place(e) && e.node.is(content.node) && Some(&e.inputs) == inputs.as_ref() && (interacting || !e.moved) }; if interacting && whole @@ -2065,6 +2099,8 @@ impl Renderer { let doc = self.doc_inputs(f.doc); let mut h = DefaultHasher::new(); (doc.cms, f.opts.anti_alias.threshold(), self.knockout, f.doc.raster_effects_ppi.to_bits()).hash(&mut h); + // Cached effects on containers can include paths made into guides. + f.opts.hide_guides.hash(&mut h); if let Some((isolated, path)) = self.isolation_path(f) && n.id != isolated && path.contains(&n.id) @@ -2928,8 +2964,15 @@ mod tests { let mut d = Document::new(100.0, 100.0); // A PNG signature with nothing after it. d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = - ImageObject { key: "cut".into(), width: 40, height: 40, xf: Affine::translate((30.0, 30.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "cut".into(), + width: 40, + height: 40, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); n.appearance.effects = vec![Effect { id: "blur.gaussian".into(), params: json!({"radius": 4.0}), visible: true }]; d.insert(Some(d.layers[0].id), 0, n).unwrap(); @@ -2953,8 +2996,15 @@ mod tests { let mut d = Document::new(100.0, 100.0); // A PNG signature with nothing after it. d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = - ImageObject { key: "cut".into(), width: 40, height: 40, xf: Affine::translate((30.0, 30.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "cut".into(), + width: 40, + height: 40, + xf: Affine::translate((30.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); n.appearance.effects = [ ("rasterize", json!({"background": "white"})), diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index c7f7b5acd..4383d515a 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -19,8 +19,9 @@ pub(crate) struct Composite<'a> { pub clip: Option<(&'a BezPath, FillRule)>, pub blend: Blend, pub opacity: f32, - /// Opacity mask (the context's size). - pub mask: Option<vello_cpu::Mask>, + /// Opacity mask: the coverage of each pixel (row-major) of the region `bounds` covers + /// ([`area`]). A masked group is drawn offscreen over that region only. + pub mask: Option<Vec<u8>>, /// The content starts from transparency instead of from the art below. pub isolated: bool, /// Blending inside reaches the group's backdrop ([`Node::blends_through`]). @@ -41,11 +42,11 @@ pub(crate) type Content<'d> = dyn FnMut(&mut Renderer, &mut RenderContext, &Fram /// A pixel region of a context: origin and size. #[derive(Clone, Copy)] -struct Region { - x: u16, - y: u16, - w: u16, - h: u16, +pub(crate) struct Region { + pub x: u16, + pub y: u16, + pub w: u16, + pub h: u16, } impl Renderer { @@ -73,13 +74,23 @@ impl Renderer { return; } if c.blends && (c.isolated || self.nested == 0) { - return self.group_offscreen(ctx, f, c, draw); + let Some(r) = region(ctx, f, c.bounds) else { return }; + // Past the budget for offscreen pixels, the group is drawn as a layer (isolated). + // A masked group is drawn offscreen whatever the budget: its mask scales its pixels. + if c.mask.is_some() || self.offscreen_fits(ctx, r, c.isolated) { + return self.group_offscreen(ctx, f, c, r, draw); + } + } + if c.mask.is_some() { + // Its backdrop out of reach (or not needed), a masked group starts from transparency. + let Some(r) = region(ctx, f, c.bounds) else { return }; + return self.group_offscreen(ctx, f, Composite { isolated: true, ..c }, r, draw); } ctx.set_transform(f.view); if let Some((_, rule)) = c.clip { ctx.set_fill_rule(fill_rule(rule)); } - ctx.push_layer(c.clip.map(|(p, _)| p), Some(blend_mode(c.blend)), Some(c.opacity), c.mask, None); + ctx.push_layer(c.clip.map(|(p, _)| p), Some(blend_mode(c.blend)), Some(c.opacity), None, None); self.nested += 1; // The layer starts from transparency: no backdrop for the content. let backdrop = self.backdrop.take(); @@ -89,47 +100,69 @@ impl Renderer { ctx.pop_layer(); } - fn group_offscreen(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, draw: &mut Content) { - let Composite { clip, blend, opacity, mask, isolated, bounds, .. } = c; - let Some(r) = region(ctx, f, bounds) else { return }; - let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view; - let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64)); - let sub = Frame { mt: false, view, visible, ..*f }; + /// Whether a group drawn offscreen over region `r` keeps the offscreen pixels alive within + /// [`OFFSCREEN_FRAMES`] times the frame `ctx` (the frame's own context when none is alive). + fn offscreen_fits(&self, ctx: &RenderContext, r: Region, isolated: bool) -> bool { + let budget = + if self.offscreen_live == 0 { OFFSCREEN_FRAMES * u64::from(ctx.width()) * u64::from(ctx.height()) } else { self.offscreen_budget }; + self.offscreen_live.saturating_add(offscreen_cost(r, isolated)) <= budget + } + + fn group_offscreen(&mut self, ctx: &mut RenderContext, f: &Frame, c: Composite, r: Region, draw: &mut Content) { + let Composite { clip, blend, opacity, mask, isolated, .. } = c; + if self.offscreen_live == 0 { + self.offscreen_budget = OFFSCREEN_FRAMES * u64::from(ctx.width()) * u64::from(ctx.height()); + } + let cost = offscreen_cost(r, isolated); + self.offscreen_live = self.offscreen_live.saturating_add(cost); + let sub = frame_over(f, r); let backdrop = (!isolated).then(|| self.flatten(ctx, r)); let content = self.offscreen(r, &sub, backdrop.clone(), draw); let at = (r.x as f64, r.y as f64); - let push = |ctx: &mut RenderContext, mode: BlendMode, mask: Option<vello_cpu::Mask>| { + let push = |ctx: &mut RenderContext, mode: BlendMode| { ctx.set_transform(f.view); if let Some((_, rule)) = clip { ctx.set_fill_rule(fill_rule(rule)); } - ctx.push_layer(clip.map(|(p, _)| p), Some(mode), Some(opacity), mask, None); + ctx.push_layer(clip.map(|(p, _)| p), Some(mode), Some(opacity), None, None); + }; + // The mask scales what the group draws, as a layer mask does. + let masked = |pm: Pixmap| match &mask { + Some(m) => apply_mask(pm, m), + None => pm, }; match backdrop { // The backdrop under the content, mixed with it by opacity, mask and clip: take that // share of the backdrop out, then add the same share of the content. Some(_) if blend == Blend::Normal => { - push(ctx, BlendMode::new(Mix::Normal, Compose::DestOut), mask.clone()); - ctx.set_transform(Affine::IDENTITY); - ctx.set_paint(peniko::Color::BLACK); - ctx.fill_rect(&Rect::new(at.0, at.1, at.0 + r.w as f64, at.1 + r.h as f64)); + push(ctx, BlendMode::new(Mix::Normal, Compose::DestOut)); + if mask.is_some() { + let mut black = Pixmap::new(r.w, r.h); + black.data_mut().iter_mut().for_each(|p| p.a = 255); + draw_pixmap(ctx, Arc::new(masked(black)), at); + } else { + ctx.set_transform(Affine::IDENTITY); + ctx.set_paint(peniko::Color::BLACK); + ctx.fill_rect(&Rect::new(at.0, at.1, at.0 + r.w as f64, at.1 + r.h as f64)); + } ctx.pop_layer(); - push(ctx, BlendMode::new(Mix::Normal, Compose::Plus), mask); - draw_pixmap(ctx, Arc::new(content), at); + push(ctx, BlendMode::new(Mix::Normal, Compose::Plus)); + draw_pixmap(ctx, Arc::new(masked(content)), at); } // A blend mode applies to the group as an object of its own: take the backdrop's share // out of the content (it needs the content's own coverage), then blend the rest. Some(b) => { let alone = self.offscreen(r, &sub, None, draw); - push(ctx, blend_mode(blend), mask); - draw_pixmap(ctx, Arc::new(remove_backdrop(content, &alone, &b)), at); + push(ctx, blend_mode(blend)); + draw_pixmap(ctx, Arc::new(masked(remove_backdrop(content, &alone, &b))), at); } None => { - push(ctx, blend_mode(blend), mask); - draw_pixmap(ctx, Arc::new(content), at); + push(ctx, blend_mode(blend)); + draw_pixmap(ctx, Arc::new(masked(content)), at); } } ctx.pop_layer(); + self.offscreen_live = self.offscreen_live.saturating_sub(cost); } /// Render what `ctx` holds so far, and start it again from that picture (so later drawing sees @@ -288,6 +321,16 @@ thread_local! { pub(crate) static PIXELS_DRAWN: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; } +/// The offscreen pixels that groups drawn offscreen may keep alive at once, in frames: deeper +/// nested blending groups are drawn as layers instead (#3581). +const OFFSCREEN_FRAMES: u64 = 4; + +/// The pixels a group drawn offscreen over `r` keeps alive: its content, and when it isn't +/// isolated the backdrop copy and the content drawn alone. +fn offscreen_cost(r: Region, isolated: bool) -> u64 { + u64::from(r.w) * u64::from(r.h) * if isolated { 1 } else { 3 } +} + /// The pixel region of `ctx` that `bounds` (document space) covers, with a margin for /// antialiasing; `None` when it is off the context. fn region(ctx: &RenderContext, f: &Frame, bounds: Option<Rect>) -> Option<Region> { @@ -300,6 +343,29 @@ fn region(ctx: &RenderContext, f: &Frame, bounds: Option<Rect>) -> Option<Region (x1 > x0 && y1 > y0).then_some(Region { x: x0 as u16, y: y0 as u16, w: (x1 - x0) as u16, h: (y1 - y0) as u16 }) } +/// The pixel region of `ctx` that `bounds` covers ([`region`]), and the frame that draws into a +/// context of that region's size. +pub(crate) fn area<'a>(ctx: &RenderContext, f: &Frame<'a>, bounds: Option<Rect>) -> Option<(Region, Frame<'a>)> { + let r = region(ctx, f, bounds)?; + Some((r, frame_over(f, r))) +} + +/// Frame `f` drawing into a context of region `r`'s size (on the calling thread). +fn frame_over<'a>(f: &Frame<'a>, r: Region) -> Frame<'a> { + let view = Affine::translate((-(r.x as f64), -(r.y as f64))) * f.view; + let visible = view.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, r.w as f64, r.h as f64)); + Frame { mt: false, view, visible, ..*f } +} + +/// `pm` (premultiplied) with each pixel scaled by its coverage in `mask` (row-major, `pm`'s size). +fn apply_mask(mut pm: Pixmap, mask: &[u8]) -> Pixmap { + let scale = |v: u8, m: u8| ((v as u32 * m as u32 + 127) / 255) as u8; + for (p, &m) in pm.data_mut().iter_mut().zip(mask) { + (p.r, p.g, p.b, p.a) = (scale(p.r, m), scale(p.g, m), scale(p.b, m), scale(p.a, m)); + } + pm +} + /// The pixels of region `r` of `pm`. fn cropped(pm: &Pixmap, r: Region) -> Pixmap { let mut out = Pixmap::new(r.w, r.h); diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 4c41f71ca..602711329 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -66,11 +66,13 @@ pub(crate) struct ImageKey { pub mip: u8, } -/// A blob's content key and whether it holds CMYK inks, worked out once per blob allocation. +/// A blob's content key and whether it holds CMYK inks, worked out once per blob allocation, and +/// the size of its pixels once they have been decoded ([`remember_size`]). #[derive(Clone)] pub(crate) struct BlobId { pub content: Arc<str>, pub cmyk: bool, + pub size: Option<(u16, u16)>, } struct Entry { @@ -114,6 +116,14 @@ impl ImageStore { self.entries.get(key).map(|e| e.pm.clone()) } + /// Drop `key`'s pixels, as the budget drops the least recently used. + #[cfg(test)] + pub(crate) fn remove(&mut self, key: &ImageKey) { + if let Some(e) = self.entries.remove(key) { + self.bytes = self.bytes.saturating_sub(pixmap_bytes(&e.pm)); + } + } + /// Keep `pm` as `key` (or, when another renderer made it meanwhile, return theirs), then drop /// entries over budget. pub(crate) fn insert(&mut self, key: ImageKey, pm: Arc<Pixmap>) -> Arc<Pixmap> { @@ -167,6 +177,15 @@ impl ImageStore { } self.ids.insert(Arc::as_ptr(bytes) as usize, (Arc::downgrade(bytes), id)); } + + fn remember_size(&mut self, bytes: &Arc<Vec<u8>>, size: (u16, u16)) { + let at = Arc::as_ptr(bytes); + if let Some((w, id)) = self.ids.get_mut(&(at as usize)) + && std::ptr::eq(w.as_ptr(), at) + { + id.size = Some(size); + } + } } static STORE: LazyLock<Mutex<ImageStore>> = LazyLock::new(|| Mutex::new(ImageStore::new(default_budget()))); @@ -193,11 +212,17 @@ pub(crate) fn blob_id(blob: &ImageBlob) -> BlobId { return id; } // Outside the lock: hashing a large image takes a while. - let id = BlobId { content: format!("{}:{}", blob.content_key(), blob.bytes.len()).into(), cmyk: blob.is_cmyk() }; + let id = BlobId { content: format!("{}:{}", blob.content_key(), blob.bytes.len()).into(), cmyk: blob.is_cmyk(), size: None }; store().remember_id(&blob.bytes, id.clone()); id } +/// Note that `blob`'s pixels are `size`: [`blob_id`] tells it from then on, so that the mips made +/// of them are found while the store holds the mips but not the pixels. +pub(crate) fn remember_size(blob: &ImageBlob, size: (u16, u16)) { + store().remember_size(&blob.bytes, size); +} + /// `pm` at half the size (rounded up), each pixel the mean of the 2×2 it covers (the last row and /// column repeat on odd sizes). pub(crate) fn half(pm: &Pixmap) -> Pixmap { diff --git a/crates/render/src/ink.rs b/crates/render/src/ink.rs index 64e6f45db..1dd894b4a 100644 --- a/crates/render/src/ink.rs +++ b/crates/render/src/ink.rs @@ -121,12 +121,12 @@ impl Renderer { /// The inks of `c` in the working CMYK space: CMYK as it is, grey as black ink, RGB and Lab /// separated with the colour settings' intent (cached: separating searches the profile). fn inks(c: &Color) -> [f32; 4] { - let key = match *c { + match *c { Color::Cmyk { c, m, y, k } => return [c, m, y, k], Color::Gray { k } => return [0.0, 0.0, 0.0, k], - Color::Rgb { r, g, b } => [0, r.to_bits(), g.to_bits(), b.to_bits()], - Color::Lab { l, a, b } => [1, l.to_bits(), a.to_bits(), b.to_bits()], - }; + Color::Rgb { .. } | Color::Lab { .. } => {} + } + let key = c.key(); let cms = cms::active(); SEPARATED.with(|s| { let (owner, seen) = &mut *s.borrow_mut(); @@ -141,7 +141,7 @@ fn inks(c: &Color) -> [f32; 4] { } /// Separated colours, for the colour settings they were separated with. -type Separated = (Option<Arc<Cms>>, HashMap<[u32; 4], [f32; 4]>); +type Separated = (Option<Arc<Cms>>, HashMap<[u32; 5], [f32; 4]>); thread_local! { static SEPARATED: RefCell<Separated> = RefCell::default(); @@ -311,18 +311,33 @@ pub(crate) fn cmyk_pixmap(blob: &ImageBlob, lut: &InkLut) -> Option<Pixmap> { inks_pixmap(&blob.cmyk()?, lut) } +/// The separation tables of placed images ([`Ink::Cmy`], [`Ink::K`]) and their generation. +fn separation() -> (Arc<[ProofLut; 2]>, u64) { + cached(&SEPARATE, |cms| { + let intent = cms.settings().intent; + [Ink::Cmy, Ink::K].map(|ink| ProofLut::build(|rgb| ink.plane(cms.srgb_to_cmyk(rgb, intent)))) + }) +} + +/// The key of image pixels `key` painted as `plane` with the separation tables of `generation`. +fn plane_key(key: &ImageKey, plane: Ink, generation: u64) -> ImageKey { + ImageKey { ink: plane, luts: (key.luts.0, generation), ..key.clone() } +} + +/// The key [`ink_image`] gives image pixels `key` painted with `ink`. +pub(crate) fn ink_key(key: &ImageKey, ink: Ink) -> ImageKey { + if matches!(ink, Ink::Display | Ink::RichBlack) { key.clone() } else { plane_key(key, ink, separation().1) } +} + /// Image pixels `pm` (cached as `key`, from `blob` when it is a document's image as it is) as /// painted with `ink`, cached with the images, and their key. pub(crate) fn ink_image(key: &ImageKey, pm: &Arc<Pixmap>, ink: Ink, blob: Option<&ImageBlob>, touch: bool) -> (ImageKey, Arc<Pixmap>) { if matches!(ink, Ink::Display | Ink::RichBlack) { return (key.clone(), pm.clone()); } - let (luts, generation) = cached(&SEPARATE, |cms| { - let intent = cms.settings().intent; - [Ink::Cmy, Ink::K].map(|ink| ProofLut::build(|rgb| ink.plane(cms.srgb_to_cmyk(rgb, intent)))) - }); - let plane_key = |plane: Ink| ImageKey { ink: plane, luts: (key.luts.0, generation), ..key.clone() }; - let want = plane_key(ink); + let (luts, generation) = separation(); + let keyed = |plane: Ink| plane_key(key, plane, generation); + let want = keyed(ink); let hit = { let mut s = images::store(); if touch { s.get(&want) } else { s.peek(&want) } @@ -357,8 +372,8 @@ pub(crate) fn ink_image(key: &ImageKey, pm: &Arc<Pixmap>, ink: Ink, blob: Option }); let [cmy, k] = planes; let mut s = images::store(); - let cmy = s.insert(plane_key(Ink::Cmy), cmy); - let k = s.insert(plane_key(Ink::K), k); + let cmy = s.insert(keyed(Ink::Cmy), cmy); + let k = s.insert(keyed(Ink::K), k); (want, if ink == Ink::K { k } else { cmy }) } @@ -395,7 +410,8 @@ mod tests { let mut d = Document::new_with_mode(20.0, 10.0, ColorMode::Cmyk); d.images.insert("img".into(), ImageBlob::cmyk_tiff(&Inks::new(2, 2, ink.repeat(4)).unwrap()).unwrap()); let l = d.layers[0].id; - let im = ImageObject { key: "img".into(), width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default() }; + let im = + ImageObject { key: "img".into(), width: 2, height: 2, xf: Affine::scale(5.0), link: None, placement: Default::default(), pixels: None }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); d.insert(Some(l), 0, n).unwrap(); // The same inks as a fill beside it. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 442e156cb..e5f345ef0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,8 @@ #![forbid(unsafe_code)] mod brush_fx; +mod changed; +mod dither; pub mod encode; mod freeform; mod fx; @@ -35,6 +37,7 @@ use ink::Ink; use lru::FrameCache; pub use brush_fx::instance_art; +pub use changed::{Changed, changed, view_pixels}; pub use effects::stroke::width_outline; pub use images::{IMAGE_CACHE_MAX, image_cache_bytes, set_image_cache_budget}; pub use live::expand_live; @@ -80,6 +83,11 @@ pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { /// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser /// addresses at most `u16::MAX` pixels per side (and panics at that edge). pub const MAX_RASTER_SIDE: u32 = 32_768; +/// Largest side of a render context the renderer makes. The CPU rasteriser keeps pixel positions +/// in `u16` and overflows near `u16::MAX` (its tile and bucket arithmetic rounds past it), so +/// contexts stay within the side raster exports are limited to. +const MAX_CONTEXT_SIDE: u32 = MAX_RASTER_SIDE; + /// Largest raster an export may ask for in total (16384², 1 GiB of RGBA pixels). pub const MAX_RASTER_PIXELS: u64 = 1 << 28; @@ -101,7 +109,7 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { } /// Rendering options. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct RenderOptions { /// Outline (wireframe) view: 1 px black paths, no paint. pub outline: bool, @@ -125,6 +133,9 @@ pub struct RenderOptions { pub skip_templates: bool, /// View › Hide Template: template layers left out of the screen view (guides stay). pub hide_templates: bool, + /// View › Hide Guides: paths made into guides left out of the screen view. Exports use + /// `skip_templates`, which independently leaves both templates and guides out. + pub hide_guides: bool, /// Pattern editing mode's tile edge and swatch bounds colour (Object → Pattern → Tile Edge /// Color), RGB. pub tile_edge: [u8; 3], @@ -246,6 +257,7 @@ impl Default for RenderOptions { trim: false, skip_templates: false, hide_templates: false, + hide_guides: false, tile_edge: vectorcraft_doc::LAYER_COLORS[0].1, guide: ([0x4a, 0xd8, 0xff], false), mask_view: None, @@ -276,7 +288,7 @@ const MAX_SUPERSAMPLED_PIXELS: u64 = 40_000_000; pub fn supersample_factor(w: u32, h: u32) -> Option<u32> { (2..=4u32).rev().find(|k| { let (sw, sh) = (u64::from(w) * u64::from(*k), u64::from(h) * u64::from(*k)); - sw <= u64::from(u16::MAX) && sh <= u64::from(u16::MAX) && sw * sh <= MAX_SUPERSAMPLED_PIXELS + sw <= u64::from(MAX_CONTEXT_SIDE) && sh <= u64::from(MAX_CONTEXT_SIDE) && sw * sh <= MAX_SUPERSAMPLED_PIXELS }) } @@ -440,8 +452,6 @@ pub struct Renderer { text_fonts: u64, /// Clip regions per clipping path (see [`Self::clip_of`]). clips: FrameCache<usize, ClipEntry>, - /// Context reused when rendering single-threaded (`threads == 0`). - ctx_st: Option<RenderContext>, /// Worker threads for the multithreaded rasterizer (0 = single-threaded). pub threads: u16, geom: PtrMap<usize, GeomEntry>, @@ -467,12 +477,19 @@ pub struct Renderer { /// [`Self::draw_outline_member`] sets it to each member of a clip group or shape of an Image /// Trace object that it draws. fx_owner: Option<Arc<Node>>, + /// The live object [`Self::draw_kept_node`] is drawing (kept symbol art, an opacity mask's art, + /// a member of a clip group in Outline view): [`Self::draw_content`] draws its evaluation from + /// the live cache. + live_root: Option<Arc<Node>>, /// Live-effect geometry per node ([`fx::FxGeom`]), keyed by `Arc` identity like `geom`. fx_geom: PtrMap<usize, fx::FxGeom>, stamp: u64, /// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer). alpha: f32, - ctx: Option<RenderContext>, + /// The contexts kept for the next frames, with the threads they draw on, the last used last: a + /// frame of the size of one reuses it instead of making a context and its threads. Two are + /// kept, so a view's frames and the parts of them drawn again ([`changed`]) each keep theirs. + contexts: Vec<(u16, RenderContext)>, resources: Resources, /// Statistics of the last frame. pub stats: FrameStats, @@ -515,6 +532,11 @@ pub struct Renderer { /// The picture [`Self::flatten`] started the current context again from, with 8-bit /// compositing: [`Self::finish`] renders the context over it. picture: Option<Arc<Pixmap>>, + /// Pixels of the groups being drawn offscreen (contexts and backdrop copies), and the most + /// they may hold (set from the frame when the first opens, see [`Self::group`]); both reset + /// at the start of every frame. + offscreen_live: u64, + offscreen_budget: u64, /// What clipping paths paint, per clipping path (see [`Self::clip_paint_of`]). clip_paints: FrameCache<usize, ClipPaintEntry>, /// Gradients along or across strokes (see [`Self::fill_path_gradient`]), keyed by @@ -597,6 +619,8 @@ impl Frame<'_> { /// A render context for drawing this frame's art offscreen (on the calling thread), with its /// edge smoothing: hard edges must not depend on where the art is drawn. fn offscreen_context(&self, w: u16, h: u16) -> RenderContext { + #[cfg(test)] + OFFSCREEN_PIXELS.with(|n| n.set(n.get() + u64::from(w) * u64::from(h))); let mut ctx = single_threaded_context(w, h); ctx.set_aliasing_threshold(self.opts.anti_alias.threshold()); ctx @@ -609,7 +633,6 @@ impl Renderer { texts: FrameCache::new(lru::TEXT_BUDGET, 1024), text_fonts: 0, clips: FrameCache::new(lru::CLIP_BUDGET, 256), - ctx_st: None, threads: default_threads(), geom: PtrMap::default(), symbols: vec![], @@ -618,10 +641,11 @@ impl Renderer { cur: None, made_again: false, fx_owner: None, + live_root: None, fx_geom: PtrMap::default(), stamp: 0, alpha: 1.0, - ctx: None, + contexts: Vec::new(), resources: Resources::new(), stats: FrameStats::default(), brushes: Default::default(), @@ -639,6 +663,8 @@ impl Renderer { blends: PtrMap::default(), clip_paths: vec![], picture: None, + offscreen_live: 0, + offscreen_budget: 0, clip_paints: FrameCache::new(lru::CLIP_BUDGET, 256), stroke_slices: PtrMap::default(), adjusts: HashMap::new(), @@ -661,7 +687,7 @@ impl Renderer { fn render_as(&mut self, doc: &Document, width: u32, height: u32, view: Affine, opts: &RenderOptions, inks: bool) -> Rendered { let start = now(); if opts.supersample && opts.anti_alias == AntiAlias::Art && opts.mask_view.is_none() { - let (w, h) = (width.clamp(1, u16::MAX as u32), height.clamp(1, u16::MAX as u32)); + let (w, h) = (width.clamp(1, MAX_CONTEXT_SIDE), height.clamp(1, MAX_CONTEXT_SIDE)); if let Some(k) = supersample_factor(w, h) { let hard = RenderOptions { anti_alias: AntiAlias::None, supersample: false, ..opts.clone() }; let big = self.render_as(doc, w * k, h * k, Affine::scale(f64::from(k)) * view, &hard, inks); @@ -675,8 +701,8 @@ impl Renderer { self.check_symbols(doc); let prepared = proof::prepare(doc, opts, &mut self.proof_art); let doc: &Document = &prepared; - let w = width.clamp(1, u16::MAX as u32) as u16; - let h = height.clamp(1, u16::MAX as u32) as u16; + let w = width.clamp(1, MAX_CONTEXT_SIDE) as u16; + let h = height.clamp(1, MAX_CONTEXT_SIDE) as u16; // Raster filters (drop shadow, glows, blur) are rendered offscreen on this thread when the // main context is multithreaded (see `fx::with_filters`). let threads = self.threads; @@ -722,6 +748,9 @@ impl Renderer { /// Start a frame: cache entries used from now on are marked with its stamp. fn begin_frame(&mut self) { self.stamp += 1; + self.inline_depth = 0; + // Nothing is drawn offscreen yet (even if an earlier frame stopped part way). + (self.offscreen_live, self.offscreen_budget) = (0, 0); } /// End a frame: drop cache entries not used for a few frames (every render, thumbnails too, @@ -757,13 +786,13 @@ impl Renderer { /// Draw frame `f` (`w`×`h` pixels): the background, the artboards and the page's art. fn draw_frame(&mut self, f: &Frame, w: u16, h: u16) -> Vec<u8> { let (threads, opts) = (self.threads, f.opts); - let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() }; - let mut ctx = match slot { - Some(mut c) if c.width() == w && c.height() == h => { + let kept = self.contexts.iter().position(|(t, c)| *t == threads && c.width() == w && c.height() == h); + let mut ctx = match kept.map(|i| self.contexts.remove(i)) { + Some((_, mut c)) => { c.reset(); c } - _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), + None => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), }; // `reset` keeps the threshold of the previous render: set it every time. ctx.set_aliasing_threshold(opts.anti_alias.threshold()); @@ -796,10 +825,9 @@ impl Renderer { self.draw_page(&mut ctx, f); } let pm = self.finish(&mut ctx, self.raster); - if threads == 0 { - self.ctx_st = Some(ctx); - } else { - self.ctx = Some(ctx); + self.contexts.push((threads, ctx)); + if self.contexts.len() > 2 { + self.contexts.remove(0); } pm.data_as_u8_slice().to_vec() } @@ -905,7 +933,7 @@ impl Renderer { let b = brush_fx::cull_bounds(&doc.symbol_boxes(), n)?; let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6); let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2()); - let w = size.clamp(1, u16::MAX as u32) as u16; + let w = size.clamp(1, MAX_CONTEXT_SIDE) as u16; let mut ctx = single_threaded_context(w, w); if let Some(bg) = background { ctx.set_paint(peniko::Color::from_rgba8(bg[0], bg[1], bg[2], bg[3])); @@ -1036,9 +1064,9 @@ impl Renderer { /// Whether `a` is left out of this frame: hidden, a skipped template or guide, or culled. fn skipped(&mut self, f: &Frame, a: &Arc<Node>) -> bool { - let skipped_template = (f.opts.skip_templates || f.opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) - || f.opts.skip_templates && matches!(a.kind, NodeKind::Path { guide: true, .. }); - if !a.visible || skipped_template || f.opts.hidden.contains(&a.id) { + let skipped_view_item = (f.opts.skip_templates || f.opts.hide_templates) && matches!(a.kind, NodeKind::Layer { template: true, .. }) + || (f.opts.skip_templates || f.opts.hide_guides) && matches!(a.kind, NodeKind::Path { guide: true, .. }); + if !a.visible || skipped_view_item || f.opts.hidden.contains(&a.id) { return true; } match self.bounds_of(f.symbols, a) { @@ -1063,21 +1091,18 @@ impl Renderer { && !f.opts.outline && !self.is_shape(a) { - // A masked object is a non-isolated group of its own. - let mask = Some(self.opacity_mask(f, m, ctx.width(), ctx.height())); + // 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 mask = Some(self.mask_values(&sub, m, r.w, r.h)); let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a)); - let bounds = if blends { self.bounds_of(f.symbols, a) } else { None }; self.group(ctx, f, Composite { mask, blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a)); return; } self.draw_arc_body(ctx, f, a); } - /// Render an opacity mask's art offscreen and turn its luminance into a coverage mask. - fn opacity_mask(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> vello_cpu::Mask { - vello_cpu::Mask::from_parts(self.mask_values(f, m, w, h), w, h) - } - /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, /// as luminance ([`mask_value`]). fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec<u8> { @@ -1091,7 +1116,7 @@ impl Renderer { std::mem::take(&mut self.clip_paths), self.picture.take(), ); - self.draw_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art, true); + self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art); let pm = self.finish(&mut mctx, self.raster); (self.knockout, self.nested, self.backdrop, self.clip_paths, self.picture) = outer; pm.data().iter().map(|p| mask_value(p.r, p.g, p.b, p.a, m.clip, m.invert)).collect() @@ -1345,9 +1370,7 @@ impl Renderer { NodeKind::SymbolInstance { symbol, xf } => { if let Some(sym) = f.doc.symbols.iter().find(|s| &s.name == symbol) { let (art, kept) = self.placed_art(&sym.art, *xf, false, Some(n)); - let outer = std::mem::replace(&mut self.made_again, !kept); - self.draw_node(ctx, f, &art, true); - self.made_again = outer; + self.draw_placed_art(ctx, f, &art, kept); } } NodeKind::Blend { .. } @@ -1355,10 +1378,35 @@ impl Renderer { | NodeKind::Mesh(_) | NodeKind::Repeat(_) | NodeKind::PlacedDocument(_) - | NodeKind::Intertwine { .. } => self.draw_live_node(ctx, f, n), + | NodeKind::Intertwine { .. } => match self.live_root.clone().filter(|a| std::ptr::eq(&**a, n)) { + Some(a) => self.draw_live_body(ctx, f, &a, true), + None => self.draw_live_node(ctx, f, n), + }, } } + /// Draw symbol art `art` as [`Self::placed_art`] placed it for an instance or an inline graphic + /// (`kept`: kept between frames, see [`Self::draw_kept_node`]). + fn draw_placed_art(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc<Node>, kept: bool) { + let outer = std::mem::replace(&mut self.made_again, !kept); + if kept { + self.draw_kept_node(ctx, f, art); + } else { + self.draw_node(ctx, f, art, true); + } + self.made_again = outer; + } + + /// [`Self::draw_node`] of `a` (drawn whatever its visibility and bounds), whose `Arc` stays the + /// same from frame to frame while the art does. When `a` is a live object, + /// [`Self::draw_content`] draws its evaluation from the live cache ([`Self::live_root`]). + 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.live_root = outer; + } + /// An Image Trace object's `children` (the hidden source image, then the traced shapes) as /// `view` shows them on screen. fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], view: TraceView, knockout: bool) { @@ -1380,9 +1428,10 @@ impl Renderer { /// [`Self::draw_node`] for `c`, a member of a clip group or a shape of an Image Trace object in /// Outline view, culled by its cached bounds ([`Self::bounds_of`]) and drawn with `c` as - /// `fx_owner`, so its path and live-effect geometry come from the caches. + /// `fx_owner`, so its path, its live-effect geometry and, through [`Self::draw_kept_node`], a + /// live object's evaluation come from the caches. fn draw_outline_member(&mut self, ctx: &mut RenderContext, f: &Frame, c: &Arc<Node>) { - if !c.visible || f.opts.hidden.contains(&c.id) { + if !c.visible || f.opts.hidden.contains(&c.id) || f.opts.hide_guides && matches!(c.kind, NodeKind::Path { guide: true, .. }) { return; } match self.bounds_of(f.symbols, c) { @@ -1397,7 +1446,7 @@ impl Renderer { None => {} } let outer = self.fx_owner.replace(c.clone()); - self.draw_node(ctx, f, c, true); + self.draw_kept_node(ctx, f, c); self.fx_owner = outer; } @@ -1508,7 +1557,7 @@ impl Renderer { ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); } ctx.set_transform(f.view); - if paint::set_paint(ctx, &fl.paint, bounds, f) { + if paint::set_fill_paint(ctx, &fl.paint, bounds, f) { self.fold_alpha(ctx, f.ink, &fl.paint); ctx.set_fill_rule(fill_rule(rule)); ctx.fill_path(bp); @@ -1726,24 +1775,23 @@ impl Renderer { let Some(sym) = f.doc.symbols.iter().find(|s| s.name == art.symbol) else { continue }; // Strokes scale with the art (it is sized to the type, as in the SVG `<use>`). let (node, kept) = self.placed_art(&sym.art, t.xf * ig.xf * f.doc.symbol_natural_xf(&art.symbol), true, None); - let outer = std::mem::replace(&mut self.made_again, !kept); - self.draw_node(ctx, f, &node, true); - self.made_again = outer; + self.draw_placed_art(ctx, f, &node, kept); } self.inline_depth -= 1; } /// Symbol art `art` as an instance or an inline graphic draws it ([`place`]), and whether it is - /// kept. Art that is a group or a layer without an appearance of its own is kept between frames - /// under the art's `Arc`, `xf`, `scale_strokes` and the stain of instance `inst`: its members - /// keep their `Arc`s from frame to frame, and the caches keyed by a node's address (geometry, - /// stroke outlines, glyphs, live-effect geometry) hit for them. Other art is placed again in - /// every frame, and so is art that holds symbol instances, type with inline graphics, images, - /// placed documents, live blends, envelopes or repeats, or raster effects + /// kept. Art that is a group, a layer or a live object without an appearance of its own is kept + /// between frames under the art's `Arc`, `xf`, `scale_strokes` and the stain of instance + /// `inst`: it and its members keep their `Arc`s from frame to frame, and the caches keyed by a + /// node's address (geometry, stroke outlines, glyphs, live-effect geometry, evaluated live + /// objects) hit for them. Other art is placed again in every frame, and so is art that holds + /// symbol instances, type with inline graphics, images, placed documents or raster effects /// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is /// placed again when the fonts change. fn placed_art(&mut self, art: &Arc<Node>, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc<Node>, bool) { - if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || fx::has_object_fx(art) { + let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art); + if !kept_kind || fx::has_object_fx(art) { return (place(art, xf, scale_strokes, inst), false); } let stain = stain_bits(inst.and_then(vectorcraft_brush::stain)); @@ -1800,6 +1848,16 @@ impl Renderer { ctx.set_stroke(kurbo::Stroke::new(1.0)); ctx.set_paint(peniko::Color::BLACK); ctx.stroke_path(&p); + // Colour bitmap glyphs: their boxes, as images show in outline mode. + for (_, image, to_text) in &g.images { + let mut b = image.rect().to_path(0.1); + b.apply_affine(t.xf * *to_text); + self.hairline(ctx, f, &b, [0, 0, 0, 255]); + } + // COLR glyphs: their layers' outlines. + for (_, art) in &g.colours { + self.draw_colour_glyph(ctx, f, art, t.xf); + } self.draw_inlines(ctx, f, t, g); return; } @@ -1841,6 +1899,14 @@ impl Renderer { } } for (i, run) in t.runs.iter().enumerate() { + // Colour bitmap glyphs paint themselves: the run's fill and stroke don't. + for (_, image, to_text) in g.images.iter().filter(|(r, ..)| *r == i) { + overprint(ctx, false); + self.draw_glyph_image(ctx, f, image, t.xf * *to_text); + } + for (_, art) in g.colours.iter().filter(|(r, _)| *r == i) { + self.draw_colour_glyph(ctx, f, art, t.xf); + } let path = g.run(i); let path = path.as_ref(); if path.elements().is_empty() { @@ -1848,7 +1914,7 @@ impl Renderer { } ctx.set_transform(xf); ctx.set_fill_rule(peniko::Fill::NonZero); - if paint::set_paint(ctx, &run.style.fill, g.bounds, f) { + if paint::set_fill_paint(ctx, &run.style.fill, g.bounds, f) { self.fold_alpha(ctx, f.ink, &run.style.fill); overprint(ctx, run.style.overprint_fill); ctx.fill_path(path); @@ -1879,7 +1945,7 @@ impl Renderer { ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); } ctx.set_transform(f.view); - if paint::set_paint(ctx, &fl.paint, tb, f) { + if paint::set_fill_paint(ctx, &fl.paint, tb, f) { ctx.set_fill_rule(peniko::Fill::NonZero); ctx.fill_path(all); } @@ -1897,6 +1963,35 @@ impl Renderer { } } + /// Draw COLR glyph `art` (its layers in text space) through `xf` (text space → document), as + /// art: kept between frames as symbol art is ([`Self::placed_art`]). + fn draw_colour_glyph(&mut self, ctx: &mut RenderContext, f: &Frame, art: &Arc<Node>, xf: Affine) { + let (node, kept) = self.placed_art(art, xf, true, None); + let outer = std::mem::replace(&mut self.made_again, !kept); + self.draw_node(ctx, f, &node, true); + self.made_again = outer; + } + + /// Draw colour bitmap glyph `image` through `xf` (its pixels → document space), its pixels + /// decoded once and kept with the images' pixels. + fn draw_glyph_image(&mut self, ctx: &mut RenderContext, f: &Frame, image: &vectorcraft_text::GlyphImage, xf: Affine) { + let Some((key, pm)) = blob_pixmap(&image.blob, true) else { return }; + let (key, pm) = ink::ink_image(&key, &pm, f.ink, Some(&image.blob), true); + let unit = f.view * xf * Affine::scale_non_uniform(image.width as f64, image.height as f64); + let pm = image_mip(&key, pm, unit); + let sx = image.width as f64 / pm.width().max(1) as f64; + let sy = image.height as f64 / pm.height().max(1) as f64; + let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { quality, ..Default::default() }, + }); + ctx.set_transform(f.view * xf); + ctx.set_paint_transform(Affine::scale_non_uniform(sx, sy)); + ctx.fill_rect(&image.rect()); + ctx.reset_paint_transform(); + } + fn draw_image(&mut self, ctx: &mut RenderContext, f: &Frame, im: &vectorcraft_doc::ImageObject) { self.draw_image_in(ctx, f, im, None); } @@ -1908,12 +2003,21 @@ impl Renderer { // Outline mode draws the image's frame, and with Document Setup → Show Images in Outline // Mode its pixels in greyscale under the frame. let outline = f.opts.outline; - // With mips the full pixels are only looked at (not marked used) until they're drawn. + // With mips the full pixels are only looked at (not marked used) until they're drawn, and + // a mip the store holds is drawn without them: they may have been dropped (#2820). let mips = f.opts.image_mips; - let pixels = if outline && !f.doc.setup.outline_images { None } else { self.image_pixmap(f.doc, &im.key, outline, !mips) }; - if let Some((key, pm)) = pixels { - let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.images.get(&im.key), !mips) }; - let pm = if mips { image_mip(&key, pm, f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64)) } else { pm }; + let unit = f.view * im.xf * Affine::scale_non_uniform(im.width as f64, im.height as f64); + let shown = if outline && !f.doc.setup.outline_images { + None + } else if let Some(pm) = mips.then(|| self.kept_mip(f, &im.key, im.pixels.as_deref(), outline, unit)).flatten() { + Some(pm) + } else { + self.image_pixmap(f.doc, &im.key, im.pixels.as_deref(), outline, !mips).map(|(key, pm)| { + let (key, pm) = if outline { (key, pm) } else { ink::ink_image(&key, &pm, f.ink, f.doc.image_blob(im), !mips) }; + if mips { image_mip(&key, pm, unit) } else { pm } + }) + }; + if let Some(pm) = shown { let sx = im.width as f64 / pm.width().max(1) as f64; let sy = im.height as f64 / pm.height().max(1) as f64; let quality = if f.opts.smooth_images { peniko::ImageQuality::Medium } else { peniko::ImageQuality::Low }; @@ -1971,15 +2075,22 @@ impl Renderer { self.adjusts.insert(derived, (key.to_string(), hash, map.clone())); } - /// The pixels of image `key` (a blob of `doc`, or a recoloured one named by - /// [`Self::adjusted_image`]), or a greyscale copy of them, with their key in the shared store. - /// `touch` marks them used there. - fn image_pixmap(&mut self, doc: &Document, key: &str, grey: bool, touch: bool) -> Option<(images::ImageKey, Arc<Pixmap>)> { - let (key, colour) = match doc.images.get(key) { + /// The pixels of image `key` (`own`, the pixels an image holds itself, a blob of `doc`, or a + /// recoloured one named by [`Self::adjusted_image`]), or a greyscale copy of them, with their + /// key in the shared store. `touch` marks them used there. + fn image_pixmap( + &mut self, + doc: &Document, + key: &str, + own: Option<&vectorcraft_doc::ImageBlob>, + grey: bool, + touch: bool, + ) -> Option<(images::ImageKey, Arc<Pixmap>)> { + let (key, colour) = match own.or_else(|| doc.images.get(key)) { Some(blob) => blob_pixmap(blob, touch)?, None => { let (src, hash, map) = self.adjusts.get(key)?.clone(); - let (src_key, src) = self.image_pixmap(doc, &src, false, touch)?; + let (src_key, src) = self.image_pixmap(doc, &src, None, false, touch)?; let key = images::ImageKey { adjust: Some(hash), ..src_key }; match lookup(&key, touch) { Some(pm) => (key, pm), @@ -2017,6 +2128,36 @@ impl Renderer { let pm = images::store().insert(key.clone(), Arc::new(pm)); Some((key, pm)) } + + /// The key [`Self::image_pixmap`] gives the pixels of image `key` (or `own`) and their size, without + /// making or looking at them: `None` until the image has been decoded. + fn image_key(&self, doc: &Document, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool) -> Option<(images::ImageKey, (u16, u16))> { + let (key, size) = match own.or_else(|| doc.images.get(key)) { + Some(blob) => { + let (key, _, size) = blob_key(blob); + (key, size?) + } + None => { + let (src, hash, _) = self.adjusts.get(key)?; + let (src_key, size) = self.image_key(doc, src, None, false)?; + (images::ImageKey { adjust: Some(*hash), ..src_key }, size) + } + }; + Some((if grey { images::ImageKey { grey: true, ..key } } else { key }, size)) + } + + /// The mip [`image_mip`] draws image `key` (or `own`) with through `m`, in greyscale with + /// `grey` (the outline view's), when the store holds it, marked used there; `None` when it + /// draws the full pixels or the mip is yet to be made. + fn kept_mip(&self, f: &Frame, key: &str, own: Option<&vectorcraft_doc::ImageBlob>, grey: bool, m: Affine) -> Option<Arc<Pixmap>> { + let (key, (w, h)) = self.image_key(f.doc, key, own, grey)?; + let key = if grey { key } else { ink::ink_key(&key, f.ink) }; + let level = mip_level(m, w, h); + if level == 0 { + return None; + } + images::store().get(&images::ImageKey { mip: level, ..key }) + } } /// `key`'s pixels in the shared image store; `touch` marks them used. @@ -2025,31 +2166,49 @@ fn lookup(key: &images::ImageKey, touch: bool) -> Option<Arc<Pixmap>> { if touch { s.get(key) } else { s.peek(key) } } -/// The decoded pixels of `blob` (a CMYK image's inks shown through the working CMYK profile, again -/// when the colour settings change), from the shared store, and their key there. -fn blob_pixmap(blob: &vectorcraft_doc::ImageBlob, touch: bool) -> Option<(images::ImageKey, Arc<Pixmap>)> { +/// The key of `blob`'s decoded pixels in the shared store, the display table a CMYK image's inks +/// show through (its generation is in the key), and the pixels' size once decoded. +fn blob_key(blob: &vectorcraft_doc::ImageBlob) -> (images::ImageKey, Option<Arc<ink::InkLut>>, Option<(u16, u16)>) { let id = images::blob_id(blob); let lut = id.cmyk.then(ink::display_lut); let key = images::ImageKey { content: id.content, adjust: None, grey: false, ink: Ink::Display, luts: (lut.as_ref().map_or(0, |l| l.1), 0), mip: 0 }; - if let Some(pm) = lookup(&key, touch) { - return Some((key, pm)); - } - // Decoded outside the lock: other renderers carry on meanwhile. - let pm = match lut.and_then(|(lut, _)| ink::cmyk_pixmap(blob, &lut)) { + (key, lut.map(|l| l.0), id.size) +} + +/// The decoded pixels of `blob` (a CMYK image's inks shown through the working CMYK profile, again +/// when the colour settings change), from the shared store, and their key there. +fn blob_pixmap(blob: &vectorcraft_doc::ImageBlob, touch: bool) -> Option<(images::ImageKey, Arc<Pixmap>)> { + let (key, lut, size) = blob_key(blob); + let pm = match lookup(&key, touch) { Some(pm) => pm, - None => paint::decode_pixmap(&blob.bytes)?, + None => { + // Decoded outside the lock: other renderers carry on meanwhile. + let pm = match lut.and_then(|lut| ink::cmyk_pixmap(blob, &lut)) { + Some(pm) => pm, + None => paint::decode_pixmap(&blob.bytes)?, + }; + images::store().insert(key.clone(), Arc::new(pm)) + } }; - let pm = images::store().insert(key.clone(), Arc::new(pm)); + let decoded = (pm.width(), pm.height()); + if size != Some(decoded) { + images::remember_size(blob, decoded); + } Some((key, pm)) } +/// How many times to halve image pixels of `w`×`h` drawn through `m` (unit square → device +/// pixels): [`images::mip_level`]. +fn mip_level(m: Affine, w: u16, h: u16) -> u8 { + images::mip_level(m * Affine::scale_non_uniform(1.0 / w.max(1) as f64, 1.0 / h.max(1) as f64), w, h) +} + /// Image pixels `pm` (stored as `key`) to draw through `m` (unit square → device pixels), shrunk /// by halves while still at least as fine as the device pixels ([`images::mip_level`]); the /// pixels drawn are marked used in the store. fn image_mip(key: &images::ImageKey, pm: Arc<Pixmap>, m: Affine) -> Arc<Pixmap> { - let (w, h) = (pm.width(), pm.height()); - let level = images::mip_level(m * Affine::scale_non_uniform(1.0 / w.max(1) as f64, 1.0 / h.max(1) as f64), w, h); + let level = mip_level(m, pm.width(), pm.height()); let mut s = images::store(); if level == 0 { let _ = s.get(key); // Marks the full pixels used (they were only looked at). @@ -2130,17 +2289,20 @@ fn single_plain_fill(n: &Node) -> bool { matches!((painted.next(), painted.next()), (Some(AppearanceItem::Fill(f)), None) if f.blend == vectorcraft_color::BlendMode::Normal && f.opacity >= 1.0 && !matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })) } -/// Number of visible painted fill/stroke items (opacity folding is exact only for one). +/// Number of visible painted fill/stroke items (opacity folding is exact only for one). A dashed +/// stroke is one paint: its dashes are outlined into one path and filled once, so overlapping +/// dashes don't darken where they overlap. A dashed brush stroke draws its pieces one by one, so +/// it takes the layered path, with or without other paints. fn painted_items(n: &Node) -> usize { n.appearance .items .iter() .filter(|i| match i { AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && matches!(f.paint, vectorcraft_color::Paint::Solid { .. }), - AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, vectorcraft_color::Paint::Solid { .. }) && s.dash.is_none(), + AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, vectorcraft_color::Paint::Solid { .. }), }) .count() - .max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None))) { 2 } else { 0 }) + .max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && (!matches!(s.paint, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None) || (s.dash.is_some() && s.brush.is_some() && !s.paint.is_none())))) { 2 } else { 0 }) } /// Whether `st` is one of `n`'s own strokes (the strokes of the brush art drawn for `n` are not). @@ -2160,19 +2322,14 @@ fn place(art: &Node, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> Ar } /// Does `n` hold, in it or in the opacity masks of the objects in it, a symbol instance, type -/// with an inline graphic, an image, a placed document, a blend, an envelope, a repeat, or an -/// object or a fill or stroke with a visible raster effect? +/// with an inline graphic, an image, a placed document, or an object or a fill or stroke with a +/// visible raster effect? fn depends_on_document(n: &Node) -> bool { let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && effects::is_raster(&e.id)); let mut depends = false; n.walk(&mut |c| { depends |= match &c.kind { - NodeKind::SymbolInstance { .. } - | NodeKind::Image(_) - | NodeKind::PlacedDocument(_) - | NodeKind::Blend { .. } - | NodeKind::Envelope { .. } - | NodeKind::Repeat(_) => true, + NodeKind::SymbolInstance { .. } | NodeKind::Image(_) | NodeKind::PlacedDocument(_) => true, NodeKind::Text(t) => t.runs.iter().any(|r| r.inline.is_some()), _ => false, } || raster(&c.appearance.effects) @@ -2268,6 +2425,10 @@ struct TextGeom { alternate_glyphs: std::sync::OnceLock<BezPath>, /// Inline graphics placed by the layout. inlines: Vec<vectorcraft_text::InlineGlyph>, + /// Colour bitmap glyphs: (run, image, its pixels → text space), in text order. + images: Vec<(usize, Arc<vectorcraft_text::GlyphImage>, Affine)>, + /// COLR colour glyphs: (run, their layers placed in text space), in text order. + colours: Vec<(usize, Arc<Node>)>, } impl TextGeom { @@ -2316,6 +2477,8 @@ impl TextGeom { + self.alternate_glyphs.get().map_or(0, lru::path_bytes) + self.spans.len() * size_of::<(usize, usize, usize)>() + self.inlines.len() * size_of::<vectorcraft_text::InlineGlyph>() + + self.images.len() * size_of::<(usize, Arc<vectorcraft_text::GlyphImage>, Affine)>() + + self.colours.iter().map(|(_, art)| lru::node_bytes(art)).sum::<usize>() } } @@ -2396,8 +2559,15 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom { .collect(); let (mut substituted_fonts, mut substituted_glyphs, mut alternate_candidates) = (BezPath::new(), BezPath::new(), vec![]); let plain = t.plain_text(); + let (mut images, mut colours) = (vec![], vec![]); for g in &layout.glyphs { push(&mut all, g.run, &g.outline); + if let Some((image, xf)) = g.placed_image() { + images.push((g.run, image.clone(), xf)); + } + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + colours.push((g.run, Arc::new(art))); + } let Some(&(missing, face)) = faces.get(g.run) else { continue }; let Some(line) = layout.lines.get(g.line) else { continue }; let mut cell = Rect::new(g.origin.x, g.origin.y - line.ascent, g.origin.x + g.advance, g.origin.y + line.descent).to_path(0.1); @@ -2419,6 +2589,10 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom { alternate_candidates.push(AlternateCandidate { byte: g.byte, len: g.len, font_id: g.font_id, gid: g.gid, cell }); } } + // Ruby (furigana) glyphs: painted with their base text's run. + for g in &layout.ruby { + push(&mut all, g.run, &g.outline); + } // Underline and strikethrough bars, painted as their run's type is (#847). for (run, bar) in vectorcraft_text::decorations(&layout, db, t) { push(&mut all, run, &bar); @@ -2432,6 +2606,8 @@ fn text_geom_snapped(t: &TextObject, snap: Option<Affine>) -> TextGeom { alternate_candidates, alternate_glyphs: std::sync::OnceLock::new(), inlines: layout.inlines, + images, + colours, } } @@ -2486,6 +2662,12 @@ fn now() -> u64 { 0 } +#[cfg(test)] +thread_local! { + /// The pixels of the offscreen contexts [`Frame::offscreen_context`] has made on this thread. + static OFFSCREEN_PIXELS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) }; +} + #[cfg(test)] mod tests; #[cfg(test)] @@ -2493,16 +2675,24 @@ mod tests_adjust; #[cfg(test)] mod tests_alternate; #[cfg(test)] +mod tests_bitmapglyphs; +#[cfg(test)] mod tests_blend; #[cfg(test)] +mod tests_changed; +#[cfg(test)] mod tests_charstroke; #[cfg(test)] mod tests_clip; #[cfg(test)] mod tests_cmykblend; #[cfg(test)] +mod tests_colrglyphs; +#[cfg(test)] mod tests_container; #[cfg(test)] +mod tests_dither; +#[cfg(test)] mod tests_fontchange; #[cfg(test)] mod tests_freeform; diff --git a/crates/render/src/live.rs b/crates/render/src/live.rs index ad25aeb8a..992a7051d 100644 --- a/crates/render/src/live.rs +++ b/crates/render/src/live.rs @@ -39,11 +39,20 @@ type MeshEntry = (Arc<Node>, Quads, Arc<DocInputs>); pub(crate) struct LiveCache { expanded: FrameCache<usize, (Arc<Node>, Expanded, Found)>, meshes: FrameCache<(usize, usize), MeshEntry>, + /// How many live objects [`Renderer::live_expanded`] evaluated and how many meshes + /// [`Renderer::mesh_quads`] tessellated. + #[cfg(test)] + pub(crate) evaluated: usize, } impl Default for LiveCache { fn default() -> Self { - Self { expanded: FrameCache::new(lru::LIVE_BUDGET, 256), meshes: FrameCache::new(lru::MESH_BUDGET, 256) } + Self { + expanded: FrameCache::new(lru::LIVE_BUDGET, 256), + meshes: FrameCache::new(lru::MESH_BUDGET, 256), + #[cfg(test)] + evaluated: 0, + } } } @@ -179,7 +188,8 @@ const MESH_RASTER_MAX_PIXELS: usize = 4 << 20; /// Draw a tessellated gradient mesh as one image: its quads are filled into a pixel buffer of the /// mesh's on-screen box by scanline (each pixel takes the quad its centre is in, so neighbouring -/// quads meet without seams or overlap), and the buffer is drawn through the mesh's outline, which +/// quads meet without seams or overlap, and is shaded between the quad's corner colours, so the +/// colour changes smoothly instead of in steps), and the buffer is drawn through the mesh's outline, which /// antialiases the outer edge. The outline is grown by half a device pixel ([`grown_outline`]), as /// the quads drawn one by one are: a page of meshes laid edge to edge would otherwise show the /// background through each shared, antialiased edge. Drawing every quad as a path of its own costs a @@ -208,9 +218,12 @@ fn draw_mesh_raster(ctx: &mut RenderContext, f: &Frame, m: &GradientMesh, quads: let mut filled = vec![false; w * h]; let to_buffer = Affine::translate((-x0, -y0)) * f.view; for q in quads { - // A fully transparent quad still claims its pixels, so the edge fill below leaves them clear. - let c = if q.opacity > 0.0 { f.ink.color(&q.color, q.opacity * alpha).premultiply().to_rgba8() } else { clear }; - fill_quad(&mut px, &mut filled, w, h, q.pts.map(|p| to_buffer * p), c); + // A fully transparent corner still shades toward clear, so the edge fill below leaves it clear. + let corners = q.corners.map(|(color, opacity)| { + let c = if opacity > 0.0 { f.ink.color(&color, opacity * alpha).premultiply().to_rgba8() } else { clear }; + [c.r, c.g, c.b, c.a].map(f32::from) + }); + fill_quad(&mut px, &mut filled, w, h, q.pts.map(|p| to_buffer * p), corners); } // Twice: the grown edge samples pixels up to about a pixel and a half out from the quads. let filled = spread_to_edges(&mut px, filled, w, h); @@ -280,7 +293,7 @@ fn grown_outline(path: &BezPath, grow: f64) -> BezPath { /// Fill the pixels of the `w`×`h` buffer whose centres are inside quad `p` (buffer pixels, even-odd /// so a twisted quad is filled as drawn) with `c`, and mark them `filled`. -fn fill_quad(px: &mut [vello_cpu::color::PremulRgba8], filled: &mut [bool], w: usize, h: usize, p: [Point; 4], c: vello_cpu::color::PremulRgba8) { +fn fill_quad(px: &mut [vello_cpu::color::PremulRgba8], filled: &mut [bool], w: usize, h: usize, p: [Point; 4], c: [[f32; 4]; 4]) { if !p.iter().all(|p| p.x.is_finite() && p.y.is_finite()) { return; } @@ -304,16 +317,52 @@ fn fill_quad(px: &mut [vello_cpu::color::PremulRgba8], filled: &mut [bool], w: u let from = (a - 0.5).ceil().clamp(0.0, w as f64) as usize; let to = (b - 0.5).ceil().clamp(0.0, w as f64) as usize; let row = y * w; - for (d, k) in - px.get_mut(row + from..row + to).unwrap_or_default().iter_mut().zip(filled.get_mut(row + from..row + to).unwrap_or_default()) + for ((x, d), k) in (from..) + .zip(px.get_mut(row + from..row + to).unwrap_or_default().iter_mut()) + .zip(filled.get_mut(row + from..row + to).unwrap_or_default()) { - *d = c; + *d = shade(p, &c, Point::new(x as f64 + 0.5, yc)); *k = true; } } } } +/// The colour at `q` in quad `p` with corner colours `c` (premultiplied, 0–255): the corners' +/// colours mixed by `q`'s barycentric weights in the quad's triangle 0-1-2 or 0-2-3, whichever +/// holds it. A tessellated quad is a few pixels across, so the split along its diagonal doesn't show. +fn shade(p: [Point; 4], c: &[[f32; 4]; 4], q: Point) -> vello_cpu::color::PremulRgba8 { + let weights = |a: Point, b: Point, d: Point| -> Option<[f64; 3]> { + let det = (b.x - a.x) * (d.y - a.y) - (d.x - a.x) * (b.y - a.y); + if det.abs() < 1e-12 { + return None; + } + let wb = ((q.x - a.x) * (d.y - a.y) - (d.x - a.x) * (q.y - a.y)) / det; + let wd = ((b.x - a.x) * (q.y - a.y) - (q.x - a.x) * (b.y - a.y)) / det; + Some([1.0 - wb - wd, wb, wd]) + }; + let inside = |w: &[f64; 3]| w.iter().all(|w| *w >= -1e-6); + let first = weights(p[0], p[1], p[2]).map(|w| (w, [0, 1, 2])); + let second = weights(p[0], p[2], p[3]).map(|w| (w, [0, 2, 3])); + // The triangle holding `q`, else (at the quad's edge, or a folded quad) the nearer one. + let pick = match (first, second) { + (Some(a), _) if inside(&a.0) => Some(a), + (_, Some(b)) if inside(&b.0) => Some(b), + (Some(a), Some(b)) => Some(if a.0.iter().fold(0.0f64, |m, w| m.min(*w)) >= b.0.iter().fold(0.0f64, |m, w| m.min(*w)) { a } else { b }), + (a, b) => a.or(b), + }; + let mix = match pick { + Some((w, idx)) => { + let w = w.map(|w| w.clamp(0.0, 1.0)); + let sum = w.iter().sum::<f64>().max(1e-12); + std::array::from_fn(|ch| (0..3).map(|k| c[idx[k]][ch] * (w[k] / sum) as f32).sum::<f32>()) + } + None => std::array::from_fn(|ch| c.iter().map(|corner| corner[ch]).sum::<f32>() / 4.0), + }; + let [r, g, b, a] = mix.map(|v| v.round().clamp(0.0, 255.0) as u8); + vello_cpu::color::PremulRgba8 { r: r.min(a), g: g.min(a), b: b.min(a), a } +} + /// Give each pixel no quad covered the colour of a covered neighbour: the outline's antialiased /// edge samples pixels whose centres fall just outside the quads, which would otherwise fade it. /// → the pixels covered now (the given ones and those just given a colour). @@ -360,6 +409,10 @@ impl Renderer { { return e.1.clone(); } + #[cfg(test)] + { + self.live.evaluated += 1; + } let v: Expanded = match crate::effects::pathfinder_children(a, crate::effects::text_outliner()) { Some(children) => Arc::new(children), None => Arc::new(crate::effects::expand_live(Some(doc), a).into_iter().map(Arc::new).collect()), @@ -383,6 +436,10 @@ impl Renderer { { return e.1.clone(); } + #[cfg(test)] + { + self.live.evaluated += 1; + } let q = Arc::new(m.quads(level)); if cache { let bytes = q.len() * size_of::<MeshQuad>(); @@ -555,12 +612,36 @@ mod tests { assert!(holes < 60, "{holes} pixels the smooth render covers are empty with hard edges"); } + /// A quad is shaded between its corner colours: across a quad running from black on the left to + /// white on the right, each column is lighter than the one before (no flat step), the left and + /// right edges are near their corners' colours, and a column's pixels are alike. + #[test] + fn a_quad_shades_smoothly_between_its_corner_colours() { + let (w, h) = (40, 10); + let clear = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }; + let mut px = vec![clear; w * h]; + let mut filled = vec![false; w * h]; + let (black, white) = ([0.0, 0.0, 0.0, 255.0], [255.0, 255.0, 255.0, 255.0]); + let pts = [Point::new(0.0, 0.0), Point::new(40.0, 0.0), Point::new(40.0, 10.0), Point::new(0.0, 10.0)]; + fill_quad(&mut px, &mut filled, w, h, pts, [black, white, white, black]); + let at = |x: usize, y: usize| px[y * w + x].r; + for y in 0..h { + for x in 1..w { + assert!(at(x, y) > at(x - 1, y), "column {x} row {y}: {} after {}", at(x, y), at(x - 1, y)); + } + assert!(at(0, y) < 8 && at(w - 1, y) > 247, "row {y}: {} to {}", at(0, y), at(w - 1, y)); + } + for x in 0..w { + assert!(at(x, 0).abs_diff(at(x, h - 1)) <= 1, "column {x}: {} vs {}", at(x, 0), at(x, h - 1)); + } + } + /// Quads that share edges fill every pixel of their union once: no gap, no overlap. #[test] fn quads_meeting_edge_to_edge_fill_their_union_once() { let (w, h) = (20, 20); let clear = vello_cpu::color::PremulRgba8 { r: 0, g: 0, b: 0, a: 0 }; - let red = vello_cpu::color::PremulRgba8 { r: 255, g: 0, b: 0, a: 255 }; + let red = [[255.0, 0.0, 0.0, 255.0]; 4]; let mut px = vec![clear; w * h]; let mut count = vec![0u8; w * h]; // A 2×2 grid over (2.3, 2.7)–(17.6, 16.2) with an inner corner off the pixel grid. diff --git a/crates/render/src/lru.rs b/crates/render/src/lru.rs index d9fec8752..45a159bbb 100644 --- a/crates/render/src/lru.rs +++ b/crates/render/src/lru.rs @@ -43,6 +43,9 @@ pub(crate) const MESH_BUDGET: usize = 32 << 20; pub(crate) const PATTERN_BUDGET: usize = 128 << 20; /// Freeform gradient grids, per thread (`freeform::set_freeform_paint`). pub(crate) const FREEFORM_BUDGET: usize = 32 << 20; +/// Dithered gradient grids, per thread (`dither::set_dithered_paint`): a grid is at most 4096² +/// pixels, 64 MiB. +pub(crate) const DITHER_BUDGET: usize = 64 << 20; /// Frames an entry may go unused before [`FrameCache::sweep`] drops it. const AGE: u64 = 3; diff --git a/crates/render/src/paint.rs b/crates/render/src/paint.rs index 2c491459a..4d0250acf 100644 --- a/crates/render/src/paint.rs +++ b/crates/render/src/paint.rs @@ -52,6 +52,17 @@ pub(crate) fn set_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Fr } } +/// [`set_paint`] for a fill: a dithered gradient (dither applies to fills only) with stops is +/// drawn as pixels with fine noise ([`crate::dither`]). +pub(crate) fn set_fill_paint(ctx: &mut RenderContext, p: &Paint, bounds: Rect, f: &Frame) -> bool { + match p { + Paint::Gradient(g) if g.gradient.dither && g.gradient.kind != GradientKind::Freeform && !g.gradient.stops.is_empty() => { + crate::dither::set_dithered_paint(ctx, g, bounds, f.ink) + } + _ => set_paint(ctx, p, bounds, f), + } +} + /// The most memory decoding one image may take: print rasters run to gigabytes (a 300 ppi roll, /// 6614 x 59055 px, is 1.56 GB as RGBA), past the decoder's 512 MB default. The web keeps it. const MAX_DECODE_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index d1605377a..c2150c76e 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -22,7 +22,7 @@ use std::sync::{Arc, Weak}; use vectorcraft_color::cms::Cms; use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType}; use vectorcraft_doc::{Document, Node}; -use vectorcraft_geom::{Affine, Rect, Shape}; +use vectorcraft_geom::{Affine, Rect, Shape, Vec2}; use vello_cpu::peniko::{Extend, ImageQuality, ImageSampler}; use vello_cpu::{Pixmap, RenderContext}; @@ -205,10 +205,18 @@ fn with_tile_renderer<T>(f: impl FnOnce(&mut Renderer) -> T) -> Option<T> { })) })?; let mut r = r; + // Restores the depth even if `f` panics and the panic is caught (the renderer is then dropped). + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + CACHE.with(|c| c.borrow_mut().depth -= 1); + } + } + let guard = Restore; let out = f(&mut r); + drop(guard); CACHE.with(|c| { let mut c = c.borrow_mut(); - c.depth -= 1; if c.renderer.is_none() { c.renderer = Some(r); } @@ -299,6 +307,22 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Re } impl Renderer { + /// Dimmed pattern-editing preview copies at `offsets`. + fn draw_dimmed_pattern_copies(&mut self, ctx: &mut RenderContext, f: &Frame, dim: f32, offsets: &[Vec2], children: &[Arc<Node>]) { + if offsets.is_empty() { + return; + } + let comp = crate::group::Composite { opacity: dim, ..Default::default() }; + self.group(ctx, f, comp, &mut |r, c, fr| { + for o in offsets { + let frame = Frame { view: fr.view * Affine::translate(*o), visible: fr.visible - *o, ..*fr }; + for a in children { + r.draw_arc(c, &frame, a); + } + } + }); + } + /// Pattern editing mode: draw the tile layer, its dimmed copies and the tile edge instead of /// the document. Returns false when not in pattern editing mode. pub(crate) fn draw_pattern_edit(&mut self, ctx: &mut RenderContext, f: &Frame) -> bool { @@ -308,17 +332,18 @@ impl Renderer { let children: Vec<Arc<Node>> = layer.children().cloned().unwrap_or_default(); if layer.visible && !children.is_empty() { let dim = (def.dim_copies / 100.0).clamp(0.0, 1.0); - let comp = crate::group::Composite { opacity: dim, ..Default::default() }; - self.group(ctx, f, comp, &mut |r, c, fr| { - for o in def.preview_offsets() { - let frame = Frame { view: fr.view * Affine::translate(o), visible: fr.visible - o, ..*fr }; - for a in &children { - r.draw_arc(c, &frame, a); - } - } - }); + let (before, after) = def.preview_offsets_dimmed_sides(); + self.draw_dimmed_pattern_copies(ctx, f, dim, &before, &children); + self.draw_arc(ctx, f, layer); + self.draw_dimmed_pattern_copies(ctx, f, dim, &after, &children); + } else { + self.draw_arc(ctx, f, layer); + } + // The tile edge and swatch bounds are editing aids: exports and thumbnails leave them out, + // as they do guides (#3261). + if f.opts.skip_templates { + return true; } - self.draw_arc(ctx, f, layer); let [r, g, b] = f.opts.tile_edge; if def.show_tile_edge { self.hairline(ctx, f, &def.tile.to_path(0.1), [r, g, b, 255]); @@ -596,7 +621,8 @@ mod tests { let mut d = doc_with_pattern(TileType::Grid); // A PNG signature with nothing after it. d.images.insert("cut".into(), ImageBlob::new("image/png", b"\x89PNG\r\n\x1a\n".to_vec())); - let im = ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = + ImageObject { key: "cut".into(), width: 10, height: 10, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.patterns[0].art = vec![Arc::new(Node::new(NodeId(901), NodeKind::Image(Box::new(im))))]; let mut r = Renderer::new(); let start = tiles_rasterized(); @@ -670,4 +696,13 @@ mod tests { assert_eq!(raster_scale(2.1, (10.0, 10.0)), raster_scale(3.9, (10.0, 10.0))); assert!(raster_scale(64.0, (1000.0, 10.0)) * 1000.0 <= MAX_TILE_PX); } + + #[test] + fn tile_renderer_depth_survives_caught_panics() { + for _ in 0..4 { + let r = std::panic::catch_unwind(|| with_tile_renderer(|_| panic!("tile boom"))); + assert!(r.is_err()); + } + assert!(with_tile_renderer(|_| ()).is_some(), "depth leaked after caught panics"); + } } diff --git a/crates/render/src/placed_document.rs b/crates/render/src/placed_document.rs index b207e2c49..c26336de1 100644 --- a/crates/render/src/placed_document.rs +++ b/crates/render/src/placed_document.rs @@ -120,12 +120,25 @@ impl Bitmaps { } } +/// Runs a closure in the file-access scope of whoever scheduled a job (see [`set_scope_capture`]). +pub type Scoped = Box<dyn FnOnce(&mut dyn FnMut()) + Send>; + +static SCOPE_CAPTURE: std::sync::OnceLock<fn() -> Scoped> = std::sync::OnceLock::new(); + +/// Register how the scope the caller reads files in is carried to the background workers, which +/// read placed files too (the first registration stays). The engine registers its automation roots. +pub fn set_scope_capture(f: fn() -> Scoped) { + let _ = SCOPE_CAPTURE.set(f); +} + /// What a bitmap is made from. struct Job { key: BitmapKey, object: PlacedDocument, blob: ImageBlob, step: f64, + /// The scheduler's file-access scope, which the worker runs the job in. + scope: Option<Scoped>, } /// Device pixels per point of `object`'s box its preview `bytes` have. @@ -192,12 +205,19 @@ fn pixels(v: f64) -> u16 { } /// Make `job`'s bitmap and keep it. -fn run(r: &mut Renderer, job: Job) { - if let Some(pm) = make(r, &job.object, &job.blob, job.step) { - lock(&BITMAPS).insert(job.key.clone(), pm, job.step); +fn run(r: &mut Renderer, mut job: Job) { + let scope = job.scope.take(); + let mut body = || { + if let Some(pm) = make(r, &job.object, &job.blob, job.step) { + lock(&BITMAPS).insert(job.key.clone(), pm, job.step); + } + lock(&PENDING).remove(&job.key); + GENERATION.fetch_add(1, Ordering::Relaxed); + }; + match scope { + Some(scoped) => scoped(&mut body), + None => body(), } - lock(&PENDING).remove(&job.key); - GENERATION.fetch_add(1, Ordering::Relaxed); } #[cfg(not(target_arch = "wasm32"))] @@ -317,7 +337,7 @@ impl Renderer { let shown = match found { Some(pm) => Some(pm), None => { - schedule(Job { key, object: p.clone(), blob, step }); + schedule(Job { key, object: p.clone(), blob, step, scope: SCOPE_CAPTURE.get().map(|capture| capture()) }); // Made here and now (no threads) or meanwhile another step's. let mut b = lock(&BITMAPS); b.get(&(p.key.clone(), step.to_bits())).or(nearest.map(|n| n.0)) diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 57e0e5304..bc5ede38b 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -664,6 +664,34 @@ fn opacity_mask_without_clip_and_inverted() { assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible"); } +/// #3580: each opacity mask is rendered over its object's pixels, not over the whole frame. 300 +/// masked 4 × 4 pt squares on a 1000 × 1000 page draw offscreen in proportion to their bounds, and +/// still draw where their masks show them. +#[test] +fn small_masks_render_over_their_objects_only() { + let mut d = Document::new(1000.0, 1000.0); + let l = d.layers[0].id; + for i in 0..300 { + let (x, y) = (10.0 + (i % 30) as f64 * 30.0, 10.0 + (i / 30) as f64 * 30.0); + let square = Rect::new(x, y, x + 4.0, y + 4.0); + let mut n = rect_node(&mut d, square, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let white = rect_node(&mut d, square, Paint::solid(Color::WHITE), Paint::None, 0.0); + n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(white, true))); + d.insert(Some(l), usize::MAX, n).unwrap(); + } + let mut r = Renderer::new(); + r.threads = 0; + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + OFFSCREEN_PIXELS.with(|n| n.set(0)); + let img = r.render(&d, 1000, 1000, Affine::IDENTITY, &opts); + let px = OFFSCREEN_PIXELS.with(|n| n.get()); + // Each square's region is at most 4 + 2 × 2 margin + 1 rounding pixels a side, mask and content. + assert!(px <= 300 * 2 * 9 * 9, "{px} offscreen pixels for 300 masked 4 × 4 squares"); + assert_eq!(img.pixel(12, 12), [255, 0, 0, 255]); + assert_eq!(img.pixel(12 + 29 * 30, 12 + 9 * 30), [255, 0, 0, 255]); + assert_eq!(img.pixel(20, 20), [255, 255, 255, 255]); +} + #[test] fn disabled_or_outline_mask_draws_unmasked() { let mut d = masked_doc(true, false); @@ -805,7 +833,7 @@ fn images_take_their_nearest_pixel_without_smoothing() { let mut d = Document::new(40.0, 20.0); d.images.insert("bw".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); - let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; + let im = ImageObject { key: "bw".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None }; d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); // How many pixels across the image are neither black nor white. let greys = |smooth_images| { @@ -877,7 +905,8 @@ fn a_gray_image_draws_its_gray_levels() { let mut d = Document::new(40.0, 20.0); d.images.insert("gray".into(), ImageBlob::new("image/png", png)); let id = d.alloc_id(); - let im = ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default() }; + let im = + ImageObject { key: "gray".into(), width: 2, height: 1, xf: Affine::scale(20.0), link: None, placement: Default::default(), pixels: None }; d.insert(Some(d.layers[0].id), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let r = Renderer::new().render(&d, 40, 20, Affine::IDENTITY, &RenderOptions { smooth_images: false, ..Default::default() }); assert_eq!((r.pixel(5, 10), r.pixel(35, 10)), ([40, 40, 40, 255], [200, 200, 200, 255])); @@ -956,3 +985,37 @@ fn zero_length_subpaths_draw_dots_with_their_cap() { let butt = dots(vectorcraft_doc::LineCap::Butt); assert_eq!(butt.pixels.iter().filter(|&&p| p != 255).count(), 0, "a butt cap draws no dot"); } + +/// #2788: a dashed solid stroke is one paint, so its object opacity folds into its colour instead +/// of compositing a viewport-wide layer; the result matches the layered one, overlapping dashes +/// included. +#[test] +fn dashed_stroke_opacity_folds_into_its_colour() { + let dashed = |d: &mut Document, object: f32, stroke: f32| { + let mut n = rect_node(d, Rect::new(20.0, 20.0, 80.0, 60.0), Paint::None, Paint::solid(Color::rgb(0.2, 0.4, 0.9)), 6.0); + let st = n.appearance.stroke_mut().unwrap(); + st.dash = Some(vectorcraft_doc::Dash { pattern: vec![4.0, 1.0], ..Default::default() }); + st.cap = vectorcraft_doc::LineCap::Round; + st.opacity = stroke; + n.opacity = object; + n + }; + let doc = |object: f32, stroke: f32| { + let mut d = Document::new(100.0, 100.0); + let n = dashed(&mut d, object, stroke); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d + }; + let mut d = Document::new(100.0, 100.0); + assert_eq!(painted_items(&dashed(&mut d, 0.6, 1.0)), 1, "a dashed stroke takes the folded fast path"); + let mut brushed = dashed(&mut d, 0.6, 1.0); + brushed.appearance.stroke_mut().unwrap().brush = Some("Calligraphic".into()); + assert_ne!(painted_items(&brushed), 1, "a dashed brush stroke draws its pieces one by one: no folding"); + let folded = render(&doc(0.6, 1.0)); + let layered = render(&doc(1.0, 0.6)); + assert_ne!(folded.pixel(20, 40), [255, 255, 255, 255], "the stroke is drawn"); + for (a, b) in folded.pixels.chunks(4).zip(layered.pixels.chunks(4)) { + assert!(a.iter().zip(b).all(|(x, y)| (*x as i32 - *y as i32).abs() <= 1), "folded {a:?} vs layered {b:?}"); + } +} diff --git a/crates/render/src/tests_adjust.rs b/crates/render/src/tests_adjust.rs index 5ff5a35d4..1457c4df5 100644 --- a/crates/render/src/tests_adjust.rs +++ b/crates/render/src/tests_adjust.rs @@ -93,6 +93,7 @@ fn an_embedded_image_is_recoloured_and_the_original_pixels_stay() { xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default(), + pixels: None, })), ); n.appearance.effects.push(hue()); diff --git a/crates/render/src/tests_bitmapglyphs.rs b/crates/render/src/tests_bitmapglyphs.rs new file mode 100644 index 000000000..eddff0c36 --- /dev/null +++ b/crates/render/src/tests_bitmapglyphs.rs @@ -0,0 +1,84 @@ +//! Colour bitmap glyphs (emoji): drawn as their images, which the type's fill doesn't paint. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::Point; +use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLOUR_LARGE, colour_bitmap_family, colour_bitmap_font}; + +use super::*; + +/// 100 pt [`COLOUR_CHAR`] in the colour bitmap test font (`sbix` or `CBDT`), filled blue, its +/// baseline at (20, 120), on a white 160×160 page. +fn render(sbix: bool, opts: RenderOptions) -> Rendered { + let _adding = crate::tests_fontchange::adding_fonts(); + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(sbix).unwrap()); + let mut d = Document::new(160.0, 160.0); + let st = CharStyle { + font_family: colour_bitmap_family(sbix).into(), + size: 100.0, + fill: Paint::solid(Color::rgb(0.0, 0.0, 1.0)), + ..Default::default() + }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st)))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..opts }) +} + +#[test] +fn a_bitmap_glyph_draws_its_largest_strike_not_the_fill() { + for sbix in [true, false] { + let img = render(sbix, RenderOptions::default()); + // Inside the em square the image covers (the CBDT one from 75 above the baseline to 25 + // below, the sbix one from 87.2 above to 12.8 below), away from its edges. + assert_eq!(img.pixel(70, 80), COLOUR_LARGE, "sbix {sbix}"); + let blue = (0..160).flat_map(|y| (0..160).map(move |x| (x, y))).any(|(x, y)| img.pixel(x, y)[2] > 200 && img.pixel(x, y)[0] < 50); + assert!(!blue, "the fill doesn't paint the glyph (sbix {sbix})"); + } +} + +#[test] +fn outline_mode_draws_a_bitmap_glyph_as_its_box() { + let img = render(false, RenderOptions { outline: true, ..Default::default() }); + assert_eq!(img.pixel(70, 80), [255, 255, 255, 255], "no pixels inside"); + // The box's left edge at x 32.5 (the bearing of 2 px of 6.25 pt), from y 45 to 145. + assert!((31..=34).any(|x| img.pixel(x, 100)[0] < 128), "the box's edge shows"); +} + +/// 100 pt [`COLOUR_CHAR`] in the `CBDT` test font with `effects`, on a white 160×160 page. +fn render_with(effects: Vec<vectorcraft_doc::Effect>) -> Rendered { + let _adding = crate::tests_fontchange::adding_fonts(); + vectorcraft_text::FontDb::global().add_font(colour_bitmap_font(false).unwrap()); + let mut d = Document::new(160.0, 160.0); + let st = CharStyle { font_family: colour_bitmap_family(false).into(), size: 100.0, ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st)))); + let mut appearance = (*n.appearance).clone(); + appearance.effects = effects; + n.appearance = appearance.into(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn effect(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect { + vectorcraft_doc::Effect { id: id.into(), params, visible: true } +} + +/// Type with a geometry effect is drawn as its outlines: its emoji go with them, as images that +/// hold their own pixels (here moved 40 pt by a Transform effect). +#[test] +fn bitmap_glyphs_go_with_type_that_a_geometry_effect_reshapes() { + let img = render_with(vec![effect("distort.transform", serde_json::json!({"moveH": 40}))]); + assert_eq!(img.pixel(110, 80), COLOUR_LARGE, "moved with the type"); + assert_eq!(img.pixel(50, 80), [255, 255, 255, 255], "not where it was"); +} + +/// A colour adjustment leaves the emoji of reshaped type in their colours, as it does those of +/// type, and as exports and Expand Appearance draw them. +#[test] +fn a_colour_adjustment_leaves_the_emoji_of_reshaped_type_as_they_are() { + let moved = effect("distort.transform", serde_json::json!({"moveH": 40})); + let img = render_with(vec![moved, effect("adjust.hueSaturation", serde_json::json!({"hue": 120}))]); + assert_eq!(img.pixel(110, 80), COLOUR_LARGE); +} diff --git a/crates/render/src/tests_changed.rs b/crates/render/src/tests_changed.rs new file mode 100644 index 000000000..108e7604e --- /dev/null +++ b/crates/render/src/tests_changed.rs @@ -0,0 +1,260 @@ +//! Drawing again only what an edit changed gives the pixels of drawing it all again. + +use super::*; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::Appearance; +use vectorcraft_geom::{Point, shapes}; + +const W: u32 = 160; +const H: u32 = 120; + +/// A view that doesn't line the art up with the pixels, so the edges are anti-aliased. +fn view() -> Affine { + Affine::translate((3.3, 2.7)) * Affine::scale(1.37) +} + +fn rect(d: &mut Document, r: Rect, fill: Color) -> Node { + let id = d.alloc_id(); + Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::solid(Color::BLACK), 2.0)) +} + +fn shadow() -> vectorcraft_doc::Effect { + vectorcraft_doc::Effect { + id: "stylize.dropShadow".into(), + params: serde_json::json!({"x": 6.0, "y": 6.0, "blur": 4.0, "opacity": 75.0}), + visible: true, + } +} + +/// A layer with a rectangle, a group at half opacity holding two, a clip group, and one with a +/// drop shadow; the ids of the loose rectangle, a member of each group, and the shadowed one. +fn document() -> (Document, [NodeId; 5]) { + let mut d = Document::new(110.0, 80.0); + let l = d.layers[0].id; + let loose = rect(&mut d, Rect::new(5.0, 5.0, 25.0, 20.0), Color::rgb(1.0, 0.0, 0.0)); + let loose = d.insert(Some(l), 0, loose).unwrap(); + let (a, b) = ( + rect(&mut d, Rect::new(30.0, 5.0, 50.0, 25.0), Color::rgb(0.0, 0.6, 0.0)), + rect(&mut d, Rect::new(40.0, 15.0, 60.0, 35.0), Color::rgb(0.0, 0.0, 1.0)), + ); + let mut group = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(a), Arc::new(b)], clip: false }); + group.opacity = 0.5; + let member = group.children().unwrap()[1].id; + d.insert(Some(l), 1, group).unwrap(); + let (mask, art) = + (rect(&mut d, Rect::new(5.0, 40.0, 30.0, 60.0), Color::WHITE), rect(&mut d, Rect::new(0.0, 45.0, 40.0, 55.0), Color::rgb(0.8, 0.4, 0.0))); + let clipped = art.id; + let clip = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(art)], clip: true }); + d.insert(Some(l), 2, clip).unwrap(); + let mut shadowed = rect(&mut d, Rect::new(60.0, 45.0, 80.0, 60.0), Color::rgb(0.9, 0.9, 0.2)); + shadowed.appearance.effects.push(shadow()); + let shadowed = d.insert(Some(l), 3, shadowed).unwrap(); + let other = rect(&mut d, Rect::new(85.0, 10.0, 105.0, 30.0), Color::rgb(0.5, 0.0, 0.5)); + let other = d.insert(Some(l), 4, other).unwrap(); + (d, [loose, member, clipped, shadowed, other]) +} + +/// Draws `new` again only where it changed from `old`, over `old` drawn whole, and checks that +/// this is `new` drawn whole; returns what changed. +fn redraw(old: &Document, new: &Document) -> Changed { + let mut r = Renderer::new(); + let opts = RenderOptions::default(); + let before = r.render(old, W, H, view(), &opts); + let after = r.render(new, W, H, view(), &opts); + let change = changed(old, new); + let mut patched = Rendered { width: W, height: H, pixels: before.pixels.clone() }; + match change { + Changed::Everything => patched.pixels = after.pixels.clone(), + Changed::Nothing => {} + Changed::Area(area) => { + if let Some([x0, y0, x1, y1]) = view_pixels(area, view(), W, H) { + let part = r.render(new, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view(), &opts); + for y in 0..y1 - y0 { + let (from, to) = ((y * (x1 - x0) * 4) as usize, (((y0 + y) * W + x0) * 4) as usize); + let n = ((x1 - x0) * 4) as usize; + patched.pixels[to..to + n].copy_from_slice(&part.pixels[from..from + n]); + } + } + } + } + assert!(patched.pixels == after.pixels, "{change:?}: drawn again only there, it differs from drawing it all"); + change +} + +fn area(c: Changed) -> Rect { + match c { + Changed::Area(a) => a, + other => panic!("an area: {other:?}"), + } +} + +#[test] +fn moving_an_object_changes_where_it_was_and_is() { + let (old, [loose, ..]) = document(); + let mut new = old.clone(); + new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.contains(Point::new(6.0, 6.0)) && a.contains(Point::new(31.0, 22.0)), "before and after: {a:?}"); + assert!(a.x1 < 35.0, "not the objects beside it: {a:?}"); +} + +#[test] +fn recolouring_a_member_of_a_translucent_group_changes_only_the_member() { + let (old, [_, member, ..]) = document(); + let mut new = old.clone(); + new.node_mut(member).unwrap().appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(1.0, 0.5, 0.0)); + let a = area(redraw(&old, &new)); + assert!(a.x0 > 35.0, "not the group's other member: {a:?}"); +} + +#[test] +fn moving_clipped_art_changes_its_whole_clip_group() { + let (old, [_, _, clipped, ..]) = document(); + let mut new = old.clone(); + new.node_mut(clipped).unwrap().transform(Affine::translate((0.0, 4.0)), false); + redraw(&old, &new); +} + +#[test] +fn moving_a_shadowed_object_changes_where_its_shadow_falls() { + let (old, [.., shadowed, _]) = document(); + let mut new = old.clone(); + new.node_mut(shadowed).unwrap().transform(Affine::translate((-5.0, 5.0)), false); + let a = area(redraw(&old, &new)); + assert!(a.x1 > 86.0 && a.y1 > 70.0, "the shadow, before and after: {a:?}"); +} + +#[test] +fn adding_removing_and_restacking_change_those_objects() { + let (old, [loose, _, _, _, other]) = document(); + let mut added = old.clone(); + let n = rect(&mut added, Rect::new(70.0, 5.0, 90.0, 15.0), Color::BLACK); + let l = added.layers[0].id; + added.insert(Some(l), 5, n).unwrap(); + redraw(&old, &added); + let mut removed = old.clone(); + removed.remove(loose).unwrap(); + redraw(&old, &removed); + let mut restacked = old.clone(); + let n = restacked.remove(other).unwrap(); + restacked.insert(Some(l), 0, (*n).clone()).unwrap(); + redraw(&old, &restacked); +} + +/// A blur too wide for a raster of all of it is cropped to the frame. A part of the frame drawn +/// again, during a drag too, draws it from that raster: the same pixels, and no preview at the +/// drag's lower resolution. +#[test] +fn a_part_of_a_frame_draws_a_cropped_blur_from_the_frames_raster() { + let (mut d, _) = document(); + let mut back = rect(&mut d, Rect::new(0.0, 0.0, 110.0, 80.0), Color::rgb(0.1, 0.3, 0.9)); + back.appearance.effects.push(vectorcraft_doc::Effect { id: "blur.gaussian".into(), params: serde_json::json!({"radius": 500.0}), visible: true }); + let l = d.layers[0].id; + d.insert(Some(l), 0, back).unwrap(); + let view = Affine::scale(2.0) * Affine::translate((-20.0, -15.0)); + let mut r = Renderer::new(); + let whole = r.render(&d, W, H, view, &RenderOptions::default()); + let [x0, y0, x1, y1] = [40, 30, 100, 80]; + let dragging = RenderOptions { interacting: true, dragging: true, ..Default::default() }; + let part = r.render(&d, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &dragging); + assert_eq!(r.stats.previewed, 0, "drawn from the frame's raster"); + for y in 0..y1 - y0 { + for x in 0..x1 - x0 { + assert_eq!(part.pixel(x, y), whole.pixel(x0 + x, y0 + y), "at {x}, {y}"); + } + } +} + +/// Type on a path whose path a script reshapes: its letters stand above the path and run past +/// where it ends, and they change where they were and where they are, outside the path's box. +#[test] +fn reshaping_the_path_of_type_on_a_path_changes_where_its_letters_were_and_are() { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let on = |y: f64| { + let mut t = vectorcraft_doc::text::TextObject::point( + Point::ZERO, + "Text along a path", + vectorcraft_doc::text::CharStyle { size: 14.0, ..Default::default() }, + ); + let line = kurbo::Line::new((10.0, 60.0), (90.0, y)).to_path(0.1); + t.kind = vectorcraft_doc::TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + // The engine lays out type that changed ([`vectorcraft_doc::TextObject::cached_bounds`]). + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + t + }; + let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(on(55.0))))).unwrap(); + let mut new = old.clone(); + if let Some(NodeKind::Text(t)) = new.node_mut(text).map(|n| &mut n.kind) { + **t = on(25.0); + } + redraw(&old, &new); +} + +/// Stroked type with sharp corners: the miter spikes of its characters' strokes reach past half +/// the weight, and change where they were and where they are. +#[test] +fn moving_type_with_mitred_strokes_changes_where_its_spikes_reach() { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let style = vectorcraft_doc::text::CharStyle { + size: 30.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 10.0, + stroke_join: vectorcraft_doc::LineJoin::Miter, + stroke_miter_limit: 10.0, + ..Default::default() + }; + let mut t = vectorcraft_doc::text::TextObject::point(Point::new(10.0, 50.0), "VAWV", style); + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(t)))).unwrap(); + let mut new = old.clone(); + new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false); + redraw(&old, &new); +} + +#[test] +fn an_unchanged_document_changes_nothing_and_a_new_symbol_everything() { + let (old, _) = document(); + assert_eq!(redraw(&old, &old.clone()), Changed::Nothing); + let mut new = old.clone(); + let art = Arc::new(rect(&mut new, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK)); + new.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art }); + assert_eq!(redraw(&old, &new), Changed::Everything); +} + +/// While a pattern is edited its art also draws in the copies round it: an edit of it changes +/// everything. +#[test] +fn an_edit_while_a_pattern_is_edited_changes_everything() { + let (mut old, [loose, ..]) = document(); + let l = old.layers[0].id; + old.pattern_edit = Some(vectorcraft_doc::PatternEdit { pattern: "Tile".into(), layer: l, original: None }); + let mut new = old.clone(); + new.node_mut(loose).unwrap().transform(Affine::translate((7.0, 3.0)), false); + assert_eq!(changed(&old, &new), Changed::Everything); +} + +/// Type stretched more on one axis than its area grows: its characters' strokes stretch with it. +#[test] +fn moving_stretched_type_changes_where_its_stretched_strokes_reach() { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let style = vectorcraft_doc::text::CharStyle { + size: 4.0, + stroke: Paint::solid(Color::BLACK), + stroke_width: 1.0, + stroke_join: vectorcraft_doc::LineJoin::Miter, + stroke_miter_limit: 10.0, + ..Default::default() + }; + let mut t = vectorcraft_doc::text::TextObject::point(Point::ZERO, "VAWV", style); + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + // Its area stays, so its scale factor is 1, while it grows ten times as wide. + t.xf = Affine::translate((10.0, 40.0)) * Affine::scale_non_uniform(10.0, 0.1); + let text = old.insert(Some(l), 0, Node::new(NodeId(0), NodeKind::Text(Box::new(t)))).unwrap(); + let mut new = old.clone(); + new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false); + area(redraw(&old, &new)); +} diff --git a/crates/render/src/tests_cmykblend.rs b/crates/render/src/tests_cmykblend.rs index 9d3f1d933..6a603842a 100644 --- a/crates/render/src/tests_cmykblend.rs +++ b/crates/render/src/tests_cmykblend.rs @@ -154,7 +154,7 @@ fn busy_page() -> Document { shadowed.appearance.effects.push(vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: shadow, visible: true }); add(&mut d, shadowed); let stops = vec![GradientStop::new(0.0, CYAN), GradientStop { opacity: 0.3, ..GradientStop::new(1.0, MAGENTA) }]; - let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); let n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.0, 90.0, 120.0, 130.0)), Appearance::basic(ramp, Paint::None, 0.0)); add(&mut d, n); let mut over = rect(&mut d, Rect::new(100.0, 80.0, 160.0, 120.0), Color::cmyk(0.0, 0.0, 1.0, 0.0)); @@ -222,6 +222,7 @@ fn placed_images_are_separated_not_blackened() { xf: Affine::IDENTITY, link: None, placement: Default::default(), + pixels: None, })), ); add(&mut d, n); @@ -256,7 +257,7 @@ fn rich_black_shows_black_ink_alone_as_rgb_black() { let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); d.images.insert("red".into(), ImageBlob::new("image/png", png)); - let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: "red".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; let n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); add(&mut d, n); assert_eq!(centre_with(&d, true), centre_with(&d, false), "an image shows as it is"); diff --git a/crates/render/src/tests_colrglyphs.rs b/crates/render/src/tests_colrglyphs.rs new file mode 100644 index 000000000..38f07f888 --- /dev/null +++ b/crates/render/src/tests_colrglyphs.rs @@ -0,0 +1,123 @@ +//! COLR colour glyphs: drawn as their layers (or, for paints documents have no form for, their +//! image), which the type's fill doesn't paint, except in the layers in the foreground colour. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Node, NodeKind}; +use vectorcraft_geom::Point; +use vectorcraft_text::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_family, colr_font}; + +use super::*; + +/// 100 pt `text` in the COLR test font (v1 or v0), filled magenta, its baseline at (20, 120) +/// turned by `turn` about there, on a white 160×160 page. +fn render(v1: bool, text: char, turn: f64, opts: RenderOptions) -> Rendered { + let _adding = crate::tests_fontchange::adding_fonts(); + vectorcraft_text::FontDb::global().add_font(colr_font(v1).unwrap()); + let mut d = Document::new(160.0, 160.0); + let st = CharStyle { font_family: colr_family(v1).into(), size: 100.0, fill: Paint::solid(Color::rgb(1.0, 0.0, 1.0)), ..Default::default() }; + let mut t = TextObject::point(Point::new(20.0, 120.0), &text.to_string(), st); + t.xf = Affine::rotate_about(turn, (20.0, 120.0)) * t.xf; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..opts }) +} + +/// The box of the pixels `like` says yes to. +fn ink(img: &Rendered, like: impl Fn([u8; 4]) -> bool) -> Option<(u32, u32, u32, u32)> { + let mut b: Option<(u32, u32, u32, u32)> = None; + for y in 0..img.height { + for x in 0..img.width { + if like(img.pixel(x, y)) { + b = Some(b.map_or((x, y, x, y), |(x0, y0, x1, y1)| (x0.min(x), y0.min(y), x1.max(x), y1.max(y)))); + } + } + } + b +} + +fn magenta(p: [u8; 4]) -> bool { + p[0] > 200 && p[2] > 200 && p[1] < 60 +} + +#[test] +fn colr_layers_draw_their_colours_not_the_fill() { + let img = render(true, COLOUR_CHAR, 0.0, RenderOptions::default()); + // The `O` with its linear gradient red → blue from x 0 to 500 units (20 to 70 pt). + let reddish = ink(&img, |p| p[0] > 180 && p[2] < 90 && p[1] < 60).expect("red at the gradient's start"); + let bluish = ink(&img, |p| p[2] > 180 && p[0] < 90 && p[1] < 60).expect("blue at its end"); + assert!(reddish.0 < bluish.0, "red left of blue: {reddish:?} {bluish:?}"); + assert!(ink(&img, magenta).is_none(), "the fill doesn't paint the glyph"); +} + +#[test] +fn the_foreground_layer_takes_the_fill() { + let img = render(false, COLOUR_CHAR, 0.0, RenderOptions::default()); + assert!(ink(&img, magenta).is_some(), "the `I` layer is in the foreground colour"); + assert!(ink(&img, |p| p == [255, 0, 0, 255]).is_some(), "the `O` layer in red"); +} + +#[test] +fn turned_type_turns_its_colour_layers() { + let size = |turn| { + let (x0, y0, x1, y1) = ink(&render(false, COLOUR_CHAR, turn, RenderOptions::default()), magenta).unwrap(); + (x1 - x0, y1 - y0) + }; + let (w, h) = size(0.0); + assert!(h > w * 3, "the `I` stands: {w}×{h}"); + let (w, h) = size(std::f64::consts::FRAC_PI_2); + assert!(w > h * 3, "turned, it lies: {w}×{h}"); +} + +#[test] +fn outline_mode_shows_the_layers_outlines() { + let filled = render(false, COLOUR_CHAR, 0.0, RenderOptions::default()); + let outlined = render(false, COLOUR_CHAR, 0.0, RenderOptions { outline: true, ..Default::default() }); + let (x0, y0, x1, y1) = ink(&filled, |p| p == [255, 0, 0, 255]).unwrap(); + let dark = |p: [u8; 4]| p[0] < 128 && p[1] < 128 && p[2] < 128; + assert!(ink(&outlined, |p| p == [255, 0, 0, 255]).is_none(), "no colours in outline mode"); + let (ox0, oy0, ox1, oy1) = ink(&outlined, dark).expect("the outlines show"); + assert!(ox0 <= x0 + 2 && oy0 <= y0 + 2 && ox1 + 2 >= x1 && oy1 + 2 >= y1, "around the layers: {:?} {:?}", (ox0, oy0, ox1, oy1), (x0, y0, x1, y1)); +} + +#[test] +fn a_sweep_glyph_draws_its_image() { + let img = render(true, COLR_SWEEP_CHAR, 0.0, RenderOptions::default()); + assert!(ink(&img, |p| p[0] > 180 && p[1] < 60 && p[2] < 120).is_some(), "red where the sweep starts"); + assert!(ink(&img, magenta).is_none()); +} + +/// Type with a geometry effect is drawn reshaped as its outlines: its colour glyphs go with them, +/// as their art (here moved 40 pt by a Transform effect). +#[test] +fn colour_glyphs_go_with_type_that_a_geometry_effect_reshapes() { + let _adding = crate::tests_fontchange::adding_fonts(); + vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap()); + let mut d = Document::new(160.0, 160.0); + let st = CharStyle { font_family: colr_family(false).into(), size: 100.0, ..Default::default() }; + let mut n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &COLOUR_CHAR.to_string(), st)))); + let moved = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 40}), visible: true }; + let mut appearance = (*n.appearance).clone(); + appearance.effects.push(moved); + n.appearance = appearance.into(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let img = Renderer::new().render(&d, 160, 160, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }); + let red = ink(&img, |p| p == [255, 0, 0, 255]).expect("the `O` layer, in red"); + assert!(red.0 >= 58, "moved right with the type: {red:?}"); +} + +/// Type's outlines stack as the canvas paints type: a run's colour glyphs under its fill, so a +/// plain character set over a colour one stays on top. +#[test] +fn outlined_colour_glyphs_stay_under_their_runs_fill() { + let _adding = crate::tests_fontchange::adding_fonts(); + vectorcraft_text::FontDb::global().add_font(colr_font(false).unwrap()); + let mut d = Document::new(160.0, 160.0); + let st = CharStyle { font_family: colr_family(false).into(), size: 100.0, ..Default::default() }; + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(20.0, 120.0), &format!("{COLOUR_CHAR}F"), st)))); + let outlined = vectorcraft_effects::outline_text(&n).unwrap(); + let kinds: Vec<bool> = outlined.children().unwrap().iter().map(|c| matches!(c.kind, NodeKind::Group { .. })).collect(); + assert_eq!(kinds, [true, false], "the colour glyph's group, then the run's path"); +} diff --git a/crates/render/src/tests_container.rs b/crates/render/src/tests_container.rs index 558b78f65..0bb071147 100644 --- a/crates/render/src/tests_container.rs +++ b/crates/render/src/tests_container.rs @@ -158,7 +158,15 @@ fn group_geometry_effects_move_and_copy_images_too() { image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); d.images.insert("red".into(), ImageBlob::new("image/png", png)); let b = rect(&mut d, Rect::new(0.0, 0.0, 20.0, 20.0), solid(0.0, 0.0, 1.0)); - let im = ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((10.0, 30.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "red".into(), + width: 30, + height: 30, + xf: Affine::translate((10.0, 30.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let image = Arc::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))); let mut g = Node::group(d.alloc_id(), vec![b, image]); g.appearance.effects.push(fx("distort.transform", json!({"moveH": 50, "copies": 1}))); diff --git a/crates/render/src/tests_dither.rs b/crates/render/src/tests_dither.rs new file mode 100644 index 000000000..55e70e88e --- /dev/null +++ b/crates/render/src/tests_dither.rs @@ -0,0 +1,172 @@ +//! Dithered gradient fills: fine noise instead of bands, the same colours on average, the same +//! pixels on every render, and nothing changed without dither or on strokes. + +use super::*; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, Node}; +use vectorcraft_geom::shapes; + +const W: u32 = 400; +const H: u32 = 40; + +/// A shallow grey ramp, #404040 → #4a4a4a: ten levels over 400 pixels, bands 40 pixels wide. +fn shallow(kind: GradientKind, dither: bool) -> Paint { + let mut g = + Gradient::new(kind, vec![GradientStop::new(0.0, Color::rgb8(0x40, 0x40, 0x40)), GradientStop::new(1.0, Color::rgb8(0x4a, 0x4a, 0x4a))]); + g.dither = dither; + Paint::Gradient(Box::new(GradientPaint::new(g))) +} + +/// A W × H document with a rectangle over it painted `fill`, stroked `stroke` 10 wide. +fn doc(fill: Paint, stroke: Paint) -> Document { + let mut d = Document::new(W as f64, H as f64); + let id = d.alloc_id(); + let n = Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, W as f64, H as f64)), Appearance::basic(fill, stroke, 10.0)); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + d +} + +fn render(d: &Document, xf: Affine) -> Rendered { + Renderer::new().render(d, W, H, xf, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +/// The longest run of identical pixels along row `y` (the stroke's 5 pixels at each end left out). +fn longest_run(r: &Rendered, y: u32) -> usize { + let row: Vec<[u8; 4]> = (5..W - 5).map(|x| r.pixel(x, y)).collect(); + row.chunk_by(|a, b| a == b).map(<[_]>::len).max().unwrap_or(0) +} + +#[test] +fn a_dithered_shallow_gradient_has_no_bands_where_the_plain_one_does() { + let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), Affine::IDENTITY); + let dithered = render(&doc(shallow(GradientKind::Linear, true), Paint::None), Affine::IDENTITY); + for y in [3, 20, 36] { + assert!(longest_run(&plain, y) >= 30, "the plain ramp bands: row {y}, longest run {}", longest_run(&plain, y)); + assert!(longest_run(&dithered, y) <= 12, "dithered: row {y}, longest run {}", longest_run(&dithered, y)); + } + // The noise moves no colour: each 20-pixel stretch averages what the plain ramp does. + for x0 in (0..W).step_by(20) { + let mean = |r: &Rendered| { + (x0..x0 + 20).flat_map(|x| (0..H).map(move |y| (x, y))).map(|(x, y)| f64::from(r.pixel(x, y)[0])).sum::<f64>() / f64::from(20 * H) + }; + assert!((mean(&plain) - mean(&dithered)).abs() < 0.35, "x {x0}: {} vs {}", mean(&plain), mean(&dithered)); + } + // Noise of at most a level or so either way. + for (x, y) in [(10, 10), (200, 20), (390, 30)] { + let (a, b) = (i32::from(plain.pixel(x, y)[0]), i32::from(dithered.pixel(x, y)[0])); + assert!((a - b).abs() <= 2, "({x}, {y}): {a} vs {b}"); + } +} + +#[test] +fn dithered_renders_are_the_same_every_time() { + let d = doc(shallow(GradientKind::Radial, true), Paint::None); + let (a, b) = (render(&d, Affine::IDENTITY), render(&d, Affine::IDENTITY)); + assert!((0..H).all(|y| (0..W).all(|x| a.pixel(x, y) == b.pixel(x, y)))); + // And with the grid drawn afresh on another renderer, panned by a whole pixel. + let moved = Renderer::new().render( + &d, + W, + H, + Affine::translate((1.0, 0.0)), + &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }, + ); + assert!((0..H).all(|y| (1..W).all(|x| moved.pixel(x, y) == a.pixel(x - 1, y))), "the noise moves with the art"); +} + +#[test] +fn dither_leaves_strokes_and_undithered_fills_as_they_were() { + // Dither applies to fills only: a stroke draws its gradient as before. + let fill = Paint::solid(Color::WHITE); + let a = render(&doc(fill.clone(), shallow(GradientKind::Linear, false)), Affine::IDENTITY); + let b = render(&doc(fill, shallow(GradientKind::Linear, true)), Affine::IDENTITY); + assert!((0..H).all(|y| (0..W).all(|x| a.pixel(x, y) == b.pixel(x, y))), "a dithered gradient on a stroke draws as an undithered one"); + // Off: the vector gradient, its bands where they always were. + let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), Affine::IDENTITY); + assert_eq!(plain.pixel(0, 20)[0], 0x40); + assert_eq!(plain.pixel(W - 1, 20)[0], 0x4a); +} + +#[test] +fn a_zoomed_or_rotated_dithered_gradient_keeps_its_colours() { + for xf in [Affine::scale(3.0), Affine::rotate_about(0.3, vectorcraft_geom::Point::new(200.0, 20.0)), Affine::scale(40.0)] { + let plain = render(&doc(shallow(GradientKind::Linear, false), Paint::None), xf); + let dithered = render(&doc(shallow(GradientKind::Linear, true), Paint::None), xf); + for (x, y) in [(50, 10), (150, 20), (250, 30)] { + let (a, b) = (plain.pixel(x, y), dithered.pixel(x, y)); + assert!(a.iter().zip(b).all(|(p, q)| (i32::from(*p) - i32::from(q)).abs() <= 2), "{xf:?} ({x}, {y}): {a:?} vs {b:?}"); + } + } +} + +#[test] +fn a_large_dithered_box_mostly_off_screen_samples_only_what_shows() { + // A 2000-unit square, the shallow ramp across it: a 2048-cell grid a side, 16 MiB. + const S: u32 = 2000; + let mut d = Document::new(f64::from(S), f64::from(S)); + let id = d.alloc_id(); + let n = Node::path( + id, + shapes::rectangle(Rect::new(0.0, 0.0, f64::from(S), f64::from(S))), + Appearance::basic(shallow(GradientKind::Linear, true), Paint::None, 0.0), + ); + d.insert(Some(d.layers[0].id), 0, n).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let whole = Renderer::new().render(&d, S, S, Affine::IDENTITY, &opts); + // A 64-pixel view of its middle draws the pixels the whole render has there... + let mut part = None; + let bytes = dither::grid_bytes_after(|| part = Some(Renderer::new().render(&d, 64, 64, Affine::translate((-1000.0, -1000.0)), &opts))); + let part = part.unwrap(); + assert!((0..64).all(|y| (0..64).all(|x| part.pixel(x, y) == whole.pixel(1000 + x, 1000 + y))), "the view draws what the whole render does"); + // ...from the cells around it only, not the whole grid. + assert!(bytes > 0 && bytes <= 512 * 512 * 4, "{bytes} bytes of grid for a 64-pixel view"); +} + +#[test] +fn a_dithered_gradient_without_stops_draws_nothing_and_samples_nothing() { + let mut g = Gradient::new(GradientKind::Linear, Vec::new()); + g.dither = true; + let d = doc(Paint::Gradient(Box::new(GradientPaint::new(g))), Paint::None); + let mut r = None; + let bytes = dither::grid_bytes_after(|| r = Some(render(&d, Affine::IDENTITY))); + assert_eq!(bytes, 0, "no grid is sampled"); + let r = r.unwrap(); + assert!((0..H).all(|y| (0..W).all(|x| r.pixel(x, y) == [255, 255, 255, 255])), "nothing is drawn"); +} + +/// Blue to yellow across the document, mixed `method`'s way, dithered or not. +fn blue_yellow(method: vectorcraft_color::GradientMethod, dither: bool) -> Paint { + let mut g = Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))], + ); + (g.method, g.dither) = (method, dither); + Paint::Gradient(Box::new(GradientPaint::new(g))) +} + +#[test] +fn a_dithered_perceptual_gradient_is_the_perceptual_mix_with_noise() { + use vectorcraft_color::GradientMethod::{Classic, Perceptual}; + // Classic first: the two share stops and box, and must not share a dithered grid. + let classic = render(&doc(blue_yellow(Classic, true), Paint::None), Affine::IDENTITY); + let dithered = render(&doc(blue_yellow(Perceptual, true), Paint::None), Affine::IDENTITY); + // Each pixel within a level or so of the perceptual mix across it (its grid cell's centre lies + // in it; the noise, rounding, and the mix's stops within half a level), and dither's noise + // moving many off the rounded mix at its centre. + let Paint::Gradient(want) = blue_yellow(Perceptual, false) else { panic!("a gradient") }; + let mix = |x: f32| want.gradient.sample(x / W as f32).0.to_rgb().map(|v| v.clamp(0.0, 1.0) * 255.0); + let mut noisy = 0; + for x in 5..W - 5 { + let p = dithered.pixel(x, 20); + let (a, m, b) = (mix(x as f32), mix(x as f32 + 0.5), mix(x as f32 + 1.0)); + let near = |k: usize| (a[k].min(b[k]) - 2.0..=a[k].max(b[k]) + 2.0).contains(&f32::from(p[k])); + assert!((0..3).all(near), "({x}, 20): {p:?} vs {a:?} to {b:?}"); + noisy += usize::from((0..3).any(|k| p[k] != m[k].round() as u8)); + } + assert!(noisy > 100, "dither's noise: {noisy} pixels off the rounded mix"); + // The middle: a blue-grey (OKLab), where Classic+Dither gives the display RGB mid grey. + let [r, g, b, _] = dithered.pixel(W / 2, 20).map(i32::from); + assert!(b > r + 20 && b > g + 10, "perceptual: {:?}", [r, g, b]); + let [r, g, b, _] = classic.pixel(W / 2, 20).map(i32::from); + assert!((r - b).abs() <= 4 && (g - b).abs() <= 4, "classic: {:?}", [r, g, b]); +} diff --git a/crates/render/src/tests_fontchange.rs b/crates/render/src/tests_fontchange.rs index 9f7b543b7..3d8e158ea 100644 --- a/crates/render/src/tests_fontchange.rs +++ b/crates/render/src/tests_fontchange.rs @@ -20,7 +20,7 @@ pub(crate) fn fonts_steady() -> RwLockReadGuard<'static, ()> { } /// No test holding [`fonts_steady`] runs until the guard is dropped. -fn adding_fonts() -> RwLockWriteGuard<'static, ()> { +pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> { FONTS.write().unwrap_or_else(PoisonError::into_inner) } diff --git a/crates/render/src/tests_isolation.rs b/crates/render/src/tests_isolation.rs index 7b2296920..d4d05b48a 100644 --- a/crates/render/src/tests_isolation.rs +++ b/crates/render/src/tests_isolation.rs @@ -416,3 +416,27 @@ fn isolation_mode_dims_the_art_around_the_isolated_group() { // An object that isn't in the document dims nothing. assert_eq!(isolated(NodeId(u64::MAX)).pixels, plain.pixels); } + +/// Deeply nested isolated blending groups (#3581): each level is drawn offscreen only while the +/// offscreen pixels alive stay within a few frames' worth; deeper levels are drawn as layers. +#[test] +fn nested_isolated_blending_groups_stay_within_the_offscreen_budget() { + let d = doc(|d| { + let mut n = multiply(d); + for _ in 0..30 { + n = group(d, vec![n], 1.0, Blend::Multiply, true); + } + n + }); + let drawn = || crate::group::PIXELS_DRAWN.with(std::cell::Cell::get); + let before = drawn(); + let frame = render(&d); + let images = drawn() - before; + // Each level's offscreen region is the content's bounds (20..80) plus `region`'s 2 px + // antialiasing margin on each side: 64 × 64. A budget of 4 frames fits whole levels only. + let level = 64 * 64; + let budget = 4 * 100 * 100 / level * level; + assert!(images <= budget, "{images} pixels drawn from offscreen groups, budget {budget}"); + assert_near(frame.pixel(50, 50), blend_rgb(Blend::Multiply, B, S), "inside the groups"); + assert_near(frame.pixel(10, 10), B, "outside the groups"); +} diff --git a/crates/render/src/tests_layeropts.rs b/crates/render/src/tests_layeropts.rs index ffac0cd3c..7c76f4c31 100644 --- a/crates/render/src/tests_layeropts.rs +++ b/crates/render/src/tests_layeropts.rs @@ -27,6 +27,7 @@ fn doc(layer: impl FnOnce(&mut NodeKind)) -> Document { xf: Affine::translate((50.0, 10.0)), link: None, placement: Default::default(), + pixels: None, })), ); im.id = d.alloc_id(); @@ -166,3 +167,71 @@ fn guides_draw_on_screen_only() { assert!(drawn(false) > 0, "on screen the guide shows"); assert_eq!(drawn(true), 0, "exports leave it out"); } + +/// #3219: View > Hide Guides hides paths made into guides without using the export/template +/// filtering switch; Show Guides (the default) draws them again. +#[test] +fn hide_guides_hides_guide_paths_on_screen() { + let mut d = Document::new(100.0, 100.0); + let l = d.layers[0].id; + let mut g = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(70.0, 70.0, 90.0, 90.0)), Appearance::default()); + if let NodeKind::Path { guide, .. } = &mut g.kind { + *guide = true; + } + d.insert(Some(l), 0, g).unwrap(); + let pixels = |hide_guides: bool| { + let opts = RenderOptions { background: Some([255; 4]), hide_guides, ..Default::default() }; + let r = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts); + (60..100).flat_map(|y| (60..100).map(move |x| (x, y))).filter(|&(x, y)| r.pixel(x, y) != [255; 4]).count() + }; + assert!(pixels(false) > 0, "Show Guides draws the guide path"); + assert_eq!(pixels(true), 0, "Hide Guides leaves the guide path out"); +} + +/// #3219: hiding guides omits guide paths, including those inside cached group effects, without +/// hiding ordinary artwork. The same renderer must show them again when the view is toggled back. +#[test] +fn hidden_guide_paths_leave_the_artwork_and_refresh_cached_effects() { + for effect in [false, true] { + let mut d = Document::new(120.0, 120.0); + let layer = d.layers[0].id; + let mut guide = + Node::path(d.alloc_id(), shapes::rectangle(Rect::new(20.5, 20.5, 60.5, 60.5)), Appearance::basic(Paint::None, Paint::None, 0.0)); + if let NodeKind::Path { guide, .. } = &mut guide.kind { + *guide = true; + } + let id = guide.id; + let art = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(80.0, 80.0, 100.0, 100.0)), + Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0), + ); + let mut group = Node::group(d.alloc_id(), vec![Arc::new(guide), Arc::new(art)]); + if effect { + group.appearance.effects.push(vectorcraft_doc::Effect { + id: "blur.gaussian".into(), + params: serde_json::json!({"radius":2}), + visible: true, + }); + } + d.insert(Some(layer), 0, group).unwrap(); + let mut without_guide = d.clone(); + without_guide.remove(id).unwrap(); + let mut renderer = Renderer::new(); + for outline in [false, true] { + let opts = RenderOptions { outline, background: Some([255; 4]), ..Default::default() }; + let shown = renderer.render(&d, 120, 120, Affine::IDENTITY, &opts); + let hidden = RenderOptions { hide_guides: true, ..opts.clone() }; + let expected = Renderer::new().render(&without_guide, 120, 120, Affine::IDENTITY, &hidden); + assert_ne!(shown.pixels, expected.pixels, "the guide is initially visible, effect={effect}, outline={outline}"); + assert!( + renderer.render(&d, 120, 120, Affine::IDENTITY, &hidden).pixels == expected.pixels, + "only guides hide, effect={effect}, outline={outline}" + ); + assert!( + renderer.render(&d, 120, 120, Affine::IDENTITY, &opts).pixels == shown.pixels, + "guides show again, effect={effect}, outline={outline}" + ); + } + } +} diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index 46c649f2e..8b1751c85 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -26,7 +26,7 @@ fn doc_with_image(key: &str, bytes: Vec<u8>, w: u32, h: u32) -> Document { let mut d = Document::new(w as f64, h as f64); d.images.insert(key.into(), ImageBlob::new("image/png", bytes)); let (id, layer) = (d.alloc_id(), d.layers[0].id); - let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: w, height: h, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } @@ -39,16 +39,16 @@ fn renderers_share_one_decode_of_an_image() { // Another document holding the same image under another key (its own copy of the bytes). let b = doc_with_image("photo", bytes, 16, 16); let (mut r1, mut r2) = (Renderer::new(), Renderer::new()); - let (k1, p1) = r1.image_pixmap(&a, "img", false, true).unwrap(); - let (k2, p2) = r2.image_pixmap(&b, "photo", false, true).unwrap(); + let (k1, p1) = r1.image_pixmap(&a, "img", None, false, true).unwrap(); + let (k2, p2) = r2.image_pixmap(&b, "photo", None, false, true).unwrap(); assert_eq!(k1, k2); assert!(Arc::ptr_eq(&p1, &p2), "decoded once for both renderers"); // Drawing goes through the store too: the pixels stay the ones decoded. let _ = r2.render(&b, 16, 16, Affine::IDENTITY, &RenderOptions::default()); - assert!(Arc::ptr_eq(&r1.image_pixmap(&b, "photo", false, true).unwrap().1, &p1)); + assert!(Arc::ptr_eq(&r1.image_pixmap(&b, "photo", None, false, true).unwrap().1, &p1)); // A document using the same key for other pixels gets its own. let c = doc_with_image("img", png(16, 16, 102), 16, 16); - let (k3, p3) = r1.image_pixmap(&c, "img", false, true).unwrap(); + let (k3, p3) = r1.image_pixmap(&c, "img", None, false, true).unwrap(); assert!(k3 != k1 && !Arc::ptr_eq(&p3, &p1)); assert!( r1.render(&c, 16, 16, Affine::IDENTITY, &RenderOptions::default()).pixels @@ -102,7 +102,7 @@ fn image_mips_are_opt_in_and_change_nothing_off() { // On, the quarter-size image samples the quarter-size mip: close to the full pixels' average // but not the same pixels. assert!(mipped.pixels != cold.pixels); - let (key, _) = warm.image_pixmap(&d, "img", false, true).unwrap(); + let (key, _) = warm.image_pixmap(&d, "img", None, false, true).unwrap(); let mip = images::store().peek(&images::ImageKey { mip: 2, ..key }).expect("quarter mip kept"); assert_eq!((mip.width(), mip.height()), (16, 16)); // Above half size, mips on draw the full pixels. @@ -110,6 +110,29 @@ fn image_mips_are_opt_in_and_change_nothing_off() { assert!(Renderer::new().render(&d, 48, 48, near, &on).pixels == Renderer::new().render(&d, 48, 48, near, &off).pixels); } +/// #2820: with mips on, an image drawn at a quarter of its size draws its kept mip in later frames +/// without its full pixels. Those go first when the store is over budget (only the mip is marked +/// used), and were decoded again in every frame: dragging one of 20 large photos took seconds a +/// frame. +#[test] +fn kept_mips_draw_without_decoding_the_full_pixels_again() { + let _fonts = fonts_steady(); + let d = doc_with_image("img", png(64, 64, 104), 64, 64); + let on = RenderOptions { image_mips: true, ..Default::default() }; + let quarter = Affine::scale(0.25); + let mut r = Renderer::new(); + let _ = r.render(&d, 32, 16, quarter, &on); + let full = r.image_pixmap(&d, "img", None, false, false).unwrap().0; + images::store().remove(&full); + // The image dragged 4 pt (a device pixel) right. + let mut moved = d.clone(); + let id = moved.layers[0].children().unwrap()[0].id; + moved.node_mut(id).unwrap().transform(Affine::translate((4.0, 0.0)), false); + let frame = r.render(&moved, 32, 16, quarter, &on); + assert!(images::store().peek(&full).is_none(), "the full pixels were decoded again"); + assert!(frame.pixels == Renderer::new().render(&moved, 32, 16, quarter, &on).pixels); +} + /// A document of `n` point type objects reading `word`, in rows. fn text_doc(n: usize, word: &str) -> Document { use vectorcraft_doc::text::{CharStyle, TextObject}; @@ -452,7 +475,15 @@ fn art_placed_again_in_every_frame_keeps_one_outline_per_stroke() { let mut d = Document::new(200.0, 100.0); d.images.insert("dot".into(), ImageBlob::new("image/png", png(4, 4, 108))); let mut art = symbol_art(&mut d); - let im = ImageObject { key: "dot".into(), width: 4, height: 4, xf: Affine::translate((-2.0, -2.0)), link: None, placement: Default::default() }; + let im = ImageObject { + key: "dot".into(), + width: 4, + height: 4, + xf: Affine::translate((-2.0, -2.0)), + link: None, + placement: Default::default(), + pixels: None, + }; let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); if let Some(c) = art.children_mut() { c.push(Arc::new(image)); @@ -784,9 +815,8 @@ fn art_with_raster_effects_draws_pattern_and_resolution_edits() { }); } -/// Symbol art with a live object (an envelope that distorts a pattern fill) is placed again in -/// every frame: when the pattern's tile type changes, the next frame draws what a new renderer -/// draws. +/// Symbol art with a live object (an envelope that distorts a pattern fill) is kept between +/// frames: when the pattern's tile type changes, the next frame draws what a new renderer draws. #[test] fn art_with_live_objects_draws_pattern_layout_edits() { let _fonts = fonts_steady(); @@ -822,6 +852,128 @@ fn art_with_live_objects_draws_pattern_layout_edits() { }); } +/// Instances of four symbols whose art shows a live object (a group holding an envelope, an +/// envelope, a blend and a grid repeat), and an inline graphic of the envelope's symbol: the second +/// frame draws the evaluations the first made, and draws what a new renderer draws. +#[test] +fn live_objects_in_symbol_art_keep_their_evaluations_between_frames() { + let _fonts = fonts_steady(); + use vectorcraft_doc::text::{CharStyle, InlineArt, TextObject, TextRun}; + use vectorcraft_geom::shapes::rectangle; + let mut d = Document::new(200.0, 100.0); + let band = |d: &mut Document| filled(d, rectangle(Rect::new(-15.0, -8.0, 15.0, 8.0)), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), vec![]); + let b = band(&mut d); + let bent = arc_envelope(&mut d, vec![b], false); + let bent = Node::group(d.alloc_id(), vec![Arc::new(bent)]); + let b = band(&mut d); + let envelope = arc_envelope(&mut d, vec![b], false); + let square = |d: &mut Document, x: f64, c: Color| Arc::new(filled(d, rectangle(Rect::new(x, -4.0, x + 8.0, 4.0)), Paint::solid(c), vec![])); + let ends = vec![square(&mut d, -16.0, Color::rgb(1.0, 0.0, 0.0)), square(&mut d, 8.0, Color::rgb(0.0, 0.0, 1.0))]; + let spec = vectorcraft_doc::live::BlendSpec { spacing: vectorcraft_doc::live::BlendSpacing::Steps(4), ..Default::default() }; + let blend = Node::new(d.alloc_id(), NodeKind::Blend { children: ends, spec: Box::new(spec) }); + let source = symbol_art(&mut d); + let grid = Node::new(d.alloc_id(), NodeKind::Repeat(vectorcraft_doc::pattern::RepeatSpec::grid(vec![Arc::new(source)], 4.0, 4.0))); + let l = d.layers[0].id; + for (name, art, at) in + [("Bent", bent, (40.0, 25.0)), ("Envelope", envelope, (100.0, 25.0)), ("Blend", blend, (160.0, 25.0)), ("Grid", grid, (20.0, 60.0))] + { + d.symbols.push(vectorcraft_doc::Symbol { name: name.into(), art: Arc::new(art) }); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate(at) })).unwrap(); + } + let style = CharStyle { size: 24.0, ..Default::default() }; + let mut t = TextObject::point(vectorcraft_geom::Point::new(120.0, 85.0), "A", style.clone()); + t.runs.push(TextRun::inline(InlineArt::new("Envelope"), style)); + let id = d.alloc_id(); + d.insert(Some(l), usize::MAX, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + let evaluated = r.live.evaluated; + assert!(evaluated >= 5, "the first frame evaluates the four live objects and the inline graphic's envelope: {evaluated}"); + let second = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert_eq!(r.live.evaluated, evaluated, "the second frame evaluates none of them again"); + assert!(second == first, "the second frame draws the same"); + assert!(second == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels, "a new renderer draws the same"); +} + +/// A rectangle whose opacity mask is an envelope, drawn in Preview, and an envelope in a clip +/// group, drawn in Outline view: the second frame of each draws the evaluation the first made, and +/// draws what a new renderer draws. +#[test] +fn live_mask_art_and_live_clip_group_members_keep_their_evaluations_between_frames() { + let _fonts = fonts_steady(); + use vectorcraft_geom::shapes::rectangle; + let mut d = Document::new(200.0, 100.0); + let white = filled(&mut d, rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), Paint::solid(Color::WHITE), vec![]); + let mask = arc_envelope(&mut d, vec![white], false); + let mut masked = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Paint::solid(Color::BLACK), vec![]); + masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(mask, true))); + let clip = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 190.0, 90.0)), Paint::None, vec![]); + let band = filled(&mut d, rectangle(Rect::new(100.0, 30.0, 200.0, 70.0)), Paint::solid(Color::rgb(0.0, 0.5, 0.0)), vec![]); + let bent = arc_envelope(&mut d, vec![band], false); + let clipped = Node::new(d.alloc_id(), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(bent)], clip: true }); + let d = with_art(d, vec![masked, clipped]); + let preview = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let outline = RenderOptions { outline: true, ..preview.clone() }; + for (view, opts) in [("Preview", &preview), ("Outline", &outline)] { + let mut r = Renderer::new(); + let first = r.render(&d, 200, 100, Affine::IDENTITY, opts).pixels; + let evaluated = r.live.evaluated; + assert!(evaluated >= 1, "{view}: the first frame evaluates the envelope: {evaluated}"); + let second = r.render(&d, 200, 100, Affine::IDENTITY, opts).pixels; + assert_eq!(r.live.evaluated, evaluated, "{view}: the second frame evaluates nothing again"); + assert!(second == first, "{view}: the second frame draws the same"); + assert!(second == Renderer::new().render(&d, 200, 100, Affine::IDENTITY, opts).pixels, "{view}: a new renderer draws the same"); + } +} + +/// Symbol art that is an envelope, or a group holding one, bends a band filled with pattern `Dots` +/// (an instance of `S` at half size in each 20 × 20 tile). After two frames of its instance, the +/// next frame draws an edit of `S`'s art (a red bar) and a change of `Dots` to bricks as a new +/// renderer draws them. +#[test] +fn live_objects_in_symbol_art_draw_symbol_and_pattern_edits() { + let _fonts = fonts_steady(); + use vectorcraft_doc::pattern::{PatternDef, TileType, pattern_paint}; + use vectorcraft_geom::shapes::rectangle; + for grouped in [false, true] { + for bricks in [false, true] { + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let xf = Affine::translate((10.0, 10.0)) * Affine::scale(0.5); + let dot = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf }); + let mut dots = PatternDef::new("Dots", vec![Arc::new(dot)]); + dots.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(dots); + let band = filled(&mut d, rectangle(Rect::new(-80.0, -30.0, 80.0, 30.0)), pattern_paint("Dots"), vec![]); + let envelope = arc_envelope(&mut d, vec![band], true); + let art = if grouped { Node::group(d.alloc_id(), vec![Arc::new(envelope)]) } else { envelope }; + d.symbols.push(vectorcraft_doc::Symbol { name: "Bent".into(), art: Arc::new(art) }); + let (l, id) = (d.layers[0].id, d.alloc_id()); + d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bent".into(), xf: Affine::translate((100.0, 50.0)) })).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let mut r = Renderer::new(); + r.render(&d, 200, 100, Affine::IDENTITY, &opts); + let before = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + if bricks { + if let Some(def) = d.pattern_mut("Dots") { + def.tile_type = TileType::BrickByRow { offset: 0.5 }; + } + } else { + let bar = rectangle(Rect::new(-20.0, -4.0, 20.0, 4.0)); + let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + d.symbols[0].art = Arc::new(Node::path(d.alloc_id(), bar, red)); + } + let after = r.render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(after != before, "grouped {grouped}, bricks {bricks}: the edit changes the frame"); + let fresh = Renderer::new().render(&d, 200, 100, Affine::IDENTITY, &opts).pixels; + assert!(after == fresh, "grouped {grouped}, bricks {bricks}: a new renderer draws the same"); + } + } +} + /// An envelope of `d` bending `content` with an arc warp (30%); with `patterns`, Distort /// Appearance and Distort Pattern Fills are on, and the tiles of its pattern fills bend with it. fn arc_envelope(d: &mut Document, content: Vec<Node>, patterns: bool) -> Node { @@ -1332,7 +1484,7 @@ fn blurred_image_doc(key: &str, seed: u8) -> Document { let mut d = Document::new(200.0, 100.0); d.images.insert(key.into(), ImageBlob::new("image/png", png(16, 16, seed))); let xf = Affine::translate((20.0, 10.0)) * Affine::scale(5.0); - let im = ImageObject { key: key.into(), width: 16, height: 16, xf, link: None, placement: Default::default() }; + let im = ImageObject { key: key.into(), width: 16, height: 16, xf, link: None, placement: Default::default(), pixels: None }; let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im))); n.appearance.effects.push(effect("blur.gaussian", serde_json::json!({"radius": 1.0}))); with_art(d, vec![n]) diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs index 24d941d26..799de969d 100644 --- a/crates/render/src/tests_objectfx.rs +++ b/crates/render/src/tests_objectfx.rs @@ -285,6 +285,7 @@ fn image_doc(effects: Vec<Effect>) -> Document { xf: Affine::translate((20.0, 20.0)), link: None, placement: Default::default(), + pixels: None, })), ); n.appearance.effects = effects; @@ -370,7 +371,7 @@ fn type_drawn_through_its_outlines_keeps_its_stroke_weight_when_scaled() { #[test] fn type_drawn_through_its_outlines_keeps_its_gradients_in_place() { let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let mut g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); g.angle = 90.0; let ramp = Paint::Gradient(Box::new(g)); // On the characters, the gradient fits the layout box in text space and turns with the type. @@ -387,7 +388,7 @@ fn type_drawn_through_its_outlines_keeps_its_gradients_in_place() { /// that runs left to right (0°) or bottom to top (90°). fn gradient_text_doc(angle: f64) -> Document { let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let mut g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); g.angle = angle; styled_text_doc(CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(g)), ..Default::default() }, Affine::IDENTITY) } @@ -443,7 +444,7 @@ fn a_transform_effect_that_scales_type_out_of_proportion_scales_its_radial_and_d let mut changed = vec![]; // A radial gradient turns into an ellipse, and a linear one at 45° turns its isolines. for (kind, angle) in [(GradientKind::Radial, 0.0), (GradientKind::Linear, 45.0)] { - let mut g = GradientPaint::new(Gradient { kind, stops: stops.clone() }); + let mut g = GradientPaint::new(Gradient::new(kind, stops.clone())); g.angle = angle; let d = styled_text_doc(CharStyle { size: 40.0, fill: Paint::Gradient(Box::new(g)), ..Default::default() }, Affine::IDENTITY); let id = d.layers[0].children().unwrap()[0].id; @@ -593,7 +594,7 @@ fn geometry_effects_reshape_type_through_its_outlines() { assert!(changed > 100, "{changed} pixels changed"); // The reshaped art is plain paths painted with the text's fill. let n = &d.layers[0].children().unwrap()[0]; - let art = vectorcraft_effects::reshape(n, None).unwrap(); + let art = vectorcraft_effects::reshape(None, n, None).unwrap(); let (mut text, mut black) = (0, 0); art.walk(&mut |m| { text += usize::from(matches!(m.kind, NodeKind::Text(_))); @@ -616,7 +617,7 @@ fn outline_object_draws_type_as_its_outlines_and_leaves_its_look() { // The type is drawn through its outlines: plain paths painted with the text's fill. let d = text_doc(vec![fx("path.outlineObject", json!({}))]); let n = &d.layers[0].children().unwrap()[0]; - let art = vectorcraft_effects::reshape(n, None).unwrap(); + let art = vectorcraft_effects::reshape(None, n, None).unwrap(); let (mut text, mut black) = (0, 0); art.walk(&mut |m| { text += usize::from(matches!(m.kind, NodeKind::Text(_))); @@ -649,7 +650,7 @@ fn outline_object_leaves_paths_and_images_as_they_are() { assert!(changed.iter().all(|&n| n < 10), "pixels changed: {changed:?}"); // The image is drawn through its frame, as under any geometry effect. let d = image_doc(vec![fx("path.outlineObject", json!({}))]); - assert!(vectorcraft_effects::reshape(&d.layers[0].children().unwrap()[0], None).is_some()); + assert!(vectorcraft_effects::reshape(None, &d.layers[0].children().unwrap()[0], None).is_some()); } #[test] @@ -761,7 +762,7 @@ fn a_strokes_own_glow_paints_around_that_stroke_only() { fn a_path_with_its_own_transform_paints_each_copy_with_its_own_gradient() { use vectorcraft_color::GradientGeom; let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let ramp = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(20.0, 50.0), aspect: 1.0, focal: None }; // Fitted to the box on each render, and placed on the object. for paint in [ramp.clone(), GradientPaint { geom: Some(geom), ..ramp }] { diff --git a/crates/render/src/tests_placed_document.rs b/crates/render/src/tests_placed_document.rs index cedfbcdc2..cc1af75ce 100644 --- a/crates/render/src/tests_placed_document.rs +++ b/crates/render/src/tests_placed_document.rs @@ -31,6 +31,7 @@ fn doc_with(key: &str, bytes: &[u8]) -> Document { link: LinkInfo::new("/art/card.vectorcraft"), key: key.into(), bounding: false, + page_box: Default::default(), width: 40.0, height: 20.0, xf: Affine::translate((10.0, 10.0)) * Affine::scale(2.0), diff --git a/crates/render/src/tests_setup.rs b/crates/render/src/tests_setup.rs index e8129f467..a03130c5f 100644 --- a/crates/render/src/tests_setup.rs +++ b/crates/render/src/tests_setup.rs @@ -10,7 +10,7 @@ fn doc_with_red_image() -> Document { image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); d.images.insert("red".into(), ImageBlob::new("image/png", png)); let (id, layer) = (d.alloc_id(), d.layers[0].id); - let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default() }; + let im = ImageObject { key: "red".into(), width: 40, height: 40, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None }; d.insert(Some(layer), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); d } diff --git a/crates/render/src/tests_tileedge.rs b/crates/render/src/tests_tileedge.rs index 2c487ff8f..06b27fe16 100644 --- a/crates/render/src/tests_tileedge.rs +++ b/crates/render/src/tests_tileedge.rs @@ -68,6 +68,19 @@ fn swatch_bounds_outline_the_period_dashed() { assert!((51..70).all(|x| is_white(off.pixel(x, 90)))); } +/// #3261: exports (which leave templates and guides out) don't draw the tile edge or the swatch +/// bounds, but do draw the tile's art. +#[test] +fn exports_leave_the_tile_edge_and_swatch_bounds_out() { + let d = editing(TileType::BrickByRow { offset: 0.5 }, true, true); + let opts = RenderOptions { background: Some([255; 4]), tile_edge: [0, 200, 0], skip_templates: true, ..Default::default() }; + let out = Renderer::new().render(&d, 120, 120, Affine::translate((50.5, 50.5)), &opts); + assert!(is_white(out.pixel(50, 65)), "tile edge: {:?}", out.pixel(50, 65)); + assert!((51..70).all(|x| is_white(out.pixel(x, 90))), "swatch bounds"); + let p = out.pixel(55, 55); + assert!(p[0] > 200 && p[1] < 60 && p[2] < 60, "the red square is still drawn: {p:?}"); +} + /// #1534: a path made into a guide is drawn in the guide colour and style (Preferences › Guides & /// Grid), as ruler guides are: a line, or dots with gaps between them. #[test] diff --git a/crates/render/tests/dash_align.rs b/crates/render/tests/it/dash_align.rs similarity index 100% rename from crates/render/tests/dash_align.rs rename to crates/render/tests/it/dash_align.rs diff --git a/crates/render/tests/golden.rs b/crates/render/tests/it/golden.rs similarity index 98% rename from crates/render/tests/golden.rs rename to crates/render/tests/it/golden.rs index 20955f178..4820cf8b6 100644 --- a/crates/render/tests/golden.rs +++ b/crates/render/tests/it/golden.rs @@ -1,7 +1,5 @@ //! Pixel-sample "golden" tests on small fixtures: gradients, blend modes, clip groups, stroke //! alignment/dashes/arrowheads, effects, text, and opacity folding vs layer compositing. -// 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 serde_json::json; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; @@ -21,7 +19,7 @@ fn stops(a: Color, b: Color) -> Vec<GradientStop> { } fn gradient(kind: GradientKind, a: Color, b: Color, angle: f64) -> Paint { - let mut gp = GradientPaint::new(Gradient { kind, stops: stops(a, b) }); + let mut gp = GradientPaint::new(Gradient::new(kind, stops(a, b))); gp.angle = angle; Paint::Gradient(Box::new(gp)) } diff --git a/crates/render/tests/gradient_alpha.rs b/crates/render/tests/it/gradient_alpha.rs similarity index 97% rename from crates/render/tests/gradient_alpha.rs rename to crates/render/tests/it/gradient_alpha.rs index 15cf21247..92e515917 100644 --- a/crates/render/tests/gradient_alpha.rs +++ b/crates/render/tests/it/gradient_alpha.rs @@ -51,7 +51,7 @@ fn transparent_stops_blend_colour_and_opacity_apart() { (GradientKind::Linear, GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }), (GradientKind::Radial, GradientGeom { start: Point::new(50.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None }), ] { - let gradient = Gradient { kind, stops: stops.clone() }; + let gradient = Gradient::new(kind, stops.clone()); assert_follows_model(&render_view(&filled(&gradient, geom), 100, 100), &gradient, &geom); } } diff --git a/crates/render/tests/gradient_text.rs b/crates/render/tests/it/gradient_text.rs similarity index 94% rename from crates/render/tests/gradient_text.rs rename to crates/render/tests/it/gradient_text.rs index 6307a406a..2857058ce 100644 --- a/crates/render/tests/gradient_text.rs +++ b/crates/render/tests/it/gradient_text.rs @@ -1,6 +1,4 @@ //! Gradients on type: a vector across the text, and run strokes fitted like object strokes. -// 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 serde_json::json; use vectorcraft_color::Paint; diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs new file mode 100644 index 000000000..67afd6993 --- /dev/null +++ b/crates/render/tests/it/main.rs @@ -0,0 +1,10 @@ +//! Integration tests: one test binary for the crate, one module per area. +// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod dash_align; +mod golden; +mod gradient_alpha; +mod gradient_text; +mod radial_focal; +mod stroke_geometry; diff --git a/crates/render/tests/radial_focal.rs b/crates/render/tests/it/radial_focal.rs similarity index 100% rename from crates/render/tests/radial_focal.rs rename to crates/render/tests/it/radial_focal.rs diff --git a/crates/render/tests/stroke_geometry.rs b/crates/render/tests/it/stroke_geometry.rs similarity index 97% rename from crates/render/tests/stroke_geometry.rs rename to crates/render/tests/it/stroke_geometry.rs index b05b0d828..a8813da23 100644 --- a/crates/render/tests/stroke_geometry.rs +++ b/crates/render/tests/it/stroke_geometry.rs @@ -1,7 +1,5 @@ //! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas: //! dotted lines (zero-length dashes) and arrowheads (hollow kinds, alignment, compositing). -// 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_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Document, LineCap, StrokeLayer}; @@ -157,7 +155,7 @@ fn line_and_head_take_the_opacity_once() { #[test] fn a_gradient_runs_on_into_the_head() { let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]; - let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }))); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); let d = arrow_doc(Arrowhead::Square, ArrowAlign::Tip, 1.0, |s| s.paint = paint); let h = head_of(&d); let img = render(&d); diff --git a/crates/svg/src/css.rs b/crates/svg/src/css.rs index e9b4ef67a..178528887 100644 --- a/crates/svg/src/css.rs +++ b/crates/svg/src/css.rs @@ -109,11 +109,10 @@ fn generic_fallback(family: &str) -> &'static str { pub(crate) fn font_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { let mut p = Props::new(); // A quoted family escapes `\` and `'`, which would otherwise end the string early. - let fam = if st.font_family.contains(|c: char| c.is_whitespace() || matches!(c, ',' | '\'' | '\\')) { - format!("'{}'", st.font_family.replace('\\', "\\\\").replace('\'', "\\'")) - } else { - st.font_family.clone() - }; + // Only a plain identifier (no leading digit) can be written bare; anything else is quoted. + let bare = st.font_family.chars().next().is_some_and(|c| !c.is_ascii_digit() && c != '-') + && st.font_family.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_')); + let fam = if !bare { format!("'{}'", st.font_family.replace('\\', "\\\\").replace('\'', "\\'")) } else { st.font_family.clone() }; p.push(("font-family", format!("{fam}{}", generic_fallback(&st.font_family)))); p.push(("font-size", len(st.size * st.v_scale / 100.0))); let (weight, italic) = font_descriptor(st); @@ -153,9 +152,12 @@ pub(crate) fn type_props(st: &CharStyle, len: &dyn Fn(f64) -> String) -> Props { .features .iter() .map(|t| match t.strip_prefix('-') { - Some(off) => format!("\"{off}\" 0"), - None => format!("\"{t}\" 1"), + Some(off) => (off, 0), + None => (t.as_str(), 1), }) + // A feature tag is four letters or digits: anything else could end the CSS string. + .filter(|(tag, _)| tag.len() == 4 && tag.bytes().all(|b| b.is_ascii_alphanumeric())) + .map(|(tag, on)| format!("\"{tag}\" {on}")) .collect(); // Proportional Metrics: `palt` (SVG writes vertical type as outlines). if st.proportional_metrics { @@ -573,6 +575,12 @@ impl Rules<'_> { if n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) { art.unsupported("fills or strokes on the type object"); } + // Area type that Shrink Text to Fit scales down is written in the styles it draws in. + let fitted = matches!((&t.kind, t.area.fit), (TextKind::Area { .. }, vectorcraft_doc::AreaFit::ShrinkText { .. })) + .then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).fit_scale) + .filter(|f| *f != 1.0) + .map(|f| vectorcraft_text::scaled(t, f)); + let t = fitted.as_ref().unwrap_or(t); let st = t.first_style(); if t.runs.iter().any(|r| r.style != st) { self.warn("type with several character styles is written in its first one"); @@ -659,12 +667,15 @@ fn corners(path: &PathData, live: Option<&LiveShape>) -> Option<Corners> { // Sizes and radii in document units. match live.map(LiveShape::folded).as_ref() { // CSS rounds corners only: an inverted round or chamfered corner has no border radius. - Some(LiveShape::Rectangle { w, h, radii, kinds, xf }) + Some(LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf }) if upright(xf) && radii.iter().zip(kinds).all(|(r, k)| *r <= 0.0 || *k == CornerKind::Round) => { // The radii as drawn: no two neighbours' cuts add up to more than their side, so CSS // draws them as they are. - let radii = shapes::fitted_corner_radii(*w, *h, *radii); + let base = shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, *w, *h)); + let resolved = vectorcraft_doc::corners::resolve_stored_radii(&base, radii, *radii_relative); + let stored = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0)); + let radii = shapes::fitted_corner_radii(*w, *h, stored); if radii.iter().all(|r| *r <= 0.0) { Some(Corners::Square) } else if radii.iter().all(|r| (r - radii[0]).abs() < 1e-9) { @@ -695,3 +706,23 @@ fn is_upright_rectangle(path: &PathData) -> bool { && b.width() > 0.0 && b.height() > 0.0 } + +#[cfg(test)] +mod tests { + use super::*; + + fn family(name: &str) -> String { + let st = CharStyle { font_family: name.into(), ..CharStyle::default() }; + let p = font_props(&st, &|v| format!("{v}px")); + p.into_iter().find(|(k, _)| *k == "font-family").unwrap().1 + } + + #[test] + fn font_family_quoted_unless_plain_identifier() { + assert!(family("04b03").starts_with("'04b03'"), "{}", family("04b03")); + assert!(family("a;b").starts_with("'a;b'")); + assert!(family("a\"b").starts_with("'a\"b'")); + assert!(family("a(b").starts_with("'a(b'")); + assert!(family("Arial").starts_with("Arial,")); + } +} diff --git a/crates/svg/src/entities.rs b/crates/svg/src/entities.rs new file mode 100644 index 000000000..a055e55fd --- /dev/null +++ b/crates/svg/src/entities.rs @@ -0,0 +1,99 @@ +//! A cap on what the entities of an SVG's document type declaration expand to. The XML reader +//! replaces every `&name;` with the entity's text as it reads, with no total limit: a short file +//! that repeats a large entity in an attribute allocates far more than it holds. + +use std::collections::HashMap; + +/// The most text the references to entities may expand to in all (bytes). +const MAX_EXPANDED: usize = 16 << 20; + +/// How deep entities that refer to entities are followed (the reader's own limit is lower). +const MAX_NESTING: u32 = 16; + +/// The internal general entities declared in `svg`: name → value. +fn declared(svg: &str) -> HashMap<&str, &str> { + let mut map = HashMap::new(); + for (at, _) in svg.match_indices("<!ENTITY") { + let rest = svg.get(at + "<!ENTITY".len()..).unwrap_or("").trim_start(); + // A parameter entity (`%`) isn't referenced as `&name;`. + if rest.starts_with('%') { + continue; + } + let Some(end) = rest.find(char::is_whitespace) else { continue }; + let (name, rest) = rest.split_at(end); + let rest = rest.trim_start(); + let Some(quote) = rest.chars().next().filter(|q| matches!(q, '"' | '\'')) else { continue }; + let Some(value) = rest.get(1..).and_then(|r| r.split_once(quote)).map(|(v, _)| v) else { continue }; + map.entry(name).or_insert(value); + } + map +} + +/// The names of the `&name;` references in `text`. +fn references(text: &str) -> impl Iterator<Item = &str> { + text.match_indices('&').filter_map(|(at, _)| { + let rest = text.get(at + 1..)?; + // Look no further than the longest name: a file of `&`s without `;` would be read quadratically. + let end = rest.bytes().take(257).position(|b| b == b';')?; + let name = rest.get(..end)?; + (!name.is_empty() && name.len() <= 256 && !name.starts_with('#') && !name.contains(char::is_whitespace)).then_some(name) + }) +} + +/// What entity `name` expands to (bytes), references in its value followed. +fn expanded<'a>(name: &'a str, entities: &HashMap<&'a str, &'a str>, memo: &mut HashMap<&'a str, usize>, depth: u32) -> usize { + if let Some(n) = memo.get(name) { + return *n; + } + let Some(value) = entities.get(name) else { return 0 }; + if depth >= MAX_NESTING { + return usize::MAX; + } + let mut total = value.len(); + for inner in references(value) { + total = total.saturating_add(expanded(inner, entities, memo, depth + 1)); + } + memo.insert(name, total); + total +} + +/// Whether the references to the entities `svg` declares expand to more than [`MAX_EXPANDED`] +/// bytes in all. False for a file that declares none. +pub(crate) fn expansion_too_large(svg: &str) -> bool { + let entities = declared(svg); + if entities.is_empty() { + return false; + } + let mut memo = HashMap::new(); + let mut total = 0usize; + for name in references(svg) { + total = total.saturating_add(expanded(name, &entities, &mut memo, 0)); + if total > MAX_EXPANDED { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_repeated_large_entity_is_too_large_and_a_namespace_entity_is_not() { + let big = "x".repeat(1 << 20); + let refs = "&a;".repeat(20); + let bomb = format!("<!DOCTYPE svg [<!ENTITY a \"{big}\">]><svg xmlns=\"http://www.w3.org/2000/svg\" id=\"{refs}\"/>"); + assert!(expansion_too_large(&bomb)); + let nested = format!("<!DOCTYPE svg [<!ENTITY a \"{big}\"><!ENTITY b \"{}\">]><svg id=\"{}\"/>", "&a;".repeat(8), "&b;".repeat(8)); + assert!(expansion_too_large(&nested)); + let ns = "<!DOCTYPE svg [<!ENTITY ns_svg \"http://www.w3.org/2000/svg\">]><svg xmlns=\"&ns_svg;\"/>"; + assert!(!expansion_too_large(ns)); + assert!(!expansion_too_large("<svg a=\"& \"/>")); + // Many `&`s with no `;` after them are read in linear time. + let ampersands = format!("<!DOCTYPE svg [<!ENTITY a \"x\">]><svg id=\"{}\"/>", "&".repeat(1 << 20)); + let start = std::time::Instant::now(); + assert!(!expansion_too_large(&ersands)); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + } +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 1f9f79ac0..179cde321 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -1,10 +1,10 @@ //! The SVG writer. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fmt::Write as _; use std::sync::Arc; -use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, SVG_EVENTS, StrokeAlign, StrokeLayer, TextKind, TextObject, }; @@ -35,6 +35,8 @@ pub(crate) fn export(doc: &Document, opts: &ExportOptions, native: Option<&[u8]> write(doc, opts, native, &prefix) } +/// `doc` as SVG with `opts`, the native file `native` embedded for Preserve Editing, its generated +/// ids starting with `id_prefix`. fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: &str) -> Output { let rect = opts .artboard @@ -52,6 +54,8 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: classes: Vec::new(), class_ids: HashMap::new(), used_ids: HashSet::new(), + next_suffix: HashMap::new(), + linked_names: HashSet::new(), names: HashMap::new(), depth: 1, patterns: HashMap::new(), @@ -71,6 +75,7 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: shared_images: HashMap::new(), layer_nest: 0, non_latin1_names: false, + foreign_ns: BTreeMap::new(), }; w.assign_name_ids(); // Page Isolated Blending / Page Knockout Group: the page content is one isolated group. @@ -108,6 +113,9 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: } let (ww, hh) = (w.num(rect.width()), w.num(rect.height())); out.push_str("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\""); + for (ns, prefix) in &w.foreign_ns { + out.push_str(&format!(" xmlns:{prefix}=\"{}\"", xml_attr_escape(ns))); + } if w.tiny() { out.push_str(" version=\"1.2\" baseProfile=\"tiny\""); } @@ -133,9 +141,13 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix: out.push_str(&w.indent(1)); out.push_str(&format!("</defs>{nl}")); } - if !doc.title.is_empty() { + // An artboard exported on its own is named after it (#3544), else after the document. + // A name that is blank once the characters XML can't hold are dropped falls back too. + let board = opts.artboard.filter(|_| opts.artboard_title).and_then(|i| doc.artboards.get(i)).map(|a| xml_text(&a.name)); + let title = board.filter(|n| !n.trim().is_empty()).unwrap_or_else(|| xml_text(&doc.title)); + if !title.is_empty() { out.push_str(&w.indent(1)); - out.push_str(&format!("<title>{}{nl}", xml_text(&doc.title))); + out.push_str(&format!("{title}{nl}")); } // File Info's description. if !doc.metadata.description.trim().is_empty() { @@ -193,6 +205,12 @@ fn valid_xml_value(s: &str) -> bool { s.chars().all(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{d7ff}' | '\u{e000}'..='\u{fffd}' | '\u{10000}'..='\u{10ffff}')) } +/// An XML name without a colon (an NCName): a letter or `_`, then [`xml_name_char`]s. +fn xml_ncname(s: &str) -> bool { + let mut chars = s.chars(); + chars.next().is_some_and(|c| c.is_alphabetic() || c == '_') && chars.all(xml_name_char) +} + /// Namespace-free XML 1.0 NameChar. The fixed `data-` prefix supplies NameStartChar. fn xml_name_char(c: char) -> bool { matches!(c, @@ -288,6 +306,10 @@ struct Writer<'a> { /// 1-based class/entity number by declaration; avoids a linear scan for each style. class_ids: HashMap, used_ids: HashSet, + /// The next suffix to try for a base name, so a repeated name doesn't probe the ones taken again. + next_suffix: HashMap, + /// The names in `linked`. + linked_names: HashSet, names: HashMap, depth: usize, /// `` def ids by pattern name + placement. @@ -326,6 +348,9 @@ struct Writer<'a> { layer_nest: usize, /// An emitted attribute name requires an encoding wider than Latin-1. non_latin1_names: bool, + /// The prefix written for each namespace of other applications' attributes, by its URI + /// ([`Writer::foreign_attrs`]): declared on the root. + foreign_ns: BTreeMap, } /// The characters type uses from one face, under one `@font-face` description: the family the @@ -521,7 +546,7 @@ impl Writer<'_> { /// missing), else its bytes. `None` when it has neither. fn image_href(&mut self, im: &vectorcraft_doc::ImageObject) -> Option { let (doc, link) = (self.doc, self.opts.images == ImageMode::Link); - match (im.link.as_ref().filter(|_| link), doc.images.get(&im.key)) { + match (im.link.as_ref().filter(|_| link), doc.image_blob(im)) { (Some(l), _) => Some(l.path.clone()), (None, Some(b)) if !b.bytes.is_empty() => Some(self.blob_href(&web_image(b))), _ => im.link.as_ref().map(|l| l.path.clone()), @@ -531,17 +556,16 @@ impl Writer<'_> { /// Images drawn more than once in `art` (the pieces of a distorted image) go into the defs /// once; each piece then draws them with ``. fn share_images(&mut self, art: &Node) { - let mut seen: HashMap<(String, u32, u32), usize> = HashMap::new(); + // Each image's count and its first piece, which shows what they all show. + let mut seen: HashMap<(String, u32, u32), (usize, vectorcraft_doc::ImageObject)> = HashMap::new(); art.walk(&mut |c| { if let NodeKind::Image(im) = &c.kind { - *seen.entry((im.key.clone(), im.width, im.height)).or_default() += 1; + seen.entry((im.key.clone(), im.width, im.height)).or_insert_with(|| (0, (**im).clone())).0 += 1; } }); - let mut repeated: Vec<_> = seen.into_iter().filter(|(k, n)| *n > 1 && !self.shared_images.contains_key(k)).map(|(k, _)| k).collect(); - repeated.sort(); - for (key, width, height) in repeated { - let link = self.linked_of(art, &key); - let im = vectorcraft_doc::ImageObject { key: key.clone(), width, height, xf: Affine::IDENTITY, link, placement: Default::default() }; + let mut repeated: Vec<_> = seen.into_iter().filter(|(k, (n, _))| *n > 1 && !self.shared_images.contains_key(k)).collect(); + repeated.sort_by(|a, b| a.0.cmp(&b.0)); + for ((key, width, height), (_, im)) in repeated { let Some(href) = self.image_href(&im) else { continue }; let id = self.fresh_id("image"); self.def( @@ -555,20 +579,6 @@ impl Writer<'_> { } } - /// The link of the first image with blob `key` in `art`. - fn linked_of(&self, art: &Node, key: &str) -> Option { - let mut link = None; - art.walk(&mut |c| { - if let NodeKind::Image(im) = &c.kind - && im.key == key - && link.is_none() - { - link = im.link.clone(); - } - }); - link - } - fn fresh_id(&mut self, prefix: &str) -> String { let mut i = 1; loop { @@ -582,14 +592,17 @@ impl Writer<'_> { /// `base` (with the unique prefix) if free, else `base-2`, `base-3`… fn unique_id(&mut self, base: &str) -> String { let base = format!("{}{base}", self.id_prefix); - let mut cand = base.clone(); - for i in 2.. { + if self.used_ids.insert(base.clone()) { + return base; + } + let next = self.next_suffix.entry(base.clone()).or_insert(2); + loop { + let cand = format!("{base}-{next}"); + *next += 1; if self.used_ids.insert(cand.clone()) { - break; + return cand; } - cand = format!("{base}-{i}"); } - cand } /// Reserve ids for every named object up front so generated def ids never collide with them. @@ -648,9 +661,50 @@ impl Writer<'_> { self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff); out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v))); } + self.foreign_attrs(n, &mut out); self.event_attrs(n, &mut out); out } + /// `n`'s attributes in other applications' namespaces (`plotter:pen="2"`), added to `out`. + /// Each namespace is declared once, on the root, under the prefix the file gave it unless the + /// export uses that prefix itself or for another namespace ([`Writer::foreign_prefix`]). + fn foreign_attrs(&mut self, n: &Node, out: &mut String) { + let attrs = n.extras().attrs.as_deref().map_or(&[][..], |a| a.foreign.as_slice()); + let mut seen = HashSet::new(); + for a in attrs { + if !xml_ncname(&a.prefix) || !xml_ncname(&a.name) || a.ns.is_empty() || !valid_xml_value(&a.ns) || !valid_xml_value(&a.value) { + self.warn("Attributes of other applications with invalid XML names or characters were left out of SVG"); + continue; + } + // Native data is a list: one attribute per namespace and name. + if !seen.insert((a.ns.as_str(), a.name.as_str())) { + self.warn("Repeated attributes of other applications were written only once in SVG"); + continue; + } + let prefix = self.foreign_prefix(&a.ns, &a.prefix); + self.non_latin1_names |= prefix.chars().chain(a.name.chars()).any(|c| u32::from(c) > 0xff); + out.push_str(&format!(" {prefix}:{}=\"{}\"", a.name, xml_attr_escape(&a.value))); + } + } + /// The prefix namespace `ns` is written under: `wanted` (the file's), else `wanted2`, + /// `wanted3`… when the export's own prefixes or another namespace already have it. XML keeps + /// prefixes starting with `xml` for itself: those become `ns`, `ns2`… + fn foreign_prefix(&mut self, ns: &str, wanted: &str) -> String { + if let Some(p) = self.foreign_ns.get(ns) { + return p.clone(); + } + const OURS: [&str; 5] = ["svg", "xlink", "rdf", "dc", "vectorcraft"]; + let base = if wanted.to_ascii_lowercase().starts_with("xml") { "ns" } else { wanted }; + let taken = |p: &str| OURS.contains(&p) || self.foreign_ns.values().any(|q| q == p); + let mut prefix = base.to_string(); + let mut k = 2; + while taken(&prefix) { + prefix = format!("{base}{k}"); + k += 1; + } + self.foreign_ns.insert(ns.to_string(), prefix.clone()); + prefix + } /// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to /// `out`. The SVG Tiny 1.2 profile leaves them out with a warning. fn event_attrs(&mut self, n: &Node, out: &mut String) { @@ -1043,7 +1097,7 @@ impl Writer<'_> { && strokes <= 1 && !items.iter().any(|i| has_raster(i.effects())) && items.iter().all(|i| i.blend() == BlendMode::Normal) - && !items.iter().any(|i| freeform(i.paint()).is_some()) + && !items.iter().any(|i| pixel_gradient(i.paint(), matches!(i, AppearanceItem::Fill(_))).is_some()) && plans.iter().flatten().all(|p| matches!(p, StrokePlan::Written(Written { shape: WrittenShape::Stroke { .. }, side: None }))); if simple { let mut p = Props::new(); @@ -1077,10 +1131,10 @@ impl Writer<'_> { match (it, plan) { (AppearanceItem::Fill(f), _) => { let blend = (f.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(f.blend).to_string())); - if let (Some(g), Some(b)) = (freeform(&f.paint), bounds) { + if let (Some(g), Some(b)) = (pixel_gradient(&f.paint, true), bounds) { p.extend((f.opacity < 1.0).then(|| ("opacity", fmt_num(f.opacity as f64, 3)))); p.extend(blend); - self.raster_paint(g, b, &[d.to_string()], rule, &p, ""); + self.raster_paint(g, b, &[d.to_string()], rule, &p, "", true); } else { self.fill_props(f, rule, bounds, &mut p); p.extend(blend); @@ -1109,7 +1163,7 @@ impl Writer<'_> { let side = self.side_attr(w.side, d, rule, w.reach(s, bounds.unwrap_or_default())); let blend = (s.blend != BlendMode::Normal).then(|| ("mix-blend-mode", blend_css(s.blend).to_string())); let opacity = (s.opacity < 1.0).then(|| ("opacity", fmt_num(s.opacity as f64, 3))); - match (&w.shape, freeform(&s.paint).zip(paint_bounds(s))) { + match (&w.shape, pixel_gradient(&s.paint, false).zip(paint_bounds(s))) { (WrittenShape::Stroke { width }, Some((g, b))) => { // The stroke's outline clips the gradient's image. let bp = bp.get_or_insert_with(|| doc_path(paths)); @@ -1117,13 +1171,13 @@ impl Writer<'_> { let clip = self.path_d(&PathData::from_bezpath(&outline), self.xf); p.extend(opacity); p.extend(blend); - self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side); + self.raster_paint(g, b, &[clip], FillRule::NonZero, &p, &side, false); } (WrittenShape::Fill(outlines), Some((g, b))) if s.path_gradient().is_none() => { let clip: Vec = outlines.iter().map(|o| self.path_d(&PathData::from_bezpath(o), self.xf)).collect(); p.extend(opacity); p.extend(blend); - self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side); + self.raster_paint(g, b, &clip, FillRule::NonZero, &p, &side, false); } (WrittenShape::Stroke { width }, _) => { p.push(("fill", "none".into())); @@ -1524,30 +1578,46 @@ impl Writer<'_> { return format!("data:{};base64,{}", b.mime, base64_encode(&b.bytes)); } let name = format!("{}.{}", b.content_key(), image_ext(&b.mime)); - if !self.linked.iter().any(|l| l.name == name) { + if self.linked_names.insert(name.clone()) { self.linked.push(LinkedImage { name: name.clone(), bytes: b.bytes.clone() }); } name } - /// A freeform gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user - /// space), which SVG can't express: an `` of the colour field over its painted box, - /// sampled as the canvas samples it (at twice the box's size in points) and clipped to - /// `clip` (outlines in user space filled by `rule`), in a group with `props` and the `side` - /// attribute. Nothing is written for an empty box. - fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str) { + /// A gradient `g` painted over `bounds` (the space [`Self::xf`] maps into user space) that is + /// drawn as pixels ([`GradientPaint::draws_as_pixels`]) when it paints a fill (`fill`) or a + /// stroke: an `` over its painted box, clipped to `clip` (outlines in user space filled + /// by `rule`), in a group with `props` and the `side` attribute. A freeform gradient's colour + /// field, which SVG can't express, is sampled as the canvas samples it (at twice the box's size + /// in points); a linear or radial gradient at the document's raster effects resolution (at most + /// [`MAX_GRADIENT_PX`] a side), with dither's noise on a fill. Nothing is written for an empty + /// box. + #[allow(clippy::too_many_arguments)] + fn raster_paint(&mut self, g: &GradientPaint, bounds: Rect, clip: &[String], rule: FillRule, props: &Props, side: &str, fill: bool) { use vectorcraft_color::freeform::{grid_size, painted_box, spread_scale}; let Some(b) = painted_box(bounds) else { return }; - let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height())); - let field = g.freeform_on(b).field(spread_scale(b)); - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - let rgba: Vec = field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(); + let freeform = g.gradient.kind == GradientKind::Freeform; + let (rgba, cols, rows): (Vec, u16, u16) = if freeform { + let (cols, rows) = grid_size(b, 2.0 * b.width().max(b.height())); + let field = g.freeform_on(b).field(spread_scale(b)); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + (field.grid(b, cols, rows).flat_map(|([r, g, bl], a)| [q(r), q(g), q(bl), q(a)]).collect(), cols, rows) + } else { + let k = self.doc.raster_effects_ppi / 72.0; + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; + let (cols, rows) = (along(b.width()), along(b.height())); + (g.pixels(b, b, cols, rows, fill, &Color::to_rgb).flatten().collect(), cols, rows) + }; let mut png = Vec::new(); let Some(img) = image::RgbaImage::from_raw(u32::from(cols), u32::from(rows), rgba) else { return }; if img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).is_err() { return; } - self.warn("freeform gradients are written as images clipped to their shapes"); + self.warn(if freeform { + "freeform gradients are written as images clipped to their shapes" + } else { + "linear and radial gradients drawn as pixels are written as images clipped to their shapes" + }); let href = self.blob_href(&ImageBlob::new("image/png", png)); let cid = self.fresh_id("clip-path"); let r = if rule == FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" }; @@ -1758,6 +1828,12 @@ impl Writer<'_> { // Character paints resolve in the text's space (`to_user` maps it into user space). let (bounds, to_user) = space; let saved = std::mem::replace(&mut self.xf, to_user); + if let Paint::Gradient(g) = &st.fill + && g.gradient.dither + && g.gradient.kind != GradientKind::Freeform + { + self.warn("dithered gradients on characters are written without dither"); + } let fill = self.paint(&st.fill, Some(bounds)); p.push(("fill", fill)); // An inside or outside stroke is drawn on the glyph outlines instead @@ -1787,7 +1863,12 @@ impl Writer<'_> { let resolved = doc.inline_resolved(t); let t = &*resolved; let inline = t.runs.iter().any(|r| r.inline.is_some()); - let outlined = self.opts.outline_text || t.vertical || (inline && matches!(t.kind, TextKind::OnPath { .. })); + // Ruby is written as outlines with its base text: a reader's can't place it. So are + // small capitals, superscript and subscript and tab leaders, which SVG text doesn't draw (#3869). + let unwritable = t.runs.iter().any(|r| r.style.small_caps.is_some() || r.style.position != vectorcraft_doc::CharPosition::Normal) + || std::iter::once(&t.para).chain(&t.paras).any(|p| p.tabs.iter().any(|tab| !tab.leader.is_empty())); + let outlined = + self.opts.outline_text || t.vertical || !t.ruby.is_empty() || (inline && matches!(t.kind, TextKind::OnPath { .. })) || unwritable; let chars = |w: &mut Self, n: &Node| if outlined { w.text_outlines(n, t) } else { w.text(n, t) }; let painted = n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()); // Live type with an inside or outside character stroke writes that stroke apart from the @@ -1837,17 +1918,23 @@ impl Writer<'_> { } /// Text as glyph outlines: one compound path per run, painted like the run (gradients span - /// the whole text, as on the canvas). + /// the whole text), over its colour glyphs, as on the canvas. fn text_outlines(&mut self, n: &Node, t: &TextObject) { let db = vectorcraft_text::FontDb::global(); let lay = vectorcraft_text::layout(db, t); - let mut runs: Vec<(usize, kurbo::BezPath)> = vec![]; - // Underline and strikethrough bars join their run's outlines. + let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; + // Underline and strikethrough bars join their run's outlines; ruby glyphs are painted + // with the run of the text they annotate. let bars = vectorcraft_text::decorations(&lay, db, t); - for (run, outline) in lay.glyphs.iter().map(|g| (g.run, &g.outline)).chain(bars.iter().map(|(r, b)| (*r, b))) { - match runs.iter_mut().find(|(r, _)| *r == run) { - Some((_, bp)) => bp.extend(outline.iter()), - None => runs.push((run, outline.clone())), + for (run, outline) in lay + .glyphs + .iter() + .map(|g| (g.run, &g.outline)) + .chain(lay.ruby.iter().map(|g| (g.run, &g.outline))) + .chain(bars.iter().map(|(r, b)| (*r, b))) + { + if let Some(bp) = runs.get_mut(run) { + bp.extend(outline.iter()); } } let id = self.id_attr(n); @@ -1857,9 +1944,28 @@ impl Writer<'_> { // The glyphs stay in text space; the text's transform joins the document's meanwhile. let saved = self.xf; self.xf = saved * t.xf; - for (r, bp) in runs { - let Some(run) = t.runs.get(r) else { continue }; - let pd = PathData::from_bezpath(&bp); + for (i, (bp, run)) in runs.iter().zip(&t.runs).enumerate() { + // Colour glyphs paint themselves, under their run's paint: bitmap glyphs (emoji) as + // their images, COLR and SVG glyphs as their art. + for g in lay.glyphs.iter().filter(|g| g.run == i) { + if let Some((image, to_text)) = g.placed_image() { + let m = self.matrix(self.xf * to_text); + let href = self.blob_href(&image.blob); + self.line(&format!( + "", + image.width, + image.height, + xml_escape(&href) + )); + } + if let Some(art) = g.placed_colour(Affine::IDENTITY) { + self.node(&art); + } + } + if bp.elements().is_empty() { + continue; + } + let pd = PathData::from_bezpath(bp); // An unnamed id: the pieces carry no id attribute (the group has it). let glyphs = Node::path(NodeId(u64::MAX), pd.clone(), run.style.appearance()); let d = self.path_d(&pd, self.xf); @@ -1877,9 +1983,11 @@ impl Writer<'_> { fn text(&mut self, n: &Node, t: &TextObject) { // Live SVG text is written in logical order from left-aligned pieces: bidirectional text // (and right-to-left paragraphs) keep their look as outlines until it is written with - // `direction`/`unicode-bidi`. + // `direction`/`unicode-bidi`. So do runs set Right to Left (Character Direction), whose + // `direction="rtl"` would anchor their pieces at the right end. let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); - if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) { + let rtl_runs = t.runs.iter().any(|r| r.style.char_direction == Some(vectorcraft_doc::ParaDirection::RightToLeft)); + if lay.glyphs.iter().any(|g| g.rtl) || lay.lines.iter().any(|l| l.rtl) || rtl_runs { self.warn("bidirectional text is outlined to preserve shaping and visual order"); return self.text_outlines(n, t); } @@ -1890,10 +1998,23 @@ impl Writer<'_> { self.warn("type on a path with an end bracket, Align to Path or Spacing is outlined to keep its look"); return self.text_outlines(n, t); } + // It cannot hang glyphs outside the start bracket either. + if hangs_on_path(t, &lay) { + self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look"); + return self.text_outlines(n, t); + } + // A `` holds the characters of the text, not the bullets drawn before them. + if !lay.markers.is_empty() { + self.warn("type on a path with bullets is outlined to keep its look"); + return self.text_outlines(n, t); + } return self.text_on_path(n, t, path, *start); } self.note_fonts(t, &lay); let lines = text_lines(t, &lay, self.opts.fewer_tspans); + // `drawn` holds the styles the layout draws the text in: Shrink Text to Fit scales its sizes, explicit leading and baseline shifts. + let fitted = (lay.fit_scale != 1.0).then(|| vectorcraft_text::scaled(t, lay.fit_scale)); + let drawn = fitted.as_ref().unwrap_or(t); // Each line is anchored as its paragraph is aligned. A tab starts a new chunk at its // stop, and so does the text after an inline graphic: lines with tabs, and text with // inline graphics, are never anchored. @@ -1914,7 +2035,13 @@ impl Writer<'_> { let uniform = anchors.first().copied().filter(|a| a.is_some() && anchors.iter().all(|b| b == a)).flatten(); // The element's user space is text space. let space = (lay.bounds, Affine::IDENTITY); - let base = TextBase { props: self.char_props(&t.first_style(), space), space, anchored: uniform.is_some() }; + let mut base_props = self.char_props(&drawn.first_style(), space); + // A `` cannot undo the decoration of the `` around it: with bullets, which + // have none, each run's `` carries its own. + if !lay.markers.is_empty() { + base_props.retain(|(k, _)| *k != "text-decoration"); + } + let base = TextBase { props: base_props, space, anchored: uniform.is_some() }; let mut props = base.props.clone(); if let Some((a, _)) = uniform { props.push(("text-anchor", a.into())); @@ -1939,10 +2066,10 @@ impl Writer<'_> { Some((a, _)) => { s.push_str(&format!("", self.num(start.0), self.num(start.1))); let inner = TextBase { anchored: true, ..base.clone() }; - self.text_line(&mut s, t, &line.segs, start, &inner, false); + self.text_line(&mut s, drawn, &line.segs, start, &inner, false); s.push_str(""); } - None => self.text_line(&mut s, t, &line.segs, start, &base, true), + None => self.text_line(&mut s, drawn, &line.segs, start, &base, true), } } s.push_str(""); @@ -1998,7 +2125,14 @@ impl Writer<'_> { // The baseline shift the current text position carries. let mut shift = 0.0; for seg in line { - let Some(st) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let Some(run_style) = t.runs.get(seg.run).map(|r| &r.style) else { continue }; + let marker_style; + let st = if seg.marker { + marker_style = vectorcraft_doc::text::ParaList::marker_style(run_style); + &marker_style + } else { + run_style + }; let mut attrs = String::new(); if place { let (x, y) = if first { start } else { (seg.x, seg.y) }; @@ -2019,7 +2153,8 @@ impl Writer<'_> { if st.rotation != 0.0 { attrs.push_str(&format!(" rotate=\"{}\"", self.num(-st.rotation))); } - let diff = run_diff(&base.props, self.char_props(st, base.space)); + let mut diff = run_diff(&base.props, self.char_props(st, base.space)); + diff.extend(direction_props(st)); attrs.push_str(&self.attrs(&diff)); let text = xml_text(&seg.text); if attrs.is_empty() && fewer { @@ -2045,8 +2180,14 @@ impl Writer<'_> { let db = FontDb::global(); let plain = t.plain_text(); let mut face: Option> = None; - for g in &lay.glyphs { + for (i, g) in lay.glyphs.iter().enumerate() { let Some(st) = t.runs.get(g.run).map(|r| &r.style) else { continue }; + // A colour font's subset would hold no bitmaps or COLR layers: its emoji are left to the + // viewer. + if g.image.is_some() || g.colour.is_some() { + self.warn("colour emoji fonts are not embedded: viewers show emoji in their own fonts"); + continue; + } if face.as_ref().is_none_or(|f| f.id() != g.font_id) { face = db.face_by_id(g.font_id); } @@ -2063,8 +2204,9 @@ impl Writer<'_> { self.fonts.len() - 1 }); let Some(chars) = self.fonts.get_mut(at).map(|u| &mut u.chars) else { continue }; - // As written: a hyphen where a line breaks, capitals for All Caps, the style's Digits. - let src = if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; + // As written: a list marker's characters, a hyphen where a line breaks, capitals for All + // Caps, the style's Digits. + let src = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }); for c in src.chars().filter(|c| !c.is_control() && *c != '\u{ad}').map(|c| st.digits.map(c)) { if st.all_caps { chars.extend(c.to_uppercase()); @@ -2149,7 +2291,8 @@ impl Writer<'_> { let offset = fmt_num(start * 100.0, 3); let mut s = format!(""); for run in &t.runs { - let diff = run_diff(&base, self.char_props(&run.style, space)); + let mut diff = run_diff(&base, self.char_props(&run.style, space)); + diff.extend(direction_props(&run.style)); let a = self.attrs(&diff); s.push_str(&format!("{}", xml_text(&run.text.replace('\n', " ")))); } @@ -2164,6 +2307,34 @@ fn aligned(st: &CharStyle) -> bool { st.has_stroke() && st.stroke_align != StrokeAlign::Center } +/// Does type on a path `t`, laid out as `lay`, hang glyphs outside its brackets? It does when +/// Optical Margin Alignment or its first paragraph's Roman Hanging Punctuation is on and its +/// glyphs sit elsewhere with both off. +fn hangs_on_path(t: &TextObject, lay: &vectorcraft_text::TextLayout) -> bool { + if !t.optical_margin && !t.para_at(0).roman_hanging { + return false; + } + let mut flush = t.clone(); + flush.optical_margin = false; + let mut styles = flush.paragraph_styles(); + for p in &mut styles { + p.roman_hanging = false; + } + flush.set_paragraph_styles(styles); + let flush = vectorcraft_text::layout(FontDb::global(), &flush); + lay.glyphs.len() != flush.glyphs.len() || lay.glyphs.iter().zip(&flush.glyphs).any(|(a, b)| a.origin != b.origin) +} + +/// The `direction` and `unicode-bidi` of a `` of characters in style `st`: an embedding in +/// its Character Direction, and nothing without one. Each `` of characters with a direction +/// carries them, and the `` does not. Type with a run set Right to Left is written as +/// outlines ([`Writer::text`]), so live text writes `direction="ltr"` only. +fn direction_props(st: &CharStyle) -> Props { + let Some(d) = st.char_direction else { return Props::new() }; + let direction = if d == vectorcraft_doc::ParaDirection::RightToLeft { "rtl" } else { "ltr" }; + vec![("direction", direction.into()), ("unicode-bidi", "embed".into())] +} + /// What the `` of a run whose properties are `run` sets over its ``'s `base`: the /// properties that differ, and the initial value of each one `base` sets and the run doesn't. fn run_diff(base: &Props, run: Props) -> Props { @@ -2200,10 +2371,14 @@ fn doc_path(paths: &[&PathData]) -> BezPath { /// How closely stroke outlines that clip an image follow the stroke (points). const OUTLINE_TOLERANCE: f64 = 0.01; -/// The freeform gradient a paint is, if it is one (SVG has no freeform gradients). -fn freeform(p: &Paint) -> Option<&GradientPaint> { +/// Most pixels along a side of a linear or radial gradient's image. +const MAX_GRADIENT_PX: f64 = 4096.0; + +/// The gradient a fill (`fill`) or stroke paint is, if it is drawn as pixels +/// ([`GradientPaint::draws_as_pixels`]; SVG has no freeform gradients, for one). +fn pixel_gradient(p: &Paint, fill: bool) -> Option<&GradientPaint> { match p { - Paint::Gradient(g) if g.gradient.kind == GradientKind::Freeform => Some(g), + Paint::Gradient(g) if g.draws_as_pixels(fill) => Some(g), _ => None, } } @@ -2249,6 +2424,9 @@ struct Segment { /// Glyph Scaling of the segment's one glyph (Justification…), which a reader stretches it to /// (`textLength`); 1 for other segments. scale: f64, + /// A list marker's characters and tab, written in + /// [`vectorcraft_doc::text::ParaList::marker_style`] of the run's style. + marker: bool, } /// How far a reader may draw a character from where the layout draws it before the character is @@ -2285,18 +2463,24 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let mut tail = None; let mut segs: Vec = Vec::new(); let mut cluster = None; - let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }; + // A list marker's glyphs write its characters and then a tab (glyph `i` of the layout). + let source = |i: usize, g: &vectorcraft_text::PositionedGlyph| { + lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") }) + }; let mut glyphs = lay.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or_default(); // Spaces where the line wrapped (it doesn't end its paragraph) don't belong to the line. if plain.get(line.end..).is_some_and(|rest| !rest.is_empty() && !rest.starts_with('\n')) { - while let Some((_, rest)) = glyphs.split_last().filter(|(g, _)| source(g).chars().all(|c| c.is_whitespace() && c != '\t')) { + while let Some((_, rest)) = + glyphs.split_last().filter(|(g, rest)| source(line.glyph_start + rest.len(), g).chars().all(|c| c.is_whitespace() && c != '\t')) + { glyphs = rest; } } let mut after_inline = false; - for g in glyphs { + for (k, g) in glyphs.iter().enumerate() { let Some(run) = t.runs.get(g.run) else { continue }; - if run.inline.is_some() { + // A list marker before an inline graphic is in the graphic's run, and is text. + if run.inline.is_some() && lay.marker_of(line.glyph_start + k).is_none() { // An inline graphic (written as art): the text after it is placed anew. if let Some(last) = segs.last_mut() { last.brk = true; @@ -2315,7 +2499,9 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - } continue; } - cluster = Some(g.byte); + // A glyph that stands for no character (a list marker before the paragraph's first + // character) starts no cluster. + cluster = (g.len > 0).then_some(g.byte); let (advance, x) = match set { Some(&(advance, offset)) => { // A glyph the layout draws before its pen (Mojikumi) starts that much earlier. @@ -2324,7 +2510,7 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - } None => (g.advance, g.origin.x), }; - let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); + let src: String = source(line.glyph_start + k, g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect(); let piece = if run.style.all_caps { src.to_uppercase() } else { src }; let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace)); // A glyph Glyph Scaling widens or narrows is a segment of its own, stretched to its width. @@ -2334,18 +2520,23 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) - let moved = reader.is_some() && segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE); pen = x + advance; + // A list marker is a segment of its own, and so is the text after it. In horizontally + // scaled type a tab is a segment apart from the characters before it, so a reader + // stretching those characters to their width (`textLength`) stretches them alone. + let marker = lay.marker_of(line.glyph_start + k).is_some(); + let tab_apart = piece == "\t" && (run.style.h_scale - run.style.v_scale).abs() > 1e-9; match segs.last_mut() { - Some(last) if last.run == g.run && !last.brk && !fresh && !moved => { + Some(last) if last.run == g.run && !last.brk && !fresh && !moved && last.marker == marker && !tab_apart => { last.text.push_str(&piece); last.advance += advance; last.brk = brk; } last => { if let Some(last) = last { - last.brk |= moved; + last.brk |= moved || last.marker != marker; end_segment(last, tail, t, lay, &mut unkerned); } - segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale }); + segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk, scale, marker }); } } tail = set.map(|_| (g.byte, advance)); diff --git a/crates/svg/src/glyph.rs b/crates/svg/src/glyph.rs new file mode 100644 index 000000000..601ca2e63 --- /dev/null +++ b/crates/svg/src/glyph.rs @@ -0,0 +1,192 @@ +//! OpenType SVG glyphs: the art of a font's glyph from the SVG document its `SVG ` table holds for +//! it, made as the importer makes art of any SVG file. The text crate asks for it through the +//! reader the app registers ([`vectorcraft_text::set_svg_glyph_reader`]). + +use std::ops::Range; +use std::sync::Arc; +use std::sync::atomic::AtomicU64; + +use usvg::roxmltree; +use vectorcraft_doc::color::Color; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; +use vectorcraft_geom::Affine; + +use crate::ImportOptions; + +/// The most bytes a glyph's document may have, unpacked. +const MAX_SVG_GLYPH_BYTES: u64 = 1 << 20; + +/// Elements left out of a glyph, as the `SVG ` table rules them out: text (type inside type), +/// embedded content and scripts (and images of SVG documents, in [`glyph`]). +const LEFT_OUT: [&str; 3] = ["text", "foreignObject", "script"]; + +/// Glyph `gid`'s art from its SVG `document` (possibly gzipped), in a font of `upem` units per em: +/// its element `glyph` (or the whole document when that is its root), in font units (y down), +/// the root's `viewBox` (or else its size) fitted into the em square by its larger side, centred. +/// The text's colour is `fill`: `currentColor`, and the legacy `context-fill` and `context-stroke` +/// (none without a `stroke`). Its nodes have no ids and its images their own pixels +/// ([`own_art`]). With the art, whether it uses the text's colours. `None` when the document doesn't read, is too large or has +/// no such element. +pub fn glyph_art(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Option) -> Option<(Node, bool)> { + // The SVGZ budget left is the glyph's cap. + let unpacked = AtomicU64::new(crate::MAX_SVGZ.saturating_sub(MAX_SVG_GLYPH_BYTES)); + let text = crate::text_within(document, &unpacked).ok()?; + if text.len() as u64 > MAX_SVG_GLYPH_BYTES { + return None; + } + let id = format!("glyph{gid}"); + let hex = |c: Color| { + let [r, g, b, _] = c.to_rgba8(1.0); + format!("#{r:02x}{g:02x}{b:02x}") + }; + let Glyph { svg, whole, frame } = glyph(&text, &id, &hex(fill))?; + let coloured = ["currentColor", "currentcolor", "context-fill", "context-stroke"].iter().any(|k| svg.contains(k)); + // usvg resolves `context-fill` and `context-stroke` only for markers. + let svg = svg.replace("context-fill", &hex(fill)).replace("context-stroke", &stroke.map_or_else(|| "none".into(), hex)); + let (doc, _) = crate::import::import(&svg, &ImportOptions::default()).ok()?; + let mut art = if whole { + Node::group(NodeId(0), doc.layers.iter().map(|l| Arc::new(Node::group(NodeId(0), l.children().cloned().unwrap_or_default()))).collect()) + } else { + // The glyph, top-level, is a layer when a ``, else an object named after its id. + let named = |n: &Node| n.name.as_deref() == Some(id.as_str()); + match doc.layers.iter().find(|l| named(l)) { + Some(layer) => Node::group(NodeId(0), layer.children().cloned().unwrap_or_default()), + None => { + let mut found = None; + for layer in &doc.layers { + layer.walk(&mut |n| { + if found.is_none() && named(n) { + found = Some(n.clone()); + } + }); + } + found? + } + } + }; + // Without a size of its own, the document's viewport is its frame at one unit per unit. + if let Some((w, h)) = frame.filter(|(w, h)| *w > 0.0 && *h > 0.0 && w.is_finite() && h.is_finite()) { + let s = upem / w.max(h); + art.transform(Affine::translate(((upem - w * s) / 2.0, (upem - h * s) / 2.0)) * Affine::scale(s), true); + } + Some((own_art(&art, &doc)?, coloured)) +} + +/// A glyph's document made ready for the importer. +struct Glyph { + svg: String, + /// The glyph is the whole document (its root has the glyph's id). + whole: bool, + /// The width and height of the root's `viewBox`, or else of its size, if it has either. + frame: Option<(f64, f64)>, +} + +/// `svg` with only what glyph `id` draws, as if the root `use`d it: the rest of the document (a +/// font may keep all its glyphs in one) only defines what it refers to, and what its parents apply +/// doesn't reach it. Without the [`LEFT_OUT`] elements and event attributes, the root without a +/// size (the em square is its frame) and in the text's colour `color`. `None` when it doesn't read +/// or has no element `id`. +fn glyph(svg: &str, id: &str, color: &str) -> Option { + // Before the XML reader: it nests by recursion and expands entities without a total limit. + if crate::import::xml_too_deep(svg) || crate::entities::expansion_too_large(svg) { + return None; + } + let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).ok()?; + let root = xml.root_element(); + let whole = root.attribute("id") == Some(id); + let target = if whole { root } else { xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))? }; + let at = target.range(); + // Edits: byte ranges to cut and what to put in their place, in order. + let mut edits: Vec<(Range, String)> = vec![]; + // Cut with the left-out elements: images of SVG documents, as the table asks (a glyph draws no + // documents inside it). Their link is `href`, else `xlink:href`, as the importer reads it. + let svg_image = |n: &roxmltree::Node| { + let href = n.attribute("href").or_else(|| n.attribute(("http://www.w3.org/1999/xlink", "href"))); + n.tag_name().name() == "image" && href.and_then(|h| h.trim_start().get(..14)).is_some_and(|v| v.eq_ignore_ascii_case("data:image/svg")) + }; + for n in root.descendants().filter(|n| n.is_element() && (LEFT_OUT.contains(&n.tag_name().name()) || svg_image(n))) { + edits.push((n.range(), String::new())); + } + for a in root.attributes().filter(|a| matches!(a.name(), "width" | "height" | "color")) { + edits.push((a.range(), String::new())); + } + // A glyph is art: event attributes (scripts) go too. + for a in root.descendants().filter(|n| n.is_element()).flat_map(|n| n.attributes()).filter(|a| a.name().starts_with("on")) { + edits.push((a.range(), String::new())); + } + // The text's colour, right after the root's name. + let start = root.range().start + 1; + let name = start + svg.get(start..)?.find(|c: char| c.is_whitespace() || c == '>' || c == '/')?; + edits.push((name..name, format!(" color=\"{color}\""))); + // The namespaces the glyph's parents declare, which it leaves: the root declares them too. + for ns in target.namespaces() { + if let Some(prefix) = ns.name().filter(|p| root.lookup_namespace_uri(Some(p)).is_none()) { + edits.push((name..name, format!(" xmlns:{prefix}=\"{}\"", crate::xml_escape(ns.uri())))); + } + } + edits.sort_by_key(|(r, _)| (r.start, r.end)); + let out = if whole { + edited(svg, 0..svg.len(), &edits)? + } else { + // The root's content goes in `defs`, and the glyph after it, on its own. + let content = root.first_child()?.range().start; + let close = svg.get(..root.range().end)?.rfind("".into(), + edited(svg, content..at.start, &edits)?, + edited(svg, at.end..close, &edits)?, + "".into(), + edited(svg, at.clone(), &edits)?, + edited(svg, close..svg.len(), &edits)?, + ] + .concat() + }; + let number = |v: &str| v.trim().trim_end_matches("px").parse::().ok(); + let view_box = root.attribute("viewBox").and_then(|v| { + let n: Vec = v.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).filter_map(|s| s.parse().ok()).collect(); + match n.as_slice() { + [_, _, w, h] => Some((*w, *h)), + _ => None, + } + }); + let size = || Some((number(root.attribute("width")?)?, number(root.attribute("height")?)?)); + Some(Glyph { svg: out, whole, frame: view_box.or_else(size) }) +} + +/// `svg[range]` with the `edits` (in order) that lie in it made: one around the range (a left-out +/// element holding the glyph) isn't, so it stays whole, defining only. +fn edited(svg: &str, range: Range, edits: &[(Range, String)]) -> Option { + let mut out = String::new(); + let mut at = range.start; + for (r, with) in edits.iter().filter(|(r, _)| range.start <= r.start && r.end <= range.end) { + // An element inside one already cut goes with it. + if r.start < at { + continue; + } + out.push_str(svg.get(at..r.start)?); + out.push_str(with); + at = r.end; + } + out.push_str(svg.get(at..range.end)?); + Some(out) +} + +/// `n` as art of its own, made from `doc` (the glyph's document): no ids (they aren't a document's), +/// its members' neither, and its images holding the pixels `doc` keeps for them, named by their +/// content. A linked image, or one whose pixels `doc` doesn't hold, is left out (`None` when `n` +/// is one). +fn own_art(n: &Node, doc: &Document) -> Option { + let mut n = n.clone(); + n.id = NodeId(u64::MAX); + if let NodeKind::Image(im) = &mut n.kind { + // A glyph's images are embedded: a link (to no file of the font's) shows nothing. + let blob = doc.images.get(&im.key).filter(|b| im.link.is_none() && !b.bytes.is_empty())?; + im.key = blob.content_key(); + im.pixels = Some(Arc::new(blob.clone())); + } + if let Some(children) = n.children_mut() { + *children = children.iter().filter_map(|c| own_art(c, doc).map(Arc::new)).collect(); + } + Some(n) +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 8074d439b..aa827d762 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use usvg::roxmltree; use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; use vectorcraft_doc::{ - Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, MAX_SVG_SCRIPTS, Node, NodeId, - NodeKind, PatternDef, SVG_EVENTS, StrokeLayer, Unit, + Appearance, AppearanceItem, Dash, Document, FillLayer, ForeignAttribute, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, MAX_SVG_SCRIPTS, + Node, NodeId, NodeKind, PatternDef, SVG_EVENTS, StrokeLayer, Unit, }; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes}; @@ -34,22 +34,27 @@ mod vector_effect; /// The XML of `svg`. fn parse(svg: &str) -> Result, SvgError> { + if crate::entities::expansion_too_large(svg) { + return Err(SvgError::Parse("the entities the document declares expand to too much text".into())); + } roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) .map_err(|e| SvgError::Parse(e.to_string())) } +/// 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) -> Result<(Document, Vec), SvgError> { // Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by // recursion. let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH); - if depth::xml_too_deep(svg) { + let scan = depth::prescan(svg); + if scan.too_deep { return Err(too_deep()); } let xml = parse(svg)?; - if depth::too_deep(&xml) { + let Some(height) = depth::height(&xml, depth::MAX_ELEMENT_DEPTH) else { return Err(too_deep()); - } - let (svg, found) = prepass(svg, &xml, opts); + }; + let (svg, found) = prepass(svg, &scan.markers, &xml, opts); // The prepass's edits are in the text: the XML is read again only when it made any. let xml = match &svg { Cow::Borrowed(_) => xml, @@ -65,7 +70,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec Result<(Document, Vec Result<(Document, Vec Result<(Document, Vec Option>, /// The event attributes of elements, by id ([`user_events`]). events: HashMap>, + /// The attributes of elements in other applications' namespaces, by id ([`foreign`]). + foreign: HashMap>, /// Ids of the `` elements marked as layers ([`marked_layer`]). sublayers: HashSet, /// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]). @@ -495,6 +511,8 @@ struct Found { data: HashMap>, /// The event attributes of elements, by id ([`user_events`]). events: HashMap>, + /// The attributes of elements in other applications' namespaces, by id ([`foreign`]). + foreign: HashMap>, /// The JavaScript files ` + "##; + let art = art(doc, 1).unwrap(); + let mut kinds = vec![]; + art.walk(&mut |n| kinds.push(n.kind_label())); + assert!(art.children().is_some_and(|c| !c.is_empty()), "the path stays"); + let mut other = false; + art.walk(&mut |n| other |= matches!(n.kind, NodeKind::Text(_))); + assert!(!other, "{kinds:?}"); +} + +#[test] +fn gzipped_documents_are_read_and_malformed_ones_are_no_art() { + let doc = r##""##; + assert!(glyph_art(&vectorcraft_svg::compress(doc), 4, 1000.0, Color::BLACK, None).is_some(), "gzipped"); + assert!(art("not xml <", 4).is_none()); + // A document that unpacks past the glyph cap (a bomb). + let huge = format!("", "x".repeat(4 << 20)); + assert!(glyph_art(&vectorcraft_svg::compress(&huge), 4, 1000.0, Color::BLACK, None).is_none()); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// A font's glyph documents are untrusted: whatever they hold, reading one doesn't panic. + #[test] + fn any_document_reads_without_panicking(body in "[<>/=\"a-z0-9 #:.()-]{0,200}", bytes in proptest::collection::vec(any::(), 0..200)) { + let doc = format!("{body}"); + let _ = glyph_art(doc.as_bytes(), 1, 1000.0, Color::BLACK, Some(BLUE)); + let _ = glyph_art(&bytes, 1, 0.0, Color::BLACK, None); + } +} + +/// A left-out element inside another glyph's element goes with it, and the glyph asked for, nested +/// in a group that holds other glyphs' art, is still found. +#[test] +fn cuts_inside_cuts_and_nested_glyphs_read() { + let doc = r##" + A + + "##; + assert!(fills(&art(doc, 2).unwrap()).contains(&Paint::solid(Color::rgb8(255, 0, 0)))); + assert!(art(doc, 1).is_some_and(|a| fills(&a).contains(&Paint::solid(Color::rgb8(0, 255, 0))))); +} + +/// A font's glyph is art, never a script: event attributes on its shapes are left out, so no +/// export carries them on. +#[test] +fn event_attributes_are_left_out() { + let doc = r##" + "##; + let art = art(doc, 1).unwrap(); + let mut events = vec![]; + art.walk(&mut |n| events.extend(n.extras().attrs.as_ref().map(|a| a.events.clone()).unwrap_or_default())); + assert!(events.is_empty(), "{events:?}"); +} + +/// A size on the root without a `viewBox` sets up the em square as a `viewBox` would. +#[test] +fn a_root_size_is_the_em_square_without_a_view_box() { + let doc = r##" + "##; + let b = art(doc, 2).unwrap().geometric_bounds().unwrap(); + assert!((b.x0 - 250.0).abs() < 1e-6 && (b.width() - 250.0).abs() < 1e-6, "{b:?}"); +} + +/// A `viewBox` that isn't square fits the em square by its larger side, centred along the other. +#[test] +fn a_view_box_that_isnt_square_is_centred_in_the_em_square() { + // 2000 wide, 1000 high: half a unit per unit, the box 500 high centred in the em's 1000. + let doc = r##" + "##; + let b = art(doc, 2).unwrap().geometric_bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.y0 - 250.0).abs() < 1e-6 && (b.height() - 500.0).abs() < 1e-6, "{b:?}"); +} + +/// A glyph element inside others is drawn on its own, as if used: what its parents apply doesn't +/// reach it, and what it refers to still does. +#[test] +fn a_nested_glyph_is_drawn_on_its_own() { + let doc = r##" + + + "##; + let art = art(doc, 3).unwrap(); + let b = art.geometric_bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.x1 - 500.0).abs() < 1e-6, "not moved by its parent: {b:?}"); + let mut opacities = vec![]; + art.walk(&mut |n| opacities.push(n.opacity)); + assert!(opacities.iter().all(|o| *o == 1.0), "not faded by its parent: {opacities:?}"); + assert!(fills(&art).iter().any(|p| matches!(p, Paint::Gradient(_))), "its gradient"); +} + +/// A namespace the glyph's parents declare still binds it when it is drawn on its own. +#[test] +fn a_nested_glyph_keeps_the_namespaces_its_parents_declare() { + let doc = r##" + + + "##; + assert!(art(doc, 3).is_some_and(|a| fills(&a).contains(&Paint::solid(Color::rgb8(255, 0, 0))))); +} + +/// An image in a glyph's document is in its art, holding its pixels (the art has no document to +/// keep them in), named by their content; one whose pixels don't read is left out. +#[test] +fn images_are_in_the_art_with_their_pixels() { + let png = vectorcraft_text::test_fonts::solid_png(4, [0, 0, 255, 255]); + let doc = format!( + r##" + + + "##, + vectorcraft_svg::base64_encode(&png) + ); + let art = art(&doc, 1).unwrap(); + let mut images = vec![]; + art.walk(&mut |n| { + if let NodeKind::Image(im) = &n.kind { + images.push((**im).clone()); + } + }); + assert_eq!(images.len(), 1, "the one that reads"); + let pixels = images[0].pixels.as_ref().expect("its own pixels"); + assert_eq!(pixels.bytes.as_slice(), png.as_slice()); + assert_eq!(images[0].key, pixels.content_key()); + let b = art.geometric_bounds().unwrap(); + assert!(b.x0.abs() < 1e-6 && (b.width() - 500.0).abs() < 1e-6 && (b.y0 + 500.0).abs() < 1e-6, "in font units: {b:?}"); +} + +/// An image whose data is an SVG document is left out, as the `SVG ` table asks: a glyph draws no +/// documents inside it. +#[test] +fn svg_images_are_left_out() { + let inner = r##""##; + let doc = format!( + r##" + + + + "##, + vectorcraft_svg::base64_encode(inner.as_bytes()), + vectorcraft_svg::xml_escape(&inner.replace('#', "%23")) + ); + let fills = fills(&art(&doc, 1).unwrap()); + assert!(fills.contains(&Paint::solid(Color::rgb8(0, 0, 255))), "the glyph's own art"); + assert!(!fills.contains(&Paint::solid(Color::rgb8(255, 0, 0))), "not the images' documents: {fills:?}"); +} + +/// Of `href` and `xlink:href`, the plain one counts, as the importer reads them (SVG 2): an image +/// whose `href` is a PNG stays, whatever its `xlink:href`; one whose `href` is a document goes. +#[test] +fn an_images_plain_href_decides_whether_it_is_an_svg_image() { + let png = vectorcraft_svg::base64_encode(&vectorcraft_text::test_fonts::solid_png(4, [0, 0, 255, 255])); + let svg = vectorcraft_svg::base64_encode( + br##""##, + ); + let image = |href: &str, xlink: &str| { + format!( + r##""##, + if href == "png" { &png } else { &svg }, + if xlink == "png" { &png } else { &svg } + ) + }; + let images = |inner: String| { + let doc = format!( + r##"{inner}"## + ); + let mut n = 0; + art(&doc, 1).unwrap().walk(&mut |c| n += usize::from(matches!(c.kind, NodeKind::Image(_)))); + n + }; + assert_eq!(images(image("png", "svg+xml")), 1, "the PNG stays"); + assert_eq!(images(image("svg+xml", "png")), 0, "the document goes"); +} diff --git a/crates/svg/tests/symbols_layers.rs b/crates/svg/tests/it/symbols_layers.rs similarity index 99% rename from crates/svg/tests/symbols_layers.rs rename to crates/svg/tests/it/symbols_layers.rs index 832112ea3..387c9d092 100644 --- a/crates/svg/tests/symbols_layers.rs +++ b/crates/svg/tests/it/symbols_layers.rs @@ -3,8 +3,6 @@ //! hidden with their `data-*` attributes, clipped layers come in as clipping layers, layers and //! sublayers keep their names and nesting (a one-layer file's from its root element), and the //! editing data notices edits made elsewhere. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::sync::Arc; diff --git a/crates/svg/tests/text_layout.rs b/crates/svg/tests/it/text_layout.rs similarity index 69% rename from crates/svg/tests/text_layout.rs rename to crates/svg/tests/it/text_layout.rs index 649aadf47..3b8dd79c6 100644 --- a/crates/svg/tests/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -1,8 +1,6 @@ //! Laid-out type in SVG: every line of area type is its own positioned ``, rich text keeps //! its leading, baseline shift, scaling and kerning, and a renderer with the same font draws the //! export where the canvas draws the text. -// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use std::sync::Arc; @@ -125,6 +123,43 @@ fn proportional_metrics_write_palt() { assert!(tags[2].contains("dlig") && tags[2].contains("palt"), "{}", tags[2]); } +/// Character Direction: live text writes each run set Left to Right as a bidi embedding +/// (`direction` and `unicode-bidi`). Type with a run set Right to Left is written as outlines. +#[test] +fn character_direction_is_written_on_its_runs() { + use vectorcraft_doc::ParaDirection; + let ltr = CharStyle { char_direction: Some(ParaDirection::LeftToRight), ..style(12.0) }; + let d = area_doc(vec![run("Due ", style(12.0)), run("21 10 2026", ltr.clone()), run(" ok", style(12.0))], 280.0, Justify::Left); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert_eq!(tags.len(), 3, "{svg}"); + assert!(!tags[0].contains("direction") && !tags[2].contains("direction"), "{svg}"); + assert!(tags[1].contains(" direction=\"ltr\" unicode-bidi=\"embed\""), "{}", tags[1]); + // Type on a path: on the run's `` in the `` too. + let mut on_path = TextObject::point(Point::ZERO, "", style(12.0)); + on_path.runs = vec![run("Due ", style(12.0)), run("21 10 2026", ltr)]; + let mut line = BezPath::new(); + line.move_to((20.0, 100.0)); + line.line_to((280.0, 100.0)); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(on_path))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let svg = export(&d, &ExportOptions::default()); + let tags = tspans(&svg); + assert!(svg.contains(" (Vec, Vec = vec![]; let mut faces: Vec> = vec![]; let mut last = None; - for g in &lay.glyphs { - let text = if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] }; + for (i, g) in lay.glyphs.iter().enumerate() { + let text = lay.marker_text(i).unwrap_or_else(|| if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] }); if text.trim().is_empty() || (g.len > 0 && last == Some(g.byte)) { continue; } - last = Some(g.byte); + // A list marker's glyphs (length 0) start no cluster. + last = (g.len > 0).then_some(g.byte); if let Some(f) = db.face_by_id(g.font_id).filter(|f| !faces.iter().any(|o| o.id() == f.id())) { faces.push(f); } @@ -246,6 +282,95 @@ fn assert_drawn_in_place(d: &Document) { } } +/// Bullets: live text writes each list paragraph's bullet as its character before the paragraph's +/// text, not as a hyphen, and a viewer draws the bullet and the text where the canvas draws them. +#[test] +fn bullets_are_written_as_their_characters_where_they_are_drawn() { + use vectorcraft_doc::text::{Bullet, ParaList}; + for (bullet, character) in [(Bullet::Dot, "\u{2022}"), (Bullet::Dash, "\u{2013}"), (Bullet::Square, "\u{25AA}")] { + let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta", style(12.0)); + t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 160.0)) }; + t.para.list = Some(ParaList::bulleted(bullet)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let svg = export(&d, &ExportOptions::default()); + assert_eq!(svg.matches(character).count(), 2, "one bullet before each paragraph: {svg}"); + assert!(svg.find(character) < svg.find("alpha") && !svg.contains(">-<") && !svg.contains("-alpha"), "{svg}"); + if bullet == Bullet::Dot { + assert_drawn_in_place(&d); + } + } + // A raised and rotated first character: the bullet is written on the baseline, upright. + let raised = CharStyle { baseline_shift: 4.0, rotation: 20.0, ..style(12.0) }; + let mut t = TextObject::point(Point::new(20.0, 20.0), "", style(12.0)); + t.runs = vec![run("R", raised), run("aised", style(12.0))]; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let svg = export(&d, &ExportOptions::default()); + // Each `` start tag with the text after it. + let pieces: Vec<(&str, &str)> = svg.split("')).collect(); + let (tag, _) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); + assert!(!tag.contains("dy=") && !tag.contains("rotate="), "{svg}"); + assert!( + pieces.iter().any(|(tag, text)| text.starts_with('R') && tag.contains("dy=") && tag.contains("rotate=")), + "the R keeps its shift and rotation: {svg}" + ); + // Horizontally scaled type: a viewer stretches the bullet to the bullet's own width, not to the + // gap after it. + let mut t = TextObject::point(Point::new(20.0, 20.0), "Narrow", CharStyle { h_scale: 50.0, ..style(12.0) }); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t); + let width = lay.glyphs[lay.markers[0].glyphs.start].advance; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let svg = export(&d, &ExportOptions::default()); + let pieces: Vec<(&str, &str)> = svg.split("')).collect(); + let (tag, text) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); + let length: f64 = tag.split("textLength=\"").nth(1).and_then(|r| r.split('"').next()).and_then(|v| v.parse().ok()).expect("a textLength"); + assert!(text.starts_with("\u{2022}<") && (length - width).abs() < 0.01, "the bullet alone, {width} wide: {svg}"); + // Underlined text: the underline is the text's, not the bullet's. + let mut t = TextObject::point(Point::new(20.0, 20.0), "Underlined", CharStyle { underline: true, ..style(12.0) }); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let svg = export(&d, &ExportOptions::default()); + let text_tag = svg.split("').next()).expect("a "); + let pieces: Vec<(&str, &str)> = svg.split("')).collect(); + let (bullet_tag, _) = pieces.iter().find(|(_, text)| text.starts_with('\u{2022}')).expect("the bullet's tspan"); + assert!(!text_tag.contains("text-decoration") && !bullet_tag.contains("text-decoration"), "{svg}"); + assert!(pieces.iter().any(|(tag, text)| text.starts_with("Underlined") && tag.contains("text-decoration=\"underline\"")), "{svg}"); + // Type on a path with a bullet is written as outlines, with a warning: a `` holds the + // characters of the text only. + for list in [None, Some(ParaList::bulleted(Bullet::Dot))] { + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((250.0, 50.0)); + let mut t = TextObject::point(Point::new(20.0, 20.0), "Path", style(12.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.para.list = list; + let mut d = Document::new(300.0, 200.0); + let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t))); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + let out = vectorcraft_svg::export_full(&d, &ExportOptions::default(), None); + let warned = out.warnings.iter().any(|w| w.contains("bullets")); + assert_eq!((out.svg.contains("` Elements writes the shrunk size too. + let fewer = export(&d, &ExportOptions { fewer_tspans: true, ..Default::default() }); + let size = attr(&fewer[fewer.find("Poster v2")); + assert!(svg.contains("ab")); +} + +#[test] +fn title_and_description_markup_is_escaped() { + let mut d = Document::new(200.0, 200.0); + d.title = "A & ".into(); + d.metadata.description = "x & y".into(); + let svg = export(&d, &ExportOptions::default()); + assert!(import(&svg).is_ok(), "export wrote invalid XML: {svg}"); + assert!(svg.contains("A & <B>")); + assert!(svg.contains("x & y")); +} + +/// #3544: an artboard exported on its own has its own name as `` (escaped); the document +/// (Save, or no artboard of its own) keeps the document's title, and so does an unnamed artboard. +#[test] +fn an_artboard_exported_alone_is_titled_after_it() { + let mut d = Document::new(24.0, 24.0); + d.title = "Icons".into(); + let mut second = d.artboards[0].clone(); + (second.id, second.name) = (2, "arrow <right> & co".into()); + second.rect = second.rect.with_origin((100.0, 0.0)); + d.artboards[0].name = "arrow-left".into(); + d.artboards.push(second); + let title = |d: &Document, opts: ExportOptions| { + let svg = export(d, &opts); + assert!(import(&svg).is_ok(), "export wrote invalid XML: {svg}"); + svg.lines().find(|l| l.contains("<title>")).map(|l| l.trim().to_string()) + }; + let alone = |artboard| ExportOptions { artboard, artboard_title: true, ..ExportOptions::default() }; + assert_eq!(title(&d, alone(Some(1))).as_deref(), Some("<title>arrow <right> & co")); + assert_eq!(title(&d, alone(Some(0))).as_deref(), Some("arrow-left")); + // The document: Save's first artboard, or the bounds of all art. + assert_eq!(title(&d, ExportOptions::default()).as_deref(), Some("Icons")); + assert_eq!(title(&d, ExportOptions { artboard: Some(1), ..ExportOptions::default() }).as_deref(), Some("Icons")); + assert_eq!(title(&d, alone(None)).as_deref(), Some("Icons")); + // An unnamed artboard falls back to the document's title. + d.artboards[1].name = " ".into(); + assert_eq!(title(&d, alone(Some(1))).as_deref(), Some("Icons")); + // So does one whose only characters can't appear in XML. + d.artboards[1].name = "\u{1}\u{2}".into(); + assert_eq!(title(&d, alone(Some(1))).as_deref(), Some("Icons")); +} diff --git a/crates/svg/tests/raster_paints.rs b/crates/svg/tests/raster_paints.rs deleted file mode 100644 index 31eb41a2d..000000000 --- a/crates/svg/tests/raster_paints.rs +++ /dev/null @@ -1,106 +0,0 @@ -//! Paints SVG can't express: freeform gradients fill and stroke objects as an `` of their -//! colour field clipped to the shape (or the stroke's outline), and the SVG reads back looking as -//! the canvas paints it. -// 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_color::freeform::spread_scale; -use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind}; -use vectorcraft_geom::{Point, Rect, shapes}; -use vectorcraft_svg::{ExportOptions, ImageMode, export_full, import}; -use vectorcraft_testkit::format::base64_decode; -use vectorcraft_testkit::raster::{assert_similar, render_artboard}; - -/// Red at the top left, blue at the bottom right, a half-transparent green at the top right. -fn freeform() -> Paint { - let p = |x, y, c, opacity| FreeformPoint { opacity, spread: 0.1, ..FreeformPoint::new(Point::new(x, y), c) }; - let f = Freeform { - points: vec![ - p(30.0, 30.0, Color::rgb(1.0, 0.0, 0.0), 1.0), - p(170.0, 130.0, Color::rgb(0.0, 0.0, 1.0), 1.0), - p(170.0, 30.0, Color::rgb(0.0, 1.0, 0.0), 0.5), - ], - ..Default::default() - }; - let mut g = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Default::default() }); - g.freeform = Some(f); - Paint::Gradient(Box::new(g)) -} - -/// A 200 × 160 document holding `n`. -fn doc_with(mut n: Node) -> Document { - let mut d = Document::new(200.0, 160.0); - n.id = d.alloc_id(); - let l = d.layers[0].id; - d.insert(Some(l), 0, n).unwrap(); - d -} - -/// The data of the one embedded PNG. -fn png_of(svg: &str) -> image::RgbaImage { - let data = svg.split("data:image/png;base64,").nth(1).expect("an embedded PNG").split('"').next().unwrap(); - image::load_from_memory(&base64_decode(data).unwrap()).unwrap().to_rgba8() -} - -#[test] -fn a_freeform_fill_is_an_image_clipped_to_the_shape() { - let shape = shapes::ellipse(Rect::new(20.0, 20.0, 180.0, 140.0)); - let d = doc_with(Node::path(NodeId(0), shape, Appearance::basic(freeform(), Paint::None, 0.0))); - let o = export_full(&d, &ExportOptions::default(), None); - let svg = &o.svg; - assert!(!svg.contains("linearGradient"), "{svg}"); - assert_eq!(svg.matches(""), "{svg}"); - assert!(o.warnings.iter().any(|w| w.contains("freeform")), "{:?}", o.warnings); - // The image is the colour field over the shape's box, straight alpha: the green point is - // half transparent. - let png = png_of(svg); - let (w, h) = png.dimensions(); - assert!((8..=256).contains(&w) && (8..=256).contains(&h), "{w}×{h}"); - let b = Rect::new(20.0, 20.0, 180.0, 140.0); - let Paint::Gradient(g) = freeform() else { panic!("a gradient") }; - let field = g.freeform.unwrap().field(spread_scale(b)); - for (x, y) in [(2, 2), (w / 2, h / 2), (w - 3, 2), (w - 3, h - 3)] { - let at = Point::new(b.x0 + (x as f64 + 0.5) * b.width() / w as f64, b.y0 + (y as f64 + 0.5) * b.height() / h as f64); - let ([r, g, bl], a) = field.sample(at); - let want = [r, g, bl, a].map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as i32); - let got = png.get_pixel(x, y).0.map(i32::from); - assert!(want.iter().zip(got).all(|(w, g)| (w - g).abs() <= 1), "({x}, {y}): {got:?} vs {want:?}"); - } - // Read back, it looks as the canvas paints the gradient. - let back = import(svg).unwrap(); - let mut images = 0; - back.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_)))); - assert_eq!(images, 1, "the image comes back (in its clip group)"); - assert_similar(&render_artboard(&d), &render_artboard(&back), 12.0, 0.01); -} - -#[test] -fn a_freeform_stroke_is_an_image_clipped_to_the_stroke_outline() { - let shape = shapes::rectangle(Rect::new(40.0, 40.0, 160.0, 120.0)); - let mut ap = Appearance::basic(Paint::solid(Color::WHITE), freeform(), 16.0); - if let Some(AppearanceItem::Stroke(s)) = ap.items.get_mut(1) { - s.opacity = 0.8; - } - let d = doc_with(Node::path(NodeId(0), shape, ap)); - let svg = export_full(&d, &ExportOptions::default(), None).svg; - assert_eq!(svg.matches("Poster v2")); - assert!(svg.contains("ab")); -} - -#[test] -fn title_and_description_markup_is_escaped() { - let mut d = Document::new(200.0, 200.0); - d.title = "A & ".into(); - d.metadata.description = "x & y".into(); - let svg = export(&d, &ExportOptions::default()); - assert!(import(&svg).is_ok(), "export wrote invalid XML: {svg}"); - assert!(svg.contains("A & <B>")); - assert!(svg.contains("x & y")); -} diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml index 06db033b4..cc627bb7e 100644 --- a/crates/testkit/Cargo.toml +++ b/crates/testkit/Cargo.toml @@ -13,7 +13,7 @@ workspace = true vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } -vectorcraft-engine = { workspace = true } +vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] } vectorcraft-render = { workspace = true } vectorcraft-format = { workspace = true } vectorcraft-svg = { workspace = true } diff --git a/crates/testkit/src/invariants.rs b/crates/testkit/src/invariants.rs index a08210272..c03b33dd5 100644 --- a/crates/testkit/src/invariants.rs +++ b/crates/testkit/src/invariants.rs @@ -13,7 +13,8 @@ pub fn doc_json(doc: &Document) -> Value { /// Tree invariants: ids unique, `node_count` equals the number of walked nodes, `next_id` above /// every id, top level made of layers, layers only at the top or inside layers, clip groups have -/// a first child, compound children are paths. +/// a first child, compound children are paths, no image holds its own pixels (the document keeps +/// them). pub fn check_document(doc: &Document) -> Result<(), String> { let mut seen = HashSet::new(); let mut walked = 0usize; @@ -32,6 +33,9 @@ pub fn check_document(doc: &Document) -> Result<(), String> { NodeKind::Compound { children, .. } if children.iter().any(|c| !matches!(c.kind, NodeKind::Path { .. })) && err.is_none() => { err = Some(format!("compound {} has a non-path child", n.id)); } + NodeKind::Image(im) if im.pixels.is_some() && err.is_none() => { + err = Some(format!("image {} holds its own pixels", n.id)); + } _ => {} } if !(n.opacity.is_finite() && (0.0..=1.0).contains(&n.opacity)) && err.is_none() { diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs index dac0e86b1..6a6f4338b 100644 --- a/crates/testkit/src/lib.rs +++ b/crates/testkit/src/lib.rs @@ -39,10 +39,28 @@ pub use vectorcraft_engine::{Session, doc::NodeId}; /// Re-exports so dependents without direct dependencies (e.g. app test crates) can use them. pub use {vectorcraft_doc as doc, vectorcraft_format as format, vectorcraft_render as render, vectorcraft_svg as svg}; -/// A per-process temporary directory for test output (created on first use). +/// A per-process temporary directory for test output (created on first use). It is made with +/// `create_dir` (owner-only on Unix), and a directory that already exists is reused only when it is a +/// real directory, not a link, with no access for others and writable by this user: one pre-made in a shared temp folder is refused. pub fn temp_dir(tag: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!("vectorcraft-testkit-{tag}-{}", std::process::id())); - std::fs::create_dir_all(&dir).expect("create temp dir"); + // Unix sets the mode on it; elsewhere it stays as made. + #[cfg_attr(not(unix), allow(unused_mut))] + let mut b = std::fs::DirBuilder::new(); + #[cfg(unix)] + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + if let Err(e) = b.create(&dir) { + let m = std::fs::symlink_metadata(&dir).expect("temp dir"); + assert!(e.kind() == std::io::ErrorKind::AlreadyExists && m.is_dir(), "create temp dir: {e}"); + #[cfg(unix)] + assert!(std::os::unix::fs::PermissionsExt::mode(&m.permissions()) & 0o077 == 0, "{}: not private", dir.display()); + // A private directory made by another user passes the mode check but is closed to this one: + // writing a probe file tells it apart from ours without needing the user ids. + // Named for this call: tests running alongside probe the same directory. + static PROBES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let probe = dir.join(format!(".probe-{}", PROBES.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + assert!(std::fs::write(&probe, b"").and_then(|()| std::fs::remove_file(&probe)).is_ok(), "{}: not owned by this user", dir.display()); + } dir } diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index b4426dd7b..fe857506c 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -273,7 +273,7 @@ impl Op { pub fn command(&self, s: &Session) -> (String, Value) { let doc = s.active().map(|d| d.doc.clone()); // Children of compound paths are excluded: grouping one of them nests a group inside the - // compound (known bug, see crates/engine/tests/known_bugs.rs). + // compound (known bug, see crates/engine/tests/it/known_bugs.rs). let art: Vec<_> = doc .as_ref() .map(|d| { @@ -313,7 +313,7 @@ impl Op { Op::Ungroup => c("object.ungroup", json!({})), Op::Arrange(i) => { let i = *i as usize % ARRANGE.len(); - // Known bug (crates/engine/tests/known_bugs.rs): Bring to Front/Forward panics when + // Known bug (crates/engine/tests/it/known_bugs.rs): Bring to Front/Forward panics when // the selection spans several parents. Avoid generating that case. let multi_parent = doc.as_ref().zip(s.active()).is_some_and(|(d, st)| { let mut parents: Vec<_> = st.selection.objects.iter().map(|id| d.parent_of(*id)).collect(); diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 7f3b52b1d..1f8ddc375 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -28,6 +28,9 @@ icu_segmenter = "2.3" # Grapheme clusters (a Thai consonant with its vowel and tone marks, an accented letter, an emoji # sequence) for caret movement; MIT OR Apache-2.0, already in the build. unicode-segmentation = "1.13" +# Draws COLR colour glyphs whose paints documents have no form for (sweep gradients, compositing +# modes) into images: the canvas's renderer, already in the build. +vello_cpu = { workspace = true } # Builds a small variable font for tests (`test_fonts`; no font file is committed). MIT OR # Apache-2.0, already in the build through subsetter. write-fonts = { version = "0.48", optional = true } @@ -37,7 +40,8 @@ default = ["bundled-fonts"] # The OFL fonts of `assets/fonts` (Source Sans 3, Source Serif 4, Inter, JetBrains Mono) compiled # in: 3.4 MB of the binary. Off, the font database starts without them (craft-fonts and system # fonts are unaffected), for an embedder that adds its own with `FontDb::add_font` and would ship -# these twice. vectorcraft-engine turns it on by default (its `bundled-fonts` feature). +# these twice. vectorcraft-engine turns it on by default (its `bundled-fonts` feature), and +# vectorcraft-ui-egui and vectorcraft-mcp by default through the engine. bundled-fonts = [] # `vectorcraft_text::test_fonts` for other crates' tests (dev-dependencies only). test-fonts = ["dep:write-fonts"] diff --git a/crates/text/src/bitmap.rs b/crates/text/src/bitmap.rs new file mode 100644 index 000000000..c74967db3 --- /dev/null +++ b/crates/text/src/bitmap.rs @@ -0,0 +1,164 @@ +//! Colour bitmap glyphs (`sbix`, as in Apple Color Emoji, and `CBDT`/`CBLC`, as in Noto Color +//! Emoji): the PNG of a glyph's largest strike, placed in font units. Other graphic types (`sbix` +//! `dupe`, `flip`, `jpg `, `tiff`; `CBDT` BGRA and mask data) and `EBDT` (hinting bitmaps of +//! outline fonts) draw nothing. + +use kurbo::{Affine, Rect}; +use skrifa::GlyphId; +use skrifa::bitmap::{BitmapData, BitmapFormat, BitmapStrikes, Origin}; +use skrifa::instance::Size; +use vectorcraft_doc::ImageBlob; + +/// The most pixels along either side of a glyph image read from a font, as its PNG header gives +/// them. Emoji strikes are at most a few hundred pixels (Apple Color Emoji's largest is 160): a +/// font is untrusted input, and each glyph image is decoded and kept apart (at most 4 MB of pixels). +pub(crate) const MAX_SIDE: u32 = 1024; + +/// The most bytes of a glyph image's PNG: twice its largest pixels' raw size, which a PNG +/// (compressed, or stored uncompressed) stays well within. +pub(crate) const MAX_PNG_BYTES: usize = 2 * 4 * (MAX_SIDE as usize) * (MAX_SIDE as usize); + +/// A colour bitmap glyph: a PNG drawn in place of an outline. Fill and stroke don't paint it. +#[derive(Debug)] +pub struct GlyphImage { + /// The PNG, as image objects hold their pixels. + pub blob: ImageBlob, + pub width: u32, + pub height: u32, + /// Pixel space (0..width, 0..height, y down) → font units (y down, as outlines are). + pub xf: Affine, +} + +impl GlyphImage { + /// The image's pixel rectangle (0..width, 0..height). + pub fn rect(&self) -> Rect { + Rect::new(0.0, 0.0, self.width as f64, self.height as f64) + } +} + +/// Does `font` have colour bitmap strikes? +pub(crate) fn has_colour_bitmaps(font: &skrifa::FontRef<'_>) -> bool { + strikes(font).is_some_and(|s| !s.is_empty()) +} + +fn strikes<'a>(font: &skrifa::FontRef<'a>) -> Option> { + BitmapStrikes::with_format(font, BitmapFormat::Sbix).or_else(|| BitmapStrikes::with_format(font, BitmapFormat::Cbdt)) +} + +/// Glyph `gid`'s image in its largest strike, if it is a PNG. +pub(crate) fn glyph_image(font: &skrifa::FontRef<'_>, upem: f64, gid: u32) -> Option { + let strikes = strikes(font)?; + let sbix = strikes.format() == Some(BitmapFormat::Sbix); + let g = strikes.glyph_for_size(Size::unscaled(), GlyphId::new(gid))?; + let BitmapData::Png(png) = g.data else { return None }; + // The size the PNG decodes to is its header's (`CBDT`'s own metrics may say otherwise). + let (w, h) = png_size(png)?; + if has_compressed_metadata(png) { + return None; + } + let (ppem_x, ppem_y) = (f64::from(g.ppem_x), f64::from(g.ppem_y)); + if png.len() > MAX_PNG_BYTES || w > MAX_SIDE || h > MAX_SIDE || !(ppem_x >= 1.0 && ppem_y >= 1.0 && ppem_x.is_finite() && ppem_y.is_finite()) { + return None; + } + let (sx, sy) = (upem / ppem_x, upem / ppem_y); + // The image's top left in font units (y up): the outer bearings in font units, the inner ones + // in pixels, from the bottom left corner for `sbix`. + let left = f64::from(g.bearing_x) + f64::from(g.inner_bearing_x) * sx; + let mut top = f64::from(g.bearing_y) + f64::from(g.inner_bearing_y) * sy; + if g.placement_origin == Origin::BottomLeft { + top += h as f64 * sy; + } + // Apple Color Emoji sits lower than its `sbix` table says (CoreText's look, which the PDF + // writer's Type 3 glyphs imitate too). + if sbix { + top -= 0.128 * upem; + } + if !(left.is_finite() && top.is_finite()) { + return None; + } + Some(GlyphImage { blob: ImageBlob::png(png.to_vec()), width: w, height: h, xf: Affine::new([sx, 0.0, 0.0, sy, left, -top]) }) +} + +/// Whether PNG `bytes` has a chunk of compressed metadata (a colour profile, compressed text): a +/// decoder unpacks it before it reads the image, whatever size the image says. +fn has_compressed_metadata(bytes: &[u8]) -> bool { + let mut at: usize = 8; + while let Some(head) = at.checked_add(8).and_then(|end| bytes.get(at..end)) { + if matches!(&head[4..], b"iCCP" | b"zTXt") { + return true; + } + let len32 = u32::from_be_bytes([head[0], head[1], head[2], head[3]]); + if &head[4..] == b"iTXt" && !itxt_uncompressed(bytes, at, len32) { + return true; + } + // The chunk's data and its checksum follow its length and type. + at = at.saturating_add(len32 as usize).saturating_add(12); + } + false +} + +/// Whether an `iTXt` chunk's `data` is stored uncompressed: the compression flag (0 = plain text) +/// follows the keyword's nul. Anything that does not parse as that layout may still be compressed, so +/// the caller treats it as such. The scan stays inside the chunk's declared data: beyond it lie other +/// chunks, and a byte there could pose as the keyword's terminator. +fn itxt_uncompressed(bytes: &[u8], head_at: usize, len: u32) -> bool { + let from = head_at + 8; + let data = bytes.get(from..from.saturating_add(len as usize)).unwrap_or(&[]); + data.iter().position(|&b| b == 0).and_then(|kw| data.get(kw + 1)).is_some_and(|&flag| flag == 0) +} + +/// The width and height in PNG `bytes`' header (its signature, then the IHDR chunk); `None` when +/// it doesn't read or a side is 0. +fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.get(..8)? != b"\x89PNG\r\n\x1a\n" || bytes.get(12..16)? != b"IHDR" { + return None; + } + let be = |at: usize| Some(u32::from_be_bytes(bytes.get(at..at + 4)?.try_into().ok()?)); + let (w, h) = (be(16)?, be(20)?); + (w > 0 && h > 0).then_some((w, h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A PNG signature and the chunks `types` (each with 2 bytes of data and a checksum). + fn png(types: &[&[u8; 4]]) -> Vec { + let mut b = b"\x89PNG\r\n\x1a\n".to_vec(); + for t in types { + b.extend([0, 0, 0, 2]); + b.extend(*t); + b.extend([0; 6]); + } + b + } + + #[test] + fn compressed_metadata_chunks_are_found_wherever_they_are() { + assert!(!has_compressed_metadata(&png(&[b"IHDR", b"IDAT", b"IEND"]))); + assert!(has_compressed_metadata(&png(&[b"IHDR", b"iCCP", b"IDAT", b"IEND"]))); + assert!(has_compressed_metadata(&png(&[b"IHDR", b"IDAT", b"zTXt"]))); + // A length past the end ends the walk. + let mut cut = png(&[b"IHDR"]); + cut.extend([255, 255, 255, 255, b'I', b'D', b'A', b'T']); + assert!(!has_compressed_metadata(&cut)); + } + + /// A PNG signature and one chunk `t` carrying `data` (the stand-in CRC is never read: the + /// scan walks lengths and types only). + fn png_chunk(t: &[u8; 4], data: &[u8]) -> Vec { + let mut b = b"\x89PNG\r\n\x1a\n".to_vec(); + b.extend(u32::try_from(data.len()).unwrap_or(u32::MAX).to_be_bytes()); + b.extend(t); + b.extend(data); + b.extend([0; 4]); + b + } + + #[test] + fn an_uncompressed_itxt_needs_no_decompression() { + assert!(!has_compressed_metadata(&png_chunk(b"iTXt", b"hi\0\0\0\0")), "compression flag 0 stores plain text"); + assert!(has_compressed_metadata(&png_chunk(b"iTXt", b"hi\0\x01\0\0\0")), "compression flag 1 is unpacked by the decoder"); + assert!(has_compressed_metadata(&png_chunk(b"iTXt", b"hi")), "no keyword nul: conservative stays safe"); + } +} diff --git a/crates/text/src/colr.rs b/crates/text/src/colr.rs new file mode 100644 index 000000000..9045a4f55 --- /dev/null +++ b/crates/text/src/colr.rs @@ -0,0 +1,604 @@ +//! COLR colour glyphs (v0 layers, v1 paint graphs): read into a tree of document nodes in font +//! units (y down, as outlines are), which the renderer, the exporters and Create Outlines draw and +//! keep as they do any art. A glyph whose paints VectorCraft has no form for is drawn into an image +//! instead, as bitmap glyphs are ([`GlyphImage`]); [`Tree::unsupported`] names what made it one. + +use std::sync::Arc; + +use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2}; +use skrifa::color::{Brush, ColorGlyph, ColorGlyphCollection, ColorPainter, ColorPalettes, ColorStop, CompositeMode, Extend, Transform}; +use skrifa::instance::{LocationRef, Size}; +use skrifa::outline::DrawSettings; +use skrifa::raw::TableProvider; +use skrifa::raw::types::BoundingBox; +use skrifa::{GlyphId, MetadataProvider}; +use vectorcraft_doc::color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint}; +use vectorcraft_doc::{Appearance, FillLayer, ImageBlob, Node, NodeId, NodeKind}; +use vectorcraft_geom::PathData; +use vello_cpu::peniko; + +use crate::GlyphImage; +use crate::fontdb::FlipPen; + +/// The most nodes a colour glyph's tree may have: past it, a font's glyph draws its outline. +const MAX_COLR_NODES: usize = 4096; + +/// The most path elements the clips and fills of a colour glyph's tree may hold in all: layers +/// that repeat one large outline each hold a copy of it. +const MAX_COLR_POINTS: usize = 1 << 20; +/// The deepest the groups of a colour glyph may nest. +const MAX_COLR_DEPTH: usize = 64; +/// The pixels per em an image of a colour glyph is drawn at. +const FALLBACK_PPEM: f64 = 256.0; +/// The largest side of such an image, in pixels. +const MAX_FALLBACK_SIDE: f64 = 1024.0; +/// The id of the layers' nodes: none, as the art exporters make on the fly has (Create Outlines +/// gives them ids of the document's). +const NO_ID: NodeId = NodeId(u64::MAX); +/// Font units y up → y down. +const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); + +/// A colour glyph as VectorCraft draws it. +#[derive(Clone)] +pub(crate) enum ColourContent { + /// Its layers as document nodes in font units (y down): a group. + Art(Arc), + /// Its picture, for paints VectorCraft has no form for. + Image(Arc), +} + +/// Does `font` have COLR colour glyphs? +pub(crate) fn has_colour_layers(font: &skrifa::FontRef<'_>) -> bool { + font.colr().is_ok() +} + +/// Does glyph `gid` of `font` have COLR layers? +pub(crate) fn has_colour_glyph(font: &skrifa::FontRef<'_>, gid: u32) -> bool { + ColorGlyphCollection::new(font).get(GlyphId::new(gid)).is_some() +} + +/// Glyph `gid`'s colour content in `font` at `location`, palette 0's colours and `foreground` for +/// palette index 0xFFFF, and whether it used `foreground`; `None` when it isn't a colour glyph, or +/// its graph doesn't read (a cycle, too deep, too many layers). +pub(crate) fn colour_glyph( + font: &skrifa::FontRef<'_>, + location: LocationRef<'_>, + upem: f64, + gid: u32, + foreground: Color, +) -> (Option, bool) { + let Some(glyph) = ColorGlyphCollection::new(font).get(GlyphId::new(gid)) else { return (None, false) }; + let mut tree = Tree::new(font, location, foreground); + if glyph.paint(location, &mut tree).is_err() { + return (None, false); + } + let used = tree.foreground_used; + if tree.unsupported.is_some() { + // What it paints, y up, for a glyph without a clip box. + let painted = tree.painted.map(|b| FLIP.transform_rect_bbox(b)); + return (raster(&glyph, font, location, upem, foreground, painted).map(|i| ColourContent::Image(Arc::new(i))), used); + } + (tree.finish().map(ColourContent::Art), used) +} + +/// Palette 0's colours with their alpha. +fn palette(font: &skrifa::FontRef<'_>) -> Vec<(Color, f32)> { + let palettes = ColorPalettes::new(font); + let Some(palette) = palettes.get(0) else { return vec![] }; + palette.colors().iter().map(|c| (Color::rgb8(c.red, c.green, c.blue), f32::from(c.alpha) / 255.0)).collect() +} + +/// The colour and alpha of palette entry `index` (0xFFFF: `foreground`), times `alpha`; `None` +/// for an entry the palette doesn't have. +fn colour(palette: &[(Color, f32)], foreground: Color, index: u16, alpha: f32) -> Option<(Color, f32)> { + let (c, a) = if index == 0xFFFF { (foreground, 1.0) } else { *palette.get(usize::from(index))? }; + Some((c, (a * alpha).clamp(0.0, 1.0))) +} + +fn affine(t: &Transform) -> Affine { + Affine::new([t.xx, t.yx, t.xy, t.yy, t.dx, t.dy].map(f64::from)) +} + +fn point(p: skrifa::raw::types::Point) -> Point { + Point::new(f64::from(p.x), f64::from(p.y)) +} + +fn box_path(b: &BoundingBox) -> BezPath { + Rect::new(f64::from(b.x_min), f64::from(b.y_min), f64::from(b.x_max), f64::from(b.y_max)).to_path(0.1) +} + +/// Glyph `gid`'s outline in font units, y up (the font database's pen draws y down, as text +/// space has it). +fn outline(font: &skrifa::FontRef<'_>, location: LocationRef<'_>, gid: GlyphId) -> BezPath { + let mut pen = FlipPen(BezPath::new()); + if let Some(g) = font.outline_glyphs().get(gid) { + let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), location), &mut pen); + } + let mut path = pen.0; + path.apply_affine(FLIP); + path +} + +/// A group being built: its clipping path, or the blend mode of a composite's layer. +struct Open { + clip: Option, + blend: Option, + children: Vec>, + /// A fill filled the clip's whole area: alone, it is the clip filled. + flooded: bool, +} + +/// A [`ColorPainter`] building a colour glyph's tree. +struct Tree<'a> { + font: &'a skrifa::FontRef<'a>, + location: LocationRef<'a>, + palette: Vec<(Color, f32)>, + foreground: Color, + /// Font units y up → the tree's space (y down), with the paint graph's transforms. + transforms: Vec, + groups: Vec, + nodes: usize, + /// The path elements of the clips and fills so far. + points: usize, + /// What VectorCraft has no form for, the first one met: the glyph becomes an image. + unsupported: Option<&'static str>, + /// The graph is past [`MAX_COLR_NODES`] or [`MAX_COLR_DEPTH`]. + too_large: bool, + /// The box of what it paints, in the tree's space. + painted: Option, + /// It paints with the foreground colour (palette index 0xFFFF). + foreground_used: bool, +} + +impl<'a> Tree<'a> { + fn new(font: &'a skrifa::FontRef<'a>, location: LocationRef<'a>, foreground: Color) -> Self { + let root = Open { clip: None, blend: Some(BlendMode::Normal), children: vec![], flooded: false }; + Tree { + font, + location, + palette: palette(font), + foreground, + transforms: vec![FLIP], + groups: vec![root], + nodes: 0, + points: 0, + unsupported: None, + too_large: false, + painted: None, + foreground_used: false, + } + } + + fn transform(&self) -> Affine { + self.transforms.last().copied().unwrap_or(FLIP) + } + + fn add(&mut self, node: Node) { + self.nodes += 1; + self.too_large |= self.nodes > MAX_COLR_NODES; + if let Some(g) = self.groups.last_mut().filter(|_| !self.too_large) { + g.children.push(Arc::new(node)); + } + } + + /// Count `path` against [`MAX_COLR_POINTS`]: whether it may be kept. + fn charge(&mut self, path: &BezPath) -> bool { + self.points = self.points.saturating_add(path.elements().len()); + self.too_large |= self.points > MAX_COLR_POINTS; + !self.too_large + } + + fn open(&mut self, group: Open) { + self.too_large |= self.groups.len() >= MAX_COLR_DEPTH; + self.groups.push(group); + } + + /// Closes the innermost group into its parent: a clip with only the fill it clips is that + /// filled path; an empty group goes. + fn close(&mut self) { + // The root stays open until `finish`. + if self.groups.len() < 2 { + return; + } + let Some(g) = self.groups.pop() else { return }; + if g.children.is_empty() { + return; + } + let mut children = g.children; + if let Some(clip) = g.clip { + if let ([only], true) = (children.as_slice(), g.flooded) { + let only = only.clone(); + return self.add_arc(only); + } + let mut path = Node::path(NO_ID, PathData::from_bezpath(&clip), Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + children.insert(0, Arc::new(path)); + let mut group = Node::group(NO_ID, children); + if let NodeKind::Group { clip, .. } = &mut group.kind { + *clip = true; + } + return self.add(group); + } + let mut group = Node::group(NO_ID, children); + group.isolate = true; + group.blend = g.blend.unwrap_or(BlendMode::Normal); + self.add(group); + } + + fn add_arc(&mut self, node: Arc) { + if let Some(g) = self.groups.last_mut() { + g.children.push(node); + } + } + + /// The tree: the glyph as a group. It needs no isolation of its own: blend modes only come + /// with composites, whose groups are isolated, so they never reach the art behind the type. + fn finish(mut self) -> Option> { + if self.too_large { + return None; + } + while self.groups.len() > 1 { + self.close(); + } + let root = self.groups.pop()?; + if root.children.is_empty() { + return None; + } + Some(Arc::new(Node::group(NO_ID, root.children))) + } + + /// `brush` as a VectorCraft paint and opacity, in the tree's space; `None` with + /// [`Self::unsupported`] set when it has no form there. + fn paint(&mut self, brush: &Brush<'_>) -> Option<(Paint, f32)> { + self.foreground_used |= match brush { + Brush::Solid { palette_index, .. } => *palette_index == 0xFFFF, + Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => { + color_stops.iter().any(|s| s.palette_index == 0xFFFF) + } + }; + let xf = self.transform(); + let stops = |stops: &[ColorStop], tree: &Self| -> Vec { + stops + .iter() + .filter_map(|s| { + let (color, opacity) = colour(&tree.palette, tree.foreground, s.palette_index, s.alpha)?; + Some(GradientStop { opacity, ..GradientStop::new(s.offset, color) }) + }) + .collect() + }; + let gradient = |kind, stops: Vec, geom| { + let mut paint = GradientPaint::new(Gradient { kind, stops, ..Gradient::default() }); + paint.geom = Some(geom); + Paint::Gradient(Box::new(paint)) + }; + match brush { + Brush::Solid { palette_index, alpha } => { + let (c, a) = colour(&self.palette, self.foreground, *palette_index, *alpha)?; + Some((Paint::solid(c), a)) + } + Brush::LinearGradient { p0, p1, color_stops, extend } => { + if *extend != Extend::Pad { + self.unsupported.get_or_insert("a repeating or reflecting gradient"); + return None; + } + let (p0, p1) = (point(*p0), point(*p1)); + let (stops, first, last) = normalized(stops(color_stops, self))?; + // Stops past 0 and 1 move the ends instead. + let mut geom = GradientGeom { start: p0.lerp(p1, f64::from(first)), end: p0.lerp(p1, f64::from(last)), aspect: 1.0, focal: None }; + geom.transform(xf, GradientKind::Linear); + Some((gradient(GradientKind::Linear, stops, geom), 1.0)) + } + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend } => { + if *extend != Extend::Pad { + self.unsupported.get_or_insert("a repeating or reflecting gradient"); + return None; + } + let (c0, c1, r0, r1) = (point(*c0), point(*c1), f64::from(*r0), f64::from(*r1)); + let concentric = (c0 - c1).hypot() < 1e-6; + if (r0 > 1e-6 && !concentric) || r1 <= r0 || (c0 - c1).hypot() >= r1 { + self.unsupported.get_or_insert("a radial gradient from a circle"); + return None; + } + let mut stops = stops(color_stops, self); + if stops.iter().any(|s| !(0.0..=1.0).contains(&s.offset)) { + self.unsupported.get_or_insert("a radial gradient with stops past its circles"); + return None; + } + // A start circle around the centre starts the stops further out. + for s in &mut stops { + s.offset = ((r0 + f64::from(s.offset) * (r1 - r0)) / r1) as f32; + } + let mut geom = GradientGeom { start: c1, end: c1 + Vec2::new(r1, 0.0), aspect: 1.0, focal: (!concentric).then_some(c0) }; + geom.transform(xf, GradientKind::Radial); + Some((gradient(GradientKind::Radial, stops, geom), 1.0)) + } + Brush::SweepGradient { .. } => { + self.unsupported.get_or_insert("a sweep gradient"); + None + } + } + } + + /// `path` (in the tree's space) filled with `brush` (in the current transform's). + fn fill_path(&mut self, path: &BezPath, brush: &Brush<'_>) { + if !self.charge(path) { + return; + } + let b = path.bounding_box(); + self.painted = Some(self.painted.map_or(b, |p| p.union(b))); + let Some((paint, opacity)) = self.paint(brush) else { return }; + let fill = vectorcraft_doc::AppearanceItem::Fill(FillLayer { opacity, ..FillLayer::new(paint) }); + self.add(Node::path(NO_ID, PathData::from_bezpath(path), Appearance { items: vec![fill], ..Appearance::default() })); + } +} + +/// `stops` sorted, from 0 to 1 (as the gradient's ends then move), and where the first and last +/// were; `None` without stops. +fn normalized(mut stops: Vec) -> Option<(Vec, f32, f32)> { + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let (first, last) = (stops.first()?.offset, stops.last()?.offset); + let span = last - first; + for s in &mut stops { + s.offset = if span > 0.0 { (s.offset - first) / span } else { 0.0 }; + } + Some((stops, first, if span > 0.0 { last } else { first + 1.0 })) +} + +impl ColorPainter for Tree<'_> { + fn push_transform(&mut self, transform: Transform) { + let t = self.transform() * affine(&transform); + self.transforms.push(t); + } + + fn pop_transform(&mut self) { + if self.transforms.len() > 1 { + self.transforms.pop(); + } + } + + fn push_clip_glyph(&mut self, glyph_id: GlyphId) { + let mut clip = outline(self.font, self.location, glyph_id); + clip.apply_affine(self.transform()); + self.charge(&clip); + self.open(Open { clip: Some(clip), blend: None, children: vec![], flooded: false }); + } + + fn push_clip_box(&mut self, clip_box: BoundingBox) { + let mut clip = box_path(&clip_box); + clip.apply_affine(self.transform()); + self.open(Open { clip: Some(clip), blend: None, children: vec![], flooded: false }); + } + + fn pop_clip(&mut self) { + self.close(); + } + + /// A fill fills the innermost clip's shape (a layer between them stays around it). + fn fill(&mut self, brush: Brush<'_>) { + let Some(at) = self.groups.iter().rposition(|g| g.clip.is_some()) else { + self.unsupported.get_or_insert("a fill outside any glyph"); + return; + }; + let Some(clip) = self.groups.get(at).and_then(|g| g.clip.clone()) else { return }; + if at + 1 == self.groups.len() + && let Some(g) = self.groups.last_mut() + { + g.flooded = g.children.is_empty(); + } + self.fill_path(&clip, &brush); + } + + /// The glyph's outline filled, the brush moved by `brush_transform` (the glyph stays). + fn fill_glyph(&mut self, glyph_id: GlyphId, brush_transform: Option, brush: Brush<'_>) { + let mut path = outline(self.font, self.location, glyph_id); + path.apply_affine(self.transform()); + let brush_xf = self.transform() * brush_transform.as_ref().map_or(Affine::IDENTITY, affine); + self.transforms.push(brush_xf); + self.fill_path(&path, &brush); + self.transforms.pop(); + } + + fn push_layer(&mut self, mode: CompositeMode) { + let blend = blend_mode(mode); + if blend.is_none() { + self.unsupported.get_or_insert("a compositing mode other than blending"); + } + self.open(Open { clip: None, blend, children: vec![], flooded: false }); + } + + fn pop_layer(&mut self) { + self.close(); + } +} + +/// COLRv1's blend composite modes as VectorCraft's (`SrcOver` is Normal); `None` for the +/// Porter-Duff modes, which it has no form for. +fn blend_mode(mode: CompositeMode) -> Option { + Some(match mode { + CompositeMode::SrcOver => BlendMode::Normal, + CompositeMode::Screen => BlendMode::Screen, + CompositeMode::Overlay => BlendMode::Overlay, + CompositeMode::Darken => BlendMode::Darken, + CompositeMode::Lighten => BlendMode::Lighten, + CompositeMode::ColorDodge => BlendMode::ColorDodge, + CompositeMode::ColorBurn => BlendMode::ColorBurn, + CompositeMode::HardLight => BlendMode::HardLight, + CompositeMode::SoftLight => BlendMode::SoftLight, + CompositeMode::Difference => BlendMode::Difference, + CompositeMode::Exclusion => BlendMode::Exclusion, + CompositeMode::Multiply => BlendMode::Multiply, + CompositeMode::HslHue => BlendMode::Hue, + CompositeMode::HslSaturation => BlendMode::Saturation, + CompositeMode::HslColor => BlendMode::Color, + CompositeMode::HslLuminosity => BlendMode::Luminosity, + _ => return None, + }) +} + +/// Colour glyph `glyph` drawn into an image at [`FALLBACK_PPEM`], placed in font units (y down) +/// as bitmap glyphs are, in its clip box or else the box of what it `painted` (y up); `None` when +/// it paints nothing or doesn't draw. +fn raster( + glyph: &ColorGlyph<'_>, + font: &skrifa::FontRef<'_>, + location: LocationRef<'_>, + upem: f64, + foreground: Color, + painted: Option, +) -> Option { + let b = glyph.bounding_box(location, Size::unscaled()).map(|b| Rect::new(b.x_min.into(), b.y_min.into(), b.x_max.into(), b.y_max.into())); + // Without a clip box, the box of what it paints (`painted`, y up). + let b = b.or(painted).filter(|b| b.area() > 0.0)?; + let s = (FALLBACK_PPEM / upem.max(1.0)).min(MAX_FALLBACK_SIDE / b.width().max(b.height())); + if !(s.is_finite() && s > 0.0) { + return None; + } + let (w, h) = ((b.width() * s).ceil() as u16, (b.height() * s).ceil() as u16); + if w == 0 || h == 0 { + return None; + } + // Font units y up → pixels. + let to_pixels = Affine::new([s, 0.0, 0.0, -s, -b.x0 * s, b.y1 * s]); + let mut ctx = vello_cpu::RenderContext::new(w, h); + let mut painter = Pixels { ctx: &mut ctx, font, location, palette: palette(font), foreground, transforms: vec![to_pixels], width: w, height: h }; + glyph.paint(location, &mut painter).ok()?; + ctx.flush(); + let mut pm = vello_cpu::Pixmap::new(w, h); + ctx.render(&mut pm, &mut vello_cpu::Resources::new()); + let png = pm.into_png().ok()?; + let xf = Affine::new([1.0 / s, 0.0, 0.0, 1.0 / s, b.x0, -b.y1]); + Some(GlyphImage { blob: ImageBlob::png(png), width: u32::from(w), height: u32::from(h), xf }) +} + +/// A [`ColorPainter`] drawing a colour glyph with vello_cpu. +struct Pixels<'a, 'c> { + ctx: &'c mut vello_cpu::RenderContext, + font: &'a skrifa::FontRef<'a>, + location: LocationRef<'a>, + palette: Vec<(Color, f32)>, + foreground: Color, + transforms: Vec, + width: u16, + height: u16, +} + +impl Pixels<'_, '_> { + fn transform(&self) -> Affine { + self.transforms.last().copied().unwrap_or_default() + } + + fn peniko_colour(&self, index: u16, alpha: f32) -> peniko::Color { + let (c, a) = colour(&self.palette, self.foreground, index, alpha).unwrap_or((Color::BLACK, 0.0)); + let [r, g, b, a] = c.to_rgba8(a); + peniko::Color::from_rgba8(r, g, b, a) + } + + fn clip(&mut self, path: &BezPath) { + self.ctx.set_transform(self.transform()); + self.ctx.push_clip_layer(path); + } +} + +impl ColorPainter for Pixels<'_, '_> { + fn push_transform(&mut self, transform: Transform) { + let t = self.transform() * affine(&transform); + self.transforms.push(t); + } + + fn pop_transform(&mut self) { + if self.transforms.len() > 1 { + self.transforms.pop(); + } + } + + fn push_clip_glyph(&mut self, glyph_id: GlyphId) { + let path = outline(self.font, self.location, glyph_id); + self.clip(&path); + } + + fn push_clip_box(&mut self, clip_box: BoundingBox) { + self.clip(&box_path(&clip_box)); + } + + fn pop_clip(&mut self) { + self.ctx.pop_layer(); + } + + fn fill(&mut self, brush: Brush<'_>) { + let stops = |stops: &[ColorStop]| -> Vec { + stops.iter().map(|s| peniko::ColorStop::from((s.offset, self.peniko_colour(s.palette_index, s.alpha)))).collect() + }; + let extend = |e: Extend| match e { + Extend::Repeat => peniko::Extend::Repeat, + Extend::Reflect => peniko::Extend::Reflect, + _ => peniko::Extend::Pad, + }; + let paint: peniko::Brush = match brush { + Brush::Solid { palette_index, alpha } => peniko::Brush::Solid(self.peniko_colour(palette_index, alpha)), + Brush::LinearGradient { p0, p1, color_stops, extend: e } => { + peniko::Gradient::new_linear(point(p0), point(p1)).with_stops(stops(color_stops).as_slice()).with_extend(extend(e)).into() + } + Brush::RadialGradient { c0, r0, c1, r1, color_stops, extend: e } => peniko::Gradient::new_two_point_radial(point(c0), r0, point(c1), r1) + .with_stops(stops(color_stops).as_slice()) + .with_extend(extend(e)) + .into(), + Brush::SweepGradient { c0, start_angle, end_angle, color_stops, extend: e } => { + peniko::Gradient::new_sweep(point(c0), start_angle.to_radians(), end_angle.to_radians()) + .with_stops(stops(color_stops).as_slice()) + .with_extend(extend(e)) + .into() + } + }; + // Everything the image shows, in the paint's space. + let xf = self.transform(); + let all = xf.inverse().transform_rect_bbox(Rect::new(0.0, 0.0, f64::from(self.width), f64::from(self.height))); + self.ctx.set_transform(xf); + match paint { + peniko::Brush::Solid(c) => self.ctx.set_paint(c), + peniko::Brush::Gradient(g) => self.ctx.set_paint(g), + peniko::Brush::Image(_) => return, + } + self.ctx.fill_rect(&all); + } + + fn push_layer(&mut self, mode: CompositeMode) { + use peniko::{BlendMode as Mode, Compose, Mix}; + let (mix, compose) = match mode { + CompositeMode::Clear => (Mix::Normal, Compose::Clear), + CompositeMode::Src => (Mix::Normal, Compose::Copy), + CompositeMode::Dest => (Mix::Normal, Compose::Dest), + CompositeMode::DestOver => (Mix::Normal, Compose::DestOver), + CompositeMode::SrcIn => (Mix::Normal, Compose::SrcIn), + CompositeMode::DestIn => (Mix::Normal, Compose::DestIn), + CompositeMode::SrcOut => (Mix::Normal, Compose::SrcOut), + CompositeMode::DestOut => (Mix::Normal, Compose::DestOut), + CompositeMode::SrcAtop => (Mix::Normal, Compose::SrcAtop), + CompositeMode::DestAtop => (Mix::Normal, Compose::DestAtop), + CompositeMode::Xor => (Mix::Normal, Compose::Xor), + CompositeMode::Plus => (Mix::Normal, Compose::Plus), + CompositeMode::Screen => (Mix::Screen, Compose::SrcOver), + CompositeMode::Overlay => (Mix::Overlay, Compose::SrcOver), + CompositeMode::Darken => (Mix::Darken, Compose::SrcOver), + CompositeMode::Lighten => (Mix::Lighten, Compose::SrcOver), + CompositeMode::ColorDodge => (Mix::ColorDodge, Compose::SrcOver), + CompositeMode::ColorBurn => (Mix::ColorBurn, Compose::SrcOver), + CompositeMode::HardLight => (Mix::HardLight, Compose::SrcOver), + CompositeMode::SoftLight => (Mix::SoftLight, Compose::SrcOver), + CompositeMode::Difference => (Mix::Difference, Compose::SrcOver), + CompositeMode::Exclusion => (Mix::Exclusion, Compose::SrcOver), + CompositeMode::Multiply => (Mix::Multiply, Compose::SrcOver), + CompositeMode::HslHue => (Mix::Hue, Compose::SrcOver), + CompositeMode::HslSaturation => (Mix::Saturation, Compose::SrcOver), + CompositeMode::HslColor => (Mix::Color, Compose::SrcOver), + CompositeMode::HslLuminosity => (Mix::Luminosity, Compose::SrcOver), + _ => (Mix::Normal, Compose::SrcOver), + }; + self.ctx.push_layer(None, Some(Mode::new(mix, compose)), None, None, None); + } + + fn pop_layer(&mut self) { + self.ctx.pop_layer(); + } +} diff --git a/crates/text/src/embed.rs b/crates/text/src/embed.rs index 6615d10c9..6349391d9 100644 --- a/crates/text/src/embed.rs +++ b/crates/text/src/embed.rs @@ -223,6 +223,9 @@ fn checksum(data: &[u8]) -> u32 { words.iter().chain([&last]).fold(0u32, |s, w| s.wrapping_add(u32::from_be_bytes(*w))) } +/// The most bytes of a font file written. +const MAX_FONT_BYTES: usize = 256 << 20; + /// An OpenType file of `tables` (unique tags) with checksums and the `head` checksum adjustment. fn sfnt(version: u32, mut tables: Vec<(Tag, Cow<'_, [u8]>)>) -> Option> { tables.sort_by_key(|t| t.0); @@ -230,7 +233,12 @@ fn sfnt(version: u32, mut tables: Vec<(Tag, Cow<'_, [u8]>)>) -> Option> let n = u16::try_from(tables.len()).ok().filter(|n| *n > 0)?; let sel = n.ilog2() as u16; let range = 2u16.checked_pow(u32::from(sel))?.checked_mul(16)?; - let mut out = Vec::with_capacity(12 + 16 * tables.len() + tables.iter().map(|t| t.1.len() + 3).sum::()); + // Tables may alias one range of the input: the file is no larger than a font file may be. + let size = tables.iter().try_fold(12 + 16 * tables.len(), |sum, t| sum.checked_add(t.1.len().checked_add(3)?))?; + if size > MAX_FONT_BYTES { + return None; + } + let mut out = Vec::with_capacity(size); out.extend(version.to_be_bytes()); for v in [n, range, sel, n.checked_mul(16)? - range] { out.extend(v.to_be_bytes()); diff --git a/crates/text/src/features.rs b/crates/text/src/features.rs index 075268465..dd71a20c9 100644 --- a/crates/text/src/features.rs +++ b/crates/text/src/features.rs @@ -46,6 +46,9 @@ pub struct OtFeatures { pub extra: u32, } +/// The most entries of a style's feature list read: every segment of its text reads the list again. +const MAX_STYLE_FEATURES: usize = 256; + /// Features beyond the named fields, off unless asked for, each bit `i` of [`OtFeatures::extra`]: /// stylistic sets `ss01`–`ss20`, stylistic alternates `salt`, titling `titl`, proportional `pnum` /// and lining `lnum` figures, superscript `sups` and subscript `subs`. @@ -146,13 +149,15 @@ impl OtFeatures { /// The harfrust features for text in style `st`. pub(crate) fn resolve(&self, st: &CharStyle) -> Vec { // The character's own OpenType settings override the layout-wide ones. - let own = self.with_tags(st.features.iter().map(String::as_str)); + // (A style lists a few features; one with thousands, in a document, is read for the first.) + let listed = st.features.get(..MAX_STYLE_FEATURES).unwrap_or(&st.features); + let own = self.with_tags(listed.iter().map(String::as_str)); let s = &own; let mut v = Vec::with_capacity(8); if st.kerning.is_some() { v.push(f(b"kern", false)); } - let liga = s.ligatures && (!ligatures_suppressed_by(st.tracking) || explicit_ligatures(&st.features)); + let liga = s.ligatures && (!ligatures_suppressed_by(st.tracking) || explicit_ligatures(listed)); if !liga { v.push(f(b"liga", false)); v.push(f(b"clig", false)); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f56eaa371..9642bf7dd 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -13,6 +13,10 @@ use skrifa::raw::FileRef; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider, Tag}; +use crate::GlyphImage; +use crate::colr::ColourContent; +use vectorcraft_doc::color::Color; + /// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; @@ -132,6 +136,12 @@ pub struct FontFace { ideographic_centre: std::sync::OnceLock, /// [`Self::icf_margins`], read once. icf_margins: std::sync::OnceLock, + /// Has `sbix` or `CBDT` strikes: colour bitmap glyphs ([`FontDb::glyph_image`]). + colour_bitmaps: bool, + /// Has `COLR` colour glyphs ([`FontDb::glyph_colour`]). + colour_layers: bool, + /// Has an `SVG ` table: OpenType SVG glyphs ([`FontDb::glyph_colour`]). + svg_glyphs: bool, } /// Where the ideographic character face (ICF) lies inside the ideographic em box: its distance @@ -216,6 +226,11 @@ impl FontFace { pub fn vertical_metrics(&self) -> (f64, f64) { (self.ascent, self.descent) } + /// The line gap the font asks for between lines, in font units (from the same table as + /// [`vertical_metrics`](Self::vertical_metrics); 0 when it has none). + pub fn line_gap(&self) -> f64 { + self.skrifa().map_or(0.0, |f| f64::from(f.metrics(Size::unscaled(), self.location()).leading)).max(0.0) + } /// Every mapped character and its glyph id, sorted by code point (the Glyphs panel). pub fn chars(&self) -> Vec<(char, u32)> { let Some(f) = self.skrifa() else { return vec![] }; @@ -379,7 +394,7 @@ struct CatalogFamily { /// Non-English family name from the name table, when the scan found one. native: Option, faces: Vec, - /// The family's kind (from its first face found). + /// The family's kind (from its first face found); variable when any face found is. traits: Option, } @@ -405,10 +420,77 @@ type PostScriptNames = HashMap; /// are neither a loaded nor an installed family. type CanonicalNames = HashMap<(String, String), (String, String)>; +/// [`FontDb::glyph_image`]'s answers by face and glyph. +type GlyphImages = GlyphCache<(u32, u32), Arc>; +/// [`FontDb::glyph_colour`]'s answers by face and glyph, and the text's fill and stroke colours for +/// glyphs that paint with them. +type GlyphColours = GlyphCache<(u32, u32, Option), ColourContent>; +/// The text's fill and stroke colours, as a cache key ([`Color::key`]). +type TextColours = ([u32; 5], Option<[u32; 5]>); + +/// What a glyph's colour content (an image, layers) takes of memory, in bytes. +trait Weight { + fn bytes(&self) -> usize; +} + +impl Weight for Arc { + fn bytes(&self) -> usize { + self.blob.bytes.len() + } +} + +impl Weight for ColourContent { + fn bytes(&self) -> usize { + match self { + ColourContent::Image(i) => i.bytes(), + // Roughly what a path node with its outline and fill takes. + ColourContent::Art(art) => { + let mut nodes = 0; + art.walk(&mut |_| nodes += 1); + nodes * 512 + } + } + } +} + +/// The colour content of glyphs ([`FontDb::glyph_image`], [`FontDb::glyph_colour`]) kept by +/// face, glyph and what else it depends on, and its bytes in all. +struct GlyphCache { + kept: HashMap>, + bytes: usize, +} + +impl Default for GlyphCache { + fn default() -> Self { + Self { kept: HashMap::new(), bytes: 0 } + } +} + +impl GlyphCache { + /// Keeps `value` (or that there is none), first letting go of all it keeps when that would + /// take more than [`IMAGE_CACHE_MAX`] glyphs or [`IMAGE_CACHE_BYTES`]. A glyph it keeps + /// already, which another thread looked up meanwhile, stays as it is. + fn keep(&mut self, key: K, value: Option) { + if self.kept.contains_key(&key) { + return; + } + let bytes = value.as_ref().map_or(0, Weight::bytes); + if self.kept.len() >= IMAGE_CACHE_MAX || self.bytes.saturating_add(bytes) > IMAGE_CACHE_BYTES { + self.kept.clear(); + self.bytes = 0; + } + self.bytes += bytes; + self.kept.insert(key, value); + } +} + /// Process-wide font database. pub struct FontDb { faces: RwLock>>, outlines: Mutex>>, + /// [`FontDb::glyph_image`]'s answers per (face, glyph), misses too. + images: Mutex, + colours: Mutex, catalog: RwLock, /// The installed faces by PostScript name (documents name fonts so: PDF, EPS, .ai). postscript: RwLock, @@ -545,6 +627,10 @@ const SYSTEM_FALLBACKS: &[&str] = &[ static NEXT_ID: AtomicU32 = AtomicU32::new(1); const OUTLINE_CACHE_MAX: usize = 50_000; +/// The most glyph images [`FontDb::glyph_image`] keeps (each a PNG of some 10–60 KB). +const IMAGE_CACHE_MAX: usize = 4_096; +/// The most PNG bytes it keeps: a font can make each one up to [`crate::bitmap::MAX_PNG_BYTES`]. +const IMAGE_CACHE_BYTES: usize = 64 << 20; /// How many answers [`FontDb::canonical`] keeps at most. When it has kept that many, it clears /// them before it keeps the next one. const CANONICAL_CACHE_MAX: usize = 4_096; @@ -553,6 +639,9 @@ fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option { ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty())) } +/// The most distinct legacy family (and style) names of a font used as keys. +const MAX_LEGACY_NAMES: usize = 64; + /// Every localized string of `id`, with its language. fn localized(font: &skrifa::FontRef<'_>, id: StringId) -> Vec<(Option, String)> { font.localized_strings(id).map(|s| (s.language().map(str::to_owned), s.to_string())).filter(|(_, s)| !s.is_empty()).collect() @@ -605,7 +694,14 @@ struct FaceKeys { impl FaceKeys { fn of(font: &skrifa::FontRef<'_>, family: &str, style: &str) -> Self { let (typo_family, typo_style) = (all_names(font, StringId::TYPOGRAPHIC_FAMILY_NAME), all_names(font, StringId::TYPOGRAPHIC_SUBFAMILY_NAME)); - let (legacy_family, legacy_style) = (localized(font, StringId::FAMILY_NAME), localized(font, StringId::SUBFAMILY_NAME)); + let (mut legacy_family, mut legacy_style) = (localized(font, StringId::FAMILY_NAME), localized(font, StringId::SUBFAMILY_NAME)); + // Records repeat (platforms, encodings) and are paired below: once each, and no more than a + // font has any use for, or the pairs multiply. + for names in [&mut legacy_family, &mut legacy_style] { + names.sort(); + names.dedup(); + names.truncate(MAX_LEGACY_NAMES); + } // Without typographic names, the legacy ones are the family's. let family_names: Vec<&str> = if typo_family.is_empty() { legacy_family.iter().map(|(_, f)| f.as_str()).collect() @@ -668,17 +764,21 @@ impl FontClass { [FontClass::Serif, FontClass::Sans, FontClass::Rounded, FontClass::Script, FontClass::Monospaced, FontClass::Decorative]; } -/// What a font menu filters on: whether a family sets Japanese or Thai, and its kind. +/// What a font menu filters on: whether a family sets Japanese or Thai, its kind, and whether it +/// is a variable font. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct FontTraits { pub japanese: bool, /// Its character map has Thai (ก and า). pub thai: bool, pub class: FontClass, + /// The face has variation axes (an `fvar` table of at most 64 axes); for a family, one of + /// its faces has. + pub variable: bool, } impl FontTraits { - /// From a face's OS/2 table (when it has one) and names. + /// From a face's OS/2 and `fvar` tables (when it has them) and names. fn of(font: &skrifa::FontRef<'_>, keys: &FaceKeys) -> Self { use skrifa::raw::TableProvider; let os2 = font.os2().ok(); @@ -789,7 +889,8 @@ impl FontTraits { } else { FontClass::Other }; - FontTraits { japanese, thai, class } + let variable = (1..=MAX_AXES).contains(&font.axes().len()); + FontTraits { japanese, thai, class, variable } } } @@ -838,6 +939,30 @@ pub(crate) fn alias_lists() -> u64 { ALIAS_LISTS.with(std::cell::Cell::get) } +#[cfg(test)] +thread_local! { + /// Tests: run on this thread each time it publishes faces to a database, to look the database + /// up at exactly that point (as another thread could). + static ON_FACES_PUBLISHED: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +/// Tests: run `hook` each time this thread publishes faces to a database. +#[cfg(test)] +pub(crate) fn on_faces_published(hook: Option>) { + ON_FACES_PUBLISHED.with(|h| *h.borrow_mut() = hook); +} + +#[cfg(test)] +fn faces_published() { + // Taken out while it runs, so a hook that adds faces itself doesn't run again. + if let Some(mut hook) = ON_FACES_PUBLISHED.with(|h| h.borrow_mut().take()) { + hook(); + ON_FACES_PUBLISHED.with(|h| { + h.borrow_mut().get_or_insert(hook); + }); + } +} + /// How a requested family and style resolved ([`FontDb::resolve`]). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FontMatch { @@ -997,12 +1122,15 @@ fn named_instances( (out, renamed) } +/// The most faces read from a (possibly damaged) collection. +const MAX_FACES: u32 = 256; + /// Parse every face in `data` (a font file or collection) and the named instances of its variable /// ones. Returns each style with the index of its face. fn enumerate_faces(data: &[u8]) -> Vec<(u32, FaceStyle)> { let count = match FileRef::new(data) { Ok(FileRef::Font(_)) => 1, - Ok(FileRef::Collection(c)) => c.len(), + Ok(FileRef::Collection(c)) => c.len().min(MAX_FACES), Err(_) => 0, }; (0..count) @@ -1021,10 +1149,11 @@ fn font_files(path: &Path) -> Vec> { std::fs::read(path).into_iter().collect() } -/// The tables holding outlines the font engine draws: TrueType, CFF, CFF2 and VARC. A face with -/// none of them (outlines in Apple's `hvgl` table, bitmaps only) draws nothing. +/// The tables holding glyphs the font engine draws: TrueType, CFF, CFF2 and VARC outlines, and +/// `sbix` and `CBDT` colour bitmaps. A face with none of them (outlines in Apple's `hvgl` table, +/// monochrome bitmaps only) draws nothing. #[cfg(not(target_arch = "wasm32"))] -const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; +const OUTLINE_TABLES: [&[u8; 4]; 6] = [b"glyf", b"CFF ", b"CFF2", b"VARC", b"sbix", b"CBDT"]; /// The styles of every face in the font file at `path` ([`face_styles`]), reading only its table /// directories and `name`, `fvar` and `OS/2` tables: a scan opens hundreds of font files, many of @@ -1032,8 +1161,6 @@ const OUTLINE_TABLES: [&[u8; 4]; 4] = [b"glyf", b"CFF ", b"CFF2", b"VARC"]; /// are left out. #[cfg(not(target_arch = "wasm32"))] fn file_face_names(path: &Path) -> Vec { - /// The most faces read from a (possibly damaged) collection. - const MAX_FACES: u32 = 256; file_face_names_within(path, MAX_FACES, u64::MAX) } @@ -1512,6 +1639,9 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option, path: Option<&Path>) -> usize { - let data = Arc::new(bytes); - let mut added = 0; - for (i, spec) in enumerate_faces(&data) { - // Another version of a family and style is kept: a document may name it (the first one - // loaded stays the one the family and style alone resolve to). - let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default(); - if self - .read_faces() - .iter() - .any(|f| f.family.eq_ignore_ascii_case(&spec.family) && f.style.eq_ignore_ascii_case(&spec.style) && f.version == version) - { - continue; - } - if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.map(Path::to_path_buf)) { - self.faces.write().unwrap_or_else(|e| e.into_inner()).push(Arc::new(f)); - added += 1; + self.add_fonts([(bytes, path.map(Path::to_path_buf))]) + } + + /// Add the faces of `files` (each file's bytes, and the file when known) all at once: a lookup + /// on another thread finds all of them or none, never a family with only some of its styles + /// loaded, whose nearest style it would take instead (Arial Bold Italic, the first of Arial's + /// files, for upright Regular text). Returns the number of faces added. + fn add_fonts(&self, files: impl IntoIterator, Option)>) -> usize { + // Another version of a family and style is kept: a document may name it (the first one + // loaded stays the one the family and style alone resolve to). + let same = |f: &FontFace, family: &str, style: &str, version: &str| { + f.family.eq_ignore_ascii_case(family) && f.style.eq_ignore_ascii_case(style) && f.version == version + }; + let mut made: Vec = Vec::new(); + for (bytes, path) in files { + let data = Arc::new(bytes); + for (i, spec) in enumerate_faces(&data) { + let version = skrifa::FontRef::from_index(&data, i).ok().and_then(|f| name(&f, &[StringId::VERSION_STRING])).unwrap_or_default(); + let present = |f: &FontFace| same(f, &spec.family, &spec.style, &version); + if self.read_faces().iter().any(|f| present(f)) || made.iter().any(present) { + continue; + } + if let Some(f) = make_face(FontBytes::Owned(data.clone()), i, spec, path.clone()) { + made.push(f); + } } } + let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner()); + let before = faces.len(); + for f in made { + // Another thread may have added the same face meanwhile. + if !faces.iter().any(|g| same(g, &f.family, &f.style, &f.version)) { + faces.push(Arc::new(f)); + } + } + let added = faces.len() - before; + drop(faces); + #[cfg(test)] + faces_published(); if added > 0 { self.changed(); } @@ -1911,7 +2067,8 @@ impl FontDb { postscript.insert(ps.clone(), (family.clone(), style.clone())); } let entry = catalog.entry(family.to_ascii_lowercase()).or_default(); - entry.traits.get_or_insert(traits); + let kind = entry.traits.get_or_insert(traits); + kind.variable |= traits.variable; if entry.name.is_empty() { entry.name = family; } @@ -1989,6 +2146,20 @@ impl FontDb { found.extend(v.iter().cloned()); } let ns = norm(style); + // A family and style split from a PostScript name by an importer that didn't have the font + // (`Honoka-Mincho` → Honoka, Mincho): once it is installed, the face of that PostScript + // name, which is its own family and style (ほのか明朝, Regular). + if found.is_empty() && !ns.is_empty() { + let joined = format!("{key}{ns}"); + let face = |a: &Alias| a.paired.is_none() && a.style.is_some(); + let ps = + self.read_faces().iter().flat_map(|f| Alias::of(&f.family, &f.style, &f.keys)).find(|(k, a)| *k == joined && face(a)).map(|(_, a)| a); + let ps = + ps.or_else(|| self.aliases.read().unwrap_or_else(|e| e.into_inner()).get(&joined).and_then(|v| v.iter().find(|a| face(a)).cloned())); + if let Some(a) = ps { + return (a.family, a.style.unwrap_or_else(|| style.to_string())); + } + } // A family name keeps the style asked for; a legacy pair names its face; a full or // PostScript name its face whatever style is asked for. if let Some(a) = found.iter().find(|a| a.style.is_none()) { @@ -2004,12 +2175,24 @@ impl FontDb { } /// The kind of `family` (whether it sets Japanese, its class), for font menu filters: from a - /// loaded face, else from the system font scan. Cheap: no font file is read. + /// loaded face, else from the system font scan. Variable when any of its faces, loaded or + /// scanned, is: a family may install static files beside a variable one. Cheap: no font file + /// is read, and a loaded family doesn't start the scan. pub fn family_traits(&self, family: &str) -> FontTraits { - if let Some(f) = self.read_faces().iter().find(|f| f.family.eq_ignore_ascii_case(family)) { - return f.traits; + let loaded = { + let faces = self.read_faces(); + let mut of_family = faces.iter().filter(|f| f.family.eq_ignore_ascii_case(family)); + of_family.next().map(|first| FontTraits { variable: first.traits.variable || of_family.any(|f| f.traits.variable), ..first.traits }) + }; + let scanned = |catalog: &Catalog| catalog.get(&family.to_ascii_lowercase()).and_then(|c| c.traits); + match loaded { + None => scanned(&self.read_catalog()).unwrap_or_default(), + Some(traits) if traits.variable => traits, + Some(traits) => { + let variable = scanned(&self.catalog.read().unwrap_or_else(|e| e.into_inner())).is_some_and(|t| t.variable); + FontTraits { variable, ..traits } + } } - self.read_catalog().get(&family.to_ascii_lowercase()).and_then(|c| c.traits).unwrap_or_default() } /// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how @@ -2055,20 +2238,17 @@ impl FontDb { self.postscript.read().unwrap_or_else(|e| e.into_inner()).get(&norm(name)).cloned() } - /// Load the files of the installed `family`. Returns whether any face was added. + /// Load the files of the installed `family`, adding all their faces at once + /// ([`Self::add_fonts`]). Returns whether the family is loaded now: by this call, or by another + /// thread meanwhile. #[cfg(not(target_arch = "wasm32"))] fn load_cataloged(&self, family: &str) -> bool { let mut paths: Vec = self.read_catalog().get(&family.to_ascii_lowercase()).map(|c| c.faces.iter().map(|cf| cf.path.clone()).collect()).unwrap_or_default(); paths.sort(); paths.dedup(); - let mut any = false; - for p in paths { - for data in font_files(&p) { - any |= self.add_font_from(data, Some(&p)) > 0; - } - } - any + let files = paths.into_iter().flat_map(|p| font_files(&p).into_iter().map(move |data| (data, Some(p.clone())))); + self.add_fonts(files) > 0 || self.is_loaded(family) } /// Resolve a family + style to a face, falling back to the closest style of the family, then to @@ -2290,10 +2470,10 @@ impl FontDb { return Some(f); } #[cfg(not(target_arch = "wasm32"))] - if self.system_fallback(c) { - return self.loaded_fallback(c, exclude, weight, italic); - } - None + self.system_fallback(c); + // Another lookup may have loaded the face while this one searched. Loading no new + // faces does not mean no face covers the character (as with `face`'s catalog lookup). + self.loaded_fallback(c, exclude, weight, italic) } /// A face that covers `c`, for text drawn outside the canvas (the app's own UI): the loaded @@ -2409,9 +2589,69 @@ impl FontDb { cache.insert(key, p.clone()); p } + + /// Glyph `gid`'s colour bitmap (`sbix`, `CBDT`) in `face`, cached per (face, glyph). + pub fn glyph_image(&self, face: &FontFace, gid: u32) -> Option> { + if !face.colour_bitmaps { + return None; + } + let key = (face.id, gid); + if let Some(i) = self.images.lock().unwrap_or_else(|e| e.into_inner()).kept.get(&key) { + return i.clone(); + } + let image = face.skrifa().and_then(|f| crate::bitmap::glyph_image(&f, face.upem, gid)).map(Arc::new); + self.images.lock().unwrap_or_else(|e| e.into_inner()).keep(key, image.clone()); + image + } + + /// Is glyph `gid` of face `face_id` drawn from its SVG document (it has one, and no COLR + /// layers, which come first)? Writers that draw a font's own glyphs can draw COLR and bitmap + /// glyphs, not always SVG ones. + pub fn is_svg_glyph(&self, face_id: u32, gid: u32) -> bool { + let Some(face) = self.face_by_id(face_id) else { return false }; + face.skrifa().is_some_and(|f| svg_drawn(&face, &f, gid) && crate::svg_glyph::has_document(&f, gid)) + } + + /// Glyph `gid`'s colour content in `face`, from its COLR layers or else its SVG document, in + /// the text's `foreground` (fill) and `stroke` colours where it uses them; cached per (face, + /// glyph), and per text colours for the glyphs that paint with them. + pub(crate) fn glyph_colour(&self, face: &FontFace, gid: u32, foreground: Color, stroke: Option) -> Option { + if !face.colour_layers && !face.svg_glyphs { + return None; + } + let f = face.skrifa()?; + // COLR first; SVG for glyphs without COLR layers (those whose layers don't read keep their + // outlines, as in fonts without SVG). + let svg = svg_drawn(face, &f, gid); + // Kept without colours, or by the text's colours it uses: the fill, and for SVG art the + // stroke too. + let coloured = Some((foreground.key(), stroke.filter(|_| svg).map(|c| c.key()))); + { + let cache = self.colours.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(c) = [None, coloured].iter().find_map(|k| cache.kept.get(&(face.id, gid, *k))) { + return c.clone(); + } + } + let (content, used) = if svg { + let (art, used) = crate::svg_glyph::svg_glyph(&f, gid, face.upem, foreground, stroke); + (art.map(ColourContent::Art), used) + } else { + crate::colr::colour_glyph(&f, face.location(), face.upem, gid, foreground) + }; + let key = (face.id, gid, if used { coloured } else { None }); + self.colours.lock().unwrap_or_else(|e| e.into_inner()).keep(key, content.clone()); + content + } } -struct FlipPen(BezPath); +/// Is glyph `gid` of `face` (as `font`) drawn from an SVG document, if it has one: the face has +/// SVG glyphs, and the glyph no COLR layers, which come first. +fn svg_drawn(face: &FontFace, font: &skrifa::FontRef<'_>, gid: u32) -> bool { + face.svg_glyphs && !crate::colr::has_colour_glyph(font, gid) +} + +/// A pen drawing outlines y down, as text space has them. +pub(crate) struct FlipPen(pub(crate) BezPath); impl OutlinePen for FlipPen { fn move_to(&mut self, x: f32, y: f32) { @@ -2461,6 +2701,26 @@ mod alias_tests { font } + #[test] + fn a_collection_lists_at_most_max_faces() { + let mut font = name_font(&[(3, 0x0409, 1, "Fam"), (3, 0x0409, 2, "Regular")]); + let n = 1000u32; + let base = 12 + 4 * n; + let table = u32::from_be_bytes(font[20..24].try_into().unwrap()) + base; + font[20..24].copy_from_slice(&table.to_be_bytes()); + let mut ttc = b"ttcf".to_vec(); + for v in [0x0001_0000, n] { + ttc.extend_from_slice(&u32::to_be_bytes(v)); + } + for _ in 0..n { + ttc.extend_from_slice(&base.to_be_bytes()); + } + ttc.extend(font); + let faces = enumerate_faces(&ttc); + assert!(!faces.is_empty()); + assert!(faces.len() <= MAX_FACES as usize, "{} faces", faces.len()); + } + fn keys(records: &[(u16, u16, u16, &str)]) -> (String, String, FaceKeys) { let font = name_font(records); let f = skrifa::FontRef::new(&font).unwrap(); @@ -2538,6 +2798,23 @@ mod alias_tests { assert!(db.has_family("SourceSans3-Regular")); } + /// A PDF names a font by PostScript name; when it isn't installed the importer splits the name + /// (`Honoka-Mincho` → Honoka, Mincho). Once a font of that PostScript name is installed, under + /// a family name of its own (ほのか明朝), the split pair finds it. + #[test] + fn a_family_and_style_split_from_a_postscript_name_find_its_face() { + let db = FontDb::with_font_dirs(vec![]); + db.ensure_catalog(); + // An installed face whose family name shares nothing with its PostScript name. + let alias = Alias { family: "Source Sans 3".into(), style: Some("Semibold".into()), paired: None }; + db.aliases.write().unwrap().insert(norm("Honoka-Mincho"), vec![alias]); + db.changed(); + let m = db.resolve("Honoka", "Mincho").map(|(face, m)| (face.family.clone(), face.style.clone(), m)).unwrap(); + assert_eq!(m, ("Source Sans 3".into(), "Semibold".into(), FontMatch::Exact)); + // Unknown names still fall back. + assert_eq!(db.resolve("Honoka", "Gothic").unwrap().1, FontMatch::Missing); + } + /// The database keeps at most [`CANONICAL_CACHE_MAX`] answers, and names asked for past that /// many still get the same answers. #[test] @@ -2570,3 +2847,29 @@ mod race_tests { } } } + +#[cfg(test)] +mod image_cache_tests { + use super::*; + + #[test] + fn the_glyph_image_cache_keeps_its_bytes_within_its_budget() { + // Images as large as a font may make them: no test font has enough of those to fill the + // budget, so they're made here. + let image = || { + let blob = vectorcraft_doc::ImageBlob::png(vec![0; crate::bitmap::MAX_PNG_BYTES]); + Some(Arc::new(GlyphImage { blob, width: 1, height: 1, xf: kurbo::Affine::IDENTITY })) + }; + let mut cache = GlyphCache::default(); + for gid in 0..20 { + cache.keep((1, gid), image()); + assert!(cache.bytes <= IMAGE_CACHE_BYTES, "{} bytes after {} images", cache.bytes, gid + 1); + } + // The same glyph again, as when two threads look it up at once, counts once. + cache.keep((1, 19), image()); + assert_eq!(cache.bytes, cache.kept.values().flatten().map(|i: &Arc| i.blob.bytes.len()).sum::()); + // A glyph without an image costs nothing, and is still kept. + cache.keep((1, 99), None); + assert!(cache.kept.contains_key(&(1, 99))); + } +} diff --git a/crates/text/src/kashida.rs b/crates/text/src/kashida.rs new file mode 100644 index 000000000..7d4cde0c9 --- /dev/null +++ b/crates/text/src/kashida.rs @@ -0,0 +1,185 @@ +//! Paragraph panel › Insert Kashidas: justified lines lengthen their Arabic words with kashidas. +//! +//! [`plan`] picks, in each Arabic word of a justified line, the place between its last pair of +//! joined letters, and shares the line's room among those places, each kashida up to its +//! [`Kashida::max_em`]. The layout draws each kashida as tatweels (U+0640) in the face of the +//! letter after it, and the word spaces take the room the kashidas leave. +//! +//! Behavior reference (public documentation): +//! +//! and . + +use std::ops::Range; + +use vectorcraft_doc::text::Kashida; + +use crate::shape::SGlyph; + +/// The tatweel (kashida) character. +pub(crate) const TATWEEL: char = '\u{0640}'; + +/// How a character joins its neighbors in cursive Arabic, from the Unicode Character Database's +/// `ArabicShaping.txt` for the Arabic block (U+0600 to U+06FF). Other characters do not join, +/// except the zero width joiner, and the nonspacing and enclosing marks and format characters +/// that the file does not list, which joining goes across. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Joining { + /// Joins the letters on both sides. + Dual, + /// Joins the letter before it only. + Right, + /// Joins both sides without changing its own form (the tatweel, the zero width joiner). + Causing, + /// Skipped over: joining goes on across it (nonspacing and enclosing marks, format + /// characters). + Transparent, + None, +} + +fn joining(c: char) -> Joining { + use unicode_general_category::{GeneralCategory, get_general_category}; + match c { + '\u{0620}' + | '\u{0626}' + | '\u{0628}' + | '\u{062A}'..='\u{062E}' + | '\u{0633}'..='\u{063F}' + | '\u{0641}'..='\u{0647}' + | '\u{0649}' + | '\u{064A}' => Joining::Dual, + '\u{066E}' | '\u{066F}' | '\u{0678}'..='\u{0687}' | '\u{069A}'..='\u{06BF}' | '\u{06C1}' | '\u{06C2}' | '\u{06CC}' | '\u{06CE}' => { + Joining::Dual + } + '\u{06D0}' | '\u{06D1}' | '\u{06FA}'..='\u{06FC}' | '\u{06FF}' => Joining::Dual, + '\u{0622}'..='\u{0625}' + | '\u{0627}' + | '\u{0629}' + | '\u{062F}'..='\u{0632}' + | '\u{0648}' + | '\u{0671}'..='\u{0673}' + | '\u{0675}'..='\u{0677}' => Joining::Right, + '\u{0688}'..='\u{0699}' + | '\u{06C0}' + | '\u{06C3}'..='\u{06CB}' + | '\u{06CD}' + | '\u{06CF}' + | '\u{06D2}' + | '\u{06D3}' + | '\u{06D5}' + | '\u{06EE}' + | '\u{06EF}' => Joining::Right, + TATWEEL | '\u{200D}' => Joining::Causing, + '\u{0600}'..='\u{0605}' | '\u{06DD}' | '\u{200C}' => Joining::None, + _ if matches!(get_general_category(c), GeneralCategory::NonspacingMark | GeneralCategory::EnclosingMark | GeneralCategory::Format) => { + Joining::Transparent + } + _ => Joining::None, + } +} + +/// Is the cluster of glyph `a` joined to the cluster of glyph `b` that follows it in `text`: both +/// right to left in one face, `a`'s last character other than a mark joining the letter after it, +/// and `b` starting with a letter that joins the one before it? +fn joined(text: &str, a: &SGlyph, b: &SGlyph) -> bool { + if a.face.id() != b.face.id() || a.level != b.level || !a.level.is_rtl() || a.byte + a.len != b.byte { + return false; + } + let last = text.get(a.byte..a.byte + a.len).and_then(|s| s.chars().rev().find(|&c| joining(c) != Joining::Transparent)); + let first = text.get(b.byte..b.byte + b.len).and_then(|s| s.chars().next()); + matches!(last.map(joining), Some(Joining::Dual | Joining::Causing)) + && matches!(first.map(joining), Some(Joining::Dual | Joining::Right | Joining::Causing)) +} + +/// The tatweel of `g`'s face and its advance in points at `g`'s scale; None when the face has +/// no tatweel. +pub(crate) fn tatweel(g: &SGlyph) -> Option<(u32, f64)> { + let gid = g.face.glyph_for(TATWEEL); + let advance = g.face.advance(gid) * g.sx; + (gid != 0 && advance.is_finite() && advance > 0.0).then_some((gid, advance)) +} + +/// The most tatweels one Stylistic kashida is drawn with. +pub(crate) const MAX_TATWEELS: f64 = 64.0; + +/// The kashidas of the glyphs `line` (indices into `sg`, a paragraph's glyphs in logical order; +/// `text` is the plain text) of a justified line with `room` points to fill: for each Arabic +/// word with joined letters, at its last pair of them, the byte offset of the cluster after the kashida and +/// the kashida's length, in text order. Words end at spaces, inline graphics, and characters other than letters +/// and marks (punctuation, digits, symbols). Every kashida takes an equal share of `room`, up to +/// `kind`'s longest in ems of the letter after it (as wide as its horizontal scale draws it); a +/// Stylistic kashida is a whole number of tatweels, at most [`MAX_TATWEELS`], and a word whose +/// share is shorter than one tatweel gets none. A line of words without joined letters, or whose +/// font has no tatweel, gets none. +pub(crate) fn plan(text: &str, sg: &[SGlyph], line: Range, kind: Kashida, room: f64) -> Vec<(usize, f64)> { + if kind == Kashida::None || !room.is_finite() || room <= 0.0 { + return vec![]; + } + // The first glyph of the cluster after each word's last joint. + let mut picks: Vec = Vec::new(); + let mut word: Option = None; + let mut prev: Option = None; + let mut j = line.start; + while j < line.end { + let Some(g) = sg.get(j) else { break }; + let end = (j + 1..line.end).find(|&e| sg.get(e).is_none_or(|n| n.byte != g.byte)).unwrap_or(line.end); + let first = text.get(g.byte..g.byte + g.len).and_then(|s| s.chars().next()); + let not_a_letter = first.is_some_and(|c| !c.is_alphabetic() && joining(c) != Joining::Transparent); + if g.is_space() || g.inline.is_some() || not_a_letter { + picks.extend(word.take()); + prev = None; + } else { + if prev.and_then(|p| sg.get(p)).is_some_and(|a| joined(text, a, g)) && tatweel(g).is_some() { + word = Some(j); + } + prev = Some(j); + } + j = end; + } + picks.extend(word); + if picks.is_empty() { + return vec![]; + } + let share = room / picks.len() as f64; + let mut out = Vec::with_capacity(picks.len()); + for g in picks.iter().filter_map(|&k| sg.get(k)) { + let Some((_, t)) = tatweel(g) else { continue }; + let longest = kind.max_em() * g.face.units_per_em() * g.sx; + let mut len = share.min(longest); + if kind == Kashida::Stylistic { + len = (len / t).floor().min(MAX_TATWEELS) * t; + } + if len.is_finite() && len > 1e-6 { + out.push((g.byte, len)); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Beh joins on both sides, alef and waw only to the letter before them, hamza to neither; + /// marks and the format characters the file does not list are skipped, the number signs and + /// the end of ayah it lists join neither side, and the tatweel and the zero width joiner join + /// both sides. + #[test] + fn arabic_letters_join_as_arabic_shaping_lists_them() { + assert_eq!(joining('ب'), Joining::Dual); + assert_eq!(joining('ي'), Joining::Dual); + assert_eq!(joining('ک'), Joining::Dual); + assert_eq!(joining('ا'), Joining::Right); + assert_eq!(joining('و'), Joining::Right); + assert_eq!(joining('ة'), Joining::Right); + assert_eq!(joining('ء'), Joining::None); + assert_eq!(joining('\u{064E}'), Joining::Transparent); + assert_eq!(joining('\u{061C}'), Joining::Transparent); + assert_eq!(joining('\u{0600}'), Joining::None); + assert_eq!(joining('\u{0605}'), Joining::None); + assert_eq!(joining('\u{06DD}'), Joining::None); + assert_eq!(joining(TATWEEL), Joining::Causing); + assert_eq!(joining('\u{200D}'), Joining::Causing); + assert_eq!(joining('\u{200C}'), Joining::None); + assert_eq!(joining('a'), Joining::None); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c61aa82f6..7390897dc 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -4,9 +4,10 @@ use std::ops::Range; use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; +use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{ Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, - SingleWordJustify, TextKind, TextObject, + RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject, }; use vectorcraft_geom::{ArcPath, PathData}; @@ -14,11 +15,12 @@ use crate::composer::{Breakpoint, Params, compose}; use crate::fontdb::FontDb; use crate::hyphen::hyphen_points_with; use crate::justify::{Flex, LineFit, apply_desired, fit_line, is_word_space}; +use crate::kashida; use crate::shape::{ Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start, style_metrics, }; -use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; +use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; const EPS: f64 = 1e-6; @@ -35,22 +37,58 @@ struct Ctx<'a> { /// a quarter turn back (see [`stands_upright`]); [`TextLayout::line_xf`] then stands the lines /// up as columns. vertical: bool, + /// Character panel menu › System Layout. On: positive advances are whole points. + system_layout: bool, + /// Character panel menu › Fractional Widths. Off, positive advances are whole points. + fractional_widths: bool, out: TextLayout, } impl Ctx<'_> { fn style_at(&self, b: usize) -> &CharStyle { - let find = |b: usize| self.runs.iter().find(|(r, _)| r.start <= b && b < r.end).map(|(_, s)| *s); + // The runs follow one another: the first that ends after `b` is the only one that can hold it. + let find = |b: usize| { + let i = self.runs.partition_point(|(r, _)| r.end <= b); + self.runs.get(i).filter(|(r, _)| r.start <= b).map(|(_, s)| *s) + }; find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) } + /// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges + /// from `r.start` with their direction, in order; neighboring runs of one direction make one + /// stretch. + fn char_directions(&self, r: &Range) -> Vec<(Range, ParaDirection)> { + let mut out: Vec<(Range, ParaDirection)> = Vec::new(); + for (rr, st) in &self.runs { + let (a, b) = (rr.start.max(r.start), rr.end.min(r.end)); + let Some(d) = st.char_direction.filter(|_| a < b) else { continue }; + let (a, b) = (a - r.start, b - r.start); + match out.last_mut() { + Some((prev, pd)) if *pd == d && prev.end == a => prev.end = b, + _ => out.push((a..b, d)), + } + } + out + } + /// Shape paragraph `r` (its bidi resolution `bidi`). fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); shape_range(self.db, self.text, r, &self.runs, &self.inlines, &self.opts.features, levels, &mut v); + self.finish_shaping(&mut v, |g| self.style_at(g.byte)); + v + } + + /// Set shaped glyphs `v` as the type sets them: in vertical type, tate-chu-yoko (by the size + /// and Tate-chu-yoko of each glyph's `style`) and the cells of upright glyphs; with System + /// Layout on or Fractional Widths off, whole-point advances. + fn finish_shaping<'s>(&self, v: &mut [SGlyph], style: impl Fn(&SGlyph) -> &'s CharStyle) { if self.vertical { - tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size); + tate_chu_yoko(v, |g| { + let st = style(g); + (st.size, st.tate_chu_yoko) + }); // An upright glyph advances down the column by its vertical advance (the font's vertical // metrics; without them at least one em, as CJK fonts' is), centred across it: a narrow // mark like § must not overlap its neighbours. @@ -69,11 +107,80 @@ impl Ctx<'_> { } } } - v + // System Layout or Fractional Widths off: whole-point advances, after tate-chu-yoko and the + // upright cell have set the widths the line breaker will use. A zero advance stays zero. + if self.system_layout || !self.fractional_widths { + whole_character_widths(v); + } + } + + /// Bullets: put the marker of list paragraph `pr` (attributes `para`, right to left when + /// `rtl`) before its glyphs `sg` and return how many glyphs went in (none for a paragraph + /// without a list or without text). The marker's characters are set in + /// [`vectorcraft_doc::text::ParaList::marker_style`], and a tab after them takes the text to + /// the next tab stop past them on the first line, the left indent counting as a stop when no + /// explicit stop comes before it. In a horizontal right-to-left paragraph, whose indents are + /// mirrored ([`line_indents`]), these are measured from the right. The tab keeps that width + /// however the line is aligned or justified. Each of these glyphs stands for no character of the text: length 0, at the + /// paragraph's first byte, in its first character's run, at the paragraph's embedding level. + fn prepend_marker(&self, pr: &Range, para: &ParaStyle, rtl: bool, sg: &mut Vec) -> usize { + let Some(list) = para.list.filter(|_| !pr.is_empty()) else { return 0 }; + let text = list.marker(); + let st = vectorcraft_doc::text::ParaList::marker_style(self.style_at(pr.start)); + let Some(run) = self.runs.iter().position(|(r, _)| r.start <= pr.start && pr.start < r.end) else { return 0 }; + // The paragraph's direction: the marker starts a right-to-left paragraph at its right. + let level = if rtl { Level::rtl() } else { Level::ltr() }; + let mut marker = Vec::with_capacity(text.len() + 1); + shape_range(self.db, &text, 0..text.len(), &[(0..text.len(), &st)], &[None], &self.opts.features, &vec![level; text.len()], &mut marker); + for g in &mut marker { + g.byte = pr.start; + g.len = 0; + g.run = run; + } + self.finish_shaping(&mut marker, |_| &st); + let Some(last) = marker.last() else { return 0 }; + let mut tab = last.clone(); + tab.ch = '\t'; + tab.gid = 0; + tab.dx = 0.0; + tab.dy = 0.0; + tab.tcy = None; + tab.vpal = None; + let pen = para.left_indent + para.first_line_indent + marker.iter().map(|g| g.adv).sum::(); + let to_indent = para.left_indent - pen; + let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < para.left_indent - EPS); + tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 }; + marker.push(tab); + let n = marker.len(); + sg.splice(0..0, marker); + n } fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) { - let src = if g.inline.is_some() { std::sync::Arc::new(BezPath::new()) } else { self.db.outline(&g.face, g.gid) }; + // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). + let blank = g.is_soft_hyphen() || g.ch.is_control(); + // A colour glyph draws its art (COLR layers, an SVG document) or its image (a bitmap, or + // COLR paints documents have no form for), not its outline; COLR, then SVG, then bitmaps. + // Art in the text's colours takes the run's fill (black when it isn't a colour) and + // stroke. + let plain = g.inline.is_some() || blank; + let colour_of = |p: &vectorcraft_doc::color::Paint| match p { + vectorcraft_doc::color::Paint::Solid { color, .. } => Some(*color), + _ => None, + }; + let st = self.style_at(g.byte); + let foreground = colour_of(&st.fill).unwrap_or(vectorcraft_doc::color::Color::BLACK); + let stroke = colour_of(&st.stroke).filter(|_| st.has_stroke()); + let (colour, image) = match (!plain).then(|| self.db.glyph_colour(&g.face, g.gid, foreground, stroke)).flatten() { + Some(crate::colr::ColourContent::Art(art)) => (Some(art), None), + Some(crate::colr::ColourContent::Image(image)) => (None, Some(image)), + None => (None, (!plain).then(|| self.db.glyph_image(&g.face, g.gid)).flatten()), + }; + let src = if g.inline.is_some() || image.is_some() || colour.is_some() { + std::sync::Arc::new(BezPath::new()) + } else { + self.db.outline(&g.face, g.gid) + }; // A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an // upright one in vertical type by moving it up the column once it stands upright. let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g); @@ -109,8 +216,7 @@ impl Ctx<'_> { * Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - upright_centre(g, cell, em))) * m; } - // Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef). - let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() { + let outline = if src.elements().is_empty() || blank { BezPath::new() } else { let mut p = BezPath::with_capacity(src.elements().len()); @@ -138,8 +244,38 @@ impl Ctx<'_> { // Vertical type keeps its logical order down the column. rtl: g.level.is_rtl() && !self.vertical, scale: 1.0, + image, + colour, }); } + + /// Draw a kashida `len` points long from `at` (the pen on the baseline) on line `line`, as + /// tatweels in the face, size and style of glyph `g`. Short, Medium and Long kashidas are one + /// tatweel stretched along the baseline; a Stylistic one is tatweels side by side. Each tatweel + /// is a glyph at `g`'s byte offset that stands for no character (length 0) and does not + /// advance: `g`'s advance takes the kashida in. + fn emit_kashida(&mut self, g: &SGlyph, kind: Kashida, len: f64, at: Point, line: usize) { + let Some((gid, t)) = kashida::tatweel(g) else { return }; + let mut k = g.clone(); + k.gid = gid; + k.adv = t; + k.dx = 0.0; + k.dy = 0.0; + k.len = 0; + k.lead = 0.0; + k.ch = kashida::TATWEEL; + k.inline = None; + k.tcy = None; + if kind == Kashida::Stylistic { + let n = (len / t).round().clamp(1.0, kashida::MAX_TATWEELS) as usize; + for m in 0..n { + let x = at.x + m as f64 * t; + self.emit(&k, Affine::translate((x, at.y)), Point::new(x, at.y), 0.0, 0.0, line); + } + return; + } + self.emit(&k, Affine::translate((at.x, at.y)) * Affine::scale_non_uniform(len / t, 1.0), at, 0.0, 0.0, line); + } } /// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]). @@ -198,10 +334,20 @@ pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usi let glyphs = &layout.glyphs; let mut i = 0; while let Some(g) = glyphs.get(i) { - // The stretch: the glyphs of this run on this line, in a row. - let end = glyphs.iter().skip(i).position(|h| h.run != g.run || h.line != g.line).map_or(glyphs.len(), |n| i + n); + // The stretch: the glyphs of this run on this line, in a row. A list marker has no + // underline or strikethrough. + let marker = layout.marker_of(i).is_some(); + let end = glyphs + .iter() + .enumerate() + .skip(i) + .position(|(k, h)| h.run != g.run || h.line != g.line || layout.marker_of(k).is_some() != marker) + .map_or(glyphs.len(), |n| i + n); let stretch = glyphs.get(i..end).unwrap_or_default(); i = end.max(i + 1); + if marker { + continue; + } let Some(style) = t.runs.get(g.run).map(|r| &r.style) else { continue }; if !(style.underline || style.strikethrough) { continue; @@ -296,7 +442,9 @@ fn shrink_to_fit(db: &FontDb, t: &TextObject, opts: &LayoutOptions, min: f64) -> /// `t` with every run's size, explicit leading and baseline shift scaled by `f`, inline art's own /// shift included (auto leading and the art's size follow the size; paragraph spacing stays). -fn scaled(t: &TextObject, f: f64) -> TextObject { +/// Shrink Text to Fit lays `t` out as `scaled(t, f)`, where `f` is the layout's +/// [`TextLayout::fit_scale`], so the result holds the styles the text is drawn in. +pub fn scaled(t: &TextObject, f: f64) -> TextObject { let mut s = t.clone(); for r in &mut s.runs { r.style.size *= f; @@ -339,8 +487,19 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout } else { opts }; - let mut cx = - Ctx { on_path: is_on_path, db, text: &text, runs, inlines, default: CharStyle::default(), opts, vertical, out: TextLayout::default() }; + let mut cx = Ctx { + on_path: is_on_path, + db, + text: &text, + runs, + inlines, + default: CharStyle::default(), + opts, + vertical, + system_layout: t.system_layout, + fractional_widths: t.fractional_widths, + out: TextLayout::default(), + }; // Vertical type: line space turned a quarter turn clockwise (lines become columns, each next // one to the left). Point type's anchor is on the first column's centre line. Vertical path // type stays on its path (each glyph turned across it in `emit`). @@ -372,6 +531,12 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout } TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path), } + // Ruby (furigana): placed over its base text in line space, before vertical type turns the + // lines into columns, so the same pass puts it above (right in vertical type) and widens the + // lines it sits on. Type on a path has no above/below; its ruby is left out for now. + if !is_on_path && !t.ruby.is_empty() { + ruby_layout(&mut cx, t); + } if vertical && !is_on_path { // Glyph origins, outlines and transforms to text space (lines stay in line space). for g in &mut cx.out.glyphs { @@ -380,6 +545,14 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout g.origin = line_xf * g.origin; g.angle += std::f64::consts::FRAC_PI_2; } + // Ruby glyphs are placed in line space too (upright, along the column, in the vertical + // pass); the same turn takes them to text space. + for g in &mut cx.out.ruby { + g.outline.apply_affine(line_xf); + g.xf = line_xf * g.xf; + g.origin = line_xf * g.origin; + g.angle += std::f64::consts::FRAC_PI_2; + } for i in &mut cx.out.inlines { i.xf = line_xf * i.xf; i.bounds = line_xf.transform_rect_bbox(i.bounds); @@ -391,38 +564,70 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout cx.out } -/// Tate-chu-yoko: a run of two or three half-width digits (`10`月, `100`年) stands upright across -/// the column as one block one em long, squeezed to the em when wider; longer numbers (`2026`) -/// stay on their side. -fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) { - let digits: Vec = g.iter().map(|g| g.ch.is_ascii_digit()).collect(); - let digit = |i: usize| digits.get(i).copied().unwrap_or(false); +/// System Layout on or Fractional Widths off: each character that takes space advances by a whole +/// number of points, at least one, so small type does not collapse onto itself. A zero advance (a +/// tate-chu-yoko continuation, a zero-width mark) stays zero. +fn whole_character_widths(glyphs: &mut [SGlyph]) { + for g in glyphs { + if g.adv.is_finite() && g.adv > 0.0 { + g.adv = g.adv.round().max(1.0); + } + } +} + +/// Tate-chu-yoko: a horizontal block one em of the column long, squeezed to the em when wider. +/// +/// Automatic: a run of two or three half-width digits (`10`月, `100`年). A single digit and a +/// longer number (`2026`) stay on their side. +/// +/// Manual (Character panel menu › Tate-chu-yoko): any characters marked `tateChuYoko`, including +/// one character, letters and numbers longer than three digits. Marked characters are taken +/// before the automatic digit rule. +/// +fn tate_chu_yoko(g: &mut [SGlyph], style: impl Fn(&SGlyph) -> (f64, bool)) { + let manual: Vec = g.iter().map(|gl| style(gl).1).collect(); + let digits: Vec = g.iter().map(|gl| gl.inline.is_none() && gl.ch.is_ascii_digit()).collect(); let mut i = 0; while i < g.len() { - if !digit(i) { + if manual.get(i).copied().unwrap_or(false) { + let mut end = i + 1; + while manual.get(end).copied().unwrap_or(false) { + end += 1; + } + let em = style(&g[i]).0; + place_tcy_block(&mut g[i..end], em); + i = end; + continue; + } + if !digits.get(i).copied().unwrap_or(false) { i += 1; continue; } - let mut end = i; - while digit(end) { + let mut end = i + 1; + while digits.get(end).copied().unwrap_or(false) && !manual.get(end).copied().unwrap_or(false) { end += 1; } if (2..=3).contains(&(end - i)) { - let em = size(&g[i]); - let width: f64 = g[i..end].iter().map(|g| g.adv).sum(); - let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 }; - let mut ink = 0.0; - for (k, gl) in g[i..end].iter_mut().enumerate() { - let adv = gl.adv; - gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze }); - gl.adv = if k == 0 { em } else { 0.0 }; - ink += adv; - } + let em = style(&g[i]).0; + place_tcy_block(&mut g[i..end], em); } i = end; } } +/// Lay `g` across one em of the column. The first glyph takes the em; the rest share its cell. +fn place_tcy_block(g: &mut [SGlyph], em: f64) { + let width: f64 = g.iter().map(|gl| gl.adv).sum(); + let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 }; + let mut ink = 0.0; + for (k, gl) in g.iter_mut().enumerate() { + let adv = gl.adv; + gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze }); + gl.adv = if k == 0 { em } else { 0.0 }; + ink += adv; + } +} + /// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its /// advance an em, give or take a tenth), the space that mojikumi can take off. fn punct_half(g: &SGlyph, kind: Punct) -> Option { @@ -605,6 +810,18 @@ fn finish_bounds(out: &mut TextLayout) { if !g.outline.elements().is_empty() { add(g.outline.bounding_box()); } + if let Some((image, xf)) = g.placed_image() { + add(xf.transform_rect_bbox(image.rect())); + } + if let Some(b) = g.colour.as_ref().and_then(|c| c.geometric_bounds()) { + add(g.xf.transform_rect_bbox(b)); + } + } + // Ruby: covered by its line's ascent, but its ink can overhang sideways past the line. + for g in &out.ruby { + if !g.outline.elements().is_empty() { + add(g.outline.bounding_box()); + } } for i in &out.inlines { add(i.bounds); @@ -731,13 +948,14 @@ fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, re std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::take(&mut cx.out.inlines), + std::mem::take(&mut cx.out.markers), std::mem::replace(&mut cx.out.overflow, false), ); flow(cx, paras, t, Some(regions)); if laid_out(&cx.out) < before { // Text no longer fits (lines got narrower, or flow around a wrap object): undo the // pass and try a smaller step. - (cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.overflow) = prev; + (cx.out.glyphs, cx.out.lines, cx.out.inlines, cx.out.markers, cx.out.overflow) = prev; for (r, (shift, gap)) in regions.iter_mut().zip(saved) { r.shift = shift; r.gap = gap; @@ -1090,8 +1308,10 @@ impl Pen<'_> { if baseline + est.desc > r.bottom() + 0.01 { break; } - // A rectangle's lines are all as wide: none below has more room for the word. - let min = if r.rect || r.polys.is_empty() { 0.0 } else { min }; + // A rectangle's lines are all as wide: none below has more room for the word + // (until wrap shapes can narrow the first baseline: they stay bounded). + let constant = (r.rect || r.polys.is_empty()) && r.wraps.is_empty(); + let min = if constant { 0.0 } else { min }; let fits = |&(a, b): &(f64, f64)| { let room = b - a - ind_l - ind_r; room > est.asc.max(1.0) && room + EPS >= min @@ -1099,6 +1319,8 @@ impl Pen<'_> { if r.wraps.is_empty() { match r.span(baseline - est.asc, baseline + est.desc) { Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)), + // Too narrow here is too narrow all the way down the cell: next cell. + Some(_) if constant => break, _ => baseline += step, } } else { @@ -1164,11 +1386,12 @@ fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, re } /// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the -/// tab origin: tabs take what [`tab_advance`] gives them, not the width of their glyph. +/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their +/// glyph. The tab after a list marker (length 0) keeps its own width. fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { let mut x = x_start; for (j, g) in sg.iter().enumerate().take(to).skip(from) { - x += if g.ch == '\t' { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; + x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv }; } x - x_start } @@ -1213,9 +1436,15 @@ fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, /// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces /// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it. +/// A list marker at `i` (glyphs of length 0) counts whole, with the word after it. fn word_width(g: &[SGlyph], i: usize) -> f64 { - let mut w = 0.0; - for gl in g.get(i..).unwrap_or_default() { + /// Glyphs measured at most: a word wrapped over many lines is measured again from each line's + /// start, and one this long is broken inside whatever cell it meets. + const MAX_GLYPHS: usize = 1024; + let rest = g.get(i..).unwrap_or_default(); + let marker = rest.iter().take_while(|gl| gl.len == 0).count(); + let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum(); + for gl in rest.iter().skip(marker).take(MAX_GLYPHS) { if gl.is_space() { break; } @@ -1253,19 +1482,35 @@ fn break_line( } let mut x = 0.0; let mut last_break: Option<(usize, bool)> = None; + // A list marker (length 0) is a cluster of its own; the line holds it and at least the first + // cluster after it. + let first = i + g.get(i..).unwrap_or_default().iter().take_while(|h| h.len == 0).count(); let mut j = i; while j < g.len() { let gl = &g[j]; let mut cluster_end = j + 1; - while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte) { + while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte && (next.len == 0) == (gl.len == 0)) { cluster_end += 1; } + // No Break: the marked characters stay on one line, including across a space inside the run. + if gl.no_break { + while let Some(n) = g.get(cluster_end) { + if !n.no_break { + break; + } + let b = n.byte; + cluster_end += 1; + while g.get(cluster_end).is_some_and(|next| next.byte == b) { + cluster_end += 1; + } + } + } let cluster_width: f64 = if gl.ch == '\t' { tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 } else { g[j..cluster_end].iter().map(|glyph| glyph.adv).sum() }; - if j > i && !gl.is_space() && x + cluster_width > width + EPS { + if j > first && !gl.is_space() && x + cluster_width > width + EPS { // It ends the line with the spaces after it. if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) { let mut end = cluster_end; @@ -1278,7 +1523,7 @@ fn break_line( } x += cluster_width; let last = &g[cluster_end - 1]; - if last.break_after() && kinsoku_allows(g, cluster_end - 1, kinsoku) { + if last.break_after() && last.len > 0 && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) { if last.is_soft_hyphen() { if x + hyphen_glyph(last).adv <= width + EPS { last_break = Some((cluster_end, true)); @@ -1310,8 +1555,10 @@ fn break_line( we += 1; } let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum(); - let pts: Vec = - hyphen_breaks(text, g, word_start, we, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)).collect(); + let pts: Vec = hyphen_breaks(text, g, word_start, we, opts) + .into_iter() + .filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) + .collect(); for &k in pts.iter().rev() { if k <= j && k > i { let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::() + hyphen_glyph(&g[k - 1]).adv; @@ -1402,6 +1649,20 @@ fn optical_hangs(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f6 (left, right, taken, right_glyphs) } +/// Works as [`optical_hangs`] does for a right-to-left line, whose `line` is also in visual order. +/// The line is read from right to left: `."` ending it hangs both its glyphs at the left, `'"` starting it hangs both at +/// the right, and the right end takes its glyphs first. +fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) { + let reversed: Vec<(&SGlyph, f64)> = line.iter().rev().copied().collect(); + let (right, left, right_glyphs, left_glyphs) = optical_hangs(&reversed, share); + (left, right, left_glyphs, right_glyphs) +} + +/// Would a line ending at glyph index `end` split a No Break run? +fn splits_no_break(g: &[SGlyph], end: usize) -> bool { + end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break) +} + /// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a /// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line /// with the one before it). @@ -1459,6 +1720,7 @@ mod wrapping_tests { proportional: false, vpal: None, level: unicode_bidi::Level::ltr(), + no_break: false, }) .collect() } @@ -1539,6 +1801,38 @@ mod wrapping_tests { let next_cluster_byte = shaped.get(first_end).map(|g| g.byte); assert_eq!(result.lines.first().map(|line| line.end), next_cluster_byte); } + + /// No Break keeps the marked characters on one line, including across a space, and moves a + /// marked run that does not fit to the next line whole. + #[test] + fn no_break_keeps_marked_characters_on_one_line() { + let text = "A B"; + let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]); + let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + assert!(!hyphen); + assert_eq!(end, 2, "without the mark the line ends after the space"); + + let mut marked = plain; + for g in &mut marked { + g.no_break = true; + } + let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + assert!(!hyphen); + assert_eq!(end, 3, "the marked words stay together even though they are wider than the line"); + + // 日 and 本 may break between them. Marked, they move together when they do not fit after "A ". + let text = "A 日本"; + let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]); + glyphs[2].no_break = true; + glyphs[3].no_break = true; + let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + assert_eq!(end, 2, "the marked pair moves to the next line together"); + let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None); + assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line"); + + let breaks = candidates(text, &glyphs, None, Kinsoku::None); + assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}"); + } } #[cfg(test)] @@ -1596,18 +1890,21 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &Hyphenat let (s, e) = (g[a].byte, g[b - 1].byte + g[b - 1].len); let Some(word) = text.get(s..e) else { return vec![] }; let offs: Vec = word.char_indices().map(|(o, _)| s + o).collect(); - hyphen_points_with(word, opts) - .into_iter() - .filter_map(|ci| { - let byte = *offs.get(ci)?; - // Only at cluster starts (not inside a ligature). - (a + 1..b).find(|&k| g[k].byte == byte && g[k - 1].byte != byte) - }) - .collect() + // The first glyph of each cluster start (not inside a ligature), by its byte: one pass, not a + // search for each point. + let mut starts = std::collections::HashMap::new(); + for k in a + 1..b { + if g[k].byte != g[k - 1].byte { + starts.entry(g[k].byte).or_insert(k); + } + } + hyphen_points_with(word, opts).into_iter().filter_map(|ci| starts.get(offs.get(ci)?).copied()).collect() } /// Break candidates for the every-line composer: hyphenation points under `hyphenate` (the -/// paragraph's Hyphenation options) when it is given. +/// paragraph's Hyphenation options) when it is given. No break comes before a space: a line that +/// may end after a dash, a Thai word or a space ends after the spaces that follow it, as in +/// [`break_line`]. fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, kinsoku: Kinsoku) -> Vec { let mut v = vec![]; let mut ws = 0; @@ -1617,12 +1914,14 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, if let Some(opts) = hyphenate && j > ws { - for k in hyphen_breaks(text, g, ws, j, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) { + for k in + hyphen_breaks(text, g, ws, j, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) + { v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); } } let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 }; - if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j, kinsoku) { + if j + 1 < g.len() && g[j + 1].byte != gl.byte && !g[j + 1].is_space() && kinsoku_allows(g, j, kinsoku) && !splits_no_break(g, j + 1) { v.push(Breakpoint { end: j + 1, hyphen: hy }); } ws = j + 1; @@ -1631,7 +1930,9 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, if let Some(opts) = hyphenate && g.len() > ws { - for k in hyphen_breaks(text, g, ws, g.len(), opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku)) { + for k in + hyphen_breaks(text, g, ws, g.len(), opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k)) + { v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv }); } } @@ -1643,8 +1944,9 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, /// Paragraphs longer than this (glyphs) are always broken line by line. const MAX_COMPOSE_GLYPHS: usize = 200_000; -/// Every-line composition of paragraph glyphs `sg` if applicable (area text with uniform line -/// metrics, justified or ragged); `None` falls back to the greedy single-line composer. +/// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged, +/// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the +/// greedy single-line composer. fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pen: &Pen<'_>, rtl: bool) -> Option> { let ragged = matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right); let composer = cx.opts.composer.unwrap_or(para.composer); @@ -1653,19 +1955,28 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pe let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs). - let has_tab = sg.iter().any(|g| g.ch == '\t'); + // The tab after a list marker always starts the first line and has a fixed width. + let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab { return None; } let m = Metrics::of(&sg[0]); - if sg.iter().any(|g| Metrics::of(g) != m) { + // Line widths are independent of the breaks when every line has the same metrics, or when + // a line's width does not depend on how far down the frame it is set: rectangular cells + // without text wrap. Then a paragraph whose characters differ in line metrics (type size, + // font, leading, vertical scale, an inline graphic taller than the text) is composed too. In + // other shapes, or with text wrap, a taller line can move the lines below it to where the + // frame is narrower, so such a paragraph is broken line by line. + let plain = pen.regions.is_some_and(|r| r.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty())); + if !plain && sg.iter().any(|g| Metrics::of(g) != m) { return None; } - // Line widths are independent of the breaks when every line has the same metrics. let total: f64 = sg.iter().map(|g| g.adv).sum(); let mut sim = pen.clone(); let mut widths = vec![]; let mut acc = 0.0; + // A line's width and placement depend on the sum of its indents alone, which the mirrored + // indents of a right-to-left list ([`line_indents`]) keep. while widths.len() < sg.len() { let first = widths.is_empty(); let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 }; @@ -1705,6 +2016,21 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pe compose(sg, &width, &start_credit, &cands, flex, ¶ms(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, ¶ms(loose))) } +/// Whether paragraph `para` (right to left when `rtl`) has its indents mirrored: a horizontal +/// right-to-left list paragraph does. +fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool { + rtl && !vertical && para.list.is_some() +} + +/// The indents at the left and at the right of a line of paragraph `para` (its first line when +/// `first`). With `mirror` ([`mirrors_indents`]) the left and first-line indents are measured from +/// the right and the right indent from the left: the bullet of a right-to-left list hangs at the +/// right, and the lines it wraps to line up with the text after the bullet. +fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) { + let start = para.left_indent + if first { para.first_line_indent } else { 0.0 }; + if mirror { (para.right_indent, start) } else { (start, para.right_indent) } +} + /// Flow paragraphs `paras` (byte ranges) of `t`, each with its own paragraph attributes /// (alignment, indents, spacing, direction, mojikumi, leading model…). fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Option<&[Region]>) { @@ -1726,18 +2052,30 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // its first line) follow its own. pen.model = para.leading_model; let text = cx.text; - let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); let rtl = is_rtl(bidi.as_ref()); let mut sg = cx.shape_para(pr.clone(), bidi.as_ref()); // Thai, Lao, Khmer and Myanmar break lines between words, not at spaces. mark_word_ends(text.get(pr.clone()).unwrap_or_default(), pr.start, &mut sg); // Japanese composition: consecutive punctuation shares one half-em space. - let compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; + let mut compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] }; // …and a quarter em between Japanese and Latin text. - let wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; + let mut wakan = if para.mojikumi == Mojikumi::LineEndHalf { space_japanese_and_latin(&mut sg) } else { vec![] }; // Justification…: the glyphs at Desired word and letter spacing and glyph scaling, and how // far a justified line may move each one from it. - let flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + let mut flex = apply_desired(&mut sg, ¶.justification, !cx.vertical); + // Bullets: the list marker's glyphs `0..marker` start the paragraph. Justification and + // Optical Margin Alignment leave them as they are. + let marker = cx.prepend_marker(pr, para, rtl, &mut sg); + if marker > 0 { + flex.splice(0..0, vec![Flex::default(); marker]); + if !compressed.is_empty() { + compressed.splice(0..0, vec![false; marker]); + } + if !wakan.is_empty() { + wakan.splice(0..0, vec![0.0; marker]); + } + } // …and Auto Leading: characters whose leading is Auto take this paragraph's share of their size. let auto_leading = |st: &CharStyle| st.leading.unwrap_or_else(|| para.justification.auto_leading_for(st.size)); if para.justification.auto_leading != vectorcraft_doc::JustificationOptions::default().auto_leading { @@ -1765,10 +2103,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let mut hyphen_run = 0; let mut li_para = 0; let mut i = 0; + let mirror = mirrors_indents(para, rtl, cx.vertical); loop { let est = if i < n { Metrics::of(&sg[i]) } else { pm }; let first_line = li_para == 0; - let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 }; + let (ind_l, ind_r) = line_indents(para, first_line, mirror); // Measured before Mojikumi narrows an opening bracket, as the composer measures it. let word = word_width(&sg, i); // Mojikumi: an opening bracket starting a line is set flush with the line's start (the @@ -1787,11 +2126,11 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // next row/column if it no longer fits). let (baseline, x0, x1, end, hyph, m) = loop { let first_in_region = pen.prev.is_none(); - let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent, word) else { + let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else { cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start }; break 'paras; }; - let width = x1 - x0 - ind_l - para.right_indent; + let width = x1 - x0 - ind_l - ind_r; let hyphenate = (para.hyphenate && (hyphen_limit == 0 || hyphen_run < hyphen_limit)).then_some((¶.hyphenation, zone)); let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) { Some(&(e, h)) if e > i => (e, h), @@ -1821,7 +2160,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio break (baseline, x0, x1, end, hyph, m); }; hyphen_run = if hyph && end > i { hyphen_run + 1 } else { 0 }; - let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - para.right_indent) } else { (x0, x1) }; + let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - ind_r) } else { (x0, x1) }; let width = ax1 - ax0; pen.pending = 0.0; let last_of_para = end >= n; @@ -1840,9 +2179,33 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio None => 0.0, }; let w: f64 = set_width(&sg, i, trimmed, ¶.tabs, ind_l) + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim; + let line_start = sg.get(i).map_or(pr.start, |g| g.byte); + let line_end = sg.get(end).map_or(pr.end, |g| g.byte); + let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); + // Optical Margin Alignment or Roman Hanging Punctuation: the share of a glyph that hangs. + let share: Option f64> = + (t.optical_margin || para.roman_hanging).then_some(if t.optical_margin { optical_share } else { roman_share }); + // The glyphs before `stop` in visual order, each with the advance it is set with. A list + // marker does not hang: it is counted with no width. + let set = |j: usize, g: &SGlyph| { + if j < marker { + 0.0 + } else if j + 1 == trimmed { + g.adv - end_trim + } else { + g.adv + } + }; + let visible = |stop: usize| -> (Vec, Vec<(&SGlyph, f64)>) { + let at: Vec = order.iter().map(|&offset| i + offset).filter(|&j| j < stop).collect(); + let glyphs = at.iter().filter_map(|&j| sg.get(j).map(|g| (g, set(j, g)))).collect(); + (at, glyphs) + }; // Burasagari: a comma or full stop ending an area type line hangs outside it (Standard: - // when it doesn't fit; Forced: always). The rest of the line is aligned and justified - // without it, and it follows the line's last character. + // when it doesn't fit, counting what hangs at the line's start as room; Forced: + // always). The rest of the line is aligned and justified without it, and it follows + // the line's last character. + let room_at_start = || share.map_or(0.0, |share| optical_hangs(&visible(trimmed).1, share).0); let hang = (regions.is_some() && !rtl && hyphen.is_none() && trimmed > i + 1) .then(|| sg.get(trimmed - 1)) .flatten() @@ -1850,45 +2213,65 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio .map(|g| g.adv - end_trim) .filter(|_| match para.burasagari { Burasagari::None => false, - Burasagari::Standard => w > width + EPS, + Burasagari::Standard => w > width + room_at_start() + EPS, Burasagari::Forced => true, }); let body = if hang.is_some() { trimmed - 1 } else { trimmed }; let w = w - hang.unwrap_or(0.0); - let line_start = sg.get(i).map_or(pr.start, |g| g.byte); - let line_end = sg.get(end).map_or(pr.end, |g| g.byte); - let order = visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, line_start..line_end, &sg[i..end]); - // Optical Margin Alignment: the glyphs at the ends of a left-to-right line hang - // outside its margins by `opt_l` and `opt_r`. The line is aligned and justified in a - // measure that much wider at each end. A left-aligned line then hangs only at its - // left, a right-aligned line hangs only at its right, and centered and justified lines - // hang at both. A line that ends in a burasagari mark hangs only at its left. - // Justification adds no space after a punctuation mark hanging at the left, nor before - // one hanging at the right (`held`). A letter that hangs by part of its width keeps its - // gaps. Roman Hanging Punctuation (a paragraph attribute) hangs the same punctuation - // by the same amounts, and no letters; with Optical Margin Alignment on as well, the - // letters hang too. - let (opt_l, opt_r, held) = if (t.optical_margin || para.roman_hanging) && !rtl { - let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share }; - let at: Vec = order.iter().map(|&offset| i + offset).filter(|&j| j < body).collect(); - let mut visible: Vec<(&SGlyph, f64)> = - at.iter().filter_map(|&j| sg.get(j).map(|g| (g, if j + 1 == trimmed { g.adv - end_trim } else { g.adv }))).collect(); + // The glyphs before the line's trailing spaces (and its burasagari mark), in the order + // they are drawn. + let (drawn, mut visible) = visible(body); + // Optical Margin Alignment: the glyphs at the ends of a line hang outside its margins + // by `opt_l` and `opt_r`. The line is aligned and justified in a measure that much + // wider at each end. A left-aligned line then hangs only at its left, a right-aligned + // line hangs only at its right, and centered and justified lines hang at both; Auto + // aligns a right-to-left line at its right. A right-to-left line is read from right to + // left ([`optical_hangs_rtl`]). A line that ends in a burasagari mark hangs only at its + // left. Justification adds no space after a punctuation mark hanging at the left, nor + // before one hanging at the right (`held`). A letter that hangs by part of its width + // keeps its gaps. Glyph Scaling leaves the glyphs that hang (`hung`) at their width, so + // they hang exactly by their published share. Roman Hanging Punctuation (a paragraph + // attribute) hangs the same punctuation by the same amounts, and no letters; with + // Optical Margin Alignment on as well, the letters hang too. + let (opt_l, opt_r, held, hung) = if let Some(share) = share { if let Some(h) = &hyphen { visible.push((h, h.adv)); } - let (l, r, left_glyphs, right_glyphs) = optical_hangs(&visible, share); + let (l, r, left_glyphs, right_glyphs) = + if rtl && !cx.vertical { optical_hangs_rtl(&visible, share) } else { optical_hangs(&visible, share) }; let (r, right_glyphs) = if hang.is_some() { (0.0, 0) } else { (r, right_glyphs) }; let n_visible = visible.len(); let punct = |k: usize| visible.get(k).is_some_and(|&(g, _)| optical_punct(g)); - let held: Vec = at + let held: Vec = drawn .iter() .enumerate() .filter(|&(k, _)| (k < left_glyphs && punct(k)) || (right_glyphs > 0 && k + 1 + right_glyphs >= n_visible && punct(k + 1))) .map(|(_, &j)| j) .collect(); - (l, r, held) + let hung: Vec = drawn + .iter() + .enumerate() + .filter(|&(k, _)| k < left_glyphs || (right_glyphs > 0 && k + right_glyphs >= n_visible)) + .map(|(_, &j)| j) + .collect(); + (l, r, held, hung) } else { - (0.0, 0.0, vec![]) + (0.0, 0.0, vec![], vec![]) + }; + // Justification adds nothing to the list marker's glyphs. + let mut held = held; + held.extend(i..marker.min(body)); + // What justification may change of glyph `j`: nothing of a `held` glyph, and no glyph + // scaling of a `hung` one. + let line_flex = |j: usize| { + if held.contains(&j) { + return Flex::default(); + } + let mut f = flex.get(j).copied().unwrap_or_default(); + if hung.contains(&j) { + f.glyph = (0.0, 0.0); + } + f }; let room = width + opt_l + opt_r; let (align, justify) = match para.justify { @@ -1904,15 +2287,23 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let justify = justify && regions.is_some(); let (mut per_space, mut per_gap, mut per_cjk) = (0.0, 0.0, 0.0); let mut fit = LineFit::default(); - let spaces = sg[i..body].iter().filter(|g| g.is_space()).count(); + let spaces = (i..body).filter(|&j| j >= marker && sg[j].is_space()).count(); + // A gap that justification adds between two glyphs goes into the advance of the one + // drawn first: `drawn_after(j)` is the glyph drawn right after glyph `j`, which in a + // right-to-left line comes before it in the text. + let mut after: Vec> = vec![None; body.saturating_sub(i)]; + for (&a, &b) in drawn.iter().zip(drawn.iter().skip(1)) { + if let Some(slot) = after.get_mut(a - i) { + *slot = Some(b); + } + } + let drawn_after = |j: usize| j.checked_sub(i).and_then(|k| after.get(k).copied().flatten()); // Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the // gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block. let cjk_gap = |j: usize| { - j + 1 < body - && !held.contains(&j) - && sg - .get(j) - .zip(sg.get(j + 1)) + !held.contains(&j) + && drawn_after(j) + .and_then(|k| sg.get(j).zip(sg.get(k))) .is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch))) }; let cjk_gaps = (i..body).filter(|&j| cjk_gap(j)).count(); @@ -1925,8 +2316,12 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio (true, SingleWordJustify::AlignCenter) => (1, false), (true, SingleWordJustify::AlignRight) => (2, false), }; + // Insert Kashidas: the Arabic words of a horizontal line take its room on kashidas, + // and the word spaces and gaps take what is left. + let kashidas = + if justify && !cx.vertical && room > w + EPS { kashida::plan(cx.text, &sg, i..body, para.kashida, room - w) } else { vec![] }; + let w = w + kashidas.iter().map(|k| k.1).sum::(); if justify && (room - w).abs() > EPS { - let line_flex = |j: usize| if held.contains(&j) { Flex::default() } else { flex.get(j).copied().unwrap_or_default() }; if cjk_gaps > 0 && room > w { // Spread over the CJK gaps and the word spaces alike. let per = (room - w) / (cjk_gaps + spaces) as f64; @@ -1935,7 +2330,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio // Word spacing, letter spacing and glyph scaling within their ranges, then the // word spaces alone; composed lines may shrink word spaces (never below zero). let line: Vec = (i..body).map(line_flex).collect(); - let floor = sg[i..body].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min); + let floor = (i..body).filter(|&j| j >= marker && sg[j].is_space()).map(|j| sg[j].adv).fold(f64::MAX, f64::min); fit = fit_line(&line, room - w, spaces, floor); } else if room > w { // One word: its letter spacing and glyph scaling, then the gaps between its glyphs. @@ -1943,8 +2338,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio fit = fit_line(&line, room - w, 0, 0.0); let used: f64 = line.iter().map(|f| fit.letter(f) + fit.glyph(f)).sum(); let rest = room - w - used; - let gaps = - (i..body).filter(|&j| j + 1 < body && !held.contains(&j) && sg.get(j + 1).is_some_and(|next| !next.continues_tcy())).count(); + let gaps = (i..body) + .filter(|&j| !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy())) + .count(); if gaps > 0 && rest > EPS { per_gap = rest / gaps as f64; } @@ -1955,8 +2351,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } else if regions.is_none() { match align { 0 => ind_l - opt_l, - 1 => (ind_l - opt_l - para.right_indent + opt_r - w) * 0.5, - _ => -para.right_indent + opt_r - w, + 1 => (ind_l - opt_l - ind_r + opt_r - w) * 0.5, + _ => -ind_r + opt_r - w, } } else { match align { @@ -1980,7 +2376,10 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let leading_space_width: f64 = order.iter().take_while(|&&offset| i + offset >= trimmed).filter_map(|&offset| sg.get(i + offset)).map(|g| g.adv).sum(); let mut x = start_x - leading_space_width; - for offset in order { + // The glyphs drawn for the list marker, and the index of its tab's glyph, when the line starts the paragraph. + let mut marker_glyphs: Option> = None; + let mut marker_tab = 0; + for (oi, &offset) in order.iter().enumerate() { let j = i + offset; let Some(g) = sg.get(j) else { continue }; let mut adv = g.adv; @@ -1991,9 +2390,12 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio let mut scale = 1.0; let mut leader = ""; if g.ch == '\t' { - (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); + // The tab after a list marker keeps the width `prepend_marker` gave it. + if g.len > 0 { + (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); + } } else if j < body { - let f = if held.contains(&j) { Flex::default() } else { flex.get(j).copied().unwrap_or_default() }; + let f = line_flex(j); if is_word_space(g) { adv += fit.space(&f); } else { @@ -2006,13 +2408,30 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio if cjk_gap(j) { adv += per_cjk; } - } else if j + 1 < body && !held.contains(&j) && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) { + } else if !held.contains(&j) && drawn_after(j).and_then(|k| sg.get(k)).is_some_and(|next| !next.continues_tcy()) { // Between glyphs, never inside a tate-chu-yoko block (one cell). adv += per_gap; } } + // The kashida before a cluster in the text is drawn at the cluster's right, between + // it and the letter before it, and the cluster's last glyph advances over it: the + // caret, selection and hit testing take it as part of the cluster. + let kashida = kashidas + .binary_search_by_key(&g.byte, |k| k.0) + .ok() + .and_then(|at| kashidas.get(at)) + .filter(|_| order.get(oi + 1).and_then(|&o| sg.get(i + o)).is_none_or(|next| next.byte != g.byte)) + .map(|k| k.1); + adv += kashida.unwrap_or(0.0); let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); let place = if scale != 1.0 { Affine::translate((x, y)) * Affine::scale_non_uniform(scale, 1.0) } else { Affine::translate((x, y)) }; + if j < marker { + let at = cx.out.glyphs.len(); + marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |r: Range| r.start.min(at)..r.end.max(at + 1))); + if j + 1 == marker { + marker_tab = at; + } + } cx.emit(g, place, Point::new(x, y), 0.0, adv, li); if let Some(out) = cx.out.glyphs.last_mut() { out.scale = flex.get(j).map_or(1.0, |f| f.scale) * scale; @@ -2025,11 +2444,17 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio tab.outline.extend(dots); } } + if let Some(len) = kashida { + cx.emit_kashida(g, para.kashida, len, Point::new(x + adv - len, y), li); + } x += adv; if j < trimmed { x_end = x; } } + if let (Some(glyphs), Some(list)) = (marker_glyphs, para.list) { + cx.out.markers.push(ListMarker { glyphs, tab: marker_tab, text: list.marker() }); + } if let Some(h) = &hyphen { // The hyphen follows the last non-space glyph. let hx = x_end; @@ -2092,28 +2517,38 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path cx.out.on_path = true; // Type on a path is one line: the first paragraph's attributes align it. let para = t.para_at(0); - let mut sg = Vec::new(); // The first paragraph's direction aligns the line (Auto) and sets the caret's. let mut rtl = None; - for pr in paras { + let mut shaped = Vec::new(); + for (pi, pr) in paras.iter().enumerate() { let text = cx.text; - let bidi = para_bidi(text.get(pr.clone()).unwrap_or_default(), para.direction); + let bidi = para_bidi_with(text.get(pr.clone()).unwrap_or_default(), para.direction, &cx.char_directions(pr)); rtl.get_or_insert(is_rtl(bidi.as_ref()) && !cx.vertical); - let shaped = cx.shape_para(pr.clone(), bidi.as_ref()); - for i in visual_order(bidi.as_ref().filter(|_| !cx.vertical), pr.start, pr.clone(), &shaped) { - if let Some(g) = shaped.get(i) { - sg.push(g.clone()); - } - } + let mut glyphs = cx.shape_para(pr.clone(), bidi.as_ref()); + // Bullets: a list paragraph's text starts after its marker here too. + cx.prepend_marker(pr, t.para_at(pi), is_rtl(bidi.as_ref()) && !cx.vertical, &mut glyphs); + shaped.push((pr.clone(), bidi.filter(|b| b.has_rtl() && !cx.vertical), glyphs)); } let rtl = rtl.unwrap_or(false); + // The text before byte `cut` in visual order, as area type reorders each line it breaks. + let visual = |cut: usize| { + let mut sg = Vec::new(); + for (pr, bidi, glyphs) in &shaped { + let n = glyphs.iter().take_while(|g| g.byte < cut).count(); + let line = glyphs.get(..n).unwrap_or_default(); + let end = glyphs.get(n).map_or(pr.end, |g| g.byte); + sg.extend(visual_order(bidi.as_ref(), pr.start, pr.start..end, line).into_iter().filter_map(|i| line.get(i).cloned())); + } + sg + }; + let text_len = cx.text.len(); + let mut sg = visual(text_len); // A path out of range holds no text, as a path with no length holds none. let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() }; let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| { let s = style_metrics(cx.db, cx.style_at(0)); (s.0, s.1) }); - let text_len = cx.text.len(); if ap.is_empty() { cx.out.overflow = !sg.is_empty(); cx.out.lines.push(LineInfo { @@ -2125,7 +2560,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path descent: m.1, first_baseline: 0.0, start: 0, - end: text_len, + end: 0, glyph_start: 0, glyph_end: 0, avail: (0.0, 0.0), @@ -2138,17 +2573,123 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path let s_start = from * ap.len(); let avail = (to - from) * ap.len(); let spacing = if t.path_spacing.is_finite() { t.path_spacing } else { 0.0 }; - let mut steps = path_steps(&ap, &sg, s_start, spacing); - let w: f64 = steps.iter().sum(); - let s0 = match para.justify { - Justify::Auto if rtl => s_start + (avail - w).max(0.0), - Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0), - Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0), - _ => s_start, + // Optical Margin Alignment and Roman Hanging Punctuation: the brackets are the margins, and the + // glyphs at the line's ends hang outside them by `hang_l` and `hang_r`, measured along the path. + // The line is aligned in a span that much longer at each end, and the glyphs that hang at the + // end (`hung_r` of them) do not make it overflow past the end bracket. On an open path a hang + // stops at the path's end. On a closed path the two hangs together take at most the stretch + // between the end and start brackets: the end where the line is placed takes its share first. + // A centered line's ends each get up to half of it, and either end takes what the other leaves. + // The hangs are measured on the steps the glyphs take where the line is placed. A right-to-left + // line is read from right to left ([`optical_hangs_rtl`]); in vertical type `sg` is in text order. + let hanging = t.optical_margin || para.roman_hanging; + let hangs = |sg: &[SGlyph], steps: &[f64]| { + if !hanging { + return (0.0, 0.0, 0); + } + let share: fn(&SGlyph) -> f64 = if t.optical_margin { optical_share } else { roman_share }; + // A list marker (the glyphs of length 0) does not hang: it is counted with no width. + let line: Vec<(&SGlyph, f64)> = sg.iter().zip(steps).map(|(g, &step)| (g, if g.len == 0 { 0.0 } else { step })).collect(); + let (l, r, _, hung_r) = if rtl { optical_hangs_rtl(&line, share) } else { optical_hangs(&line, share) }; + let (l, r) = if ap.is_closed() { + let gap = (ap.len() - avail).max(0.0); + match para.justify { + j if matches!(j, Justify::Right | Justify::JustifyRight) || (rtl && j == Justify::Auto) => { + let r = r.min(gap); + (l.min(gap - r), r) + } + Justify::Center | Justify::JustifyCenter => { + let l = l.min(gap - r.min(gap * 0.5)); + (l, r.min(gap - l)) + } + _ => { + let l = l.min(gap); + (l, r.min(gap - l)) + } + } + } else { + (l.min(s_start), r.min((ap.len() - s_start - avail).max(0.0))) + }; + (l, r, hung_r) }; - if spacing != 0.0 && s0 != s_start { - // Spaced from where the alignment puts them, the curve under them is another. - steps = path_steps(&ap, &sg, s0, spacing); + // The glyphs `sg` arranged on the path: their steps, where the first starts, and the hangs. + let arrange = |sg: &[SGlyph]| { + let mut steps = path_steps(&ap, sg, s_start, spacing); + let w: f64 = steps.iter().sum(); + let place = |(l, r, _): (f64, f64, usize)| { + let (lo, room) = (s_start - l, avail + l + r); + match para.justify { + Justify::Auto if rtl => lo + (room - w).max(0.0), + Justify::Center | Justify::JustifyCenter => lo + ((room - w) * 0.5).max(0.0), + Justify::Right | Justify::JustifyRight => lo + (room - w).max(0.0), + _ => lo, + } + }; + let mut hung = hangs(sg, &steps); + let mut s0 = place(hung); + if spacing != 0.0 && s0 != s_start { + // Spaced from where the alignment puts them, the curve under them is another. That + // changes the hanging glyphs' steps, so the line is placed again from the new steps + // until it stays where it is (at most 8 times). + steps = path_steps(&ap, sg, s0, spacing); + for _ in 0..8 { + hung = hangs(sg, &steps); + let moved = place(hung); + if (moved - s0).abs() < 1e-9 { + break; + } + s0 = moved; + steps = path_steps(&ap, sg, s0, spacing); + } + } + (steps, s0, hung) + }; + // How many of the arranged glyphs fit before one runs past the end bracket (with the hang). + let fitting = |steps: &[f64], s0: f64, (hang_l, hang_r, hung_r): (f64, f64, usize)| { + let (lo, room) = (s_start - hang_l, avail + hang_l + hang_r); + let mut x = 0.0; + steps + .iter() + .enumerate() + .take_while(|&(k, &step)| { + let end = if k + hung_r >= steps.len() { room } else { room - hang_r }; + let fit = s0 + x + step - lo <= end + 1e-6; + x += step; + fit + }) + .count() + }; + let (mut steps, mut s0, mut hung) = arrange(&sg); + // Where the text that fits ends: before the first glyph that doesn't, which threaded text + // flows on from, as from the last line of area type. + let mut fits = text_len; + // Right-to-left text that overflows keeps the start of its text, not what comes first in visual + // order: the longest start, in reading order, whose glyphs fit in visual order is laid out. + if shaped.iter().any(|(_, bidi, _)| bidi.is_some()) && fitting(&steps, s0, hung) < sg.len() { + let mut starts: Vec = shaped.iter().flat_map(|(_, _, glyphs)| glyphs.iter().map(|g| g.byte)).collect(); + starts.dedup(); + // The text before `starts[fit]` fits; before `starts[over]` (the whole text at the end) it + // doesn't. + let (mut fit, mut over) = (0, starts.len()); + while over - fit > 1 { + let mid = (fit + over) / 2; + let sg = visual(starts.get(mid).copied().unwrap_or(text_len)); + let (steps, s0, hung) = arrange(&sg); + if fitting(&steps, s0, hung) == sg.len() { + fit = mid; + } else { + over = mid; + } + } + cx.out.overflow = true; + fits = starts.get(fit).copied().unwrap_or(text_len); + sg = visual(fits); + (steps, s0, hung) = arrange(&sg); + } + let fit = fitting(&steps, s0, hung); + if fit < sg.len() { + cx.out.overflow = true; + fits = sg.get(fit..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len); } // Align to Path: how far down (glyph space) the type moves to run its ascender, centre or // descender along the path instead of its baseline. @@ -2159,12 +2700,8 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path PathAlign::Center => (m.0 - m.1) * 0.5, }; let mut x = 0.0; - for (g, &step) in sg.iter().zip(&steps) { + for (g, &step) in sg.iter().zip(&steps).take(fit) { let s = s0 + x; - if s + step - s_start > avail + 1e-6 { - cx.out.overflow = true; - break; - } let (p, dir) = ap.at(s + step * 0.5); let angle = dir.y.atan2(dir.x); let half = Affine::translate((-g.adv * 0.5, rise)); @@ -2197,6 +2734,23 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path }; // The caret's baseline moves with the type. let origin = p - dir * (g.adv * 0.5) + Vec2::new(-dir.y, dir.x) * rise; + // A list marker's glyphs (the only glyphs here with no length) are in a row at the byte of + // their paragraph's start. + if g.len == 0 { + let at = cx.out.glyphs.len(); + let starts_paragraph = |b: usize| paras.iter().position(|p| p.start == b); + match cx.out.markers.last_mut() { + Some(m) if m.glyphs.end == at && cx.out.glyphs.get(m.glyphs.start).is_some_and(|h| h.byte == g.byte) => m.glyphs.end = at + 1, + _ => { + if let Some(list) = starts_paragraph(g.byte).and_then(|pi| t.para_at(pi).list) { + cx.out.markers.push(ListMarker { glyphs: at..at + 1, tab: at, text: list.marker() }); + } + } + } + if let Some(m) = cx.out.markers.last_mut().filter(|m| m.glyphs.end == at + 1 && g.ch == '\t') { + m.tab = at; + } + } cx.emit(g, pre, origin, angle, g.adv, 0); x += step; } @@ -2211,7 +2765,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, path: &Path descent: m.1, first_baseline: 0.0, start: 0, - end: text_len, + end: fits, glyph_start: 0, glyph_end: cx.out.glyphs.len(), avail: (0.0, ap.len()), @@ -2232,6 +2786,52 @@ pub(crate) fn para_bidi(text: &str, direction: Option) -> Option< Some(BidiInfo::new(text, level)) } +/// [`para_bidi`] for a paragraph whose characters in `set` (byte ranges of `text`, in order and not +/// overlapping, from [`Ctx::char_directions`]) have a Character Direction: each range is resolved +/// as a Unicode bidi embedding, as if an LRE or RLE stood before it and a PDF after it (UAX #9 X2, +/// X3, X7). The levels, classes and paragraphs come back for the bytes of `text`. The paragraph's own +/// direction is unchanged: the first strong character is found inside embeddings too (P2). +pub(crate) fn para_bidi_with<'a>(text: &'a str, direction: Option, set: &[(Range, ParaDirection)]) -> Option> { + let plain = para_bidi(text, direction); + if set.is_empty() || (plain.is_none() && set.iter().all(|(_, d)| *d == ParaDirection::LeftToRight)) { + // A left-to-right embedding in left-to-right text takes an even level: its order is the same. + return plain; + } + // The text with the embedding controls, and where each byte of `text` is in it. + let mut marked = String::with_capacity(text.len() + set.len() * 6); + let mut at_byte: Vec = Vec::with_capacity(text.len()); + let mut copy = |marked: &mut String, from: usize, to: usize| { + if let Some(s) = text.get(from..to) { + at_byte.extend(marked.len()..marked.len() + s.len()); + marked.push_str(s); + } + }; + let mut done = 0; + for (r, d) in set { + let (a, b) = (r.start.max(done).min(text.len()), r.end.min(text.len())); + if a >= b { + continue; + } + copy(&mut marked, done, a); + marked.push(if *d == ParaDirection::RightToLeft { '\u{202B}' } else { '\u{202A}' }); + copy(&mut marked, a, b); + marked.push('\u{202C}'); + done = b; + } + copy(&mut marked, done, text.len()); + let level = direction.map(|d| if d == ParaDirection::RightToLeft { Level::rtl() } else { Level::ltr() }); + let info = BidiInfo::new(&marked, level); + let back = |i: usize| at_byte.partition_point(|&m| m < i); + let paragraphs = + info.paragraphs.iter().map(|p| unicode_bidi::ParagraphInfo { range: back(p.range.start)..back(p.range.end), level: p.level }).collect(); + Some(BidiInfo { + text, + original_classes: at_byte.iter().filter_map(|&i| info.original_classes.get(i).copied()).collect(), + levels: at_byte.iter().filter_map(|&i| info.levels.get(i).copied()).collect(), + paragraphs, + }) +} + /// Does the paragraph run right to left ([`para_bidi`])? pub(crate) fn is_rtl(bidi: Option<&BidiInfo<'_>>) -> bool { bidi.and_then(|b| b.paragraphs.first()).is_some_and(|p| p.level.is_rtl()) @@ -2248,9 +2848,10 @@ fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range return vec![]; } let levels = bidi.reordered_levels(para, line.start.saturating_sub(paragraph_start)..line.end.saturating_sub(paragraph_start)); + // A list marker (length 0) is a cluster of its own. let mut clusters: Vec> = Vec::new(); for (i, g) in glyphs.iter().enumerate() { - if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte) { + if i > 0 && glyphs.get(i - 1).is_some_and(|p| p.byte == g.byte && (p.len == 0) == (g.len == 0)) { if let Some(c) = clusters.last_mut() { c.end = i + 1; } @@ -2258,10 +2859,324 @@ fn visual_order(bidi: Option<&BidiInfo<'_>>, paragraph_start: usize, line: Range clusters.push(i..i + 1); } } + // A list marker is at the paragraph's level, and at a right-to-left one its tab is drawn + // before (left of) its characters. let cluster_levels: Vec<_> = clusters .iter() .filter_map(|c| glyphs.get(c.start)) - .map(|g| levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level)) + .map(|g| if g.len == 0 { g.level } else { levels.get(g.byte.saturating_sub(paragraph_start)).copied().unwrap_or(g.level) }) .collect(); - unicode_bidi::BidiInfo::reorder_visual(&cluster_levels).into_iter().filter_map(|i| clusters.get(i)).flat_map(|c| c.clone()).collect() + let reversed = |i: usize| glyphs.get(clusters.get(i).map_or(0, |c| c.start)).is_some_and(|g| g.len == 0 && g.level.is_rtl()); + unicode_bidi::BidiInfo::reorder_visual(&cluster_levels) + .into_iter() + .filter_map(|i| clusters.get(i).map(|c| (c.clone(), reversed(i)))) + .flat_map(|(c, rev)| { + let mut v: Vec = c.collect(); + if rev { + v.reverse(); + } + v + }) + .collect() +} + +/// Ruby (furigana): lays each reading out over its base text in line space, grows the line's +/// ascent/descent so the ruby fits, and moves the lines after it on in the block direction. +/// +/// Positions are measured from the line's baseline. Vertical type uses the same geometry — ruby +/// "above" the baseline is right of the column once the lines are turned — with each reading +/// character set upright along the column (a quarter turn that the final vertical turn undoes). +/// A reading over a line break is split between the lines, by the base characters' share of it. +/// A reading wider than its base hangs over the neighbours (`overhang`), or is squeezed to the +/// base's width. Type on a path is left out (it has no above/below). +fn ruby_layout(cx: &mut Ctx<'_>, t: &TextObject) { + use std::collections::BTreeMap; + + let db = cx.db; + let text_len = cx.text.len(); + let n_lines = cx.out.lines.len(); + if n_lines == 0 || cx.out.glyphs.is_empty() { + return; + } + let base_baselines: Vec = cx.out.lines.iter().map(|l| l.baseline).collect(); + let old_ascent: Vec = cx.out.lines.iter().map(|l| l.ascent).collect(); + let old_descent: Vec = cx.out.lines.iter().map(|l| l.descent).collect(); + // One piece of base text on one line, with the part of the reading that belongs to it. + struct Piece { + line: usize, + x0: f64, + x1: f64, + run: usize, + byte: usize, + reading: String, + style: CharStyle, + position: RubyPosition, + align: RubyAlign, + base_size: f64, + overhang: bool, + } + let mut pieces: Vec = Vec::new(); + // The glyphs by their byte, to find a span's without a scan of them all for each span. + let byte_of = |i: usize| cx.out.glyphs.get(i).map_or(0, |g| g.byte); + let mut by_byte: Vec = (0..cx.out.glyphs.len()).collect(); + by_byte.sort_by_key(|&i| byte_of(i)); + let longest = cx.out.glyphs.iter().map(|g| g.len.max(1)).max().unwrap_or(1); + for span in &t.ruby { + let (start, end) = (span.start.min(text_len), span.end.min(text_len)); + if start >= end || span.text.trim().is_empty() { + continue; + } + // The base glyphs of the span, by line; a glyph's cluster is its byte range (`len`, or + // one byte for generated glyphs). A list marker is not part of the base. + let mut by_line: BTreeMap> = BTreeMap::new(); + let hi = by_byte.partition_point(|&i| byte_of(i) < end); + let lo = by_byte.partition_point(|&i| byte_of(i).saturating_add(longest) <= start).min(hi); + let mut hits: Vec = by_byte.get(lo..hi).unwrap_or_default().to_vec(); + // In glyph order, as the scan found them. + hits.sort_unstable(); + for i in hits { + let Some(g) = cx.out.glyphs.get(i) else { continue }; + let b = g.byte.saturating_add(g.len.max(1)); + if g.byte < end && b > start && cx.out.marker_of(i).is_none() { + by_line.entry(g.line).or_default().push(i); + } + } + let total: usize = by_line.values().map(Vec::len).sum(); + if total == 0 { + continue; + } + let reading: Vec = span.text.trim().chars().collect(); + let ratio = span.safe_size_percent() / 100.0; + // Split the reading by each line's share of the base glyphs: cumulative fractions keep + // every character once and in order. + let mut taken = 0; + let mut done = 0; + let lines: Vec = by_line.keys().copied().collect(); + for line in &lines { + let count = by_line.get(line).map_or(0, Vec::len); + done += count; + let upto = if reading.is_empty() { 0 } else { (reading.len() * done / total).min(reading.len()) }; + let part: String = reading.get(taken.min(reading.len())..upto.min(reading.len())).unwrap_or_default().iter().collect(); + taken = upto; + if part.is_empty() { + continue; + } + let glyphs = by_line.get(line).map(Vec::as_slice).unwrap_or_default(); + let mut x0 = f64::INFINITY; + let mut x1 = f64::NEG_INFINITY; + let mut base_size: f64 = 0.0; + let mut run = 0; + let mut byte = start; + for (j, &gi) in glyphs.iter().enumerate() { + let Some(g) = cx.out.glyphs.get(gi) else { continue }; + x0 = x0.min(g.origin.x); + x1 = x1.max(g.origin.x + g.advance); + base_size = base_size.max(cx.style_at(g.byte).size); + if j == 0 { + run = g.run; + byte = g.byte; + } + } + if !(x0.is_finite() && x1.is_finite()) || base_size <= 0.0 { + continue; + } + let mut style = cx.style_at(byte).clone(); + style.size = base_size * ratio; + style.leading = None; + style.baseline_shift = 0.0; + style.tracking = 0.0; + style.kerning = None; + pieces.push(Piece { + line: *line, + x0, + x1, + run, + byte, + reading: part, + style, + position: span.position, + align: span.align, + base_size, + overhang: span.overhang, + }); + } + } + // Phase A: shape each reading, place it relative to the (unshifted) baseline, and note how + // far above/below the baseline its ink reaches. + let mut placed: Vec<(usize, Vec, f64, f64)> = Vec::new(); // (line, glyphs, ascent, descent) + for piece in &pieces { + let Some(baseline) = base_baselines.get(piece.line).copied() else { continue }; + let mini = TextObject::point(Point::ZERO, &piece.reading, piece.style.clone()); + let lay = layout(db, &mini); + let Some(first_line) = lay.lines.first() else { continue }; + let mini_base = first_line.baseline; + let mini_asc = first_line.ascent; + let mini_desc = first_line.descent; + let base_w = (piece.x1 - piece.x0).max(0.0); + // Above: the reading's bottom sits on the base's em box top. Below: its top sits on the + // base's em box bottom (the line's descent below the baseline). + let ruby_y = if piece.position == RubyPosition::Above { + baseline - piece.base_size - mini_desc + } else { + baseline + old_descent.get(piece.line).copied().unwrap_or(0.0) + mini_asc + }; + let dest = |width: f64| match piece.align { + RubyAlign::Center => piece.x0 + (base_w - width) * 0.5, + RubyAlign::Start => piece.x0, + RubyAlign::End => piece.x1 - width, + }; + let mut glyphs: Vec = Vec::new(); + if cx.vertical { + // Each reading character in its own cell along the column, upright once the lines turn. + let cell = (mini_asc + mini_desc).max(piece.style.size * 0.5); + let mut clusters: Vec> = Vec::new(); + for g in &lay.glyphs { + match clusters.last_mut() { + Some(c) if c.last().is_some_and(|p| p.byte == g.byte && p.len == g.len) => c.push(g), + _ => clusters.push(vec![g]), + } + } + let mut ruby_w = clusters.len() as f64 * cell; + let mut squeeze = 1.0; + if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { + squeeze = base_w / ruby_w; + ruby_w = base_w; + } + let x = dest(ruby_w); + for (ci, cluster) in clusters.iter().enumerate() { + let anchor = Point::new(x + (ci as f64 + 0.5) * cell, ruby_y); + let mut box_: Option = None; + for g in cluster { + if g.outline.elements().is_empty() { + continue; + } + let b = g.outline.bounding_box(); + box_ = Some(box_.map_or(b, |a| a.union(b))); + } + let centre = box_.map_or(anchor, |b| Point::new((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5)); + let mut m = + Affine::rotate_about(-std::f64::consts::FRAC_PI_2, anchor) * Affine::translate((anchor.x - centre.x, anchor.y - centre.y)); + if squeeze != 1.0 { + m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; + } + for g in cluster { + glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); + } + } + } else { + // The reading set along the line: the line's extent, centred or aligned to the base. + let mut ruby_w = (first_line.x1 - first_line.x0).max(0.0); + let mut squeeze = 1.0; + if !piece.overhang && ruby_w > base_w + EPS && ruby_w > EPS { + squeeze = base_w / ruby_w; + ruby_w = base_w; + } + let x = dest(ruby_w); + let mut m = Affine::translate((x - first_line.x0, ruby_y - mini_base)); + if squeeze != 1.0 { + m = Affine::translate((x, 0.0)) * Affine::scale_non_uniform(squeeze, 1.0) * Affine::translate((-x, 0.0)) * m; + } + for g in &lay.glyphs { + glyphs.push(ruby_glyph(g, m, piece.run, piece.byte, piece.line)); + } + } + // How far the reading reaches above and below the base line's baseline (its ink). + let mut ascent: f64 = 0.0; + let mut descent: f64 = 0.0; + for g in &glyphs { + if g.outline.elements().is_empty() { + continue; + } + let b = g.outline.bounding_box(); + ascent = ascent.max(baseline - b.y0); + descent = descent.max(b.y1 - baseline); + } + if !glyphs.is_empty() { + placed.push((piece.line, glyphs, ascent, descent)); + } + } + if placed.is_empty() { + return; + } + // Phase B: grow each line's box to fit its ruby, then move the lines on after it by what they + // need in the block direction (the first line too for area type, so the ruby stays inside the + // frame; point type's first baseline is its anchor and stays). + let mut growth = vec![0.0_f64; n_lines]; + for (line, _, ascent, descent) in &placed { + if let (Some(a), Some(d)) = (old_ascent.get(*line), old_descent.get(*line)) { + let grown = (a.max(*ascent) + d.max(*descent)) - (a + d); + if grown > 0.0 { + let g = growth.get_mut(*line); + if let Some(slot) = g { + *slot = slot.max(grown); + } + } + } + } + // A line's ruby grows above it: the line moves on in the block direction by its own growth, + // so the line before it keeps its room (for area type the first line moves too, keeping the + // ruby inside the frame; point type's first baseline is its anchor and stays). + let area = matches!(t.kind, TextKind::Area { .. }); + let mut shift = vec![0.0_f64; n_lines]; + let mut cum = 0.0; + for i in 0..n_lines { + if area || i > 0 { + cum += growth.get(i).copied().unwrap_or(0.0); + if let Some(s) = shift.get_mut(i) { + *s = cum; + } + } + } + // Grow the lines' boxes to fit their ruby, then move them on in the block direction. + for (line, _, ascent, descent) in &placed { + if let Some(l) = cx.out.lines.get_mut(*line) { + l.ascent = l.ascent.max(*ascent); + l.descent = l.descent.max(*descent); + } + } + for (i, line) in cx.out.lines.iter_mut().enumerate() { + line.baseline += shift.get(i).copied().unwrap_or(0.0); + } + for g in &mut cx.out.glyphs { + let s = shift.get(g.line).copied().unwrap_or(0.0); + if s != 0.0 { + g.origin.y += s; + g.outline.apply_affine(Affine::translate((0.0, s))); + g.xf = Affine::translate((0.0, s)) * g.xf; + } + } + for (line, glyphs, _, _) in placed { + let s = shift.get(line).copied().unwrap_or(0.0); + for mut g in glyphs { + if s != 0.0 { + g.origin.y += s; + g.outline.apply_affine(Affine::translate((0.0, s))); + } + g.line = line; + cx.out.ruby.push(g); + } + } +} + +/// A ruby glyph from one of a reading's laid-out glyphs, moved by `m` into the base text's line +/// space. It is not a character of the text: `len` 0, the base text's `run` for fill and stroke. +fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usize) -> PositionedGlyph { + PositionedGlyph { + outline: m * g.outline.clone(), + run, + byte, + origin: m * g.origin, + advance: 0.0, + len: 0, + angle: 0.0, + line, + font_id: g.font_id, + gid: g.gid, + xf: Affine::IDENTITY, + rtl: false, + scale: 1.0, + // Ruby draws outlines only. + image: None, + colour: None, + } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index f7701b3a1..6852f7329 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -10,6 +10,8 @@ //! Frames (`TextKind::Area`) and paths (`TextKind::OnPath`) are interpreted in text space. #![forbid(unsafe_code)] +mod bitmap; +mod colr; mod composer; pub mod craft_fonts; pub mod edit; @@ -19,14 +21,17 @@ mod fontconfig; mod fontdb; pub mod hyphen; mod justify; +mod kashida; mod layout; mod shape; #[cfg(not(target_arch = "wasm32"))] mod suitcase; +mod svg_glyph; #[cfg(any(test, feature = "test-fonts"))] pub mod test_fonts; pub mod thread; +pub use bitmap::GlyphImage; pub use craft_fonts::{CRAFT_FONTS, CraftFont, WEB_FONTS, WebFont}; pub use features::{EXTRA_FEATURES, LIGATURE_TRACKING_LIMITS, OtFeatures, explicit_ligatures, ligatures_suppressed_by}; pub use fontdb::{ @@ -34,8 +39,11 @@ pub use fontdb::{ is_font_file, is_suitcase, set_app_font_dir, set_platform_font_files, set_user_font_dirs, style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; +use std::sync::Arc; +pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; + use kurbo::{Affine, BezPath, Point, Rect, Vec2}; -pub use layout::{decorations, fit_first_line, layout, layout_with}; +pub use layout::{decorations, fit_first_line, layout, layout_with, scaled}; pub use vectorcraft_doc::TextObject; pub use vectorcraft_doc::{AreaFit, FirstBaseline, VerticalAlign}; @@ -117,6 +125,41 @@ pub struct PositionedGlyph { /// (Justification…), Desired and justification together: 1 at 100%. Horizontal Scale isn't /// included. pub scale: f64, + /// A colour bitmap glyph (emoji), drawn through `xf` instead of an outline: its `outline` is + /// empty. Also a COLR glyph whose paints documents have no form for, drawn as an image. + pub image: Option>, + /// A colour glyph's art (COLR layers, an SVG document's art), drawn through `xf` instead of an + /// outline: a group in font units (y down). Its `outline` is empty. + pub colour: Option>, +} + +impl PositionedGlyph { + /// The glyph's colour bitmap, and the transform from its pixels to text space. + pub fn placed_image(&self) -> Option<(&Arc, Affine)> { + self.image.as_ref().map(|i| (i, self.xf * i.xf)) + } + /// The glyph's colour bitmap as an image object placed through `to_doc` (text space → where it + /// goes), holding its pixels ([`vectorcraft_doc::ImageObject::pixels`]), without an id. + pub fn placed_image_node(&self, to_doc: Affine) -> Option { + let (image, to_text) = self.placed_image()?; + let im = vectorcraft_doc::ImageObject { + key: image.blob.content_key(), + width: image.width, + height: image.height, + xf: to_doc * to_text, + link: None, + placement: Default::default(), + pixels: Some(Arc::new(image.blob.clone())), + }; + Some(vectorcraft_doc::Node::new(vectorcraft_doc::NodeId(u64::MAX), vectorcraft_doc::NodeKind::Image(Box::new(im)))) + } + /// The glyph's colour art placed through `to_doc` (text space → where they go): a copy of + /// [`Self::colour`], transformed, its nodes without ids. + pub fn placed_colour(&self, to_doc: Affine) -> Option { + let mut art = (**self.colour.as_ref()?).clone(); + art.transform(to_doc * self.xf, true); + Some(art) + } } /// One line of laid-out text. @@ -171,6 +214,21 @@ pub struct InlineGlyph { pub bounds: Rect, } +/// A list marker (Paragraph panel › Bullets) the layout drew before the first line of a list +/// paragraph: glyphs of [`TextLayout::glyphs`] that stand for no characters of the text (`len` +/// 0, at the paragraph's first byte, in the run of its first character). +#[derive(Clone, Debug, PartialEq)] +pub struct ListMarker { + /// The marker's glyphs, in the order they are drawn: those of its characters and the tab that + /// takes the text to the next tab stop. + pub glyphs: std::ops::Range, + /// The tab's glyph, which draws nothing: the last of [`Self::glyphs`], or the first in a + /// right-to-left paragraph. + pub tab: usize, + /// The marker's characters ("•"). + pub text: String, +} + #[derive(Clone, Debug)] pub struct TextLayout { /// Lines retain inline/block coordinates; glyph geometry is in physical text space. @@ -179,6 +237,10 @@ pub struct TextLayout { /// turn clockwise for vertical type (point type also centres its first column on the anchor). pub line_xf: Affine, pub glyphs: Vec, + /// Ruby (furigana) glyphs, laid out over their base text: geometry like [`Self::glyphs`], but + /// they are not characters of the text (their `len` is 0 and their `run` is the base text's + /// run, for the fill and stroke). Empty when the type has no ruby. + pub ruby: Vec, pub lines: Vec, /// Ink/advance bounds in text space. pub bounds: Rect, @@ -194,6 +256,8 @@ pub struct TextLayout { pub fit_scale: f64, /// Inline graphics, in text order. pub inlines: Vec, + /// List markers, in text order. + pub markers: Vec, } impl Default for TextLayout { @@ -202,6 +266,7 @@ impl Default for TextLayout { vertical: false, line_xf: Affine::IDENTITY, glyphs: Vec::new(), + ruby: Vec::new(), lines: Vec::new(), bounds: Rect::ZERO, overflow: false, @@ -209,11 +274,31 @@ impl Default for TextLayout { frames: Vec::new(), fit_scale: 1.0, inlines: Vec::new(), + markers: Vec::new(), } } } impl TextLayout { + /// The list marker that glyph `i` of [`Self::glyphs`] belongs to, if any. + pub fn marker_of(&self, i: usize) -> Option<&ListMarker> { + let at = self.markers.partition_point(|m| m.glyphs.end <= i); + self.markers.get(at).filter(|m| m.glyphs.contains(&i)) + } + /// The text glyph `i` of [`Self::glyphs`] stands for when it belongs to a list marker: a tab + /// for its tab, the marker's characters for the first of its other glyphs, nothing for the + /// rest. + pub fn marker_text(&self, i: usize) -> Option<&str> { + let m = self.marker_of(i)?; + let first = if m.tab == m.glyphs.start { m.glyphs.start + 1 } else { m.glyphs.start }; + Some(if i == m.tab { + "\t" + } else if i == first { + &m.text + } else { + "" + }) + } /// Convert physical text coordinates to inline/block coordinates. pub fn logical_point(&self, p: Point) -> Point { if self.vertical { self.line_xf.inverse() * p } else { p } @@ -236,7 +321,7 @@ impl TextLayout { /// All glyph outlines combined (e.g. for Create Outlines). pub fn to_bezpath(&self) -> BezPath { let mut p = BezPath::new(); - for g in &self.glyphs { + for g in self.glyphs.iter().chain(&self.ruby) { p.extend(g.outline.iter()); } p @@ -286,15 +371,37 @@ fn dir(angle: f64) -> Vec2 { Vec2::new(angle.cos(), angle.sin()) } -/// Caret for byte offset `byte`, as a (top, bottom) segment in text space. +/// The left and right x of the cluster of `glyphs[i]` (glyphs of one line of horizontal type +/// that is not on a path): the glyphs next to it with its byte offset and length, and the +/// tatweels of a kashida after it (length 0 and no advance, the same byte offset). A list marker +/// next to it (length 0 and an advance, the same byte offset) is not part of it. None for +/// vertical type and type on a path. +fn cluster_extent(layout: &TextLayout, glyphs: &[PositionedGlyph], i: usize) -> Option<(f64, f64)> { + let g = glyphs.get(i).filter(|_| !layout.vertical && !layout.on_path)?; + let same = |o: &PositionedGlyph| o.byte == g.byte && (o.len == g.len || (o.len == 0 && o.advance == 0.0)); + let start = glyphs.get(..i)?.iter().rposition(|o| !(o.byte == g.byte && o.len == g.len)).map_or(0, |p| p + 1); + let end = glyphs.get(i..)?.iter().position(|o| !same(o)).map_or(glyphs.len(), |p| i + p); + let span = glyphs.get(start..end)?; + let left = span.iter().map(|o| o.origin.x).fold(f64::INFINITY, f64::min); + let right = span.iter().map(|o| o.origin.x + o.advance).fold(f64::NEG_INFINITY, f64::max); + (left.is_finite() && right.is_finite()).then_some((left, right)) +} + +/// Caret for byte offset `byte`, as a (top, bottom) segment in text space. In horizontal type, a +/// cluster drawn with several glyphs is measured whole ([`cluster_extent`]). pub fn caret_position(layout: &TextLayout, byte: usize) -> (Point, Point) { let Some(line) = layout.lines.get(layout.line_of(byte)) else { return (Point::ZERO, Point::ZERO); }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - let (pos, angle) = if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) { + let (pos, angle) = if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) { + let g = &glyphs[i]; let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; - (g.origin + dir(g.angle) * (g.advance * if g.rtl { 1.0 - frac } else { frac }), g.angle) + let (origin, advance) = match cluster_extent(layout, glyphs, i) { + Some((left, right)) => (Point::new(left, g.origin.y), right - left), + None => (g.origin, g.advance), + }; + (origin + dir(g.angle) * (advance * if g.rtl { 1.0 - frac } else { frac }), g.angle) } else if let Some(g) = glyphs.iter().max_by_key(|g| g.byte + g.len) { (g.origin + dir(g.angle) * if g.rtl { 0.0 } else { g.advance }, g.angle) } else { @@ -359,13 +466,16 @@ pub fn byte_in_line(layout: &TextLayout, li: usize, x: f64) -> usize { let Some(line) = layout.lines.get(li) else { return 0 }; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; // A cluster drawn with several adjacent glyphs (a Devanagari pre-base matra and its consonant, - // a consonant and its reph) has one caret stop at each end of its glyphs taken together. + // a consonant and its reph) has one caret stop at each end of its glyphs taken together. The + // tatweels of a kashida have no source length or advance and come after the glyphs of the + // letter after the kashida in the text; they belong to that letter's cluster. A list marker's + // glyphs (no source length, an advance) are a cluster of their own. let mut clusters: Vec<(f64, f64, &PositionedGlyph)> = Vec::with_capacity(glyphs.len()); for g in glyphs { let left = layout.logical_point(g.origin).x; let right = left + g.advance; match clusters.last_mut() { - Some((l, r, c)) if c.byte == g.byte && c.len == g.len => (*l, *r) = (l.min(left), r.max(right)), + Some((l, r, c)) if c.byte == g.byte && (c.len == g.len || (g.len == 0 && g.advance == 0.0)) => (*l, *r) = (l.min(left), r.max(right)), _ => clusters.push((left, right, g)), } } @@ -402,9 +512,11 @@ pub fn line_end_of(layout: &TextLayout, byte: usize) -> usize { fn x_in_line(layout: &TextLayout, li: usize, byte: usize) -> f64 { let line = &layout.lines[li]; let glyphs = &layout.glyphs[line.glyph_start..line.glyph_end]; - if let Some(g) = glyphs.iter().find(|g| g.byte <= byte && g.byte + g.len > byte) { + if let Some(i) = glyphs.iter().position(|g| g.byte <= byte && g.byte + g.len > byte) { + let g = &glyphs[i]; let frac = if byte <= g.byte { 0.0 } else { (byte - g.byte) as f64 / g.len.max(1) as f64 }; - return layout.logical_point(g.origin).x + g.advance * if g.rtl { 1.0 - frac } else { frac }; + let (left, advance) = cluster_extent(layout, glyphs, i).map_or((layout.logical_point(g.origin).x, g.advance), |(l, r)| (l, r - l)); + return left + advance * if g.rtl { 1.0 - frac } else { frac }; } glyphs.iter().max_by_key(|g| g.byte + g.len).map_or(line.x0, |g| layout.logical_point(g.origin).x + if g.rtl { 0.0 } else { g.advance }) } @@ -433,7 +545,8 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 return out; } if layout.on_path { - for g in layout.glyphs.iter().filter(|g| g.byte >= a && g.byte < b) { + // A list marker's glyphs stand for no characters. + for g in layout.glyphs.iter().filter(|g| g.byte >= a && g.byte < b && g.len > 0) { let li = &layout.lines[0]; let d = dir(g.angle); let up = Vec2::new(d.y, -d.x); @@ -443,7 +556,9 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 return out; } if layout.glyphs.iter().any(|g| g.rtl) { - for g in layout.glyphs.iter().filter(|g| g.byte < b && g.byte + g.len > a) { + // A list marker's glyphs stand for no characters. + let glyphs = layout.glyphs.iter().enumerate().filter(|(i, g)| g.byte < b && g.byte + g.len > a && layout.marker_of(*i).is_none()); + for (_, g) in glyphs { let Some(l) = layout.lines.get(g.line) else { continue }; let p = layout.logical_point(g.origin); let (x0, x1) = (p.x, p.x + g.advance); @@ -463,7 +578,10 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4 if l.start > b || l.end < a || (l.end == a && l.start < a && layout.lines.get(li + 1).is_some_and(|n| n.start == l.end)) { continue; } - let xa = if a <= l.start { l.x0.min(x_in_line(layout, li, l.start)) } else { x_in_line(layout, li, a) }; + // A list marker before the line's first character is not part of the selection. + let next = layout.markers.partition_point(|m| m.glyphs.start < l.glyph_start); + let marked = layout.markers.get(next).is_some_and(|m| m.glyphs.start < l.glyph_end); + let xa = if a <= l.start && !marked { l.x0.min(x_in_line(layout, li, l.start)) } else { x_in_line(layout, li, a.max(l.start)) }; let mut xb = x_in_line(layout, li, b.min(l.end)); if b > l.end { // The selection continues past the line: include the line break / trailing space. @@ -565,6 +683,10 @@ mod tests_axes; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_bitmaps; +#[cfg(test)] +mod tests_colr; +#[cfg(test)] mod tests_combos; #[cfg(test)] mod tests_devanagari; @@ -573,16 +695,22 @@ mod tests_embed; #[cfg(test)] mod tests_fit; #[cfg(test)] +mod tests_hang; +#[cfg(test)] mod tests_inline; #[cfg(test)] mod tests_joiners; #[cfg(test)] mod tests_justify; #[cfg(test)] +mod tests_ruby; +#[cfg(test)] mod tests_scripts; #[cfg(test)] mod tests_snap; #[cfg(test)] +mod tests_svg_glyphs; +#[cfg(test)] mod tests_sysfonts; #[cfg(test)] mod tests_typo; diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index daf2bafc3..45ad8ee24 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -62,6 +62,9 @@ pub(crate) struct SGlyph { /// A word of a script written without spaces (Thai, Lao, Khmer, Myanmar) ends after the /// glyph's cluster, so a line may break there ([`mark_word_ends`]). pub word_end: bool, + /// No Break (Character panel menu): keep this glyph on the same line as the neighbouring + /// glyphs that have it. + pub no_break: bool, } /// How `vpal` and the vertical kerning with it move an upright glyph in vertical type and change @@ -368,7 +371,7 @@ pub(crate) fn shape_range( let small_caps = st.small_caps.is_some() && !st.all_caps; // Split into segments by font coverage (and case, for Small Caps). let mut seg = Segment { range: a..a, runs, source_runs, st, face: primary.clone(), small: false, level: level_at(a) }; - let mut cache: Vec<(char, Arc)> = Vec::new(); + let mut cache: std::collections::HashMap> = std::collections::HashMap::new(); let mut script = Script::Common; for (i, c) in text[a..b].char_indices() { let i = a + i; @@ -381,11 +384,11 @@ pub(crate) fn shape_range( let covered = c.is_control() || pmap.as_ref().is_none_or(|m| m.map(c).is_some()); let face = if covered { primary.clone() - } else if let Some((_, f)) = cache.iter().find(|(k, _)| *k == c) { + } else if let Some(f) = cache.get(&c) { f.clone() } else { let f = db.fallback_for_style(c, primary.id(), primary.weight, primary.italic).unwrap_or_else(|| primary.clone()); - cache.push((c, f.clone())); + cache.insert(c, f.clone()); f }; let small = small_caps && c.is_lowercase(); @@ -430,9 +433,10 @@ fn source_run_at(runs: &[(Range, &CharStyle)], span: &Range, byte: /// Do text runs in styles `a` and `b` shape alike, so that they are shaped as one piece of text? /// They do when they differ only in paint and stroke, underline and strikethrough, overprint, the -/// character style's name, leading, baseline shift, rotation, Character Alignment, the kerning -/// (each glyph takes its run's, see [`shape_segment`]) and the tracking, as long as the tracking -/// keeps or drops standard ligatures alike ([`crate::features::ligatures_suppressed_by`]). +/// character style's name, leading, baseline shift, rotation, Character Alignment, Character +/// Direction (text is shaped apart where its bidi level changes), the kerning (each glyph takes its +/// run's, see [`shape_segment`]) and the tracking, as long as the tracking keeps or drops standard +/// ligatures alike ([`crate::features::ligatures_suppressed_by`]). fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { // Every field is named, so that a new attribute has to be sorted into one group or the other. let CharStyle { @@ -466,8 +470,11 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { position, small_caps, char_align: _, + char_direction: _, proportional_metrics, + tate_chu_yoko, digits, + no_break, } = a; *font_family == b.font_family && *font_style == b.font_style @@ -481,7 +488,9 @@ fn shapes_alike(a: &CharStyle, b: &CharStyle) -> bool { && *position == b.position && *small_caps == b.small_caps && *proportional_metrics == b.proportional_metrics + && *tate_chu_yoko == b.tate_chu_yoko && *digits == b.digits + && *no_break == b.no_break && ligatures_suppressed_by(*tracking) == ligatures_suppressed_by(b.tracking) } @@ -550,6 +559,7 @@ fn inline_glyph(face: &Arc, st: &CharStyle, art: &InlineArt, run: usiz level, inline: Some(InlineBox { art: drawn, xf }), word_end: false, + no_break: st.no_break, } } @@ -809,6 +819,7 @@ fn shape_segment(text: &str, context: &Range, seg: &Segment, feats: &OtFe level: *level, inline: None, word_end: false, + no_break: gst.no_break, }); } } diff --git a/crates/text/src/svg_glyph.rs b/crates/text/src/svg_glyph.rs new file mode 100644 index 000000000..fc14fe4d6 --- /dev/null +++ b/crates/text/src/svg_glyph.rs @@ -0,0 +1,49 @@ +//! OpenType SVG glyphs: a font's `SVG ` table holds SVG documents whose element `glyph` draws +//! the glyph, in font units (y down, the origin at the glyph's). Turning SVG into art is the SVG +//! importer's job, a layer above this crate, so the app registers it as the reader +//! ([`set_svg_glyph_reader`]); without one, SVG glyphs keep their outlines. + +use std::sync::{Arc, OnceLock}; + +use skrifa::GlyphId; +use skrifa::raw::TableProvider; +use vectorcraft_doc::Node; +use vectorcraft_doc::color::Color; + +/// Turns glyph `gid`'s SVG document (possibly gzipped) of a font of `upem` units per em into its +/// art in font units (y down), in the text's colours `fill` and `stroke`, and says whether it used +/// them; `None` when it can't. +pub type SvgGlyphReader = fn(document: &[u8], gid: u32, upem: f64, fill: Color, stroke: Option) -> Option<(Node, bool)>; + +static READER: OnceLock = OnceLock::new(); + +/// Registers the reader of SVG glyphs. The first one registered stays. +pub fn set_svg_glyph_reader(reader: SvgGlyphReader) { + let _ = READER.set(reader); +} + +/// Does `font` have SVG glyphs? +pub(crate) fn has_svg_glyphs(font: &skrifa::FontRef<'_>) -> bool { + font.svg().is_ok() +} + +/// Glyph `gid`'s SVG document in `font`, if it has one. +fn document<'a>(font: &skrifa::FontRef<'a>, gid: u32) -> Option<&'a [u8]> { + font.svg().ok().and_then(|t| t.glyph_data(GlyphId::new(gid))) +} + +/// Does glyph `gid` of `font` have an SVG document? +pub(crate) fn has_document(font: &skrifa::FontRef<'_>, gid: u32) -> bool { + document(font, gid).is_some() +} + +/// Glyph `gid`'s art from its SVG document, read by the registered reader, and whether it uses +/// the text's colours. `None` without a document, a reader, or art. +pub(crate) fn svg_glyph(font: &skrifa::FontRef<'_>, gid: u32, upem: f64, fill: Color, stroke: Option) -> (Option>, bool) { + let Some(document) = document(font, gid) else { return (None, false) }; + let Some(reader) = READER.get() else { return (None, false) }; + match reader(document, gid, upem, fill, stroke) { + Some((art, coloured)) => (Some(Arc::new(art)), coloured), + None => (None, false), + } +} diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 1f271d3e7..e57fb92d9 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -297,6 +297,52 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option> { Some(builder.build()) } +/// The family of [`half_form_font`]. +pub const HALF_FORM_FAMILY: &str = "Halftest Sans"; + +/// A test-only cmap over bundled Source Sans 3: Devanagari Ka uses K, the halant uses a period, Ssa +/// uses S and the zero width joiner uses the space. The `GSUB` table lists the `dev2` script either +/// way. With `half`, its `half` feature substitutes k for K followed by the period (Ka's half form); +/// without it, the script has no features and Ka has no half form. `None` when it can't be built. +pub fn half_form_font(half: bool) -> Option> { + 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::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 = [('क', 'K'), ('\u{094D}', '.'), ('ष', 'S'), ('\u{200D}', ' ')]; + 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::>>()?).ok()?; + let family = HALF_FORM_FAMILY; + let name = name_table(&[(1, family), (2, "Regular"), (4, "Halftest Sans Regular"), (6, "HalftestSans-Regular")])?; + let mut builder = FontBuilder::new(); + builder.add_table(&cmap).ok()?; + builder.add_raw(Tag::new(b"name"), name); + let (features, lookups) = if half { + let gid = |c| Some(GlyphId16::new(u16::try_from(charmap.map(c)?.to_u32()).ok()?)); + let coverage = CoverageFormat1::new(vec![gid('K')?]); + let subst = LigatureSubstFormat1::new(coverage.into(), vec![LigatureSet::new(vec![Ligature::new(gid('k')?, vec![gid('.')?])])]); + let features = FeatureList::new(vec![FeatureRecord::new(Tag::new(b"half"), Feature::new(None, vec![0]))]); + let lookups = SubstitutionLookupList::new(vec![SubstitutionLookup::Ligature(Lookup::new(Default::default(), vec![subst]))]); + (features, lookups) + } else { + (FeatureList::new(vec![]), SubstitutionLookupList::new(vec![])) + }; + let langsys = LangSys::new(if half { vec![0] } else { vec![] }); + let scripts = ScriptList::new(vec![ScriptRecord::new(Tag::new(b"dev2"), Script::new(Some(langsys), 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()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + /// The family of [`pair_kerning_font`]. pub const PAIR_KERNING_FAMILY: &str = "Pairtest Sans"; @@ -361,3 +407,344 @@ pub fn twin_font(newer: bool) -> Option> { } Some(builder.build()) } + +/// The character whose glyph [`colour_bitmap_font`] draws as a colour bitmap. +pub const COLOUR_CHAR: char = 'E'; + +/// The colour of [`colour_bitmap_font`]'s bitmap in its largest strike (RGBA); the `sbix` font's +/// smaller strike is [`COLOUR_SMALL`]. +pub const COLOUR_LARGE: [u8; 4] = [255, 0, 0, 255]; + +/// The colour of the `sbix` font's smaller strike. +pub const COLOUR_SMALL: [u8; 4] = [0, 0, 255, 255]; + +/// The pixels per em of [`colour_bitmap_font`]'s largest strike: its square bitmap is one em wide. +pub const COLOUR_PPEM: u8 = 16; + +/// Where [`colour_bitmap_font`]`(false)`'s (`CBDT`) bitmap sits, in pixels: its left edge right of +/// the glyph's origin, its top edge above the baseline. +pub const COLOUR_CBDT_BEARINGS: (i8, i8) = (2, 12); + +/// The family of [`colour_bitmap_font`] (`sbix`, or `CBDT`). +pub fn colour_bitmap_family(sbix: bool) -> &'static str { + if sbix { "Sbixtest Emoji" } else { "Cbdttest Emoji" } +} + +/// The bundled Source Sans 3 Regular as family [`colour_bitmap_family`], [`COLOUR_CHAR`] a solid +/// square colour bitmap one em wide: in `sbix` strikes of [`COLOUR_PPEM`] ([`COLOUR_LARGE`]) and +/// half that ([`COLOUR_SMALL`]), each with no origin offset, or (`sbix` false) in one `CBDT` +/// strike of [`COLOUR_PPEM`] ([`COLOUR_LARGE`], bearings [`COLOUR_CBDT_BEARINGS`]). Its outline +/// stays too. Tables written from the OpenType specification. `None` when it can't be built. +pub fn colour_bitmap_font(sbix: bool) -> Option> { + colour_bitmap_font_with(sbix, &solid_png(COLOUR_PPEM.into(), COLOUR_LARGE)) +} + +/// [`colour_bitmap_font`] with `png` as the bitmap of its largest strike (a malformed one, say). +pub fn colour_bitmap_font_with(sbix: bool, png: &[u8]) -> Option> { + const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + let font = skrifa::FontRef::new(BASE).ok()?; + let glyphs = font.maxp().ok()?.num_glyphs(); + let gid = u16::try_from(font.charmap().map(COLOUR_CHAR)?.to_u32()).ok()?; + let family = colour_bitmap_family(sbix); + let ps = family.replace(' ', ""); + let name = name_table(&[(1, family), (2, "Regular"), (4, &format!("{family} Regular")), (6, &format!("{ps}-Regular"))])?; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + if sbix { + let small = solid_png((COLOUR_PPEM / 2).into(), COLOUR_SMALL); + builder.add_raw(Tag::new(b"sbix"), sbix_table(glyphs, gid, &[(COLOUR_PPEM / 2, &small), (COLOUR_PPEM, png)])); + } else { + let (cblc, cbdt) = cbdt_tables(gid, png); + builder.add_raw(Tag::new(b"CBLC"), cblc); + builder.add_raw(Tag::new(b"CBDT"), cbdt); + } + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// An `sbix` table of `strikes` (ppem, PNG), each holding a PNG for glyph `gid` alone. +fn sbix_table(glyphs: u16, gid: u16, strikes: &[(u8, &[u8])]) -> Vec { + let mut out = Vec::new(); + out.extend(1u16.to_be_bytes()); // version + out.extend(1u16.to_be_bytes()); // flags + out.extend((strikes.len() as u32).to_be_bytes()); + let header = 8 + 4 * strikes.len(); + let mut data = Vec::new(); + for (ppem, png) in strikes { + out.extend(((header + data.len()) as u32).to_be_bytes()); + let offsets = 4 + 4 * (usize::from(glyphs) + 1); + let mut strike = Vec::new(); + strike.extend(u16::from(*ppem).to_be_bytes()); + strike.extend(72u16.to_be_bytes()); // ppi + // Glyph data offsets: empty but for `gid`'s (origin offsets 0, `png `, the PNG). + let record = 8 + png.len(); + for g in 0..=glyphs { + let at = offsets + if g > gid { record } else { 0 }; + strike.extend((at as u32).to_be_bytes()); + } + strike.extend([0, 0, 0, 0]); + strike.extend(b"png "); + strike.extend(*png); + data.extend(strike); + } + out.extend(data); + out +} + +/// `CBLC` and `CBDT` tables of one strike of [`COLOUR_PPEM`] holding `png` for glyph `gid` +/// (index subtable format 1, glyph data format 17). +fn cbdt_tables(gid: u16, png: &[u8]) -> (Vec, Vec) { + let ppem = COLOUR_PPEM; + let mut cbdt = Vec::new(); + cbdt.extend(3u16.to_be_bytes()); + cbdt.extend(0u16.to_be_bytes()); + // Small glyph metrics: height, width, bearing x, bearing y, advance. + let (bx, by) = COLOUR_CBDT_BEARINGS; + cbdt.extend([ppem, ppem, bx as u8, by as u8, ppem]); + cbdt.extend((png.len() as u32).to_be_bytes()); + cbdt.extend(png); + let mut cblc = Vec::new(); + cblc.extend(3u16.to_be_bytes()); + cblc.extend(0u16.to_be_bytes()); + cblc.extend(1u32.to_be_bytes()); // numSizes + let array = 8 + 48; + let subtable = 8; + let index_size = subtable + 8 + 2 * 4; + cblc.extend((array as u32).to_be_bytes()); + cblc.extend((index_size as u32).to_be_bytes()); + cblc.extend(1u32.to_be_bytes()); // numberOfIndexSubTables + cblc.extend(0u32.to_be_bytes()); // colorRef + // Horizontal and vertical line metrics. + let line = [ppem as i8, 0, ppem as i8, 1, 0, 0, 0, 0, 0, 0, 0, 0].map(|v| v as u8); + cblc.extend(line); + cblc.extend(line); + cblc.extend(gid.to_be_bytes()); + cblc.extend(gid.to_be_bytes()); + cblc.extend([ppem, ppem, 32, 1]); // ppem x, ppem y, bit depth, flags (horizontal) + // The index subtable array, then the subtable: format 1, image format 17, its data at 4. + cblc.extend(gid.to_be_bytes()); + cblc.extend(gid.to_be_bytes()); + cblc.extend((subtable as u32).to_be_bytes()); + cblc.extend(1u16.to_be_bytes()); + cblc.extend(17u16.to_be_bytes()); + cblc.extend(4u32.to_be_bytes()); + cblc.extend(0u32.to_be_bytes()); + cblc.extend(((cbdt.len() - 4) as u32).to_be_bytes()); + (cblc, cbdt) +} + +/// A `side`×`side` PNG of one colour (RGBA), written uncompressed (stored deflate blocks). +pub fn solid_png(side: u32, rgba: [u8; 4]) -> Vec { + fn crc32(bytes: &[u8]) -> u32 { + let mut c = !0u32; + for b in bytes { + c ^= u32::from(*b); + for _ in 0..8 { + c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }; + } + } + !c + } + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend((data.len() as u32).to_be_bytes()); + let mut body = kind.to_vec(); + body.extend(data); + out.extend(&body); + out.extend(crc32(&body).to_be_bytes()); + } + // Each row: filter type 0 (none), then its pixels. + let row: Vec = std::iter::once(0).chain(rgba.repeat(side as usize)).collect(); + let raw = row.repeat(side as usize); + let (mut a, mut b) = (1u32, 0u32); + for v in &raw { + a = (a + u32::from(*v)) % 65521; + b = (b + a) % 65521; + } + let mut zlib = vec![0x78, 0x01]; + let blocks: Vec<&[u8]> = raw.chunks(0xffff).collect(); + for (i, block) in blocks.iter().enumerate() { + zlib.push(u8::from(i + 1 == blocks.len())); + let n = block.len() as u16; + zlib.extend(n.to_le_bytes()); + zlib.extend((!n).to_le_bytes()); + zlib.extend(*block); + } + zlib.extend(((b << 16) | a).to_be_bytes()); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend(side.to_be_bytes()); + ihdr.extend(side.to_be_bytes()); + ihdr.extend([8, 6, 0, 0, 0]); // 8 bits, RGBA + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &zlib); + chunk(&mut out, b"IEND", &[]); + out +} + +/// The palette of [`colr_font`] (palette 0, RGBA): its indices 0, 1 and 2. +pub const COLR_RED: [u8; 4] = [255, 0, 0, 255]; +pub const COLR_BLUE: [u8; 4] = [0, 0, 255, 255]; +pub const COLR_GREEN: [u8; 4] = [0, 255, 0, 255]; + +/// The character whose glyph [`colr_font`]`(true)` paints with a sweep gradient. +pub const COLR_SWEEP_CHAR: char = 'S'; + +/// The family of [`colr_font`] (COLRv1, or v0). +pub fn colr_family(v1: bool) -> &'static str { + if v1 { "Colrtest Paints" } else { "Colrtest Layers" } +} + +/// The glyph ids of `chars` in the bundled Source Sans 3. +fn source_sans_gids(chars: [char; N]) -> Option<[u16; N]> { + let font = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let mut gids = [0; N]; + for (gid, ch) in gids.iter_mut().zip(chars) { + *gid = u16::try_from(font.charmap().map(ch)?.to_u32()).ok()?; + } + Some(gids) +} + +const SOURCE_SANS: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"); + +/// The bundled Source Sans 3 Regular as family [`colr_family`], with `COLR` and `CPAL` tables +/// written from the OpenType specification, palette 0 [`COLR_RED`], [`COLR_BLUE`], [`COLR_GREEN`]. +/// v0: [`COLOUR_CHAR`] is the outline of `O` in red, then that of `I` in the foreground colour +/// (palette index 0xFFFF). v1: [`COLOUR_CHAR`] is, in a clip box, `O` filled with a linear +/// gradient red → blue, `I` scaled by a half about the origin with a radial gradient blue → green, +/// and `-` in green composited with Multiply over `-` in red; [`COLR_SWEEP_CHAR`] is `O` with a +/// sweep gradient red → blue. Their outlines stay. `None` when it can't be built. +pub fn colr_font(v1: bool) -> Option> { + use write_fonts::tables::colr::{ + BaseGlyph, BaseGlyphList, BaseGlyphPaint, Clip, ClipBox, ClipList, ColorLine, ColorStop, Colr, CompositeMode, Extend, Layer, LayerList, Paint, + }; + use write_fonts::types::{FWord, GlyphId16, UfWord}; + let [base, sweep, o, i, dash] = source_sans_gids([COLOUR_CHAR, COLR_SWEEP_CHAR, 'O', 'I', '-'])?; + let (base, sweep, o, i, dash) = (GlyphId16::new(base), GlyphId16::new(sweep), GlyphId16::new(o), GlyphId16::new(i), GlyphId16::new(dash)); + let colr = if v1 { + let one = F2Dot14::from_f32(1.0); + let line = |from: u16, to: u16| { + ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), from, one), ColorStop::new(one, to, one)]) + }; + let w = FWord::new; + let layers = vec![ + Paint::glyph(Paint::linear_gradient(line(0, 1), w(0), w(0), w(500), w(0), w(0), w(500)), o), + Paint::scale_uniform( + Paint::glyph(Paint::radial_gradient(line(1, 2), w(250), w(300), UfWord::new(0), w(250), w(300), UfWord::new(300)), i), + F2Dot14::from_f32(0.5), + ), + Paint::composite(Paint::glyph(Paint::solid(2, one), dash), CompositeMode::Multiply, Paint::glyph(Paint::solid(0, one), dash)), + ]; + let mut colr = Colr::new(0, None, None, 0); + colr.layer_list = Some(LayerList::new(layers.len() as u32, layers)).into(); + let mut glyphs = vec![ + BaseGlyphPaint::new(base, Paint::colr_layers(3, 0)), + BaseGlyphPaint::new(sweep, Paint::glyph(Paint::sweep_gradient(line(0, 1), w(250), w(300), F2Dot14::from_f32(0.0), one), o)), + ]; + glyphs.sort_by_key(|g| g.glyph_id); + colr.base_glyph_list = Some(BaseGlyphList::new(glyphs.len() as u32, glyphs)).into(); + let clip = ClipBox::format_1(w(-50), w(-250), w(750), w(800)); + colr.clip_list = Some(ClipList::new(1, 1, vec![Clip::new(base, base, clip)])).into(); + colr + } else { + Colr::new(1, Some(vec![BaseGlyph::new(base, 0, 2)]), Some(vec![Layer::new(o, 0), Layer::new(i, 0xFFFF)]), 2) + }; + colr_font_with(colr_family(v1), &colr) +} + +/// The bundled Source Sans 3 Regular as `family` with `colr` and [`colr_font`]'s palette. +pub fn colr_font_with(family: &str, colr: &write_fonts::tables::colr::Colr) -> Option> { + with_colr(SOURCE_SANS, family_names(family, &[])?, colr) +} + +/// The family of [`colr_variable_font`]. +pub const COLR_VARIABLE_FAMILY: &str = "Colrtest Variable"; + +/// [`variable_font`] as family [`COLR_VARIABLE_FAMILY`], with a `COLR` (v0) table in which +/// [`COLOUR_CHAR`] is the outline of `l` (one of [`VARIABLE_CHARS`]) in red, so its layer widens +/// with the weight. `None` when it can't be built. +pub fn colr_variable_font() -> Option> { + use write_fonts::tables::colr::{BaseGlyph, Colr, Layer}; + use write_fonts::types::GlyphId16; + let [base, l] = source_sans_gids([COLOUR_CHAR, 'l'])?; + let colr = Colr::new(1, Some(vec![BaseGlyph::new(GlyphId16::new(base), 0, 1)]), Some(vec![Layer::new(GlyphId16::new(l), 0)]), 1); + let axis_names = [(256, "Weight"), (257, "Normal"), (258, "SemiBold"), (259, "Bold")]; + with_colr(&variable_font()?, family_names(COLR_VARIABLE_FAMILY, &axis_names)?, &colr) +} + +/// A `name` table for Regular of `family`, with `more` records. +fn family_names(family: &str, more: &[(u16, &str)]) -> Option> { + let ps = family.replace(' ', ""); + let (full, postscript) = (format!("{family} Regular"), format!("{ps}-Regular")); + let mut records = vec![(1, family), (2, "Regular"), (4, full.as_str()), (6, postscript.as_str())]; + records.extend_from_slice(more); + name_table(&records) +} + +/// Font `base` with the `name` table `name`, `colr` and [`colr_font`]'s palette. +fn with_colr(base: &[u8], name: Vec, colr: &write_fonts::tables::colr::Colr) -> Option> { + use write_fonts::tables::cpal::{ColorRecord, Cpal}; + let font = skrifa::FontRef::new(base).ok()?; + let records = [COLR_RED, COLR_BLUE, COLR_GREEN].map(|[r, g, b, a]| ColorRecord::new(b, g, r, a)).to_vec(); + let cpal = Cpal::new(3, 1, 3, Some(records), vec![0]); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_table(colr).ok()?; + builder.add_table(&cpal).ok()?; + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} + +/// The character whose glyph [`svg_font`]'s first document draws. +pub const SVG_CHAR: char = 'E'; + +/// The glyph id of `ch` in the bundled Source Sans 3 (and the fonts made from it). +pub fn source_sans_gid(ch: char) -> Option { + source_sans_gids([ch]).map(|[g]| g) +} + +/// The bundled Source Sans 3 Regular as `family` with an `SVG ` table written from the OpenType +/// specification: `documents` are (first glyph, last glyph, SVG document), in glyph order. Its +/// outlines stay. `None` when it can't be built. +pub fn svg_font(family: &str, documents: &[(u16, u16, &str)]) -> Option> { + let font = skrifa::FontRef::new(SOURCE_SANS).ok()?; + let ps = family.replace(' ', ""); + let name = name_table(&[(1, family), (2, "Regular"), (4, &format!("{family} Regular")), (6, &format!("{ps}-Regular"))])?; + // The header (version, the document list's offset, reserved), then the document list: its + // records (first and last glyph, the document's offset from the list and length), then the + // documents. + let records = documents.len(); + let mut list = (u16::try_from(records).ok()?).to_be_bytes().to_vec(); + let mut offset = 2 + 12 * records; + for (first, last, doc) in documents { + list.extend(first.to_be_bytes()); + list.extend(last.to_be_bytes()); + list.extend((u32::try_from(offset).ok()?).to_be_bytes()); + list.extend((u32::try_from(doc.len()).ok()?).to_be_bytes()); + offset += doc.len(); + } + for (_, _, doc) in documents { + list.extend(doc.as_bytes()); + } + let mut svg = vec![0, 0, 0, 0, 0, 10, 0, 0, 0, 0]; + svg.extend(list); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"name"), name); + builder.add_raw(Tag::new(b"SVG "), svg); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag())?.as_bytes()); + } + } + Some(builder.build()) +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 792abd9c4..f9d96deed 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -67,6 +67,63 @@ fn add_font_rejects_garbage_and_duplicates() { assert_eq!(db().add_font(bytes), 0, "already bundled"); } +/// Character panel menu › System Layout: each character that takes space advances by a whole +/// number of points, at least one. Off (the default) keeps the font's fractional advances. A +/// tate-chu-yoko continuation stays at zero. +/// +#[test] +fn system_layout_rounds_positive_advances_to_whole_points() { + let mut off = point("Hi", style(13.0)); + off.runs[0].style.tracking = 50.0; + assert!(!off.system_layout); + let fractional: Vec = layout(db(), &off).glyphs.iter().map(|g| g.advance).collect(); + assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); + let mut on = off.clone(); + on.system_layout = true; + let whole: Vec = layout(db(), &on).glyphs.iter().map(|g| g.advance).collect(); + assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); + assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); + let mut tiny = point("i", style(0.4)); + tiny.system_layout = true; + let laid = layout(db(), &tiny); + assert!(laid.glyphs.iter().any(|g| (g.advance - 1.0).abs() < 1e-9), "{:?}", laid.glyphs.iter().map(|g| g.advance).collect::>()); + let mut tcy = point("10", style(20.0)); + tcy.vertical = true; + tcy.system_layout = true; + let adv: Vec = layout(db(), &tcy).glyphs.iter().map(|g| g.advance).collect(); + assert!(adv.contains(&0.0), "a tate-chu-yoko continuation stays at zero: {adv:?}"); + assert!(adv.iter().all(|a| *a == 0.0 || (a - a.round()).abs() < 1e-9), "{adv:?}"); +} + +/// Character panel menu › Fractional Widths, off: each character that takes space advances by a +/// whole number of points, at least one. On (the default) keeps the font's fractional advances, +/// including tracking. A tate-chu-yoko continuation stays at zero. +/// +#[test] +fn fractional_widths_off_rounds_positive_advances_to_whole_points() { + let mut on = point("Hi", style(13.0)); + on.runs[0].style.tracking = 50.0; + assert!(on.fractional_widths); + let fractional: Vec = layout(db(), &on).glyphs.iter().map(|g| g.advance).collect(); + assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}"); + let mut off = on.clone(); + off.fractional_widths = false; + let whole: Vec = layout(db(), &off).glyphs.iter().map(|g| g.advance).collect(); + assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}"); + assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}"); + // Smaller than half a point: rounding alone would be 0, which would run the type together. + let mut tiny = point("i", style(0.4)); + tiny.fractional_widths = false; + let laid = layout(db(), &tiny); + assert!(laid.glyphs.iter().any(|g| (g.advance - 1.0).abs() < 1e-9), "{:?}", laid.glyphs.iter().map(|g| g.advance).collect::>()); + let mut tcy = point("10", style(20.0)); + tcy.vertical = true; + tcy.fractional_widths = false; + let adv: Vec = layout(db(), &tcy).glyphs.iter().map(|g| g.advance).collect(); + assert!(adv.contains(&0.0), "a tate-chu-yoko continuation stays at zero: {adv:?}"); + assert!(adv.iter().all(|a| *a == 0.0 || (a - a.round()).abs() < 1e-9), "{adv:?}"); +} + #[test] fn glyph_count_and_advances() { let l = layout(db(), &point("Hello", style(24.0))); @@ -580,6 +637,339 @@ fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { assert!(short.last().is_some_and(|&x| x < 120.0)); } +/// Bullets: a list paragraph's first line starts with its bullet, glyphs that stand for no +/// character, in the run and size of the paragraph's first character. After the bullet the text +/// starts at the left indent, and the lines it wraps to start there too. The caret before the +/// first character stands at the text, a click on the bullet puts it there, an underline leaves +/// the bullet out, and a paragraph without text has no bullet. +#[test] +fn a_list_paragraph_starts_with_its_bullet_and_its_text_at_the_left_indent() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let bulleted = |text: &str, size: f64| { + let mut t = area(text, style(size), Rect::new(0.0, 0.0, 200.0, 400.0), Justify::Left); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t + }; + let t = bulleted(LOREM, 12.0); + let lay = layout(db(), &t); + assert_eq!(lay.markers.len(), 1, "{:?}", lay.markers); + let m = &lay.markers[0]; + assert_eq!((m.text.as_str(), m.glyphs.start, m.glyphs.len()), ("\u{2022}", lay.lines[0].glyph_start, 2)); + let marker = &lay.glyphs[m.glyphs.clone()]; + assert!(marker.iter().all(|g| g.len == 0 && g.byte == 0 && g.run == 0 && g.line == 0), "{marker:?}"); + assert!(marker[0].origin.x.abs() < 1e-6, "the bullet starts the first line at its first-line indent: {}", marker[0].origin.x); + assert!(!marker[0].outline.elements().is_empty() && marker[1].outline.elements().is_empty(), "the bullet draws, its tab doesn't"); + assert!((marker[1].origin.x + marker[1].advance - 18.0).abs() < 1e-6, "the tab reaches the left indent"); + // The text starts at the left indent on the first line and on every line it wraps to. + assert!(lay.lines.len() > 2, "the paragraph wraps"); + for (i, line) in lay.lines.iter().enumerate() { + let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 18.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x); + } + let (top, _) = caret_position(&lay, 0); + assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}"); + assert_eq!(hit_byte(&lay, Point::new(marker[0].origin.x + 1.0, lay.lines[0].baseline - 3.0)), 0); + // A selection from the paragraph's start highlights the text, not the bullet. + let quads = selection_quads(&lay, 0, 5); + assert!((quads[0][0].x - 18.0).abs() < 1e-6, "the selection starts at the text: {:?}", quads[0]); + // The bullet takes the size of the paragraph's first character. + let big = layout(db(), &bulleted(LOREM, 24.0)); + assert!((big.glyphs[0].advance - 2.0 * lay.glyphs[0].advance).abs() < 1e-6, "{} {}", big.glyphs[0].advance, lay.glyphs[0].advance); + // A baseline shift or rotation of the first character leaves the bullet on the baseline. + let mut raised = bulleted("Raised", 12.0); + raised.runs[0].style.baseline_shift = 5.0; + raised.runs[0].style.rotation = 30.0; + let lay = layout(db(), &raised); + let (bullet, first) = (&lay.glyphs[0], lay.glyphs.iter().find(|g| g.len > 0).unwrap()); + assert!((bullet.xf.translation().y - lay.lines[0].baseline).abs() < 1e-6, "{:?}", bullet.xf); + assert!((bullet.xf.as_coeffs()[1]).abs() < 1e-9 && first.xf.as_coeffs()[1].abs() > 1e-3, "the bullet isn't rotated"); + // An underline starts at the text, not under the bullet. + let mut underlined = bulleted("Underlined", 12.0); + underlined.runs[0].style.underline = true; + let lay = layout(db(), &underlined); + let bars = decorations(&lay, db(), &underlined); + assert_eq!(bars.len(), 1); + assert!((bars[0].1.bounding_box().x0 - 18.0).abs() < 1e-6, "{:?}", bars[0].1.bounding_box()); + // Only the paragraphs with text have a bullet. + let lay = layout(db(), &bulleted("One\n\nThree", 12.0)); + assert_eq!(lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].byte).collect::>(), [0, 5]); +} + +/// Bullets: in vertical type the bullet is drawn the same whether or not the paragraph's first +/// characters are set tate-chu-yoko. +#[test] +fn a_vertical_list_paragraphs_bullet_is_not_set_tate_chu_yoko() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let bullet = |tcy: bool| { + let mut t = point("", style(12.0)); + t.vertical = true; + t.runs = vec![TextRun::new("12", CharStyle { tate_chu_yoko: tcy, ..style(12.0) }), TextRun::new(" vertical", style(12.0))]; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + let lay = layout(db(), &t); + let g = &lay.glyphs[lay.markers[0].glyphs.start]; + (g.advance, g.xf) + }; + let (plain, set) = (bullet(false), bullet(true)); + let same = (plain.0 - set.0).abs() < 1e-9 && plain.1.as_coeffs().iter().zip(set.1.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9); + assert!(same, "{plain:?} {set:?}"); +} + +/// Bullets: a right-to-left list paragraph starts at its right with the bullet, then the gap, then +/// the text, and the caret before its first character stands at the text. +#[test] +fn a_right_to_left_list_paragraph_starts_with_its_bullet_at_the_right() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut t = area("שלום עולם", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Auto); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = layout(db(), &t); + let m = &lay.markers[0]; + assert_eq!(m.tab, m.glyphs.start, "the tab is drawn first, left of the bullet"); + assert_eq!((lay.marker_text(m.tab), lay.marker_text(m.glyphs.start + 1)), (Some("\t"), Some("\u{2022}"))); + let (tab, bullet) = (&lay.glyphs[m.tab], &lay.glyphs[m.glyphs.start + 1]); + assert!((bullet.origin.x + bullet.advance - 200.0).abs() < 1e-6, "the bullet ends the line at the right: {bullet:?}"); + assert!((tab.origin.x + tab.advance - bullet.origin.x).abs() < 1e-6, "the gap is right before the bullet"); + let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(0.0, f64::max); + assert!((text_right - tab.origin.x).abs() < 1e-6, "the text ends at the gap: {text_right} {}", tab.origin.x); + let (top, _) = caret_position(&lay, 0); + assert!((top.x - text_right).abs() < 1e-6, "the caret before the first character: {top:?}"); + // A selection across two right-to-left list items highlights their characters alone. + let mut two = area("שלום\nעולם", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Auto); + two.para.list = Some(ParaList::bulleted(Bullet::Dot)); + let lay = layout(db(), &two); + assert_eq!(lay.markers.len(), 2); + let quads = selection_quads(&lay, 0, two.plain_text().len()); + assert_eq!(quads.len(), lay.glyphs.iter().filter(|g| g.len > 0).count(), "one quad per character glyph: {quads:?}"); +} + +/// Bullets: a right-to-left list paragraph has its indents mirrored. Its bullet hangs at the right, +/// its right indent is kept at the left, and the lines it wraps to start at the right where the +/// first line's text starts after the bullet. A right-to-left paragraph without a list keeps its +/// left and first-line indents at the left, and a left-to-right list keeps its bullet at the left. +#[test] +fn a_right_to_left_list_paragraph_mirrors_its_hanging_indent() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let hebrew = "שלום עולם ".repeat(12); + let para = |text: &str, justify: Justify, list: bool| { + let mut t = area(text.trim_end(), style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + t.para.right_indent = 5.0; + t.para.list = list.then(|| ParaList::bulleted(Bullet::Dot)); + t + }; + // The left and right ends of line `k`'s glyphs, its spaces left out. + let ends = |t: &TextObject, lay: &TextLayout, k: usize| { + let line = &lay.lines[k]; + let text = t.plain_text(); + let glyphs = lay.glyphs[line.glyph_start..line.glyph_end].iter().filter(|g| !text[g.byte..].starts_with(' ')); + let (l, r) = glyphs.fold((f64::MAX, f64::MIN), |(l, r), g| (l.min(g.origin.x), r.max(g.origin.x + g.advance))); + (l, r) + }; + let close = |(l, r): (f64, f64), (el, er): (f64, f64)| (l - el).abs() < 1e-6 && (r - er).abs() < 1e-6; + for justify in [Justify::Auto, Justify::JustifyLeft] { + let t = para(&hebrew, justify, true); + let lay = layout(db(), &t); + assert!(lay.lines.len() > 2, "{justify:?}: the paragraph wraps"); + let m = &lay.markers[0]; + let (tab, bullet) = (&lay.glyphs[m.tab], &lay.glyphs[m.glyphs.start + 1]); + assert!((bullet.origin.x + bullet.advance - 200.0).abs() < 1e-6, "{justify:?}: the bullet ends the first line at the right: {bullet:?}"); + assert!( + (tab.origin.x - 182.0).abs() < 1e-6, + "{justify:?}: the text after the bullet starts at the left indent from the right: {}", + tab.origin.x + ); + // Justify with Last Line Aligned Left sets the last line at the left. + let wrapped = if justify == Justify::JustifyLeft { 1..lay.lines.len() - 1 } else { 1..lay.lines.len() }; + for k in wrapped { + let (_, r) = ends(&t, &lay, k); + assert!((r - tab.origin.x).abs() < 1e-6, "{justify:?}: line {k}'s right edge is at {r}, the first line's text's at {}", tab.origin.x); + } + if justify == Justify::JustifyLeft { + assert!(close(ends(&t, &lay, 0), (5.0, 200.0)), "the first line: {:?}", ends(&t, &lay, 0)); + assert!(close(ends(&t, &lay, 1), (5.0, 182.0)), "a wrapped line: {:?}", ends(&t, &lay, 1)); + } + } + // Without a list a right-to-left paragraph keeps its indents: the left and first-line indents + // at the left, the right indent at the right. + let t = para(&hebrew, Justify::JustifyLeft, false); + let lay = layout(db(), &t); + assert!(lay.markers.is_empty() && lay.lines.len() > 2); + assert!(close(ends(&t, &lay, 0), (0.0, 195.0)), "the first line: {:?}", ends(&t, &lay, 0)); + assert!(close(ends(&t, &lay, 1), (18.0, 195.0)), "a wrapped line: {:?}", ends(&t, &lay, 1)); + // A left-to-right list: the bullet at the left, every line's text at the left indent. + let t = para(LOREM, Justify::JustifyLeft, true); + let lay = layout(db(), &t); + assert!(lay.lines.len() > 2); + assert!(lay.glyphs[lay.markers[0].glyphs.start].origin.x.abs() < 1e-6, "the bullet starts the first line"); + assert!(close(ends(&t, &lay, 0), (0.0, 195.0)), "the first line: {:?}", ends(&t, &lay, 0)); + assert!(close(ends(&t, &lay, 1), (18.0, 195.0)), "a wrapped line: {:?}", ends(&t, &lay, 1)); +} + +/// Bullets: area type in a frame that isn't a rectangle, centered vertically, flows again until it +/// settles, and keeps one marker for each list paragraph, at that paragraph's bullet. +#[test] +fn vertically_centered_list_paragraphs_in_a_round_frame_keep_one_bullet_each() { + use vectorcraft_doc::VerticalAlign; + use vectorcraft_doc::text::{Bullet, ParaList}; + let text = format!("{LOREM}\n{LOREM}"); + let frame = kurbo::Ellipse::new((150.0, 150.0), (150.0, 150.0), 0.0).to_path(0.1); + let mut t = area(&text, style(12.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) }; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let top = layout(db(), &t); + t.area.vertical_align = VerticalAlign::Center; + let lay = layout(db(), &t); + assert!(lay.lines[0].baseline > top.lines[0].baseline + 1.0, "the text moves down"); + let starts: Vec = lay.markers.iter().map(|m| m.glyphs.start).collect(); + assert_eq!(starts.len(), 2, "{:?}", lay.markers); + for (m, para_start) in lay.markers.iter().zip([0, LOREM.len() + 1]) { + let glyphs = &lay.glyphs[m.glyphs.clone()]; + assert!(glyphs.iter().all(|g| g.len == 0 && g.byte == para_start), "{m:?}: {glyphs:?}"); + assert!(!glyphs[0].outline.elements().is_empty(), "the bullet draws"); + } +} + +/// Bullets: in a frame narrower at the top than the bullet, its gap and the first word, the first +/// line goes down to where they fit, and every line stays inside the frame. +#[test] +fn a_list_paragraph_starts_where_its_bullet_and_first_word_fit() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut triangle = BezPath::new(); + triangle.move_to((150.0, 0.0)); + triangle.line_to((300.0, 300.0)); + triangle.line_to((0.0, 300.0)); + triangle.close_path(); + let mut t = area("Extraordinarily long words follow", style(12.0), Rect::new(0.0, 0.0, 300.0, 300.0), Justify::Left); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&triangle) }; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = layout(db(), &t); + assert_eq!(lay.markers.len(), 1); + for (i, line) in lay.lines.iter().enumerate() { + for g in &lay.glyphs[line.glyph_start..line.glyph_end] { + let (l, r) = (g.origin.x, g.origin.x + g.advance); + assert!(l >= line.avail.0 - 0.01 && r <= line.avail.1 + 0.01, "line {i}: {l}..{r} outside {:?}", line.avail); + } + } +} + +/// Bullets: type on a path starts with its bullet and the gap after it, the caret before its +/// first character stands at the text, and a selection of the text highlights the characters +/// alone. +#[test] +fn type_on_a_path_starts_with_its_bullet() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let mut t = point("Path", style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None }; + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = layout(db(), &t); + assert_eq!(lay.markers.len(), 1, "{:?}", lay.markers); + let m = &lay.markers[0]; + assert_eq!((m.glyphs.clone(), m.tab, lay.marker_text(0)), (0..2, 1, Some("\u{2022}"))); + assert!(lay.glyphs[0].origin.x.abs() < 1e-6 && !lay.glyphs[0].outline.elements().is_empty(), "{:?}", lay.glyphs[0].origin); + let first = lay.glyphs.iter().find(|g| g.len > 0).unwrap(); + assert!((first.origin.x - 18.0).abs() < 1e-6, "the text starts at {}", first.origin.x); + let (top, _) = caret_position(&lay, 0); + assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}"); + let quads = selection_quads(&lay, 0, 4); + assert_eq!(quads.len(), 4, "one quad per character"); + assert!(quads.iter().all(|q| q[0].x >= 18.0 - 1e-6), "{quads:?}"); + // A right-to-left list item on a path reads from its bullet: the bullet is drawn right of the + // text, with the gap between them. + let mut rtl = point("שלום", style(20.0)); + rtl.kind = t.kind.clone(); + rtl.para = t.para.clone(); + let lay = layout(db(), &rtl); + let m = &lay.markers[0]; + let (tab, bullet) = (&lay.glyphs[m.tab], &lay.glyphs[m.glyphs.start + 1]); + let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(f64::MIN, f64::max); + assert!((tab.origin.x - text_right).abs() < 1e-6 && (bullet.origin.x - tab.origin.x - tab.advance).abs() < 1e-6, "{tab:?} {bullet:?}"); + assert!((tab.advance + bullet.advance - 18.0).abs() < 1e-6, "the bullet and its gap take the left indent: {} {}", tab.advance, bullet.advance); + // Optical Margin Alignment leaves a dash bullet at the start bracket, with room before it. + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.1, end: None }; + t.para.list = Some(ParaList::bulleted(Bullet::Dash)); + for optical in [false, true] { + t.optical_margin = optical; + let lay = layout(db(), &t); + assert!((lay.glyphs[0].origin.x - 50.0).abs() < 1e-6, "optical {optical}: the dash starts at {}", lay.glyphs[0].origin.x); + } +} + +/// Bullets: after the bullet the text starts at an explicit tab stop that comes before the left +/// indent, at the next stop past the bullet when the bullet reaches past the left indent, and a +/// line holds the bullet and the start of its first word however narrow the frame. Justified lines +/// and Optical Margin Alignment leave the bullet and the gap after it as they are. +#[test] +fn a_list_paragraph_starts_its_text_at_the_next_tab_stop_after_the_bullet() { + use vectorcraft_doc::text::{Bullet, ParaList}; + use vectorcraft_doc::{TabAlign, TabStop}; + let text_x = |t: &TextObject| layout(db(), t).glyphs.iter().find(|g| g.byte == 0 && g.len > 0).map(|g| g.origin.x).unwrap(); + for (text, justify, optical) in + [(LOREM, Justify::JustifyLeft, false), (LOREM, Justify::Left, true), (LOREM, Justify::JustifyAll, true), ("Item", Justify::JustifyAll, false)] + { + let mut t = area(text, style(12.0), Rect::new(0.0, 0.0, 200.0, 400.0), justify); + t.para.list = Some(ParaList::bulleted(Bullet::Dash)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + t.optical_margin = optical; + let lay = layout(db(), &t); + let marker = &lay.glyphs[lay.markers[0].glyphs.clone()]; + assert!(marker[0].origin.x.abs() < 1e-6, "{justify:?}, optical {optical}: the dash starts at {}", marker[0].origin.x); + assert!((text_x(&t) - 18.0).abs() < 1e-6, "{justify:?}, optical {optical}: the text starts at {}", text_x(&t)); + if justify != Justify::Left && !optical { + let line = &lay.lines[0]; + let ends = lay.glyphs[line.glyph_start..line.glyph_end] + .iter() + .filter(|g| !t.plain_text()[g.byte..].starts_with(' ')) + .map(|g| g.origin.x + g.advance) + .fold(0.0, f64::max); + assert!((ends - 200.0).abs() < 1e-6, "{justify:?}: the justified first line ends at {ends}"); + } + } + // A right-aligned item ends at the frame's right edge, its bullet and gap before it. + let mut right = area("Item", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Right); + right.para.list = Some(ParaList::bulleted(Bullet::Dot)); + right.para.left_indent = 18.0; + right.para.first_line_indent = -18.0; + let lay = layout(db(), &right); + let last = lay.glyphs.last().unwrap(); + assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6, "the line ends at {}", last.origin.x + last.advance); + let marker = &lay.glyphs[lay.markers[0].glyphs.clone()]; + assert!((marker[1].origin.x + marker[1].advance - text_x(&right)).abs() < 1e-6, "the text follows the gap"); + let mut t = area("Item", style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left); + t.para.list = Some(ParaList::bulleted(Bullet::Dash)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + t.para.tabs = vec![TabStop { position: 12.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }]; + assert!((text_x(&t) - 12.0).abs() < 1e-6, "{}", text_x(&t)); + // No indents: the bullet starts the line and the text goes to the next default stop. + t.para.tabs.clear(); + t.para.left_indent = 0.0; + t.para.first_line_indent = 0.0; + assert!((text_x(&t) - 36.0).abs() < 1e-6, "{}", text_x(&t)); + // A frame with no room for a letter after the bullet: the first line still holds the bullet and + // the word's first letter. + let mut narrow = area("Pneumonoultramicroscopic", style(12.0), Rect::new(0.0, 0.0, 20.0, 400.0), Justify::Left); + narrow.para.list = Some(ParaList::bulleted(Bullet::Dot)); + narrow.para.left_indent = 18.0; + narrow.para.first_line_indent = -18.0; + let lay = layout(db(), &narrow); + let first = &lay.glyphs[lay.lines[0].glyph_start..lay.lines[0].glyph_end]; + assert!(first.iter().any(|g| g.len > 0), "the first line holds more than the bullet: {:?}", lay.lines); +} + /// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when /// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. fn japanese_fonts() -> bool { @@ -935,10 +1325,90 @@ fn optical_margin_alignment_hangs_punctuation_and_letter_edges_outside_the_margi }; let (on, off) = (vertical(true), vertical(false)); assert!((on[1].origin - off[0].origin).hypot() < 1e-6, "{:?} {:?}", on[1].origin, off[0].origin); - // Right-to-left paragraphs are placed as before: a full stop ending one, set at the left of a - // left-aligned line, doesn't hang. - let rtl = |on: bool| lay("שלום, עולם.", Justify::Left, on).iter().map(|g| g.origin).collect::>(); - assert_eq!(rtl(true), rtl(false)); +} + +/// Right-to-left paragraphs hang at the margins their alignment places their lines against: Auto +/// and right-aligned lines hang at the right, left-aligned lines hang at the left, and justified +/// lines hang at both. The line is read from right to left, so `."` ending it hangs both glyphs at its left, and `".` +/// starting it hangs only the quotation mark at its right. Roman Hanging Punctuation hangs them +/// the same way. In vertical type the characters run from the top down, and the pair is read +/// from the top. +#[test] +fn optical_margin_alignment_hangs_right_to_left_paragraphs() { + let frame = Rect::new(0.0, 0.0, 300.0, 400.0); + let object = |text: &str, justify: Justify, on: bool| { + let mut t = area(text, style(20.0), frame, justify); + t.optical_margin = on; + t + }; + let lay = |text: &str, justify: Justify, on: bool| layout(db(), &object(text, justify, on)).glyphs; + let at = |glyphs: &[PositionedGlyph], b: usize| glyphs.iter().find(|g| g.byte == b).cloned().unwrap(); + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + let end = |g: &PositionedGlyph| g.origin.x + g.advance; + let alef = 'א'.len_utf8(); + // Auto aligns the line at the right: the opening quotation mark (logically first, drawn at the + // right end) hangs wholly past the right edge, and the letter after it ends at the edge. + let text = "\"שלום\" עולם"; + let (on, off) = (lay(text, Justify::Auto, true), lay(text, Justify::Auto, false)); + assert!(off.iter().all(|g| g.rtl) && near(end(&at(&off, 0)), 300.0), "{off:?}"); + assert!(near(at(&on, 0).origin.x, 300.0) && at(&on, 0).advance > 1.0, "{:?}", at(&on, 0).origin); + assert!(near(end(&at(&on, 1)), 300.0), "{}", end(&at(&on, 1))); + // Left aligned: the full stop ending the line is drawn at its left and hangs there. + let text = "שלום, עולם."; + let stop = text.len() - 1; + let (on, off) = (lay(text, Justify::Left, true), lay(text, Justify::Left, false)); + assert!(near(off[0].origin.x, 0.0) && near(at(&on, stop).origin.x, -at(&on, stop).advance), "{:?}", at(&on, stop).origin); + // Justified: `."` ending the line hangs both glyphs at the left; the right end doesn't hang. + let text = "שלום עולם.\""; + let (quote, stop) = (text.len() - 1, text.len() - 2); + let on = lay(text, Justify::JustifyAll, true); + let (q, s) = (at(&on, quote), at(&on, stop)); + assert!(near(q.origin.x, -q.advance - s.advance) && near(s.origin.x, -s.advance), "{:?} {:?}", q.origin, s.origin); + assert!(near(end(&at(&on, 0)), 300.0), "{}", end(&at(&on, 0))); + // A justified line of one word, spread between its letters: the opening quotation mark hangs + // wholly past the right edge, the letter after it ends at the edge, and the last letter starts + // at the left edge. + let on = lay("\"שלום", Justify::JustifyAll, true); + let mem = 1 + 3 * alef; + assert!(near(at(&on, 0).origin.x, 300.0) && near(end(&at(&on, 1)), 300.0), "{:?} {:?}", at(&on, 0).origin, at(&on, 1).origin); + assert!(near(at(&on, mem).origin.x, 0.0), "{:?}", at(&on, mem).origin); + // `".` starting a right-aligned line: the quotation mark hangs and the full stop doesn't. + let on = lay("\".שלום", Justify::Right, true); + assert!(near(at(&on, 0).origin.x, 300.0) && near(end(&at(&on, 1)), 300.0), "{:?} {:?}", at(&on, 0).origin, at(&on, 1).origin); + // A wrapped left-aligned line: the comma ending it hangs, and the space after it stays to its left. + let first = width(&point("אבגד, ", style(20.0))); + let mut t = area("אבגד, הוזח", style(20.0), Rect::new(0.0, 0.0, first + 1.0, 400.0), Justify::Left); + t.optical_margin = true; + let l = layout(db(), &t); + let (comma, space) = (at(&l.glyphs, 4 * alef), at(&l.glyphs, 4 * alef + 1)); + assert!(l.lines.len() == 2 && near(comma.origin.x, -comma.advance), "{:?} {:?}", l.lines, comma.origin); + assert!(near(end(&space), comma.origin.x), "{:?} {:?}", space.origin, comma.origin); + // Point type: Auto grows the line left from its anchor, and the opening quotation mark hangs + // to the right of the anchor. + let mut t = point("\"שלום", style(20.0)); + (t.para.justify, t.optical_margin) = (Justify::Auto, true); + let l = layout(db(), &t); + assert!(near(at(&l.glyphs, 0).origin.x, 0.0) && near(end(&at(&l.glyphs, 1)), 0.0), "{:?}", at(&l.glyphs, 0).origin); + // Roman Hanging Punctuation hangs the full stop the same way. + let mut t = area("שלום, עולם.", style(20.0), frame, Justify::Left); + t.para.roman_hanging = true; + let l = layout(db(), &t); + let stop = "שלום, עולם".len(); + assert!(near(at(&l.glyphs, stop).origin.x, -at(&l.glyphs, stop).advance), "{:?}", at(&l.glyphs, stop).origin); + // Vertical type: the letter after the quotation mark takes the mark's place at the column's top. + let vertical = |text: &str, justify: Justify, on: bool| { + let mut t = object(text, justify, on); + t.vertical = true; + layout(db(), &t).glyphs + }; + let (on, off) = (vertical("\"שלום", Justify::Left, true), vertical("\"שלום", Justify::Left, false)); + assert!((at(&on, 1).origin - at(&off, 0).origin).hypot() < 1e-6, "{:?} {:?}", at(&on, 1).origin, at(&off, 0).origin); + // …and `."` ending a line at the column's bottom hangs both glyphs there. + let text = "שלום.\""; + let (on, off) = (vertical(text, Justify::Right, true), vertical(text, Justify::Right, false)); + let (stop, mem) = (text.len() - 2, text.len() - 2 - alef); + let shift = at(&off, stop).advance + at(&off, stop + 1).advance; + assert!(near((at(&on, mem).origin - at(&off, mem).origin).hypot(), shift), "{:?} {:?}", at(&on, mem).origin, at(&off, mem).origin); } /// Roman Hanging Punctuation (a paragraph attribute) hangs the punctuation Optical Margin @@ -981,6 +1451,163 @@ fn roman_hanging_punctuation_hangs_punctuation_but_not_letters() { assert!(near(at("“One\n".len()), 0.0), "second doesn't: {}", at("“One\n".len())); } +/// Type on a path hangs at its brackets, measured along the path: a left-aligned line at the +/// start bracket, a right-aligned line at the end bracket, a centered line at both, and the +/// justified alignments where they place the line. A mark hanging past the end bracket doesn't +/// overflow. A hang never takes a glyph off an open path's ends, nor past the other bracket of a +/// closed path. Right-to-left type hangs at the bracket Auto places it at and is read from right to +/// left. Roman Hanging Punctuation hangs path type the same way, without the letters. +#[test] +fn optical_margin_alignment_hangs_type_on_a_path_at_its_brackets() { + // A straight path along y = 50 from x = 0 to 500: distance along it is x. + let mut line = BezPath::new(); + line.move_to((0.0, 50.0)); + line.line_to((500.0, 50.0)); + let path = PathData::from_bezpath(&line); + let on_path = |text: &str, justify: Justify, start: f64, end: Option, on: bool| { + let mut t = point(text, style(20.0)); + t.kind = TextKind::OnPath { path: path.clone(), start, end }; + t.para.justify = justify; + t.optical_margin = on; + t + }; + let lay = |text: &str, justify: Justify, on: bool| layout(db(), &on_path(text, justify, 0.1, Some(0.9), on)); + let near = |a: f64, b: f64| (a - b).abs() < 1e-6; + let end = |g: &PositionedGlyph| g.origin.x + g.advance; + // Left aligned: the opening quotation mark hangs wholly before the start bracket (x = 50). + let (on, off) = (lay("“Hello", Justify::Left, true), lay("“Hello", Justify::Left, false)); + assert!(near(off.glyphs[0].origin.x, 50.0) && near(on.glyphs[1].origin.x, 50.0), "{:?}", on.glyphs[1].origin); + assert!(near(on.glyphs[0].origin.x, 50.0 - on.glyphs[0].advance) && on.glyphs[0].advance > 1.0); + // Auto, Justify All and Justify with Last Line Aligned Left place the line at the start bracket + // and hang there too. + for justify in [Justify::Auto, Justify::JustifyAll, Justify::JustifyLeft] { + assert!(near(lay("“Hello", justify, true).glyphs[1].origin.x, 50.0), "{justify:?}"); + } + // Right aligned: the full stop hangs past the end bracket (x = 450) without overflowing. + for justify in [Justify::Right, Justify::JustifyRight] { + let (on, off) = (lay("Hello there.", justify, true), lay("Hello there.", justify, false)); + let n = on.glyphs.len(); + assert!(near(end(&off.glyphs[n - 1]), 450.0) && near(on.glyphs[n - 1].origin.x, 450.0), "{justify:?}: {:?}", on.glyphs[n - 1].origin); + assert!(!on.overflow); + } + // A right-aligned line doesn't hang at its start, nor a left-aligned one at its end. + assert!(near(lay("“Hello", Justify::Right, true).glyphs.last().map_or(0.0, end), 450.0)); + assert!(near(lay("Hello.", Justify::Left, true).glyphs[0].origin.x, 50.0)); + // Centered: the letters between the hanging hyphens are centered between the brackets. + for justify in [Justify::Center, Justify::JustifyCenter] { + let on = lay("-ab-", justify, true).glyphs; + assert!(near((on[1].origin.x + end(&on[2])) * 0.5, 250.0), "{justify:?}: {on:?}"); + } + // The hanging full stop is room: a line that overflows by part of its full stop fits with it. + let wide = lay("Hello.", Justify::Left, false).glyphs; + let w: f64 = wide.iter().map(|g| g.advance).sum(); + let tight = |on: bool| layout(db(), &on_path("Hello.", Justify::Left, 0.1, Some((50.0 + w - 0.5 * wide[5].advance) / 500.0), on)); + let (on, off) = (tight(true), tight(false)); + assert!(off.overflow && off.glyphs.len() == 5, "{} {}", off.overflow, off.glyphs.len()); + assert!(!on.overflow && on.glyphs.len() == 6, "{} {}", on.overflow, on.glyphs.len()); + // Only the glyphs that hang take that room: a letter ending past the end bracket by less than + // the overset story's last full stop still overflows. + let story = lay("Hello there.", Justify::Left, false).glyphs; + let past = (end(&story[3]) - 0.5 * story[11].advance) / 500.0; + let overset = |on: bool| layout(db(), &on_path("Hello there.", Justify::Left, 0.1, Some(past), on)); + assert!(overset(true).overflow && overset(true).glyphs.len() == 3, "{}", overset(true).glyphs.len()); + assert_eq!(overset(false).glyphs.len(), 3); + // A hang stops at an open path's first point: no glyph is held there. + let at_start = |on: bool| layout(db(), &on_path("“Hello", Justify::Left, 0.0, None, on)).glyphs; + assert!(near(at_start(true)[0].origin.x, 0.0) && near(at_start(true)[1].origin.x, at_start(false)[1].origin.x)); + let quote = at_start(false)[0].advance; + let part = layout(db(), &on_path("“Hello", Justify::Left, 0.5 * quote / 500.0, None, true)).glyphs; + assert!(near(part[0].origin.x, 0.0), "cut to the room before the bracket: {:?}", part[0].origin); + // Vertical type on the path: the letter after the quotation mark takes the mark's place. + let vertical = |on: bool| { + let mut t = on_path("“Hello", Justify::Left, 0.1, Some(0.9), on); + t.vertical = true; + layout(db(), &t).glyphs + }; + let (on, off) = (vertical(true), vertical(false)); + assert!((on[1].origin - off[0].origin).hypot() < 1e-6 && (on[0].origin - off[0].origin).hypot() > 1.0, "{:?} {:?}", on[1].origin, off[0].origin); + // With Spacing on a curve, the letter after the hanging quotation mark still starts at the + // bracket: the rest of the line is drawn as the same text without the mark. + let mut curve = BezPath::new(); + curve.move_to((20.0, 150.0)); + curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0)); + let spaced = |text: &str, on: bool| { + let mut t = point(text, style(20.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.2, end: None }; + (t.path_spacing, t.optical_margin) = (12.0, on); + layout(db(), &t).glyphs.iter().map(|g| (g.origin, g.angle)).collect::>() + }; + let (on, off) = (spaced("“Hello", true), spaced("Hello", false)); + assert!(on.len() == 6 && on[1..].iter().zip(&off).all(|(a, b)| (a.0 - b.0).hypot() < 1e-6 && (a.1 - b.1).abs() < 1e-6), "{on:?} {off:?}"); + // A closed path whose brackets take all of it leaves no room to hang. + let circle = PathData::from_bezpath(&kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1)); + let round = |on: bool| { + let mut t = point("“Around the circle”", style(14.0)); + t.kind = TextKind::OnPath { path: circle.clone(), start: 0.0, end: None }; + t.optical_margin = on; + layout(db(), &t).glyphs.iter().map(|g| g.origin).collect::>() + }; + assert_eq!(round(true), round(false)); + // On a closed path whose brackets leave a stretch narrower than a quotation mark, a + // right-aligned line hangs its closing mark into that stretch; its opening mark doesn't take it. + let mut square = BezPath::new(); + square.move_to((0.0, 0.0)); + square.line_to((500.0, 0.0)); + square.line_to((500.0, 10.0)); + square.line_to((0.0, 10.0)); + square.close_path(); + let square = PathData::from_bezpath(&square); + let gap = 0.5 * quote; + let closed = |justify: Justify, on: bool| { + // 1,020 pt round; the brackets are at 102 and at 102 - gap on the top side. + let mut t = on_path("“Hi”", justify, 0.1, None, on); + t.kind = TextKind::OnPath { path: square.clone(), start: 0.1, end: Some((102.0 - gap) / 1020.0) }; + layout(db(), &t).glyphs + }; + let (on, off) = (closed(Justify::Right, true), closed(Justify::Right, false)); + assert!(near(off.last().map_or(0.0, end), 102.0 - gap) && near(on.last().map_or(0.0, end), 102.0), "{on:?}"); + // Centered, each mark hangs into half of it, and the line stays where it was. + let origins = |on: bool| closed(Justify::Center, on).iter().map(|g| g.origin).collect::>(); + let (on, off) = (origins(true), origins(false)); + assert!(on.iter().zip(&off).all(|(a, b)| (*a - *b).hypot() < 1e-6), "{on:?} {off:?}"); + // Right-to-left type on a path hangs too. Auto places it at the end bracket, where its opening + // quotation mark, drawn last, hangs wholly past the bracket. + let (on, off) = (lay("\"שלום\" עולם", Justify::Auto, true), lay("\"שלום\" עולם", Justify::Auto, false)); + let n = on.glyphs.len(); + assert!(near(end(&off.glyphs[n - 1]), 450.0) && near(on.glyphs[n - 1].origin.x, 450.0), "{:?}", on.glyphs[n - 1].origin); + assert!(!on.overflow); + // The line is read from right to left: left aligned, `."` ending it hangs both glyphs before the + // start bracket. + let (on, off) = (lay("עולם.\"", Justify::Left, true), lay("עולם.\"", Justify::Left, false)); + assert!(near(off.glyphs[0].origin.x, 50.0) && near(on.glyphs[2].origin.x, 50.0), "{:?}", on.glyphs[2].origin); + // On a closed path, Auto right-to-left type takes the stretch past the end bracket first, as + // right-aligned type does. + let closed_rtl = |on: bool| { + let mut t = on_path("\"שלום\"", Justify::Auto, 0.1, None, on); + t.kind = TextKind::OnPath { path: square.clone(), start: 0.1, end: Some((102.0 - gap) / 1020.0) }; + layout(db(), &t).glyphs + }; + let (on, off) = (closed_rtl(true), closed_rtl(false)); + assert!(near(off.last().map_or(0.0, end), 102.0 - gap) && near(on.last().map_or(0.0, end), 102.0), "{on:?}"); + // Vertical type on a path whose paragraph runs right to left reads from the top: the letter + // after the quotation mark takes the mark's place. + let vertical_rtl = |on: bool| { + let mut t = on_path("\"שלום", Justify::Left, 0.1, Some(0.9), on); + t.vertical = true; + layout(db(), &t).glyphs + }; + let (on, off) = (vertical_rtl(true), vertical_rtl(false)); + assert!((on[1].origin - off[0].origin).hypot() < 1e-6 && (on[0].origin - off[0].origin).hypot() > 1.0, "{:?} {:?}", on[1].origin, off[0].origin); + // Roman Hanging Punctuation hangs the quotation mark and no letters. + let roman = |text: &str| { + let mut t = on_path(text, Justify::Left, 0.1, Some(0.9), false); + t.para.roman_hanging = true; + layout(db(), &t).glyphs + }; + assert!(near(roman("“Hello")[1].origin.x, 50.0) && near(roman("AVA")[0].origin.x, 50.0)); + assert!(near(lay("AVA", Justify::Left, true).glyphs[0].origin.x, 50.0 - 0.1 * lay("AVA", Justify::Left, true).glyphs[0].advance)); +} + /// With craft-fonts and no installed fonts (the web), Thai is set in Noto Sans Thai, not as empty /// boxes. Skipped when built without craft-fonts or with a checkout that has no Thai font. #[test] @@ -1035,3 +1662,20 @@ fn thai_loanwords_stay_whole() { let word = text.find("โปรแกรม").unwrap(); assert!(l.lines.iter().all(|ln| ln.end <= word || ln.end >= word + "โปรแกรม".len()), "{lines:?}"); } + +/// No Break (Character panel menu) keeps the marked words on one line, where the same text wraps +/// at the space without the mark. +#[test] +fn no_break_keeps_marked_words_on_one_line() { + let plain = style(20.0); + let marked = CharStyle { no_break: true, ..plain.clone() }; + let hello: f64 = layout(db(), &point("hello ", plain.clone())).glyphs.iter().map(|g| g.advance).sum(); + let whole: f64 = layout(db(), &point("hello world", plain.clone())).glyphs.iter().map(|g| g.advance).sum(); + assert!(whole > hello + 1.0, "the second word takes room"); + let frame = Rect::new(0.0, 0.0, hello + 1.0, 200.0); + let wrapped = layout(db(), &area("hello world", plain, frame, Justify::Left)); + assert!(wrapped.lines.len() > 1, "it wraps at the space"); + let kept = layout(db(), &area("hello world", marked, frame, Justify::Left)); + assert_eq!(kept.lines.len(), 1, "the marked words stay together"); + assert_eq!(kept.lines[0].end, "hello world".len()); +} diff --git a/crates/text/src/tests_axes.rs b/crates/text/src/tests_axes.rs index 8273cd8dd..7bfc57e5a 100644 --- a/crates/text/src/tests_axes.rs +++ b/crates/text/src/tests_axes.rs @@ -47,6 +47,18 @@ fn a_variable_font_lists_its_axes() { assert!(db.face(FALLBACK_FAMILY, "Regular").unwrap().axes().is_empty(), "a static face"); } +/// Font menus' Font Technology › Variable: a family with variation axes is variable, the bundled +/// static families aren't. +#[test] +fn a_family_with_variation_axes_is_variable() { + let db = db(); + assert!(db.family_traits(FAMILY).variable); + assert_eq!(db.family_traits(FAMILY).class, FontClass::Sans, "its kind is read as before"); + for family in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] { + assert!(!db.family_traits(family).variable, "{family} is static"); + } +} + #[test] fn axis_settings_lay_out_and_draw_between_the_named_instances() { let db = db(); diff --git a/crates/text/src/tests_bidi.rs b/crates/text/src/tests_bidi.rs index 4646384de..07e17b0a7 100644 --- a/crates/text/src/tests_bidi.rs +++ b/crates/text/src/tests_bidi.rs @@ -1,8 +1,9 @@ //! Direction, source-cluster and editing regressions; no RTL font assets required. use super::*; use kurbo::{Affine, BezPath, Point, Rect, Shape}; +use std::ops::Range; use std::sync::Arc; -use vectorcraft_doc::{CharStyle, TextKind, TextRun}; +use vectorcraft_doc::{CharStyle, ParaDirection, TextKind, TextRun}; fn text_layout(s: &str) -> TextLayout { layout(FontDb::global(), &TextObject::point(Point::ZERO, s, CharStyle::default())) @@ -88,6 +89,40 @@ fn mixed_text_preserves_latin_and_number_order() { assert_eq!(s, "שלום Rust 123", "layout must not rewrite source text"); } +/// The characters of `s` in the order a layout of it draws them, left to right, with the bytes +/// `set` given Character Direction `d`. +fn drawn_with_direction(s: &str, set: Range, d: Option) -> String { + let mut t = TextObject::point(Point::ZERO, s, CharStyle::default()); + edit::style_range(&mut t.runs, set.start, set.end, |st| st.char_direction = d); + let l = layout(FontDb::global(), &t); + let mut glyphs: Vec<_> = l.glyphs.iter().collect(); + glyphs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + glyphs.iter().filter_map(|g| s.get(g.byte..)?.chars().next()).collect() +} + +/// Character Direction Left to Right keeps a date written with spaces in its own order inside a +/// right-to-left paragraph, where the bidi algorithm alone sets its numbers right to left. +#[test] +fn left_to_right_characters_keep_a_date_in_order_in_right_to_left_text() { + let s = "שלום 21 10 2026"; + let date = s.find('2').unwrap_or_default()..s.len(); + assert_eq!(drawn_with_direction(s, date.clone(), None), "2026 10 21 םולש"); + assert_eq!(drawn_with_direction(s, date, Some(ParaDirection::LeftToRight)), "21 10 2026 םולש"); +} + +/// Character Direction Right to Left sets a Hebrew word and the exclamation mark after it right to +/// left inside a left-to-right paragraph, where the bidi algorithm alone leaves the mark after the +/// word in the paragraph's direction. +#[test] +fn right_to_left_characters_set_a_word_and_its_mark_right_to_left_in_left_to_right_text() { + let s = "abc שלום! def"; + let word = s.find('ש').unwrap_or_default()..s.find('!').map_or(0, |i| i + 1); + assert_eq!(drawn_with_direction(s, word.clone(), None), "abc םולש! def"); + assert_eq!(drawn_with_direction(s, word.clone(), Some(ParaDirection::RightToLeft)), "abc !םולש def"); + // Left to Right on the same characters of a left-to-right paragraph draws them as without it. + assert_eq!(drawn_with_direction(s, word, Some(ParaDirection::LeftToRight)), "abc םולש! def"); +} + #[test] fn rtl_hit_testing_arrows_and_selection_follow_visual_positions() { let s = "שלום"; @@ -125,6 +160,8 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout xf: Affine::IDENTITY, rtl, scale: 1.0, + image: None, + colour: None, }); x += advance; } @@ -551,3 +588,63 @@ fn digits_draw_every_digit_in_the_styles_numeral_system() { // Default leaves each digit as typed. assert_ne!(gids(&lay("3 ٣ ۳", Digits::Default)), gids(&lay("3 ٣ ۳", Digits::Hindi))); } + +/// Right-to-left type on a path that overflows shows the start of its text, as a line of area +/// type does, not whatever comes first in visual order. +#[test] +fn overflowing_rtl_type_on_a_path_keeps_the_start_of_its_text() { + let s = "שלום עולם ".repeat(8); + let mut t = TextObject::point(Point::ZERO, &s, CharStyle::default()); + let line = kurbo::Line::new((0.0, 0.0), (100.0, 0.0)).to_path(0.1); + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None }; + let l = layout(FontDb::global(), &t); + assert!(l.overflow && !l.glyphs.is_empty()); + let shown: Vec<_> = source_clusters(&l).into_iter().map(|(byte, _)| byte).collect(); + let last = shown.last().copied().unwrap_or(0); + let start: Vec<_> = s.char_indices().map(|(i, _)| i).take_while(|i| *i <= last).collect(); + assert_eq!(shown, start, "the glyphs shown are the text's start"); + let x = |byte: usize| l.glyphs.iter().find(|g| g.byte == byte).map(|g| g.origin.x).unwrap(); + assert!(x(0) > x(last), "the text runs right to left along the path"); +} + +/// On a curve with spacing, where a glyph's step depends on where it sits, what fits is measured +/// where the glyphs are placed: the start of the text shows, and every glyph of it. +#[test] +fn overflowing_rtl_type_on_a_spaced_curve_keeps_the_start_of_its_text() { + let s = "שלום, עולם! אבג ".repeat(6); + // Straight, then tightly curved: glyphs take other steps there. + let mut path = BezPath::new(); + path.move_to((0.0, 0.0)); + path.line_to((60.0, 0.0)); + path.extend(kurbo::Arc::new((60.0, 12.0), (12.0, 12.0), -std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 0.0).append_iter(0.1)); + let mut t = TextObject::point(Point::ZERO, &s, CharStyle::default()); + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&path), start: 0.0, end: None }; + for spacing in (-12..=12).map(|i| f64::from(i) * 5.0) { + t.path_spacing = spacing; + let l = layout(FontDb::global(), &t); + assert!(l.overflow && !l.glyphs.is_empty()); + let shown: Vec<_> = source_clusters(&l).into_iter().map(|(byte, _)| byte).collect(); + let last = shown.last().copied().unwrap_or(0); + let start: Vec<_> = s.char_indices().map(|(i, _)| i).take_while(|i| *i <= last).collect(); + assert_eq!(shown, start, "the glyphs shown are the text's start ({spacing})"); + } +} + +/// A left-to-right line with right-to-left text in it fits as it is laid out: a period that +/// hangs past the end bracket (Roman Hanging Punctuation) stays, and nothing overflows. +#[test] +fn mixed_type_on_a_path_hangs_its_final_period() { + let s = "bא."; + let line = kurbo::Line::new((0.0, 0.0), (400.0, 0.0)).to_path(0.1); + let mut t = TextObject::point(Point::ZERO, s, CharStyle::default()); + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: None }; + t.para.roman_hanging = true; + let advances: Vec = layout(FontDb::global(), &t).glyphs.iter().map(|g| g.advance).collect(); + assert_eq!(advances.len(), 3); + // The end bracket halfway through the period, with path beyond it. + let end = advances[0] + advances[1] + advances[2] * 0.5; + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&line), start: 0.0, end: Some(end / 400.0) }; + let l = layout(FontDb::global(), &t); + assert!(l.glyphs.iter().any(|g| g.byte == s.len() - 1), "the period shows"); + assert!(!l.overflow, "all of it fits"); +} diff --git a/crates/text/src/tests_bitmaps.rs b/crates/text/src/tests_bitmaps.rs new file mode 100644 index 000000000..f28656d4a --- /dev/null +++ b/crates/text/src/tests_bitmaps.rs @@ -0,0 +1,155 @@ +//! Colour bitmap glyphs (`sbix`, `CBDT`): laid out as images in place of outlines, in the box +//! their strike gives, moving with the type. + +use kurbo::{Point, Rect}; +use skrifa::MetadataProvider; +use skrifa::instance::{LocationRef, Size}; +use vectorcraft_doc::CharStyle; + +use super::test_fonts::{COLOUR_CBDT_BEARINGS, COLOUR_CHAR, COLOUR_PPEM, colour_bitmap_family, colour_bitmap_font}; +use super::*; + +fn db(sbix: bool) -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(colour_bitmap_font(sbix).expect("the test font builds")), 1); + db +} + +fn style(sbix: bool) -> CharStyle { + CharStyle { font_family: colour_bitmap_family(sbix).into(), size: 100.0, ..CharStyle::default() } +} + +/// The text-space box of glyph `g`'s image. +fn image_box(g: &PositionedGlyph) -> Rect { + let (image, xf) = g.placed_image().expect("the glyph is an image"); + xf.transform_rect_bbox(image.rect()) +} + +fn assert_near(a: Rect, b: Rect) { + let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1]; + assert!(d.iter().all(|d| d.abs() < 1e-6), "{a:?} is not {b:?}"); +} + +/// The left side bearing of [`COLOUR_CHAR`] in the bundled Source Sans 3 (font units of 1000). +fn lsb() -> f64 { + let font = skrifa::FontRef::new(include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf")).unwrap(); + let gid = font.charmap().map(COLOUR_CHAR).unwrap(); + f64::from(font.glyph_metrics(Size::unscaled(), LocationRef::default()).left_side_bearing(gid).unwrap()) +} + +#[test] +fn an_sbix_glyph_is_its_largest_strike_one_em_wide_sitting_on_the_shifted_baseline() { + let db = db(true); + let l = layout(&db, &TextObject::point(Point::new(10.0, 50.0), &COLOUR_CHAR.to_string(), style(true))); + let g = &l.glyphs[0]; + assert!(g.outline.elements().is_empty(), "the image replaces the outline"); + let image = g.image.as_ref().unwrap(); + assert_eq!((image.width, image.height), (u32::from(COLOUR_PPEM), u32::from(COLOUR_PPEM)), "the largest strike"); + // From the glyph's left side bearing, its bottom 0.128 em below the baseline. + let x = g.origin.x + lsb() * 0.1; + assert_near(image_box(g), Rect::new(x, g.origin.y - 87.2, x + 100.0, g.origin.y + 12.8)); + assert!(l.bounds.contains_rect(image_box(g)), "the bounds hold the image: {:?}", l.bounds); +} + +#[test] +fn a_cbdt_glyph_sits_where_its_bearings_put_it() { + let db = db(false); + let l = layout(&db, &TextObject::point(Point::new(10.0, 50.0), &COLOUR_CHAR.to_string(), style(false))); + let g = &l.glyphs[0]; + assert!(g.outline.elements().is_empty()); + let (bx, by) = COLOUR_CBDT_BEARINGS; + let px = 100.0 / f64::from(COLOUR_PPEM); + let (x, top) = (g.origin.x + f64::from(bx) * px, g.origin.y - f64::from(by) * px); + assert_near(image_box(g), Rect::new(x, top, x + 100.0, top + 100.0)); +} + +#[test] +fn a_bitmap_glyph_scales_with_the_type() { + let db = db(false); + let st = CharStyle { h_scale: 200.0, ..style(false) }; + let l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), st)); + let b = image_box(&l.glyphs[0]); + assert!((b.width() - 200.0).abs() < 1e-6 && (b.height() - 100.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn a_bitmap_glyph_moves_when_type_snaps_to_pixels() { + let db = db(false); + let mut l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), style(false))); + let (before, origin) = (image_box(&l.glyphs[0]), l.glyphs[0].origin); + assert!(l.snap_to_pixels(Affine::translate((0.3, 0.4)))); + let g = &l.glyphs[0]; + assert!(g.origin != origin); + assert_near(image_box(g), before + (g.origin - origin)); +} + +#[test] +fn other_glyphs_of_a_colour_font_keep_their_outlines() { + let db = db(true); + let l = layout(&db, &TextObject::point(Point::ZERO, "F", style(true))); + assert!(l.glyphs[0].image.is_none()); + assert!(!l.glyphs[0].outline.elements().is_empty()); +} + +/// A strike PNG past the caps, or whose header doesn't read, gives the glyph no image: it is not +/// decoded (a font is untrusted input). +#[test] +fn a_bitmap_past_the_caps_or_without_a_header_is_no_image() { + use super::bitmap::{MAX_PNG_BYTES, MAX_SIDE}; + use super::test_fonts::{COLOUR_LARGE, colour_bitmap_font_with, solid_png}; + let good = solid_png(COLOUR_PPEM.into(), COLOUR_LARGE); + // A header claiming a side past the cap (the IHDR width). + let mut wide = good.clone(); + wide[16..20].copy_from_slice(&(MAX_SIDE + 1).to_be_bytes()); + // More bytes than a glyph's PNG may have. + let mut long = good.clone(); + long.resize(MAX_PNG_BYTES + 1, 0); + let headless = good[8..].to_vec(); + for sbix in [true, false] { + for (what, png) in [("wide", &wide), ("long", &long), ("headless", &headless), ("good", &good)] { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(colour_bitmap_font_with(sbix, png).unwrap()), 1); + let l = layout(&db, &TextObject::point(Point::ZERO, &COLOUR_CHAR.to_string(), style(sbix))); + assert_eq!(l.glyphs[0].image.is_some(), what == "good", "{what} (sbix {sbix})"); + } + } +} + +/// A colour bitmap font without outline tables (Noto Color Emoji has `CBDT` but no `glyf`) is an +/// installed font like any other. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_bitmap_only_font_file_is_cataloged() { + use crate::fontdb::file_face_names_within; + let full = colour_bitmap_font(false).unwrap(); + let font = skrifa::FontRef::new(&full).unwrap(); + let mut builder = write_fonts::FontBuilder::new(); + for r in font.table_directory.table_records() { + if ![*b"glyf", *b"loca"].contains(&r.tag().to_be_bytes()) { + builder.add_raw(write_fonts::types::Tag::new(&r.tag().to_be_bytes()), font.table_data(r.tag()).unwrap().as_bytes()); + } + } + let dir = std::env::temp_dir().join(format!("vc-bitmaps-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("Bitmaps.ttf"); + std::fs::write(&file, builder.build()).unwrap(); + let faces = file_face_names_within(&file, 4, u64::MAX); + assert_eq!(faces.len(), 1, "the face is cataloged"); +} + +/// The system's colour emoji font, when installed: an emoji draws as an image, and a ZWJ sequence +/// as one glyph. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn an_installed_colour_emoji_font_draws_emoji_as_images() { + let db = FontDb::with_font_dirs(system_font_dirs()); + let Some(family) = ["Apple Color Emoji", "Noto Color Emoji"].into_iter().find(|f| db.has_family(f)) else { + eprintln!("no colour emoji font installed: skipped"); + return; + }; + let st = CharStyle { font_family: family.into(), size: 40.0, ..CharStyle::default() }; + let l = layout(&db, &TextObject::point(Point::ZERO, "\u{1F600}", st.clone())); + assert!(l.glyphs[0].image.is_some(), "{family} draws 😀 as an image"); + let l = layout(&db, &TextObject::point(Point::ZERO, "\u{1F469}\u{200D}\u{1F4BB}", st)); + assert_eq!(l.glyphs.iter().filter(|g| g.image.is_some()).count(), 1, "the sequence is one glyph"); +} diff --git a/crates/text/src/tests_colr.rs b/crates/text/src/tests_colr.rs new file mode 100644 index 000000000..9f51e0d9e --- /dev/null +++ b/crates/text/src/tests_colr.rs @@ -0,0 +1,413 @@ +//! COLR colour glyphs (v0 layers, v1 paints): read as trees of document nodes in font units, or +//! as an image for paints VectorCraft lacks, and laid out with the type. + +use skrifa::MetadataProvider; +use skrifa::color::ColorGlyphFormat; + +use super::test_fonts::{COLOUR_CHAR, COLR_SWEEP_CHAR, colr_font}; + +#[test] +fn colr_fonts_build_and_read() { + for v1 in [false, true] { + let bytes = colr_font(v1).expect("the test font builds"); + let font = skrifa::FontRef::new(&bytes).unwrap(); + let glyph = |ch| font.color_glyphs().get(font.charmap().map(ch).unwrap()); + let format = glyph(COLOUR_CHAR).map(|g| g.format()); + assert!(matches!((v1, format), (false, Some(ColorGlyphFormat::ColrV0)) | (true, Some(ColorGlyphFormat::ColrV1))), "v1 {v1}"); + assert_eq!(glyph(COLR_SWEEP_CHAR).is_some(), v1, "only v1 has the sweep glyph"); + } +} + +use std::sync::Arc; + +use skrifa::instance::LocationRef; +use vectorcraft_doc::color::{BlendMode, Color, GradientKind, Paint}; +use vectorcraft_doc::{Node, NodeKind}; + +use super::colr::{ColourContent, colour_glyph}; +use super::test_fonts::{COLR_GREEN, COLR_RED}; + +const FOREGROUND: Color = Color::Rgb { r: 0.25, g: 0.5, b: 0.75 }; + +/// [`colour_glyph`] of `ch` in `font`. +fn content(font: &[u8], ch: char) -> Option { + let font = skrifa::FontRef::new(font).unwrap(); + let gid = font.charmap().map(ch).unwrap().to_u32(); + colour_glyph(&font, LocationRef::default(), 1000.0, gid, FOREGROUND).0 +} + +fn art(font: &[u8], ch: char) -> Arc { + match content(font, ch) { + Some(ColourContent::Art(a)) => a, + _ => panic!("vector art expected"), + } +} + +fn children(n: &Node) -> &[Arc] { + n.children().map(Vec::as_slice).unwrap_or_default() +} + +fn fill(n: &Node) -> Paint { + n.appearance.fill_paint() +} + +fn rgb([r, g, b, _]: [u8; 4]) -> Color { + Color::rgb8(r, g, b) +} + +#[test] +fn v0_layers_are_paths_in_paint_order() { + let root = art(&colr_font(false).unwrap(), COLOUR_CHAR); + assert!(!root.isolate, "a group without blending needs no layer of its own"); + let layers = children(&root); + assert_eq!(layers.len(), 2, "{layers:?}"); + assert!(layers.iter().all(|l| matches!(l.kind, NodeKind::Path { .. }))); + assert_eq!(fill(&layers[0]), Paint::solid(rgb(COLR_RED))); + assert_eq!(fill(&layers[1]), Paint::solid(FOREGROUND), "palette index 0xFFFF is the foreground"); + // In font units, y down: the `O` stands above the baseline. + let b = layers[0].geometric_bounds().unwrap(); + assert!(b.y1 <= 20.0 && b.y0 < -500.0 && b.width() > 400.0, "{b:?}"); +} + +#[test] +fn v1_paints_become_gradients_clips_and_isolated_groups() { + let root = art(&colr_font(true).unwrap(), COLOUR_CHAR); + // The clip box clips the whole glyph. + let [clipped] = children(&root) else { panic!("{:?}", children(&root)) }; + assert!(matches!(clipped.kind, NodeKind::Group { clip: true, .. }), "{:?}", clipped.kind); + let layers = &children(clipped)[1..]; + assert_eq!(layers.len(), 3, "{layers:?}"); + let gradient = |n: &Node| match fill(n) { + Paint::Gradient(g) => g, + p => panic!("a gradient: {p:?}"), + }; + let linear = gradient(&layers[0]); + assert_eq!(linear.gradient.kind, GradientKind::Linear); + let geom = linear.geom.unwrap(); + assert!((geom.start.x - 0.0).abs() < 1e-6 && (geom.end.x - 500.0).abs() < 1e-6, "{geom:?}"); + let radial = gradient(&layers[1]); + assert_eq!(radial.gradient.kind, GradientKind::Radial); + // Scaled by a half about the origin, y down: centre (125, -150), radius 150. + let geom = radial.geom.unwrap(); + assert!((geom.start.x - 125.0).abs() < 1e-3 && (geom.start.y + 150.0).abs() < 1e-3, "{geom:?}"); + assert!(((geom.end - geom.start).hypot() - 150.0).abs() < 1e-3, "{geom:?}"); + // The composite: an isolated group, the backdrop, then the source multiplied over it. + let composite = &layers[2]; + assert!(composite.isolate); + let [backdrop, source] = children(composite) else { panic!("{:?}", children(composite)) }; + assert_eq!(fill(backdrop), Paint::solid(rgb(COLR_RED))); + let source = children(source).first().cloned().unwrap_or_else(|| source.clone()); + assert_eq!(fill(&source), Paint::solid(rgb(COLR_GREEN))); + assert!(children(composite)[1].blend == BlendMode::Multiply, "{:?}", children(composite)[1].blend); +} + +#[test] +fn a_sweep_gradient_makes_the_glyph_an_image() { + let Some(ColourContent::Image(image)) = content(&colr_font(true).unwrap(), COLR_SWEEP_CHAR) else { panic!("an image") }; + assert!(image.blob.bytes.starts_with(b"\x89PNG")); + // The `O` is some 600 units tall: at 256 px per em, some 150 px. + assert!((100..300).contains(&image.height), "{}", image.height); + // Its pixels map back to the glyph's place in font units, y down. + let b = image.xf.transform_rect_bbox(image.rect()); + assert!(b.y1 <= 20.0 && b.y0 < -500.0, "{b:?}"); +} + +#[test] +fn a_colr_graph_that_paints_itself_is_no_colour_glyph() { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Paint}; + use write_fonts::types::GlyphId16; + let base = skrifa::FontRef::new(&colr_font(false).unwrap()).unwrap().charmap().map(COLOUR_CHAR).unwrap().to_u32(); + let base = GlyphId16::new(u16::try_from(base).unwrap()); + let mut colr = Colr::new(0, None, None, 0); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(base, Paint::colr_glyph(base))])).into(); + let font = super::test_fonts::colr_font_with("Colrtest Loop", &colr).unwrap(); + assert!(content(&font, COLOUR_CHAR).is_none()); +} + +#[test] +fn a_colr_graph_nested_past_the_cap_is_no_colour_glyph() { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Paint}; + use write_fonts::types::{F2Dot14, GlyphId16}; + let font = skrifa::FontRef::new(&colr_font(false).unwrap()).unwrap().charmap().map(COLOUR_CHAR).unwrap().to_u32(); + let base = GlyphId16::new(u16::try_from(font).unwrap()); + let mut paint = Paint::glyph(Paint::solid(0, F2Dot14::from_f32(1.0)), base); + for _ in 0..200 { + paint = Paint::scale_uniform(paint, F2Dot14::from_f32(1.0)); + } + let mut colr = Colr::new(0, None, None, 0); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(base, paint)])).into(); + let font = super::test_fonts::colr_font_with("Colrtest Deep", &colr).unwrap(); + assert!(content(&font, COLOUR_CHAR).is_none()); +} + +// Layout: colour glyphs are laid out with their layers, which move with the type. + +use kurbo::Point as KPoint; +use vectorcraft_doc::{CharStyle, TextObject}; + +use super::test_fonts::colr_family; +use super::{FontDb, layout}; + +fn colr_db(v1: bool) -> FontDb { + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(colr_font(v1).expect("the test font builds")), 1); + db +} + +fn colr_style(v1: bool) -> CharStyle { + CharStyle { font_family: colr_family(v1).into(), size: 100.0, ..CharStyle::default() } +} + +#[test] +fn a_colr_glyph_lays_out_its_layers_with_an_empty_outline() { + let db = colr_db(false); + let l = layout(&db, &TextObject::point(KPoint::new(10.0, 50.0), &format!("{COLOUR_CHAR}F"), colr_style(false))); + let (g, f) = (&l.glyphs[0], &l.glyphs[1]); + assert!(g.outline.elements().is_empty(), "the layers replace the outline"); + assert_eq!(g.colour.as_deref().map(|c| children(c).len()), Some(2)); + assert!(g.image.is_none()); + assert!(f.colour.is_none() && !f.outline.elements().is_empty(), "other glyphs keep their outlines"); + // In text space at 100 pt: the `O` layer stands on the baseline (y 0), some 70 pt tall. + let placed = g.placed_colour(kurbo::Affine::IDENTITY).unwrap(); + let b = placed.geometric_bounds().unwrap(); + assert!(b.y1.abs() < 3.0 && b.height() > 60.0 && b.height() < 80.0, "{b:?}"); +} + +#[test] +fn the_foreground_layer_takes_the_runs_fill_and_black_for_a_gradient_fill() { + let db = colr_db(false); + let foreground = |fill: Paint| { + let st = CharStyle { fill, ..colr_style(false) }; + let l = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st)); + let colour = l.glyphs[0].colour.clone().unwrap(); + children(&colour)[1].appearance.fill_paint() + }; + let blue = Color::rgb8(0, 0, 200); + assert_eq!(foreground(Paint::solid(blue)), Paint::solid(blue)); + assert_eq!(foreground(Paint::solid(FOREGROUND)), Paint::solid(FOREGROUND), "a new fill colour, a new glyph"); + let gradient = Paint::Gradient(Box::new(vectorcraft_doc::color::GradientPaint::new(Default::default()))); + assert_eq!(foreground(gradient), Paint::solid(Color::BLACK)); +} + +/// Fills that look alike but separate differently (a grey on the black plate, the same RGB grey on +/// all four) each paint the foreground layer in their own colour. +#[test] +fn fills_that_only_look_alike_each_paint_the_foreground_layer() { + let db = colr_db(false); + let foreground = |fill: Color| { + let st = CharStyle { fill: Paint::solid(fill), ..colr_style(false) }; + let l = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st)); + let colour = l.glyphs[0].colour.clone().unwrap(); + children(&colour)[1].appearance.fill_paint() + }; + let grey = Color::gray(0.5); + let rgb = Color::rgb8(128, 128, 128); + assert_eq!(grey.to_rgba8(1.0), rgb.to_rgba8(1.0), "they look alike"); + assert_eq!(foreground(grey), Paint::solid(grey)); + assert_eq!(foreground(rgb), Paint::solid(rgb)); +} + +#[test] +fn layout_bounds_take_in_the_colour_layers() { + let db = colr_db(true); + let l = layout(&db, &TextObject::point(KPoint::new(10.0, 50.0), &COLOUR_CHAR.to_string(), colr_style(true))); + let g = &l.glyphs[0]; + let b = g.placed_colour(kurbo::Affine::IDENTITY).unwrap().geometric_bounds().unwrap(); + assert!(l.bounds.contains_rect(b.inset(-0.01)) || l.bounds.contains_rect(b), "{:?} holds {b:?}", l.bounds); + // The `O` layer is wider than the `E` the glyph advances by. + assert!(b.width() > g.advance * 0.9, "{b:?}"); +} + +#[test] +fn a_face_with_colr_and_sbix_draws_colr() { + use write_fonts::FontBuilder; + use write_fonts::types::Tag; + let colr = colr_font(false).unwrap(); + let sbix = super::test_fonts::colour_bitmap_font(true).unwrap(); + let sbix = skrifa::FontRef::new(&sbix).unwrap(); + let font = skrifa::FontRef::new(&colr).unwrap(); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"sbix"), sbix.table_data(skrifa::Tag::new(b"sbix")).unwrap().as_bytes()); + for r in font.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes()); + } + } + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(builder.build()), 1); + let l = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), colr_style(false))); + assert!(l.glyphs[0].colour.is_some() && l.glyphs[0].image.is_none(), "vectors over bitmaps"); +} + +/// A v1 font whose glyph of `base` is `paint`, given the glyph id of `O`, its layer list one +/// red fill. +fn v1_font(family: &str, base: char, paint: impl Fn(write_fonts::types::GlyphId16) -> ColrPaint) -> Vec { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, LayerList}; + use write_fonts::types::GlyphId16; + let bytes = colr_font(false).unwrap(); + let font = skrifa::FontRef::new(&bytes).unwrap(); + let gid = |ch| GlyphId16::new(u16::try_from(font.charmap().map(ch).unwrap().to_u32()).unwrap()); + let mut colr = Colr::new(0, None, None, 0); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(base), paint(gid('O')))])).into(); + colr.layer_list = Some(LayerList::new(1, vec![solid(0)])).into(); + super::test_fonts::colr_font_with(family, &colr).unwrap() +} + +use write_fonts::tables::colr::Paint as ColrPaint; + +fn solid(index: u16) -> ColrPaint { + ColrPaint::solid(index, write_fonts::types::F2Dot14::from_f32(1.0)) +} + +fn sweep() -> ColrPaint { + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord}; + let one = F2Dot14::from_f32(1.0); + let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), 0, one), ColorStop::new(one, 1, one)]); + ColrPaint::sweep_gradient(line, FWord::new(250), FWord::new(300), F2Dot14::from_f32(0.0), one) +} + +/// A glyph filled with a composite of solid colours is vector art: the composite's fills fill +/// the glyph around them. +#[test] +fn a_glyph_filled_with_a_composite_is_vector_art() { + use write_fonts::tables::colr::CompositeMode; + let font = v1_font("Colrtest Composite", COLOUR_CHAR, |o| ColrPaint::glyph(ColrPaint::composite(solid(2), CompositeMode::Multiply, solid(0)), o)); + assert!(matches!(content(&font, COLOUR_CHAR), Some(ColourContent::Art(_)))); +} + +/// A colour of its own filling a (TrueType, quadratic) glyph's area is that glyph filled: one +/// path, not a clipping group around a copy of it. +#[test] +fn a_glyph_filled_with_one_colour_is_one_path() { + let font = v1_font("Colrtest Flood", COLOUR_CHAR, |o| ColrPaint::glyph(ColrPaint::colr_layers(1, 0), o)); + let root = art(&font, COLOUR_CHAR); + let [only] = children(&root) else { panic!("{:?}", children(&root).iter().map(|c| c.kind_label()).collect::>()) }; + assert!(matches!(only.kind, NodeKind::Path { .. }), "{}", only.kind_label()); + assert_eq!(fill(only), Paint::solid(rgb(COLR_RED))); +} + +/// A sweep glyph in a font without clip boxes, its base glyph without an outline (the space's, +/// as COLRv1 fonts make them): its image is as large as what it paints. +#[test] +fn a_sweep_glyph_without_a_clip_box_or_outline_is_an_image_of_its_paint() { + let font = v1_font("Colrtest Bare", ' ', |o| ColrPaint::glyph(sweep(), o)); + let Some(ColourContent::Image(image)) = content(&font, ' ') else { panic!("an image") }; + assert!((100..300).contains(&image.height), "{}", image.height); +} + +/// A transform that collapses the glyph (scale 0, from a font) draws nothing in its image, +/// without panicking. +#[test] +fn a_collapsed_sweep_glyph_draws_nothing() { + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Clip, ClipBox, ClipList, Colr}; + use write_fonts::types::{F2Dot14, FWord, GlyphId16}; + let bytes = colr_font(false).unwrap(); + let read = skrifa::FontRef::new(&bytes).unwrap(); + let gid = |ch| GlyphId16::new(u16::try_from(read.charmap().map(ch).unwrap().to_u32()).unwrap()); + let mut colr = Colr::new(0, None, None, 0); + let flat = ColrPaint::scale_uniform(ColrPaint::glyph(sweep(), gid('O')), F2Dot14::from_f32(0.0)); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(gid(COLOUR_CHAR), flat)])).into(); + // A clip box, so the image is made. + let clip = ClipBox::format_1(FWord::new(0), FWord::new(0), FWord::new(500), FWord::new(500)); + colr.clip_list = Some(ClipList::new(1, 1, vec![Clip::new(gid(COLOUR_CHAR), gid(COLOUR_CHAR), clip)])).into(); + let font = super::test_fonts::colr_font_with("Colrtest Flat", &colr).unwrap(); + let Some(ColourContent::Image(image)) = content(&font, COLOUR_CHAR) else { panic!("an image") }; + assert!(image.blob.bytes.starts_with(b"\x89PNG")); +} + +/// Each paint documents have no form for makes the glyph an image. +#[test] +fn paints_documents_lack_make_the_glyph_an_image() { + use write_fonts::tables::colr::{ColorLine, ColorStop, CompositeMode, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let line = |extend| ColorLine::new(extend, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), 0, one), ColorStop::new(one, 1, one)]); + let w = FWord::new; + type GlyphPaint<'a> = Box ColrPaint + 'a>; + let cases: [(&str, GlyphPaint<'_>); 3] = [ + ("Colrtest Xor", Box::new(|o| ColrPaint::composite(ColrPaint::glyph(solid(2), o), CompositeMode::Xor, ColrPaint::glyph(solid(0), o)))), + ( + "Colrtest Repeat", + Box::new(move |o| ColrPaint::glyph(ColrPaint::linear_gradient(line(Extend::Repeat), w(0), w(0), w(100), w(0), w(0), w(100)), o)), + ), + ( + "Colrtest Circles", + Box::new(move |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line(Extend::Pad), w(200), w(300), UfWord::new(50), w(250), w(300), UfWord::new(300)), o) + }), + ), + ]; + for (family, paint) in cases { + let font = v1_font(family, COLOUR_CHAR, paint); + assert!(matches!(content(&font, COLOUR_CHAR), Some(ColourContent::Image(_))), "{family}"); + } +} + +/// A radial gradient whose start circle is around its centre is vector art, its stops moved out. +#[test] +fn a_radial_gradient_from_a_circle_around_its_centre_is_vector_art() { + use write_fonts::tables::colr::{ColorLine, ColorStop, Extend}; + use write_fonts::types::{F2Dot14, FWord, UfWord}; + let one = F2Dot14::from_f32(1.0); + let line = ColorLine::new(Extend::Pad, 2, vec![ColorStop::new(F2Dot14::from_f32(0.0), 0, one), ColorStop::new(one, 1, one)]); + let w = FWord::new; + let font = v1_font("Colrtest Ring", COLOUR_CHAR, |o| { + ColrPaint::glyph(ColrPaint::radial_gradient(line.clone(), w(250), w(300), UfWord::new(100), w(250), w(300), UfWord::new(400)), o) + }); + let root = art(&font, COLOUR_CHAR); + let Paint::Gradient(g) = fill(&children(&root)[0]) else { panic!("a gradient") }; + let offsets: Vec = g.gradient.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.25, 1.0], "the stops start a quarter of the way out"); +} + +/// A glyph whose layers don't use the foreground colour is read once, whatever the type's fill. +#[test] +fn a_glyph_without_the_foreground_colour_is_read_once_for_every_fill() { + let tree = |v1: bool, fill: Color| { + let db = colr_db(v1); + let st = CharStyle { fill: Paint::solid(fill), ..colr_style(v1) }; + let first = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st.clone())).glyphs[0].colour.clone().unwrap(); + let st = CharStyle { fill: Paint::solid(FOREGROUND), ..st }; + let other = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st)).glyphs[0].colour.clone().unwrap(); + Arc::ptr_eq(&first, &other) + }; + assert!(tree(true, Color::rgb8(0, 0, 200)), "v1's glyph uses only its palette"); + assert!(!tree(false, Color::rgb8(0, 0, 200)), "v0's foreground layer follows the fill"); +} + +/// A variable font's COLR glyph draws its layers at the face's instance: at Bold, the layer's `l` +/// is as much wider as the font widens it. +#[test] +fn a_variable_fonts_colr_layers_follow_its_instance() { + use crate::test_fonts::{COLR_VARIABLE_FAMILY, VARIABLE_WIDEN, colr_variable_font}; + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(colr_variable_font().expect("the test font builds")), 3, "Regular, SemiBold and Bold"); + let width = |style: &str| { + let st = CharStyle { font_family: COLR_VARIABLE_FAMILY.into(), font_style: style.into(), size: 100.0, ..CharStyle::default() }; + let l = layout(&db, &TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st)); + l.glyphs[0].placed_colour(kurbo::Affine::IDENTITY).unwrap().geometric_bounds().unwrap().width() + }; + let (regular, bold) = (width("Regular"), width("Bold")); + assert!((bold / regular - 1.0 - f64::from(VARIABLE_WIDEN)).abs() < 0.02, "{regular} → {bold}"); +} + +/// On a path, a colour glyph's layers turn with it: along a path going down, the tall `l` layer +/// lies on its side, across the path. +#[test] +fn a_colour_glyph_on_a_path_turns_with_it() { + use crate::test_fonts::{COLR_VARIABLE_FAMILY, colr_variable_font}; + use vectorcraft_doc::text::TextKind; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(colr_variable_font().expect("the test font builds")); + let st = CharStyle { font_family: COLR_VARIABLE_FAMILY.into(), size: 100.0, ..CharStyle::default() }; + let mut t = TextObject::point(KPoint::ZERO, &COLOUR_CHAR.to_string(), st); + let upright = layout(&db, &t).glyphs[0].placed_colour(kurbo::Affine::IDENTITY).unwrap().geometric_bounds().unwrap(); + let down = kurbo::Shape::to_path(&kurbo::Line::new((0.0, 0.0), (0.0, 400.0)), 0.1); + t.kind = TextKind::OnPath { path: vectorcraft_geom::PathData::from_bezpath(&down), start: 0.0, end: None }; + let b = layout(&db, &t).glyphs[0].placed_colour(kurbo::Affine::IDENTITY).unwrap().geometric_bounds().unwrap(); + assert!(upright.height() > 2.0 * upright.width(), "upright, the `l` is tall: {upright:?}"); + assert!((b.width() - upright.height()).abs() < 1.0 && (b.height() - upright.width()).abs() < 1.0, "on its side: {b:?}"); + assert!(b.y0 >= -1.0 && b.y1 <= 400.0, "along the path: {b:?}"); +} diff --git a/crates/text/src/tests_combos.rs b/crates/text/src/tests_combos.rs index 0453058b4..d61dccc51 100644 --- a/crates/text/src/tests_combos.rs +++ b/crates/text/src/tests_combos.rs @@ -294,6 +294,126 @@ fn composer_x_inline_graphics() { } } +#[test] +fn composer_x_mixed_sizes() { + // A 24 pt word among 12 pt text: in a rectangle the every-line composer breaks the paragraph as + // it breaks uniform 12 pt text in which that word is set at 200% horizontal scale, as wide as + // at 24 pt, and not as the single-line composer does. + let st = sans(12.0); + let runs = |x: CharStyle| vec![run("Destroy all creatures with toughness ", st.clone()), run("X", x), run(" or less.", st.clone())]; + let big = sans(24.0); + let wide = CharStyle { h_scale: 200.0, ..sans(12.0) }; + let mut probe = area(runs(big.clone()), Rect::new(0.0, 0.0, 10_000.0, 100.0)); + probe.runs[2] = run(" or", st.clone()); + let w = layout(db(), &probe).lines[0].x1 + 2.0; + let breaks = |x: &CharStyle, composer: Composer| { + let mut t = area(runs(x.clone()), Rect::new(0.0, 0.0, w, 200.0)); + t.para.composer = composer; + line_texts(&t, &layout(db(), &t)) + }; + let greedy = ["Destroy all creatures with toughness X or", "less."]; + let every = ["Destroy all creatures with toughness X", "or less."]; + assert_eq!(breaks(&wide, Composer::EveryLine), every, "uniform sizes"); + assert_eq!(breaks(&wide, Composer::SingleLine), greedy, "uniform sizes"); + assert_eq!(breaks(&big, Composer::EveryLine), every, "mixed sizes"); + assert_eq!(breaks(&big, Composer::SingleLine), greedy, "mixed sizes"); + // The same in a rectangle split into two columns, one line in each. + let mut t = area(runs(big.clone()), Rect::new(0.0, 0.0, 2.0 * w + 12.0, 34.0)); + (t.area.columns, t.area.gutter) = (2, 12.0); + t.para.composer = Composer::EveryLine; + let l = layout(db(), &t); + assert_eq!(line_texts(&t, &l), every, "two columns"); + assert!(!l.overflow && l.lines[1].x0 > w, "{:?}", l.lines); + // The 24 pt word's line is set at its own size. + let mut t = area(runs(big.clone()), Rect::new(0.0, 0.0, w, 200.0)); + t.para.composer = Composer::EveryLine; + let l = layout(db(), &t); + let x = l.glyphs.iter().find(|g| t.plain_text()[g.byte..].starts_with('X')).unwrap(); + assert_eq!(x.line, 0); + assert!(l.lines[0].ascent > l.lines[1].ascent + 1.0, "{:?}", l.lines); + // A rectangle that wraps around an object, and a frame that isn't a rectangle (a corner cut), + // both far below the text, break mixed sizes line by line and still break uniform sizes with + // the every-line composer. + let below = PathData::from_bezpath(&Rect::new(0.0, 180.0, 20.0, 190.0).to_path(0.1)); + for (x, want) in [(&wide, every), (&big, greedy)] { + let mut t = area(runs(x.clone()), Rect::new(0.0, 0.0, w, 200.0)); + t.wrap = vec![vectorcraft_doc::WrapShape { path: below.clone(), wrap: Default::default() }]; + t.para.composer = Composer::EveryLine; + assert_eq!(line_texts(&t, &layout(db(), &t)), want, "wrap, {}", x.size); + } + let cut = kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((0.0, 0.0).into()), + kurbo::PathEl::LineTo((w, 0.0).into()), + kurbo::PathEl::LineTo((w, 190.0).into()), + kurbo::PathEl::LineTo((w - 10.0, 200.0).into()), + kurbo::PathEl::LineTo((0.0, 200.0).into()), + kurbo::PathEl::ClosePath, + ]); + for (x, want) in [(&wide, every), (&big, greedy)] { + let mut t = area(runs(x.clone()), Rect::ZERO); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&cut) }; + t.para.composer = Composer::EveryLine; + assert_eq!(line_texts(&t, &layout(db(), &t)), want, "cut corner, {}", x.size); + } +} + +#[test] +fn composer_x_mixed_line_metrics() { + // In a rectangle the every-line composer also breaks a paragraph whose characters differ in + // other line metrics: vertical scale, leading, font, and an inline graphic raised above the text. + let st = sans(12.0); + let wide = CharStyle { h_scale: 200.0, ..sans(12.0) }; + let xs = [ + ("vertical scale", run("X", CharStyle { v_scale: 200.0, ..wide.clone() })), + ("leading", run("X", CharStyle { leading: Some(30.0), ..wide.clone() })), + ("font", run("X", serif(12.0, 0.0))), + ("inline graphic", sym(0.7, 20.0, st.clone())), + ]; + let mut wrong = vec![]; + for (name, x) in xs { + let runs = |end: &str| vec![run("Destroy all creatures with toughness ", st.clone()), x.clone(), run(end, st.clone())]; + let w = layout(db(), &area(runs(" or"), Rect::new(0.0, 0.0, 10_000.0, 100.0))).lines[0].x1 + 2.0; + let greedy = [format!("Destroy all creatures with toughness {} or", x.text), "less.".to_string()]; + let every = [format!("Destroy all creatures with toughness {}", x.text), "or less.".to_string()]; + for (composer, want) in [(Composer::EveryLine, every), (Composer::SingleLine, greedy)] { + let mut t = area(runs(" or less."), Rect::new(0.0, 0.0, w, 200.0)); + t.para.composer = composer; + let got = line_texts(&t, &layout(db(), &t)); + if got != want { + wrong.push((name, composer, got)); + } + } + } + assert!(wrong.is_empty(), "{wrong:?}"); +} + +#[test] +fn composer_x_lines_start_after_the_spaces() { + // A line may end after a dash or between two spaces, but the every-line composer breaks after + // the spaces that follow, as the single-line composer does: no line starts with a space, in + // uniform 12 pt text and with a 24 pt word in it. + let st = sans(12.0); + let runs = |x: CharStyle| { + vec![run("Destroy all creatures \u{2014} with toughness ", st.clone()), run("X", x), run(" or less \u{2014} then draw a card.", st.clone())] + }; + let mut wrong = vec![]; + for x in [sans(12.0), sans(24.0)] { + for justify in [Justify::Left, Justify::JustifyLeft] { + for w in (40..400).step_by(4) { + let mut t = area(runs(x.clone()), Rect::new(0.0, 0.0, f64::from(w), 2000.0)); + t.para.composer = Composer::EveryLine; + t.para.justify = justify; + let text = t.plain_text(); + let l = layout(db(), &t); + if l.lines.iter().any(|li| text[li.start..].starts_with(' ')) { + wrong.push((x.size, justify, w, line_texts(&t, &l))); + } + } + } + } + assert!(wrong.is_empty(), "{wrong:?}"); +} + const INLINE: char = vectorcraft_doc::text::INLINE_CHAR; // ---------- vertical alignment × … ---------- diff --git a/crates/text/src/tests_hang.rs b/crates/text/src/tests_hang.rs new file mode 100644 index 000000000..e59d89062 --- /dev/null +++ b/crates/text/src/tests_hang.rs @@ -0,0 +1,295 @@ +//! Roman Hanging Punctuation and Optical Margin Alignment over whole paragraphs: both composers, +//! justification options, mixed directions, mojikumi and burasagari (helpx "Formatting +//! paragraphs" › Hanging punctuation, +//! ). + +use super::*; +use kurbo::{Affine, Shape}; +use vectorcraft_doc::{CharStyle, Composer, Justify, TextKind}; +use vectorcraft_geom::PathData; + +/// The bundled fonts only: what these tests measure doesn't change with the fonts installed. +fn db() -> &'static FontDb { + static DB: std::sync::OnceLock = std::sync::OnceLock::new(); + DB.get_or_init(|| FontDb::with_font_dirs(vec![])) +} + +fn area(text: &str, size: f64, frame: Rect, justify: Justify) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, CharStyle { size, ..CharStyle::default() }); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t.para.justify = justify; + t +} + +/// The character a glyph sets (an automatic hyphen has no source characters: `-`). +fn ch(t: &TextObject, g: &PositionedGlyph) -> char { + if g.len == 0 { '-' } else { t.plain_text().get(g.byte..).and_then(|s| s.chars().next()).unwrap_or('\0') } +} + +fn is_space(c: char) -> bool { + c == ' ' || c == '\n' +} + +fn is_quote(c: char) -> bool { + matches!(c, '\'' | '"' | '‘' | '’' | '‚' | '‛' | '“' | '”' | '„' | '‟') +} + +/// The published share of a character's advance that hangs (Roman Hanging Punctuation), plus the +/// slanted letters Optical Margin Alignment hangs by a tenth when `optical`. +fn ratio(c: char, optical: bool) -> f64 { + match c { + '-' | '\u{2010}' | '\u{2011}' | '.' | ',' => 1.0, + c if is_quote(c) => 1.0, + '*' | '~' | '…' | '–' | '—' | ':' | ';' => 0.5, + 'A' | 'V' | 'W' | 'Y' | 'v' | 'w' | 'y' if optical => 0.1, + _ => 0.0, + } +} + +/// The glyphs of each line, left to right, without the spaces that end it (a left-to-right +/// paragraph's trailing spaces are set at its right, outside the measure). +fn inked<'a>(t: &TextObject, l: &'a TextLayout) -> Vec<(&'a LineInfo, Vec<&'a PositionedGlyph>)> { + l.lines + .iter() + .map(|li| { + let mut gs: Vec<&PositionedGlyph> = l.glyphs.get(li.glyph_start..li.glyph_end).unwrap_or_default().iter().collect(); + gs.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + while gs.last().is_some_and(|g| is_space(ch(t, g))) { + gs.pop(); + } + (li, gs) + }) + .collect() +} + +/// How far the glyphs at the ends of `gs` hang: the end glyph's share, and a punctuation mark +/// next to a quotation mark at that end hangs with it. +fn hung(t: &TextObject, gs: &[&PositionedGlyph], optical: bool) -> (f64, f64) { + let r = |g: &PositionedGlyph| ratio(ch(t, g), optical) * g.advance; + let punct = |g: &PositionedGlyph| ratio(ch(t, g), false) > 0.0; + let quote = |g: &PositionedGlyph| is_quote(ch(t, g)); + if gs.len() < 2 { + return (0.0, 0.0); + } + let (mut left, mut right) = (0.0, 0.0); + if let [a, b, rest @ ..] = gs { + left = r(a); + if left > 0.0 && !rest.is_empty() && punct(a) && quote(b) { + left += r(b); + } + } + if let [.., a, b] = gs { + right = r(b); + if right > 0.0 && gs.len() > 2 && punct(a) && quote(b) { + right += r(a); + } + } + (left, right) +} + +/// Per line: (first character, how far the line starts before its start edge less what hangs +/// there, last character, how far it ends after its end edge less what hangs there): 0 and 0 for a +/// line that hangs exactly as published. +fn edges(t: &TextObject, l: &TextLayout, optical: bool) -> Vec<(char, f64, char, f64)> { + inked(t, l) + .into_iter() + .filter_map(|(li, gs)| { + let (first, last) = (gs.first()?, gs.last()?); + let (left, right) = hung(t, &gs, optical); + Some((ch(t, first), li.avail.0 - first.origin.x - left, ch(t, last), last.origin.x + last.advance - li.avail.1 - right)) + }) + .collect() +} + +const TEXT: &str = "\u{201C}Hanging\u{201D} quotes, commas. And periods. \"Straight\" quotes and well-known \ +hyphens, dashes \u{2014} colons: semicolons; and \u{2018}single\u{2019} ones. Extraordinarily comprehensive \ +typographical considerations notwithstanding. \u{201C}Again\u{201D}, then more words follow here."; + +/// A whole paragraph, ragged and justified, with both composers: every line starts exactly as +/// far outside its start edge as its first mark hangs; right-aligned and justified lines end +/// exactly as far outside the end edge as their last mark hangs, and ragged lines never pass it. +#[test] +fn every_line_of_a_paragraph_hangs_as_published_with_both_composers() { + let frame = Rect::new(0.0, 0.0, 150.0, 4000.0); + let (mut quote, mut hyphen, mut period) = (false, false, false); + for justify in [Justify::Left, Justify::Right, Justify::JustifyLeft] { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(TEXT, 12.0, frame, justify); + t.para.hyphenate = true; + t.para.composer = composer; + t.para.roman_hanging = true; + let on = layout(db(), &t); + assert!(!on.overflow); + let n = on.lines.len(); + for (k, (c0, before, c1, after)) in edges(&t, &on, false).into_iter().enumerate() { + if justify != Justify::Right { + assert!(before.abs() < 1e-6, "{justify:?} {composer:?} line {k} starts {c0:?}: {before}"); + quote |= matches!(c0, '\u{201C}' | '"' | '\u{2018}'); + } + let full = justify == Justify::Right || (justify == Justify::JustifyLeft && k + 1 < n); + if full { + assert!(after.abs() < 1e-6, "{justify:?} {composer:?} line {k} ends {c1:?}: {after}"); + hyphen |= c1 == '-'; + period |= matches!(c1, '.' | ','); + } else { + assert!(after <= 1e-6, "{justify:?} {composer:?} line {k} passes the hung edge: {after}"); + } + } + } + } + assert!(quote && hyphen && period, "quote {quote}, hyphen {hyphen}, period {period}"); +} + +/// Justify All Lines with Optical Margin Alignment: every line fills to the hung edges, letters +/// included, with both composers. +#[test] +fn justified_lines_fill_to_the_hung_edges_with_both_composers() { + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(TEXT, 11.0, Rect::new(0.0, 0.0, 170.0, 4000.0), Justify::JustifyAll); + t.para.composer = composer; + t.para.hyphenate = true; + t.optical_margin = true; + let l = layout(db(), &t); + let mut count = 0; + for (c0, before, c1, after) in edges(&t, &l, true) { + assert!(before.abs() < 1e-6 && after.abs() < 1e-6, "{composer:?}: {c0:?} {before} / {c1:?} {after}"); + count += usize::from(ratio(c0, true) > 0.0) + usize::from(ratio(c1, true) > 0.0); + } + assert!(count >= 3, "{composer:?}: {count}"); + } +} + +/// Centred lines centre what doesn't hang: the hung characters stand outside it at either end. +#[test] +fn centred_lines_centre_the_text_between_the_hung_characters() { + let mut t = area(TEXT, 12.0, Rect::new(0.0, 0.0, 150.0, 4000.0), Justify::Center); + t.para.roman_hanging = true; + let l = layout(db(), &t); + let mut count = 0; + for (k, (li, gs)) in inked(&t, &l).into_iter().enumerate() { + let (Some(first), Some(last)) = (gs.first(), gs.last()) else { continue }; + let (left, right) = hung(&t, &gs, false); + count += usize::from(left > 0.0) + usize::from(right > 0.0); + let (body_start, body_end) = (first.origin.x + left, last.origin.x + last.advance - right); + assert!(((body_start - li.avail.0) - (li.avail.1 - body_end)).abs() < 1e-6, "line {k}: {body_start} … {body_end} in {:?}", li.avail); + } + assert!(count >= 2, "{count}"); +} + +/// A right-to-left run inside a left-to-right paragraph: the line hangs by its visual ends, so +/// nothing passes the frame but the mark at the line's visual start or end, by what it hangs. +#[test] +fn right_to_left_runs_in_a_left_to_right_paragraph_stay_inside() { + let shalom = "\u{05E9}\u{05DC}\u{05D5}\u{05DD}"; + let text = format!("Go {shalom} {shalom} {shalom}. {shalom} {shalom}. abc def"); + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(&text, 12.0, Rect::new(0.0, 0.0, 56.0, 400.0), Justify::JustifyLeft); + t.para.composer = composer; + t.para.roman_hanging = true; + let l = layout(db(), &t); + assert!(!l.overflow); + assert!(l.glyphs.iter().any(|g| g.rtl)); + for (li, gs) in inked(&t, &l) { + let n = gs.len(); + for (k, g) in gs.iter().enumerate() { + let c = ch(&t, g); + let lo = if k == 0 && n > 1 { ratio(c, false) * g.advance } else { 0.0 }; + let hi = if k + 1 == n && n > 1 { ratio(c, false) * g.advance } else { 0.0 }; + assert!(g.origin.x >= li.avail.0 - lo - 1e-6, "{composer:?}: {c:?} starts at {} before {}", g.origin.x, li.avail.0); + assert!( + g.origin.x + g.advance <= li.avail.1 + hi + 1e-6, + "{composer:?}: {c:?} ends at {} past {}", + g.origin.x + g.advance, + li.avail.1 + ); + } + } + } +} + +/// Justification's Glyph Scaling and Letter Spacing leave the hung first and last marks as they +/// are: they hang exactly as published, and the character after a mark hung wholly outside the +/// start stands on the edge. +#[test] +fn glyph_scaling_and_letter_spacing_keep_the_hung_edges() { + use vectorcraft_doc::JustificationOptions; + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(TEXT, 12.0, Rect::new(0.0, 0.0, 160.0, 4000.0), Justify::JustifyAll); + t.para.composer = composer; + t.para.roman_hanging = true; + t.para.justification = + JustificationOptions { letter_spacing: [-5.0, 0.0, 20.0], glyph_scaling: [95.0, 100.0, 105.0], ..JustificationOptions::default() }; + let l = layout(db(), &t); + let mut count = 0; + for (c0, before, c1, after) in edges(&t, &l, false) { + assert!(before.abs() < 1e-6 && after.abs() < 1e-6, "{composer:?}: {c0:?} {before} / {c1:?} {after}"); + count += usize::from(ratio(c0, false) > 0.0) + usize::from(ratio(c1, false) > 0.0); + } + assert!(count >= 3, "{composer:?}: {count}"); + for (li, gs) in inked(&t, &l) { + if let [first, second, ..] = gs[..] + && ratio(ch(&t, first), false) == 1.0 + && !is_quote(ch(&t, second)) + { + assert!((second.origin.x - li.avail.0).abs() < 1e-6, "{composer:?}: {:?} at {}", ch(&t, second), second.origin.x); + } + } + } +} + +/// Line-end Punctuation Half Width with Roman Hanging Punctuation: both composers set every +/// line on its hung edges (a Japanese bracket isn't Roman punctuation and doesn't hang). +#[test] +fn mojikumi_and_roman_hanging_agree() { + use vectorcraft_doc::Mojikumi; + let text = format!("{TEXT} \u{300C}\u{4E00}\u{4E8C}\u{300D} \u{201C}words\u{201D} {TEXT}"); + for composer in [Composer::SingleLine, Composer::EveryLine] { + let mut t = area(&text, 12.0, Rect::new(0.0, 0.0, 150.0, 4000.0), Justify::JustifyLeft); + t.para.composer = composer; + t.para.mojikumi = Mojikumi::LineEndHalf; + t.para.hyphenate = true; + t.para.roman_hanging = true; + let l = layout(db(), &t); + assert!(!l.overflow); + let n = l.lines.len(); + for (k, (c0, before, c1, after)) in edges(&t, &l, false).into_iter().enumerate() { + if c0 != '\u{300C}' { + assert!(before.abs() < 1e-6, "{composer:?} line {k} starts {c0:?}: {before}"); + } + if k + 1 < n && !matches!(c1, '\u{300D}' | '\u{3001}' | '\u{3002}') { + assert!(after.abs() < 1e-6, "{composer:?} line {k} ends {c1:?}: {after}"); + } + } + } +} + +/// Standard burasagari with a hung opening quote: the quote's hang is room on the line, so a +/// comma that fits once the quote hangs stays inside (ends on the edge) instead of hanging. +/// Needs a font with full-width Japanese punctuation (skipped without one). +#[test] +fn burasagari_counts_the_hung_start() { + use vectorcraft_doc::Burasagari; + let lay = |text: &str, roman: bool| { + let mut t = area(text, 20.0, Rect::new(0.0, 0.0, 100.0, 200.0), Justify::JustifyLeft); + t.para.burasagari = Burasagari::Standard; + t.para.roman_hanging = roman; + // Installed fonts too: the bundled ones have no Japanese. + layout(FontDb::global(), &t) + }; + if lay("\u{4E00}\u{3001}\u{4E8C}", false).glyphs.get(1).is_none_or(|g| (g.advance - 20.0).abs() > 2.0) { + eprintln!("skipping: no font with full-width punctuation"); + return; + } + const EPS: f64 = 1e-6; + let l = lay("\u{201C}\u{4E00}\u{4E8C}\u{4E09}\u{56DB}\u{3001}\u{4E94}\u{516D}\u{4E03}", true); + // The installed font may not set the nine characters as nine glyphs (a fallback that lacks + // some, or ligates them): the positions below would then name other characters. + let (9, Some(quote), Some(comma)) = (l.glyphs.len(), l.glyphs.first(), l.glyphs.get(5)) else { + eprintln!("skipping: the installed fonts don't set the nine characters as nine glyphs ({})", l.glyphs.len()); + return; + }; + assert_eq!(comma.line, quote.line, "、 ends the first line"); + assert!((quote.origin.x + quote.advance).abs() < EPS, "the quote hangs: {:?}", quote.origin); + assert!((comma.origin.x + comma.advance - 100.0).abs() < EPS, "、 inside, on the edge: {:?} {}", comma.origin, comma.advance); +} diff --git a/crates/text/src/tests_joiners.rs b/crates/text/src/tests_joiners.rs index 3b72565d9..6c23cedff 100644 --- a/crates/text/src/tests_joiners.rs +++ b/crates/text/src/tests_joiners.rs @@ -5,7 +5,8 @@ //! specifications for Devanagari and Bengali include them in a syllable after a halant //! (https://learn.microsoft.com/en-us/typography/script-development/devanagari, //! https://learn.microsoft.com/en-us/typography/script-development/bengali). -//! No font is committed: the tests use installed faces and print SKIPPED when none is found. +//! No font is committed: the tests use installed faces and print SKIPPED when none is found, except +//! the check of the half-form test's face filter, which builds its faces in code. use super::*; use kurbo::Point; use std::sync::Arc; @@ -14,25 +15,16 @@ use vectorcraft_doc::CharStyle; const ZWNJ: char = '\u{200C}'; const ZWJ: char = '\u{200D}'; -/// A Latin face that maps both joiners but not `c`, and the face that the fallback gives for `c`. -/// With `tags`, the fallback face's GSUB script list must contain one of those script tags. Which -/// face the fallback gives depends on the faces loaded so far, so the tests compare with that face. -fn joiner_test_faces(test: &str, c: char, tags: &[&[u8; 4]]) -> Option<(Arc, Arc)> { +/// A Latin face that maps both joiners but not `c`, and the face that the fallback gives for `c`, +/// which must pass `suits`. Which face the fallback gives depends on the faces loaded so far, so the +/// tests compare with that face. +fn joiner_test_faces(test: &str, c: char, suits: impl Fn(&FontFace) -> bool) -> Option<(Arc, Arc)> { let db = FontDb::global(); - let opentype = |face: &FontFace| { - use skrifa::raw::TableProvider; - tags.is_empty() - || face - .skrifa() - .and_then(|f| f.gsub().ok()) - .and_then(|gsub| gsub.script_list().ok()) - .is_some_and(|list| list.script_records().iter().any(|r| tags.contains(&&r.script_tag().to_be_bytes()))) - }; let faces = ["Source Sans 3", "Helvetica", "Arial", "Times New Roman", "Liberation Sans", "DejaVu Sans"] .into_iter() .filter_map(|name| db.face(name, "Regular").filter(|face| face.family == name)) .filter(|primary| primary.covers(ZWNJ) && primary.covers(ZWJ) && !primary.covers(c)) - .find_map(|primary| db.fallback_for(c, primary.id()).filter(|f| opentype(f)).map(|fallback| (primary, fallback))); + .find_map(|primary| db.fallback_for(c, primary.id()).filter(|f| suits(f)).map(|fallback| (primary, fallback))); if let Some((primary, fallback)) = &faces { let path = fallback.path().map_or_else(|| "bundled font".to_string(), |p| p.display().to_string()); eprintln!("ASSERTIONS RUN: {test}; {} {} with the fallback {} {} ({path})", primary.family, primary.style, fallback.family, fallback.style); @@ -43,8 +35,38 @@ fn joiner_test_faces(test: &str, c: char, tags: &[&[u8; 4]]) -> Option<(Arc TextLayout { + layout_in_db(FontDb::global(), face, text, tracking) +} + +fn layout_in_db(db: &FontDb, face: &FontFace, text: &str, tracking: f64) -> TextLayout { let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, tracking, ..CharStyle::default() }; - layout(FontDb::global(), &TextObject::point(Point::ZERO, text, st)) + layout(db, &TextObject::point(Point::ZERO, text, st)) +} + +/// Ka + halant + ZWJ + Ssa: the half form of Ka before a full Ssa. +fn ka_half_ssa() -> String { + format!("क्{ZWJ}ष") +} + +/// Whether `layout` of [`ka_half_ssa`] in `face` starts with a glyph other than Ka's and the +/// halant's (Ka's half form) and ends with Ssa's. +fn starts_with_a_half_form(face: &FontFace, layout: &TextLayout) -> bool { + let half = layout.glyphs.first().map(|g| g.gid); + half.is_some_and(|gid| gid != face.glyph_for('क') && gid != face.glyph_for('\u{094D}')) + && layout.glyphs.last().map(|g| g.gid) == Some(face.glyph_for('ष')) +} + +/// Whether `face` can serve the half-form test: its GSUB script list contains `dev2` or `deva`, and +/// [`ka_half_ssa`] set in `face` alone, in `db`, is drawn in `face` with Ka's half form. +fn forms_a_ka_half_form(db: &FontDb, face: &FontFace) -> bool { + use skrifa::raw::TableProvider; + let devanagari = face + .skrifa() + .and_then(|f| f.gsub().ok()) + .and_then(|gsub| gsub.script_list().ok()) + .is_some_and(|list| list.script_records().iter().any(|r| matches!(&r.script_tag().to_be_bytes(), b"dev2" | b"deva"))); + let l = layout_in_db(db, face, &ka_half_ssa(), 0.0); + devanagari && l.glyphs.iter().all(|g| g.font_id == face.id()) && starts_with_a_half_form(face, &l) } /// `text` set in `primary` is drawn, spaced and stepped through exactly as `text` set in `fallback`. @@ -65,7 +87,7 @@ fn assert_shaped_as_in_the_fallback(primary: &FontFace, fallback: &FontFace, tex #[test] fn a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it() { let test = "a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it"; - let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[]) else { return }; + let Some((primary, fallback)) = joiner_test_faces(test, 'क', |_| true) else { return }; // Ka + halant + ZWNJ + Ssa: Ka keeps its explicit halant instead of forming the KaSsa conjunct. let s = format!("क्{ZWNJ}ष"); assert_shaped_as_in_the_fallback(&primary, &fallback, &s); @@ -74,7 +96,7 @@ fn a_zero_width_non_joiner_in_devanagari_is_shaped_with_the_letters_around_it() #[test] fn a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it() { let test = "a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it"; - let Some((primary, fallback)) = joiner_test_faces(test, 'ক', &[]) else { return }; + let Some((primary, fallback)) = joiner_test_faces(test, 'ক', |_| true) else { return }; let s = format!("ক্{ZWNJ}ষ"); assert_shaped_as_in_the_fallback(&primary, &fallback, &s); } @@ -82,12 +104,21 @@ fn a_zero_width_non_joiner_in_bangla_is_shaped_with_the_letters_around_it() { #[test] fn a_zero_width_joiner_in_devanagari_asks_for_a_half_form() { let test = "a_zero_width_joiner_in_devanagari_asks_for_a_half_form"; - let Some((primary, fallback)) = joiner_test_faces(test, 'क', &[b"dev2", b"deva"]) else { return }; - // Ka + halant + ZWJ + Ssa: the half form of Ka before a full Ssa. - let s = format!("क्{ZWJ}ष"); + let Some((primary, fallback)) = joiner_test_faces(test, 'क', |f| forms_a_ka_half_form(FontDb::global(), f)) else { return }; + let s = ka_half_ssa(); assert_shaped_as_in_the_fallback(&primary, &fallback, &s); let l = layout_in(&primary, &s, 0.0); - let half = l.glyphs.first().map(|g| g.gid); - assert!(half.is_some_and(|gid| gid != fallback.glyph_for('क') && gid != fallback.glyph_for('\u{094D}')), "no half form: {:?}", l.glyphs); - assert_eq!(l.glyphs.last().map(|g| g.gid), Some(fallback.glyph_for('ष')), "{:?}", l.glyphs); + assert!(starts_with_a_half_form(&fallback, &l), "no half form: {:?}", l.glyphs); +} + +#[test] +fn a_devanagari_face_without_a_half_form_is_not_used_for_the_half_form_test() { + use crate::test_fonts::{HALF_FORM_FAMILY, half_form_font}; + for half in [false, true] { + let db = FontDb::with_font_dirs(vec![]); + db.add_font(half_form_font(half).unwrap()); + let face = db.face(HALF_FORM_FAMILY, "Regular").unwrap(); + // Both faces list `dev2`; only the one with a `half` feature forms Ka's half form. + assert_eq!(forms_a_ka_half_form(&db, &face), half, "half feature {half}"); + } } diff --git a/crates/text/src/tests_justify.rs b/crates/text/src/tests_justify.rs index 2c2c92d1c..0a15520dc 100644 --- a/crates/text/src/tests_justify.rs +++ b/crates/text/src/tests_justify.rs @@ -3,6 +3,7 @@ use super::*; use kurbo::{Affine, Shape}; +use vectorcraft_doc::text::Kashida; use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind}; use vectorcraft_geom::PathData; @@ -111,6 +112,24 @@ fn justified_lines_use_word_then_letter_spacing_then_glyph_scaling() { assert!((at(&l, 2).advance - (space + 10.0 - letters)).abs() < 1e-6); } +/// Full Justify spreads a right-to-left word between its letters as they are drawn: the word runs +/// from the left edge to the right edge, with the same gap between each two letters. +#[test] +fn a_justified_right_to_left_word_is_spread_between_its_drawn_letters() { + let text = "שלום"; + let natural = layout(db(), &point(text, JustificationOptions::default())); + let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default())); + // The letters from left to right: ם, ו, ל, ש. + let mut drawn: Vec<&PositionedGlyph> = l.glyphs.iter().collect(); + drawn.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x)); + assert!(drawn.len() == 4 && drawn.iter().all(|g| g.rtl), "{drawn:?}"); + let gaps: Vec = drawn.windows(2).map(|p| p[1].origin.x - p[0].origin.x - at(&natural, p[0].byte).advance).collect(); + let last = drawn[3]; + assert!(drawn[0].origin.x.abs() < 1e-6, "{:?}", drawn[0].origin); + assert!((last.origin.x + at(&natural, last.byte).advance - 300.0).abs() < 1e-6, "{:?}", last.origin); + assert!(gaps.iter().all(|&g| g > 1.0 && (g - gaps[0]).abs() < 1e-6), "{gaps:?}"); +} + /// Single Word Justification: a justified line holding one word is spread between its letters /// (Full Justify, the default) or aligned as chosen. #[test] @@ -136,6 +155,173 @@ fn single_word_lines_follow_single_word_justification() { assert!((c0 - 15.0).abs() < 1e-3 && (c1 - (w - 15.0)).abs() < 1e-3, "centred: {c0}..{c1}"); } +/// An OpenType (GSUB) Arabic face with a tatweel: craft-fonts' Noto Sans Arabic when the build has +/// craft-fonts (`CRAFT_FONTS_DIR`), else an installed one. None skips the test. +fn arabic_face(test: &str) -> Option> { + let opentype = |face: &FontFace| { + use skrifa::raw::TableProvider; + face.skrifa().is_some_and(|f| f.gsub().is_ok()) + }; + let face = ["Noto Sans Arabic", "Arial", "Geeza Pro"] + .into_iter() + .filter_map(|name| db().face(name, "Regular")) + .find(|face| face.covers('ب') && face.covers('د') && face.covers('\u{0640}') && opentype(face)); + match &face { + Some(face) => eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {}", face.family, face.style), + None => eprintln!("SKIPPED: {test}; no font covers Arabic and the tatweel"), + } + face +} + +/// Arabic type at 20 pt in `face`, in a frame 300 pt wide, aligned as `justify` with `kashida`. +fn arabic_area(face: &FontFace, text: &str, justify: Justify, kashida: Kashida) -> TextObject { + let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() }; + let mut t = TextObject::point(Point::ZERO, text, st); + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 300.0, 1000.0).to_path(0.1)) }; + t.xf = Affine::IDENTITY; + t.para.justify = justify; + t.para.kashida = kashida; + t +} + +/// The kashida glyphs of a layout: tatweels that stand for no character. +fn kashidas<'a>(l: &'a TextLayout, face: &FontFace) -> Vec<&'a PositionedGlyph> { + let tatweel = face.glyph_for('\u{0640}'); + l.glyphs.iter().filter(|g| g.len == 0 && g.gid == tatweel).collect() +} + +/// Insert Kashidas: a justified Arabic line lengthens each word with one kashida between its last +/// two joined letters, up to a quarter, half or whole em (Stylistic: whole tatweels up to an em), +/// before it widens the word space. The line keeps its width, the kashida fills the gap between +/// the letters it joins, and None leaves the line as it was. +#[test] +fn justified_arabic_lines_take_their_room_on_kashidas() { + let Some(face) = arabic_face("justified_arabic_lines_take_their_room_on_kashidas") else { return }; + // Two words whose last letters join (teh and beh, meem and dal) and one whose letters (waw, reh, + // dal) join none after them. + let text = "كتب ورد محمد"; + let spaces: Vec = text.match_indices(' ').map(|(b, _)| b).collect(); + let off = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::None)); + assert!(off.glyphs.iter().all(|g| g.len > 0), "no kashidas without Insert Kashidas"); + let measure = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0; + assert!((measure(&off) - 300.0).abs() < 1e-6, "the line fills the frame: {}", measure(&off)); + let space = at(&off, spaces[0]).advance; + let tatweel = face.advance(face.glyph_for('\u{0640}')) * 20.0 / face.units_per_em(); + for (kind, longest) in [(Kashida::Short, 5.0), (Kashida::Medium, 10.0), (Kashida::Long, 20.0), (Kashida::Stylistic, 20.0)] { + let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, kind)); + let k = kashidas(&on, &face); + let mut bytes: Vec = k.iter().map(|g| g.byte).collect(); + bytes.sort_unstable(); + bytes.dedup(); + assert_eq!(bytes, ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()], "{kind:?}: before the last letter of each word"); + let each = if kind == Kashida::Stylistic { (longest / tatweel).floor() * tatweel } else { longest }; + let mut total = 0.0; + for &b in &bytes { + let (start, end, count) = kashida_span(&on, &face, b); + assert!((end - start - each).abs() < 1e-6, "{kind:?}: {start}..{end}"); + total += end - start; + if kind == Kashida::Stylistic { + assert!((count as f64 * tatweel - each).abs() < 1e-6, "{kind:?}: whole tatweels"); + } + // The letter after the kashida in the text advances over it to the letter before it, + // so the caret before that letter and a selection of it reach the letter before it. + let after = on.glyphs.iter().filter(|o| o.byte == b && o.len > 0); + let len = after.clone().map(|o| o.len).max().unwrap(); + let ink = after.clone().map(|o| o.origin.x).fold(f64::MAX, f64::min); + assert!(ink < start && (after.map(|o| o.origin.x + o.advance).fold(f64::MIN, f64::max) - end).abs() < 1e-6, "{kind:?}"); + let caret = caret_position(&on, b).0.x; + assert!((caret - end).abs() < 1e-6, "{kind:?}: the caret before the letter: {caret} {end}"); + let x_max = selection_quads(&on, b, b + len).iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max); + assert!((x_max - end).abs() < 1e-6, "{kind:?}: the selection takes the kashida in: {x_max} {end}"); + } + assert!((measure(&on) - measure(&off)).abs() < 1e-6, "{kind:?}: same width"); + for &b in &spaces { + assert!((at(&on, b).advance - (space - total / 2.0)).abs() < 1e-6, "{kind:?}: the spaces give up the kashidas' length"); + } + } + // A click on a letter and its kashida goes to the nearer edge of the two taken together: dal is + // narrower than a Long kashida. + let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::Long)); + let dal = text.len() - 'د'.len_utf8(); + let (start, end, _) = kashida_span(&on, &face, dal); + let ink = on.glyphs.iter().filter(|o| o.byte == dal && o.len > 0).map(|o| o.origin.x).fold(f64::MAX, f64::min); + let middle = (ink + end) / 2.0; + assert!(start < middle, "the kashida is longer than the letter: {ink} {start} {end}"); + assert_eq!(byte_in_line(&on, 0, (start + middle) / 2.0), text.len(), "left of the middle: the letter's left edge"); + assert_eq!(byte_in_line(&on, 0, (middle + end) / 2.0), dal, "right of the middle: its right edge"); + // A letter with a vowel mark is a cluster of two glyphs: the caret before it and a selection of + // it still reach the letter before the kashida. + let marked = "كتبَ ورد"; + let on = layout(db(), &arabic_area(&face, marked, Justify::JustifyAll, Kashida::Medium)); + let beh = "كت".len(); + let (_, end, _) = kashida_span(&on, &face, beh); + assert!(on.glyphs.iter().filter(|o| o.byte == beh && o.len > 0).count() >= 2, "beh and fatha"); + assert!((caret_position(&on, beh).0.x - end).abs() < 1e-6, "{} {end}", caret_position(&on, beh).0.x); + let x_max = selection_quads(&on, beh, "كتبَ".len()).iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max); + assert!((x_max - end).abs() < 1e-6, "{x_max} {end}"); + // Punctuation ends a word as a space does. + let text = "كتب/محمد"; + let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::Medium)); + let mut bytes: Vec = kashidas(&on, &face).iter().map(|g| g.byte).collect(); + bytes.sort_unstable(); + assert_eq!(bytes, ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()]); + // At 50% horizontal scale an em is half as wide; the line still fills the frame. + for kind in [Kashida::Long, Kashida::Stylistic] { + let text = "كتب ورد محمد"; + let mut t = arabic_area(&face, text, Justify::JustifyAll, kind); + for r in &mut t.runs { + r.style.h_scale = 50.0; + } + let half = layout(db(), &t); + let each = if kind == Kashida::Stylistic { (10.0 / (tatweel * 0.5)).floor() * tatweel * 0.5 } else { 10.0 }; + for b in ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()] { + let (start, end, _) = kashida_span(&half, &face, b); + assert!((end - start - each).abs() < 1e-6, "{kind:?} at 50%: {start}..{end}"); + } + assert!((measure(&half) - 300.0).abs() < 1e-6, "{kind:?} at 50%: {}", measure(&half)); + } +} + +/// Where the kashida before the cluster at byte `b` starts, where the letter before it in the text +/// starts (the kashida's end), and how many tatweels draw it. +fn kashida_span(l: &TextLayout, face: &FontFace, b: usize) -> (f64, f64, usize) { + let k: Vec<_> = kashidas(l, face).into_iter().filter(|g| g.byte == b).collect(); + let start = k.iter().map(|g| g.origin.x).fold(f64::MAX, f64::min); + let end = l.glyphs.iter().filter(|o| o.byte + o.len == b && o.len > 0).map(|o| o.origin.x).fold(f64::MAX, f64::min); + (start, end, k.len()) +} + +/// Insert Kashidas changes only justified horizontal lines of Arabic: right-aligned and Auto +/// Arabic paragraphs, a justified Latin one and justified vertical Arabic type are set as with +/// None. +#[test] +fn kashidas_leave_unjustified_and_latin_lines_as_they_were() { + let same = |a: &TextLayout, b: &TextLayout| { + assert_eq!(a.glyphs.len(), b.glyphs.len()); + for (x, y) in a.glyphs.iter().zip(&b.glyphs) { + assert_eq!((x.gid, x.byte, x.len, x.origin, x.advance), (y.gid, y.byte, y.len, y.origin, y.advance)); + } + }; + let latin = |k: Kashida| { + let mut t = area("ab cd", 300.0, Justify::JustifyAll, JustificationOptions::default()); + t.para.kashida = k; + layout(db(), &t) + }; + same(&latin(Kashida::Long), &latin(Kashida::None)); + let Some(face) = arabic_face("kashidas_leave_unjustified_and_latin_lines_as_they_were") else { return }; + let arabic = |j: Justify, k: Kashida| layout(db(), &arabic_area(&face, "كتب محمد", j, k)); + same(&arabic(Justify::Right, Kashida::Long), &arabic(Justify::Right, Kashida::None)); + same(&arabic(Justify::Auto, Kashida::Medium), &arabic(Justify::Auto, Kashida::None)); + assert!(!kashidas(&arabic(Justify::JustifyAll, Kashida::Medium), &face).is_empty()); + // Vertical type is set as with None too. + let vertical = |k: Kashida| { + let mut t = arabic_area(&face, "كتب محمد", Justify::JustifyAll, k); + t.vertical = true; + layout(db(), &t) + }; + same(&vertical(Kashida::Medium), &vertical(Kashida::None)); +} + /// Auto Leading: characters whose leading is Auto are set at the paragraph's share of their size; /// an explicit leading is kept. #[test] diff --git a/crates/text/src/tests_ruby.rs b/crates/text/src/tests_ruby.rs new file mode 100644 index 000000000..5e3a66b0a --- /dev/null +++ b/crates/text/src/tests_ruby.rs @@ -0,0 +1,252 @@ +//! Ruby (furigana) tests: geometry over the base text in horizontal and vertical type, line +//! growth, overhang/squeeze, splitting over a line break, and the byte ranges following edits. + +use super::*; +use kurbo::{Affine, Point, Rect, Shape}; +use vectorcraft_doc::{CharStyle, RubyAlign, RubyPosition, RubySpan, TextKind}; +use vectorcraft_geom::PathData; + +fn db() -> &'static FontDb { + FontDb::global() +} + +fn style(size: f64) -> CharStyle { + CharStyle { size, ..CharStyle::default() } +} + +/// An outline's bounds, or `None` for a glyph with no ink (a space). +fn ink(g: &PositionedGlyph) -> Option { + (!g.outline.elements().is_empty()).then(|| g.outline.bounding_box()) +} + +fn ruby_ink(lay: &TextLayout) -> Rect { + union_rect(lay.ruby.iter()).expect("ruby glyphs with ink") +} + +fn union_rect<'a>(glyphs: impl Iterator) -> Option { + let mut b: Option = None; + for g in glyphs { + if let Some(r) = ink(g) { + b = Some(b.map_or(r, |a| a.union(r))); + } + } + b +} + +/// A point text object with `text`, and ruby `reading` over its first `base` bytes. +fn ruby_text(text: &str, base: usize, reading: &str, size: f64) -> TextObject { + let mut t = TextObject::point(Point::ZERO, text, style(size)); + t.ruby = vec![RubySpan::new(0, base, reading)]; + t +} + +#[test] +fn horizontal_ruby_sits_above_the_base_and_grows_the_line() { + let base = TextObject::point(Point::ZERO, "漢字", style(20.0)); + let plain = layout(db(), &base); + assert!(plain.ruby.is_empty()); + let base_glyphs: Vec<_> = plain.glyphs.iter().map(|g| (g.font_id, g.gid, g.origin, g.advance)).collect(); + + let t = ruby_text("漢字", 6, "かんじ", 20.0); + let lay = layout(db(), &t); + assert_eq!(lay.ruby.len(), 3, "one glyph per kana"); + assert!(lay.ruby.iter().all(|g| g.run == 0 && g.line == 0 && g.len == 0)); + // The base text itself is unchanged: same glyphs, same places. + let now: Vec<_> = lay.glyphs.iter().map(|g| (g.font_id, g.gid, g.origin, g.advance)).collect(); + assert_eq!(now, base_glyphs); + // The reading is above the base's ink and inside the line's ascent. + let above = ruby_ink(&lay); + let below = union_rect(lay.glyphs.iter()).expect("base ink"); + assert!(above.y1 <= below.y0 + 0.5, "ruby bottom {} above base top {}", above.y1, below.y0); + assert!(above.y0 < below.y0, "ruby rises above the base"); + assert!(lay.lines[0].ascent > plain.lines[0].ascent + 0.2 * 20.0, "the line grew: {} vs {}", lay.lines[0].ascent, plain.lines[0].ascent); + // Centred over the base text by default. + let centre = (above.x0 + above.x1) * 0.5; + let base_centre = (below.x0 + below.x1) * 0.5; + assert!((centre - base_centre).abs() < 1.0, "{centre} vs {base_centre}"); + // Above the base's em box, not touching its ink: a visible gap of a few points. + assert!(above.y1 <= below.y0 - 1.0, "a gap between ruby and base ink: {} vs {}", above.y1, below.y0); +} + +/// Bullets: a reading over a list paragraph's first characters is centered over those characters, +/// not over the bullet and the gap before them. +#[test] +fn ruby_over_a_list_item_leaves_out_its_bullet() { + use vectorcraft_doc::text::{Bullet, ParaList}; + let mut t = ruby_text("漢字", 6, "かんじ", 20.0); + t.para.list = Some(ParaList::bulleted(Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + let lay = layout(db(), &t); + assert_eq!(lay.markers.len(), 1); + let above = ruby_ink(&lay); + let base = union_rect(lay.glyphs.iter().filter(|g| g.len > 0)).expect("base ink"); + let (centre, base_centre) = ((above.x0 + above.x1) * 0.5, (base.x0 + base.x1) * 0.5); + assert!((centre - base_centre).abs() < 1.0, "{centre} vs {base_centre}"); +} + +#[test] +fn ruby_below_grows_the_descent() { + let mut t = ruby_text("漢字", 6, "かんじ", 20.0); + t.ruby[0].position = RubyPosition::Below; + let lay = layout(db(), &t); + let below = union_rect(lay.glyphs.iter()).expect("base ink"); + let ruby = ruby_ink(&lay); + assert!(ruby.y0 >= below.y1 - 0.5, "ruby top {} below base bottom {}", ruby.y0, below.y1); + let plain = layout(db(), &TextObject::point(Point::ZERO, "漢字", style(20.0))); + assert!(lay.lines[0].descent > plain.lines[0].descent + 0.2 * 20.0); +} + +#[test] +fn ruby_is_half_the_base_size_by_default_and_size_follows_the_span() { + let half = ruby_text("漢", 3, "かん", 20.0); + let mut whole = half.clone(); + whole.ruby[0].size_percent = 100.0; + let half = ruby_ink(&layout(db(), &half)); + let whole = ruby_ink(&layout(db(), &whole)); + assert!(half.height() < whole.height() * 0.75, "{} vs {}", half.height(), whole.height()); +} + +#[test] +fn a_reading_wider_than_its_base_overhangs_or_is_squeezed() { + // One base character, a three-kana reading: wider than the base at the default half size. + let mut t = ruby_text("漢", 3, "かんじ", 20.0); + let wide = ruby_ink(&layout(db(), &t)); + // The measure is the base character's advance (its em), not its ink. + let base_lay = layout(db(), &TextObject::point(Point::ZERO, "漢", style(20.0))); + let base = (base_lay.lines[0].x1 - base_lay.lines[0].x0).max(0.0); + assert!(wide.width() > base, "overhanging ruby {} vs base {}", wide.width(), base); + // Without overhang the reading is squeezed to the base's width. + t.ruby[0].overhang = false; + let narrow = ruby_ink(&layout(db(), &t)); + assert!(narrow.width() <= base + 0.5, "squeezed ruby {} vs base {}", narrow.width(), base); +} + +#[test] +fn ruby_aligns_start_centre_and_end_over_its_base() { + let mut t = ruby_text("漢字", 6, "かん", 20.0); + // Alignment uses advances, not ink bearings, which differ between installed fallback fonts. + let base_lay = layout(db(), &TextObject::point(Point::ZERO, "漢字", style(20.0))); + let base_line = &base_lay.lines[0]; + let base = Rect::new(base_line.x0, 0.0, base_line.x1, 0.0); + let reading = layout(db(), &TextObject::point(Point::ZERO, "かん", style(10.0))); + let reading_line = &reading.lines[0]; + let width = reading_line.x1 - reading_line.x0; + for (align, check) in [ + (RubyAlign::Start, (|r: Rect, b: Rect| (r.x0 - b.x0).abs() < 1e-6) as fn(Rect, Rect) -> bool), + (RubyAlign::Center, |r: Rect, b: Rect| (((r.x0 + r.x1) - (b.x0 + b.x1)) * 0.5).abs() < 1e-6), + (RubyAlign::End, |r: Rect, b: Rect| (r.x1 - b.x1).abs() < 1e-6), + ] { + t.ruby[0].align = align; + let lay = layout(db(), &t); + // Ruby glyphs are attachments with zero advance: use their pen origin and the reading's + // standalone advance width. A wrong Start/Center/End placement still fails this test. + let x0 = lay.ruby[0].origin.x - reading.glyphs[0].origin.x + reading_line.x0; + let ruby = Rect::new(x0, 0.0, x0 + width, 0.0); + assert!(check(ruby, base), "{align:?}: {ruby:?} over {base:?}"); + } +} + +#[test] +fn vertical_ruby_is_upright_to_the_right_of_the_column() { + let mut t = ruby_text("漢字", 6, "かんじ", 20.0); + t.vertical = true; + let lay = layout(db(), &t); + assert!(lay.vertical); + assert!(!lay.ruby.is_empty()); + // The base columns sit around the origin; the reading is to their right (larger x). + let base = union_rect(lay.glyphs.iter()).expect("base ink"); + let ruby = ruby_ink(&lay); + assert!(ruby.x0 > base.x0, "ruby right of the column: {ruby:?} vs {base:?}"); + assert!(ruby.x0 >= base.x1 - 1.0, "ruby outside the column: {ruby:?} vs {base:?}"); + // Upright: each reading glyph's ink has the width and height of its outline at 10 pt (half the + // base size); a glyph on its side has them swapped. + let mut not_square = false; + for g in &lay.ruby { + let Some(r) = ink(g) else { continue }; + let face = db().face_by_id(g.font_id).expect("the ruby glyph's face"); + let o = db().outline(&face, g.gid).bounding_box(); + let (w, h) = (o.width() * 10.0 / face.units_per_em(), o.height() * 10.0 / face.units_per_em()); + assert!((r.width() - w).abs() < 0.01 && (r.height() - h).abs() < 0.01, "upright ruby glyph {r:?}, its outline {w} x {h}"); + not_square |= (w - h).abs() > 0.5; + } + assert!(not_square, "one reading glyph's outline is not square"); + // Down the column: the reading's glyphs have distinct y positions. + let ys: Vec = lay.ruby.iter().filter_map(|g| ink(g).map(|b| b.center().y)).collect(); + assert!(ys.windows(2).any(|w| w[0] != w[1]), "the reading runs down the column: {ys:?}"); +} + +#[test] +fn ruby_moves_the_lines_after_it_down() { + // A narrow area type: "漢字" wraps to two lines; ruby on either line needs room. + let frame = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 24.0, 200.0).to_path(0.1)) }; + let mut plain = TextObject::point(Point::ZERO, "漢字", style(20.0)); + plain.kind = frame.clone(); + plain.xf = Affine::IDENTITY; + let plain = layout(db(), &plain); + assert_eq!(plain.lines.len(), 2, "two lines to compare"); + // Ruby over the second line needs room above it between the two lines: the line moves down. + let mut t = plain_text_with(frame.clone()); + t.ruby = vec![RubySpan::new(3, 6, "じ")]; + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2); + let gap_plain = plain.lines[1].baseline - plain.lines[0].baseline; + let gap_ruby = lay.lines[1].baseline - lay.lines[0].baseline; + assert!(gap_ruby > gap_plain + 2.0, "the ruby pushes the line on: {gap_ruby} vs {gap_plain}"); + // Ruby over the first line stays inside the frame: area type moves the first line down too. + let mut first = plain_text_with(frame.clone()); + first.ruby = vec![RubySpan::new(0, 3, "かん")]; + let lay = layout(db(), &first); + assert!(lay.lines[0].baseline > plain.lines[0].baseline + 2.0, "{} vs {}", lay.lines[0].baseline, plain.lines[0].baseline); +} + +fn plain_text_with(frame: TextKind) -> TextObject { + let mut t = TextObject::point(Point::ZERO, "漢字", style(20.0)); + t.kind = frame; + t.xf = Affine::IDENTITY; + t +} + +#[test] +fn a_reading_over_a_line_break_is_split_between_the_lines() { + let frame = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 24.0, 200.0).to_path(0.1)) }; + let mut t = plain_text_with(frame); + t.ruby = vec![RubySpan::new(0, 6, "かんじ")]; + let lay = layout(db(), &t); + assert_eq!(lay.lines.len(), 2); + assert!(!lay.ruby.is_empty()); + let mut lines: Vec = lay.ruby.iter().map(|g| g.line).collect(); + lines.sort_unstable(); + lines.dedup(); + assert_eq!(lines, vec![0, 1], "part of the reading on each line: {lines:?}"); +} + +#[test] +fn ruby_is_not_laid_out_for_type_on_a_path() { + let mut t = TextObject::point(Point::ZERO, "漢字", style(20.0)); + t.kind = + TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Line::new(Point::ZERO, Point::new(200.0, 0.0)).to_path(0.1)), start: 0.0, end: None }; + t.ruby = vec![RubySpan::new(0, 6, "かんじ")]; + let lay = layout(db(), &t); + assert!(lay.ruby.is_empty(), "no above/below on a path yet"); + assert_eq!(lay.glyphs.len(), 2, "the base text is laid out as before"); +} + +#[test] +fn ruby_ranges_follow_edits_of_the_text() { + let mut t = TextObject::point(Point::ZERO, "漢字です", style(20.0)); + t.ruby = vec![RubySpan::new(0, 6, "かんじ")]; + // Typing two bytes before the base moves the ruby. + t.ruby_after_replace(0, 0, 2); + assert_eq!((t.ruby[0].start, t.ruby[0].end), (2, 8)); + // Deleting the first base character shrinks it. + t.ruby_after_replace(2, 5, 0); + assert_eq!((t.ruby[0].start, t.ruby[0].end), (2, 5)); + // Editing outside leaves it alone. + t.ruby_after_replace(8, 9, 1); + assert_eq!((t.ruby[0].start, t.ruby[0].end), (2, 5)); + // An emptied reading no longer lays anything out. + t.ruby[0].text.clear(); + t.normalize_ruby(); + assert!(t.ruby.is_empty()); +} diff --git a/crates/text/src/tests_svg_glyphs.rs b/crates/text/src/tests_svg_glyphs.rs new file mode 100644 index 000000000..dcb729ca1 --- /dev/null +++ b/crates/text/src/tests_svg_glyphs.rs @@ -0,0 +1,158 @@ +//! OpenType SVG glyphs: layout asks the reader the app registers (the SVG importer, a layer up) +//! for each glyph's art, and carries it as the glyph's colour content. + +use std::sync::Arc; + +use kurbo::{Point, Rect, Shape}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Node, NodeId, TextObject}; +use vectorcraft_geom::PathData; + +use super::test_fonts::{SVG_CHAR, source_sans_gid, svg_font}; + +/// The family of the SVG test fonts here (each database holds one). +const FAMILY: &str = "Svgtest Glyphs"; +use super::{FontDb, layout, set_svg_glyph_reader}; + +/// Stands in for the SVG importer, which this crate can't use (it is a layer up): a square one em +/// wide in the fill, stroked when there is a stroke, for a document that has the glyph's element. +fn reader(document: &[u8], gid: u32, _upem: f64, fill: Color, stroke: Option) -> Option<(Node, bool)> { + let text = std::str::from_utf8(document).ok()?; + if !text.contains(&format!("id=\"glyph{gid}\"")) { + return None; + } + let coloured = text.contains("context-"); + let path = PathData::from_bezpath(&Rect::new(0.0, -700.0, 1000.0, 0.0).to_path(0.1)); + let stroke = stroke.map_or(Paint::None, Paint::solid); + let art = Node::group(NodeId(u64::MAX), vec![Arc::new(Node::path(NodeId(u64::MAX), path, Appearance::basic(Paint::solid(fill), stroke, 10.0)))]); + Some((art, coloured)) +} + +fn gid(ch: char) -> u16 { + source_sans_gid(ch).unwrap() +} + +/// A font database with the SVG test font of `document` for [`SVG_CHAR`]'s glyph. +fn db(document: &str) -> FontDb { + set_svg_glyph_reader(reader); + let db = FontDb::with_font_dirs(vec![]); + let g = gid(SVG_CHAR); + assert_eq!(db.add_font(svg_font(FAMILY, &[(g, g, document)]).unwrap()), 1); + db +} + +fn style(fill: Color) -> CharStyle { + CharStyle { font_family: FAMILY.into(), size: 100.0, fill: Paint::solid(fill), ..CharStyle::default() } +} + +fn document(body: &str) -> String { + format!("{body}", gid(SVG_CHAR)) +} + +#[test] +fn an_svg_glyph_is_what_the_reader_makes_of_its_document() { + let db = db(&document("")); + let blue = Color::rgb8(0, 0, 200); + let l = layout(&db, &TextObject::point(Point::ZERO, &format!("{SVG_CHAR}F"), style(blue))); + let (g, f) = (&l.glyphs[0], &l.glyphs[1]); + assert!(g.outline.elements().is_empty(), "the art replaces the outline"); + let art = g.placed_colour(kurbo::Affine::IDENTITY).expect("the reader's art"); + let mut fills = vec![]; + art.walk(&mut |n| fills.push(n.appearance.fill_paint())); + assert!(fills.contains(&Paint::solid(blue)), "in the run's fill: {fills:?}"); + // One em at 100 pt. + let b = art.geometric_bounds().unwrap(); + assert!((b.width() - 100.0).abs() < 1e-6 && (b.y0 + 70.0).abs() < 1e-6, "{b:?}"); + assert!(f.colour.is_none() && !f.outline.elements().is_empty(), "glyphs without documents keep their outlines"); +} + +#[test] +fn a_glyph_the_reader_makes_nothing_of_keeps_its_outline() { + let db = db(""); + let l = layout(&db, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), style(Color::BLACK))); + assert!(l.glyphs[0].colour.is_none() && !l.glyphs[0].outline.elements().is_empty()); +} + +#[test] +fn a_document_without_text_colours_is_read_once_for_every_fill() { + let fonts = db(&document("")); + let art = |fill| layout(&fonts, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), style(fill))).glyphs[0].colour.clone().unwrap(); + assert!(Arc::ptr_eq(&art(Color::BLACK), &art(Color::rgb8(0, 0, 200)))); + let fonts = db(&document("")); + let art = |fill| layout(&fonts, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), style(fill))).glyphs[0].colour.clone().unwrap(); + assert!(!Arc::ptr_eq(&art(Color::BLACK), &art(Color::rgb8(0, 0, 200))), "context-fill follows the fill"); +} + +#[test] +fn colr_comes_before_svg() { + use write_fonts::FontBuilder; + use write_fonts::types::Tag; + set_svg_glyph_reader(reader); + let colr = super::test_fonts::colr_font(false).unwrap(); + let colr = skrifa::FontRef::new(&colr).unwrap(); + let g = gid(SVG_CHAR); + let svg = svg_font(FAMILY, &[(g, g, &document(""))]).unwrap(); + let svg = skrifa::FontRef::new(&svg).unwrap(); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"SVG "), svg.table_data(skrifa::Tag::new(b"SVG ")).unwrap().as_bytes()); + for r in colr.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, colr.table_data(r.tag()).unwrap().as_bytes()); + } + } + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(builder.build()), 1); + let st = CharStyle { font_family: super::test_fonts::colr_family(false).into(), size: 100.0, ..CharStyle::default() }; + let l = layout(&db, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), st)); + // The COLR glyph has two layers; the reader's art one path. + let colour = l.glyphs[0].colour.clone().unwrap(); + assert_eq!(colour.children().map(Vec::len), Some(2), "COLR's layers"); +} + +/// A glyph with COLR layers that don't read (one paints itself) keeps its outline, also where the +/// font has an SVG document for it: COLR glyphs never draw SVG art, so every writer agrees on what +/// it is. +#[test] +fn a_glyph_whose_colr_layers_dont_read_keeps_its_outline() { + use write_fonts::FontBuilder; + use write_fonts::tables::colr::{BaseGlyphList, BaseGlyphPaint, Colr, Paint}; + use write_fonts::types::{GlyphId16, Tag}; + set_svg_glyph_reader(reader); + let g = gid(SVG_CHAR); + let mut colr = Colr::new(0, None, None, 0); + colr.base_glyph_list = Some(BaseGlyphList::new(1, vec![BaseGlyphPaint::new(GlyphId16::new(g), Paint::colr_glyph(GlyphId16::new(g)))])).into(); + let looped = super::test_fonts::colr_font_with(FAMILY, &colr).unwrap(); + let looped = skrifa::FontRef::new(&looped).unwrap(); + let svg = svg_font(FAMILY, &[(g, g, &document(""))]).unwrap(); + let svg = skrifa::FontRef::new(&svg).unwrap(); + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"SVG "), svg.table_data(skrifa::Tag::new(b"SVG ")).unwrap().as_bytes()); + for r in looped.table_directory.table_records() { + let tag = Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, looped.table_data(r.tag()).unwrap().as_bytes()); + } + } + let fonts = FontDb::with_font_dirs(vec![]); + assert_eq!(fonts.add_font(builder.build()), 1); + let l = layout(&fonts, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), style(Color::BLACK))); + assert!(l.glyphs[0].colour.is_none() && !l.glyphs[0].outline.elements().is_empty()); + assert!(!fonts.is_svg_glyph(l.glyphs[0].font_id, l.glyphs[0].gid)); +} + +/// A glyph in the text's stroke colour follows the stroke: added later, it shows. +#[test] +fn a_glyph_in_the_texts_stroke_follows_it() { + let fonts = db(&document("")); + let stroke_of = |stroke: Paint| { + let st = CharStyle { stroke, stroke_width: 2.0, ..style(Color::BLACK) }; + let art = layout(&fonts, &TextObject::point(Point::ZERO, &SVG_CHAR.to_string(), st)).glyphs[0].colour.clone().unwrap(); + let mut strokes = vec![]; + art.walk(&mut |n| strokes.push(n.appearance.stroke_paint())); + strokes + }; + assert!(!stroke_of(Paint::None).iter().any(|p| *p != Paint::None), "no stroke at first"); + let red = Paint::solid(Color::rgb8(255, 0, 0)); + assert!(stroke_of(red.clone()).contains(&red), "the stroke added"); +} diff --git a/crates/text/src/tests_sysfonts.rs b/crates/text/src/tests_sysfonts.rs index c4f6a53fd..40cce9f06 100644 --- a/crates/text/src/tests_sysfonts.rs +++ b/crates/text/src/tests_sysfonts.rs @@ -8,6 +8,48 @@ use crate::fontdb::fonts_outside; const FAMILY: &str = "Sysfont Sans3"; +/// Concurrent first use must not report a missing glyph just because another lookup loaded it. +#[test] +fn concurrent_fallback_lookups_all_find_the_installed_face() { + const C: char = '\u{F0000}'; + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-concurrent-fallback", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let base = named(&[(3, EN, 1, "Concurrent Sans"), (3, EN, 2, "Regular"), (3, EN, 6, "ConcurrentSans-Regular")]); + let font = skrifa::FontRef::new(&base).unwrap(); + let mut builder = write_fonts::FontBuilder::new(); + builder.add_raw(write_fonts::types::Tag::new(b"cmap"), crate::embed::cmap(&[(C as u32, 1)]).unwrap()); + for r in font.table_directory.table_records() { + let tag = write_fonts::types::Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes()); + } + } + std::fs::write(dir.join("Concurrent.ttf"), builder.build()).unwrap(); + for _ in 0..4 { + let db = FontDb::with_font_dirs(vec![dir.clone()]); + assert_eq!(db.load_system_fonts(), 1); + let start = std::sync::Barrier::new(16); + let faces = std::thread::scope(|scope| { + let workers: Vec<_> = (0..16) + .map(|_| { + scope.spawn(|| { + start.wait(); + db.face_covering(C) + }) + }) + .collect(); + workers.into_iter().map(|worker| worker.join().unwrap()).collect::>() + }); + assert!( + faces.iter().all(|face| face.as_ref().is_some_and(|face| face.family == "Concurrent Sans")), + "{} of 16 lookups missed the installed face", + faces.iter().filter(|face| face.is_none()).count() + ); + } + std::fs::remove_file(dir.join("Concurrent.ttf")).unwrap(); + std::fs::remove_dir(dir).unwrap(); +} + /// A bundled Source Sans 3 file renamed [`FAMILY`] (as long as the original name). fn renamed(file: &str) -> Vec { renamed_to(file, FAMILY) @@ -15,10 +57,15 @@ fn renamed(file: &str) -> Vec { /// A bundled Source Sans 3 file renamed `family`, which is as long as the original name. fn renamed_to(file: &str, family: &str) -> Vec { - assert_eq!(family.len(), "Source Sans 3".len()); - let mut data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + let data = std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts").join(file)).unwrap(); + rename(data, "Source Sans 3", family) +} + +/// The font `data` with family name `from` renamed `to`, which is as long. +fn rename(mut data: Vec, from: &str, to: &str) -> Vec { + assert_eq!(from.len(), to.len()); let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); - for (from, to) in [(utf16("Source Sans 3"), utf16(family)), (b"Source Sans 3".to_vec(), family.as_bytes().to_vec())] { + for (from, to) in [(utf16(from), utf16(to)), (from.as_bytes().to_vec(), to.as_bytes().to_vec())] { let mut i = 0; while let Some(at) = data[i..].windows(from.len()).position(|w| w == from) { data[i + at..i + at + to.len()].copy_from_slice(&to); @@ -147,6 +194,50 @@ fn rescanning_retries_characters_no_font_covered() { assert_eq!(db.face_covering(C).unwrap().family, "Retry Sans"); } +/// A lookup on another thread while an installed family loads finds the whole family or none of +/// it: never only the face of its first file, which falling back takes for text in the others' style +/// (Arial Bold Italic for upright Regular Arabic, the same text laid out again moments later in +/// Arial Regular). +#[test] +fn a_family_loading_on_another_thread_is_not_found_in_part() { + // A private-use character none of the bundled fonts has. + const C: char = '\u{F0000}'; + let dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-whole-family", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + // [`FAMILY`] Bold and Regular, their character maps replaced by one holding only `C`. The Bold's + // file sorts first, so it is read first. + for (file, source) in [("Sysfont-Bold.ttf", "SourceSans3-Bold.ttf"), ("Sysfont-Regular.ttf", "SourceSans3-Regular.ttf")] { + let base = renamed(source); + let font = skrifa::FontRef::new(&base).unwrap(); + let mut builder = write_fonts::FontBuilder::new(); + builder.add_raw(write_fonts::types::Tag::new(b"cmap"), crate::embed::cmap(&[(C as u32, 1)]).unwrap()); + for r in font.table_directory.table_records() { + let tag = write_fonts::types::Tag::new(&r.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(r.tag()).unwrap().as_bytes()); + } + } + std::fs::write(dir.join(file), builder.build()).unwrap(); + } + let db = std::sync::Arc::new(FontDb::with_font_dirs(vec![dir.clone()])); + // Each time this thread publishes faces, another thread falls back for `C` right then. + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let (hook_db, hook_seen) = (db.clone(), seen.clone()); + crate::fontdb::on_faces_published(Some(Box::new(move || { + let db = hook_db.clone(); + let style = std::thread::spawn(move || db.fallback_for(C, 0).map(|f| f.style.clone())).join().unwrap(); + hook_seen.lock().unwrap().push(style); + }))); + let face = db.face(FAMILY, "Regular").map(|f| f.style.clone()); + crate::fontdb::on_faces_published(None); + assert_eq!(face.as_deref(), Some("Regular")); + let seen = seen.lock().unwrap().clone(); + assert!(!seen.is_empty()); + assert!(seen.iter().all(|s| s.as_deref() == Some("Regular")), "the lookups took the family's {seen:?} faces"); + std::fs::remove_dir_all(dir).unwrap(); +} + #[test] fn rescanning_finds_fonts_installed_since() { let dir = font_dir("rescan"); @@ -284,6 +375,29 @@ fn a_font_file_named_by_itself_is_scanned() { assert_eq!(db.load_system_fonts(), 1); } +/// Font menus' Font Technology › Variable: a family installed as static files beside a variable +/// one is variable, whether the scan reads a static file first or a static face is loaded. +#[test] +fn a_family_with_a_variable_file_beside_static_ones_is_variable() { + use crate::test_fonts::{VARIABLE_FAMILY, variable_font}; + let dir = font_dir("variable"); + assert!(!FontDb::with_font_dirs(vec![dir.clone()]).family_traits(FAMILY).variable, "static files only"); + let var_dir = std::env::temp_dir().join(format!("vc-sysfonts-{}-variable-file", std::process::id())); + let _ = std::fs::remove_dir_all(&var_dir); + std::fs::create_dir_all(&var_dir).unwrap(); + std::fs::write(var_dir.join("Sysfont-Variable.ttf"), rename(variable_font().unwrap(), VARIABLE_FAMILY, FAMILY)).unwrap(); + // The folders are read last to first: the static files are found first. + let db = FontDb::with_font_dirs(vec![var_dir.clone(), dir]); + assert!(db.family_traits(FAMILY).variable); + assert!(!db.is_loaded(FAMILY), "the scan's traits, nothing loaded"); + // A static face of the family loaded, its variable file only scanned. + let db = FontDb::with_font_dirs(vec![var_dir]); + assert!(db.has_family(FAMILY)); + db.add_font(renamed("SourceSans3-Regular.ttf")); + assert!(db.is_loaded(FAMILY) && !db.face(FAMILY, "Regular").unwrap().traits.variable); + assert!(db.family_traits(FAMILY).variable); +} + #[test] fn registered_fonts_outside_the_font_folders_are_their_full_paths() { let dir = std::env::temp_dir().join("Fonts"); diff --git a/crates/text/src/tests_vertical.rs b/crates/text/src/tests_vertical.rs index ddaf9826e..97154feef 100644 --- a/crates/text/src/tests_vertical.rs +++ b/crates/text/src/tests_vertical.rs @@ -2,7 +2,7 @@ //! Japanese text only with craft-fonts' faces, skipped without them). use super::*; use kurbo::Shape; -use vectorcraft_doc::{CharStyle, Justify, TextKind}; +use vectorcraft_doc::{CharStyle, Justify, TextKind, TextRun}; use vectorcraft_geom::PathData; fn vertical(text: &str) -> TextObject { @@ -318,6 +318,67 @@ fn tracking_spaces_upright_marks_without_moving_them_off_the_centre_line() { assert!((step - 24.0).abs() < 1e-6, "one em plus 200/1000 em of tracking: {step}"); } +/// Character panel menu › Tate-chu-yoko: a marked run stands across one em of the column, including +/// a number longer than three digits and Latin letters. Unmarked text keeps the automatic rule +/// (two or three digits only). Horizontal type stores the mark and does not move the letters. +#[test] +fn manual_tate_chu_yoko_sets_marked_characters_across_one_em() { + let marked = CharStyle { size: 20.0, tate_chu_yoko: true, ..CharStyle::default() }; + let year = layout(FontDb::global(), &vertical_styled("2026", marked.clone())); + assert_eq!(year.glyphs.len(), 4); + assert!((year.glyphs[0].advance - 20.0).abs() < 1e-6, "{:?}", year.glyphs[0].advance); + assert!(year.glyphs[1..].iter().all(|g| g.advance == 0.0)); + let inks: Vec = (0..4).map(|i| ink(&year, i)).collect(); + for pair in inks.windows(2) { + assert!((pair[0].center().y - pair[1].center().y).abs() < 1.0, "one row: {inks:?}"); + assert!(pair[1].center().x > pair[0].center().x, "left to right: {inks:?}"); + } + let union = inks.iter().copied().reduce(|a, b| a.union(b)).unwrap(); + assert!(union.width() <= 21.0, "squeezed into the em: {union:?}"); + assert!(union.center().x.abs() < 1.5, "centred on the column: {union:?}"); + + let plain = layout(FontDb::global(), &vertical("2026")); + let down: f64 = plain.glyphs.iter().map(|g| g.advance).sum(); + assert!(down > 30.0 && plain.glyphs.iter().all(|g| g.advance > 0.0), "unmarked 2026 runs down the column: {down}"); + + let letters = layout(FontDb::global(), &vertical_styled("AB", marked.clone())); + assert!((letters.glyphs[0].advance - 20.0).abs() < 1e-6 && letters.glyphs[1].advance == 0.0); + let (a, b) = (ink(&letters, 0), ink(&letters, 1)); + assert!((a.center().y - b.center().y).abs() < 1.0 && b.center().x > a.center().x, "letters side by side: {a:?} {b:?}"); + let side = layout(FontDb::global(), &vertical("AB")); + assert!(ink(&side, 1).center().y > ink(&side, 0).center().y + 5.0, "unmarked letters run down the column"); + + let mut horizontal = TextObject::point(Point::ZERO, "AB", marked); + horizontal.xf = Affine::IDENTITY; + let row = layout(FontDb::global(), &horizontal); + assert!(!row.vertical); + assert!(row.glyphs.iter().all(|g| g.advance > 0.0)); + assert!(ink(&row, 1).center().x > ink(&row, 0).center().x + 5.0, "horizontal type stays a row"); +} + +/// A marked run is one block; the unmarked digits beside it still follow the automatic rule. +/// A column shorter than two ems holds the marked number on one line. +#[test] +fn manual_tate_chu_yoko_leaves_unmarked_digits_and_stays_on_one_line() { + let plain = CharStyle { size: 20.0, ..CharStyle::default() }; + let marked = CharStyle { tate_chu_yoko: true, ..plain.clone() }; + let mut t = vertical_styled("", plain.clone()); + t.runs = vec![TextRun::new("X", plain), TextRun::new("2026", marked), TextRun::new("10", CharStyle { size: 20.0, ..CharStyle::default() })]; + let l = layout(FontDb::global(), &t); + assert_eq!(l.glyphs.len(), 7); + assert!(l.glyphs[0].advance > 0.0 && l.glyphs[0].advance < 20.0, "X on its side: {:?}", l.glyphs[0].advance); + assert!((l.glyphs[1].advance - 20.0).abs() < 1e-6 && l.glyphs[2].advance == 0.0 && l.glyphs[3].advance == 0.0 && l.glyphs[4].advance == 0.0); + assert!((l.glyphs[5].advance - 20.0).abs() < 1e-6 && l.glyphs[6].advance == 0.0, "unmarked 10 stays automatic"); + + t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 80.0, 30.0).to_path(0.1)) }; + t.runs = vec![TextRun::new("2026", CharStyle { size: 20.0, tate_chu_yoko: true, ..CharStyle::default() })]; + let one = layout(FontDb::global(), &t); + assert_eq!(one.lines.len(), 1, "the block is one em, so it fits a 30 pt column"); + t.runs = vec![TextRun::new("2026", CharStyle { size: 20.0, ..CharStyle::default() })]; + let wrapped = layout(FontDb::global(), &t); + assert!(wrapped.lines.len() > 1, "unmarked 2026 is longer than the column"); +} + /// Issue #260 as reported, with a Japanese face of craft-fonts (whose own digits are proportional). #[test] fn japanese_numbers_keep_their_own_cells_down_the_column() { diff --git a/crates/text/src/thread.rs b/crates/text/src/thread.rs index 357b9ba32..a1fbf9093 100644 --- a/crates/text/src/thread.rs +++ b/crates/text/src/thread.rs @@ -47,7 +47,8 @@ fn slice_paras(paras: &[ParaStyle], first: usize, runs: &[TextRun]) -> Vec usize { let mut probe = frame.clone(); probe.runs = runs.to_vec(); @@ -57,6 +58,9 @@ fn fit(db: &FontDb, frame: &TextObject, runs: &[TextRun]) -> usize { return text.len(); } let mut end = lay.lines.last().map_or(0, |l| l.end); + if lay.on_path { + return end; + } let bytes = text.as_bytes(); while end < bytes.len() && bytes[end] == b' ' { end += 1; @@ -112,7 +116,7 @@ pub fn distribute(db: &FontDb, frames: &[&TextObject], runs: &[TextRun], paras: mod tests { use super::*; use vectorcraft_doc::{CharStyle, Justify, TextKind}; - use vectorcraft_geom::{Affine, Rect, shapes}; + use vectorcraft_geom::{Affine, PathData, Rect, shapes}; fn frame(x: f64) -> TextObject { let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", CharStyle::default()); @@ -121,6 +125,90 @@ mod tests { t } + /// Type on a straight path `length` long. + fn path_frame(length: f64) -> TextObject { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", CharStyle::default()); + let line = kurbo::Line::new((0.0, 0.0), (length, 0.0)); + t.kind = TextKind::OnPath { path: PathData::from_bezpath(&kurbo::Shape::to_path(&line, 0.1)), start: 0.0, end: None }; + t.xf = Affine::IDENTITY; + t + } + + /// Type on a path that overflows ends its line where its text stops fitting, as area type's + /// last line does, so a story flows on from there through area type and type on a path alike. + #[test] + fn story_flows_across_area_type_and_type_on_a_path() { + let db = FontDb::global(); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let mut path = path_frame(100.0); + path.runs = story.clone(); + let laid = layout(db, &path); + let end = laid.lines.first().map_or(0, |l| l.end); + assert!(laid.overflow && end > 0 && end < text.len(), "the path's line ends where its text stops: {end}"); + let (a, b, c) = (frame(0.0), path_frame(100.0), frame(400.0)); + let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); + let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); + assert!(lens.iter().all(|l| *l > 0), "each frame takes a share: {lens:?}"); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let mut on_path = b.clone(); + on_path.runs = parts[1].clone(); + assert!(!layout(db, &on_path).overflow, "the path's share fits it"); + } + + /// A path that runs out of room at a space holds only what it shows: the space starts the + /// next frame, as a space with no room on a path overflows. + #[test] + fn a_path_that_runs_out_at_a_space_leaves_it_to_the_next_frame() { + let db = FontDb::global(); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let mut at_space = 0; + for length in (60..160).step_by(3) { + let (a, b) = (path_frame(f64::from(length)), frame(400.0)); + let parts: Vec> = distribute(db, &[&a, &b], &story, &[]).into_iter().map(|s| s.runs).collect(); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let mut on_path = a.clone(); + on_path.runs = parts[0].clone(); + assert!(!layout(db, &on_path).overflow, "the path's share fits it ({length})"); + at_space += usize::from(parts[1].iter().map(|r| r.text.as_str()).collect::().starts_with(' ')); + } + assert!(at_space > 0, "some lengths end at a space"); + } + + /// A right-to-left story flows on from where its text stops fitting a path, in reading order. + #[test] + fn right_to_left_story_flows_on_from_a_path() { + let db = FontDb::global(); + let text = "שלום עולם ".repeat(8); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let (a, b) = (path_frame(100.0), frame(400.0)); + let parts: Vec> = distribute(db, &[&a, &b], &story, &[]).into_iter().map(|s| s.runs).collect(); + let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); + assert!(lens.iter().all(|l| *l > 0), "each frame takes a share: {lens:?}"); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + let mut on_path = a.clone(); + on_path.runs = parts[0].clone(); + assert!(!layout(db, &on_path).overflow, "the path's share fits it"); + } + + /// A path with no length holds no text, so the story flows past it. + #[test] + fn story_flows_past_a_path_with_no_length() { + let db = FontDb::global(); + let text = "The quick brown fox jumps over the lazy dog. ".repeat(6); + let story = vec![TextRun { text: text.clone(), style: CharStyle::default(), inline: None }]; + let (a, b, c) = (frame(0.0), path_frame(0.0), frame(400.0)); + let parts: Vec> = distribute(db, &[&a, &b, &c], &story, &[]).into_iter().map(|s| s.runs).collect(); + let lens: Vec = parts.iter().map(|p| runs_len(p)).collect(); + assert!(lens[0] > 0 && lens[1] == 0 && lens[2] > 0, "{lens:?}"); + let joined: String = parts.iter().flatten().map(|r| r.text.as_str()).collect(); + assert_eq!(joined, text, "no character lost or duplicated"); + } + #[test] fn story_flows_across_frames_and_rejoins() { let db = FontDb::global(); diff --git a/crates/tools/src/bbox.rs b/crates/tools/src/bbox.rs index 2227bef27..385744d09 100644 --- a/crates/tools/src/bbox.rs +++ b/crates/tools/src/bbox.rs @@ -46,6 +46,20 @@ impl Handle { _ => (true, true), } } + /// The handle's point in the 9-point reference grid (row-major from the top-left, 4 the center) + /// that `object.setBounds` takes as `reference`. + pub fn reference(self) -> usize { + match self { + Handle::TopLeft => 0, + Handle::Top => 1, + Handle::TopRight => 2, + Handle::Left => 3, + Handle::Right => 5, + Handle::BottomLeft => 6, + Handle::Bottom => 7, + Handle::BottomRight => 8, + } + } } /// Handle under `p` (within `tol` document units). @@ -103,10 +117,12 @@ pub fn rotate_for_drag(center: Point, from: Point, to: Point, snap: bool) -> (Af (Affine::translate(center.to_vec2()) * Affine::rotate(a) * Affine::translate(-center.to_vec2()), -a.to_degrees()) } -/// Move constrained to 45° multiples when `shift`. -pub fn move_delta(from: Point, to: Point, shift: bool) -> Vec2 { +/// Move constrained to 45° multiples when `shift`, counted from `base` degrees (General › +/// Constrain Angle, [`crate::ToolContext::constrain_angle`]): Shift holds a drag to the rotated x +/// and y axes and their diagonals. +pub fn move_delta(from: Point, to: Point, shift: bool, base: f64) -> Vec2 { let d = to - from; - if shift { vectorcraft_geom::constrain_angle(d, 45.0) } else { d } + if shift { vectorcraft_geom::constrain_angle_from(d, 45.0, base) } else { d } } #[cfg(test)] @@ -122,6 +138,9 @@ mod tests { assert_eq!(Handle::Top.opposite(), Handle::Bottom); assert_eq!(hit_handle(R, Point::new(99.0, 24.0), 3.0), Some(Handle::Right)); assert_eq!(hit_handle(R, Point::new(50.0, 25.0), 3.0), None); + for h in Handle::ALL { + assert_eq!(vectorcraft_geom::reference_point(R, h.reference()), h.pos(R), "{h:?}"); + } } #[test] diff --git a/crates/tools/src/builder.rs b/crates/tools/src/builder.rs index 86f6f90db..475b20f56 100644 --- a/crates/tools/src/builder.rs +++ b/crates/tools/src/builder.rs @@ -14,11 +14,12 @@ use std::sync::Arc; use serde_json::{Value, json}; +use vectorcraft_color::{Paint, Swatch}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; -use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape as _}; +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape as _, SubPath}; use vectorcraft_pathops as po; -use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, json_ids}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids}; /// Names starting with this mark a Live Paint group. pub const LIVE_PAINT_PREFIX: &str = "Live Paint"; @@ -71,13 +72,33 @@ pub fn path_contains(path: &PathData, rule: FillRule, p: Point) -> bool { /// The face of `group` under `p` (front-most). pub fn face_at(group: &Node, p: Point) -> Option<&Arc> { - faces(group).into_iter().rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) + front_face(faces(group).into_iter(), p) +} + +/// The face of `group` under `p` (front-most) among its faces that are not hidden. +pub fn visible_face_at(group: &Node, p: Point) -> Option<&Arc> { + front_face(faces(group).into_iter().filter(|f| f.visible), p) +} + +/// The face of `faces` (paint order) under `p` (front-most). +fn front_face<'a>(faces: impl DoubleEndedIterator>, p: Point) -> Option<&'a Arc> { + faces.rev().find(|f| f.path_data().is_some_and(|pd| path_contains(pd, FillRule::NonZero, p))) } /// The edge of `group` nearest to `p` within `tol`. pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + nearest_edge(edges(group), p, tol) +} + +/// The edge of `group` nearest to `p` within `tol` among its edges that are not hidden. +pub fn visible_edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { + nearest_edge(edges(group).into_iter().filter(|e| e.visible), p, tol) +} + +/// The edge of `edges` nearest to `p` within `tol`. +fn nearest_edge<'a>(edges: impl IntoIterator>, p: Point, tol: f64) -> Option<&'a Arc> { let mut best: Option<(f64, &Arc)> = None; - for e in edges(group) { + for e in edges { if let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) && d <= tol && best.is_none_or(|b| d < b.0) @@ -88,11 +109,11 @@ pub fn edge_near(group: &Node, p: Point, tol: f64) -> Option<&Arc> { best.map(|b| b.1) } -/// The visible Live Paint group with a face under `p` (front-most in paint order). +/// The visible Live Paint group with a face under `p` that is not hidden (front-most in paint order). pub fn live_paint_at(doc: &Document, p: Point) -> Option { let mut hit = None; doc.walk(|n| { - if is_live_paint(n) && doc.is_visible(n.id) && face_at(n, p).is_some() { + if is_live_paint(n) && doc.is_visible(n.id) && visible_face_at(n, p).is_some() { hit = Some(n.id); } }); @@ -135,6 +156,30 @@ pub fn node_outline(n: &Node) -> Option<(PathData, FillRule)> { } } +/// How far (pt) a piece of the planar map may lie from `outline`, the outline it follows, to be +/// matched to it or cut out of it: a ten-thousandth of the outline's larger side, at least 0.05. +/// The map's pieces follow the outlines to within a few hundredths of a point. +pub fn outline_snap(outline: &PathData) -> f64 { + outline.bounds().map_or(0.05, |b| (b.width().max(b.height()) / 2000.0).max(0.25) * 0.2) +} + +/// The outlines of `leaves` as they are drawn ([`node_outline`]), in their order, for +/// [`BuilderMap::stroke_at`]. A leaf without an outline, and one inside a Live Paint group or a +/// Shaper Group (which make their own paths), gets an empty outline, so no stroke of it splits. +pub fn drawn_outlines(doc: &Document, leaves: &[&Node]) -> Vec { + let in_live_group = |id: NodeId| { + let mut up = doc.parent_of(id); + while let Some(p) = up { + if doc.node(p).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some()) { + return true; + } + up = doc.parent_of(p); + } + false + }; + leaves.iter().map(|l| if in_live_group(l.id) { None } else { node_outline(l) }.map_or_else(PathData::default, |o| o.0)).collect() +} + /// Pathfinder shapes for the given roots (back → front by the order given), keyed by leaf index, /// with the leaves they came from. pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, Vec<&'a Node>) { @@ -159,12 +204,13 @@ pub fn shapes_for<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, V } /// Shape Builder inputs: [`shapes_for`], with the open subpaths of filled paths closed where that -/// encloses an area (the fill bounds a region, as if an invisible edge closed it). Unfilled open -/// paths and straight lines stay open and cut the regions they cross. -fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId]) -> (Vec, Vec<&'a Node>) { +/// encloses an area (the fill bounds a region, as if an invisible edge closed it) when +/// `open_filled_as_closed`. Unfilled open paths and straight lines stay open and cut the regions +/// they cross, and so do filled open paths without `open_filled_as_closed`. +fn shape_builder_shapes<'a>(doc: &'a Document, roots: &[NodeId], open_filled_as_closed: bool) -> (Vec, Vec<&'a Node>) { let (mut shapes, leaves) = shapes_for(doc, roots); for (s, l) in shapes.iter_mut().zip(&leaves) { - if l.appearance.fill_paint().is_none() { + if !open_filled_as_closed || l.appearance.fill_paint().is_none() { continue; } for sp in s.path.subpaths.iter_mut().filter(|sp| !sp.closed) { @@ -194,7 +240,8 @@ pub struct BuilderMap { /// The inputs, back → front (see [`po::shape_builder`]). pub shapes: Vec, pub regions: Vec, - /// Pieces of the unfilled open paths, keyed by input index, with their bounds. + /// Pieces of the open paths that cut regions (see [`BuilderMap::new`]), keyed by input index, + /// with their bounds. pub lines: Vec<(po::Shape, Rect)>, /// Pieces of the other inputs' outlines (cut wherever paths meet), keyed by input index, with /// their bounds: the edges erasing deletes. Only in a map made `with_edges`. @@ -226,13 +273,20 @@ fn piece_near(pieces: &[(po::Shape, Rect)], p: Point, tol: f64) -> Option<(f64, impl BuilderMap { /// The map of `roots` (sorted back → front), with the leaves the inputs came from; the outline - /// edges only `with_edges` (they cost a planar map of their own when no open path is in). - pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool) -> (Self, Vec<&'a Node>) { - let (shapes, leaves) = shape_builder_shapes(doc, roots); + /// edges only `with_edges` (they cost a planar map of their own when no open path is in). With + /// `open_filled_as_closed` (Consider Open Filled Paths as Closed), a filled open path bounds a + /// region as if an invisible edge closed it; without it, the path cuts like a line. + pub fn new<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool) -> (Self, Vec<&'a Node>) { + Self::with_gap(doc, roots, with_edges, open_filled_as_closed, 0.0) + } + + /// The same arrangement with Gap Detection, using the shared Live Paint gap finder. + pub fn with_gap<'a>(doc: &'a Document, roots: &[NodeId], with_edges: bool, open_filled_as_closed: bool, gap: f64) -> (Self, Vec<&'a Node>) { + let (shapes, leaves) = shape_builder_shapes(doc, roots, open_filled_as_closed); if shapes.is_empty() { return (Self::default(), leaves); } - let arr = po::shape_builder(&shapes, with_edges); + let arr = po::shape_builder_with_gap(&shapes, with_edges, gap); let bounded = |pieces: Vec| { pieces .into_iter() @@ -250,6 +304,27 @@ impl BuilderMap { self.regions.iter().position(|(_, bp, b)| b.contains(p) && bp.winding(p) != 0) } + /// The regions a rectangular marquee from `from` (where the drag started) to `to` touches: each + /// region whose outline crosses the rectangle or has an anchor inside it, and the region the + /// whole rectangle lies in. Of those, the region under `from` comes first, then the one under + /// `to`, then the others in map order. A marquee of no size touches the region under `from`. + pub fn regions_in_marquee(&self, from: Point, to: Point) -> Vec { + let r = Rect::from_points(from, to); + if r.width() <= 0.0 && r.height() <= 0.0 { + return self.region_at(from).into_iter().collect(); + } + let c = r.center(); + let touches = |(reg, bp, b): &Face| (b.contains(c) && bp.winding(c) != 0) || vectorcraft_geom::hit::intersects_rect(®.path, r); + let mut out: Vec = self.regions.iter().enumerate().filter(|(_, f)| touches(f)).map(|(i, _)| i).collect(); + for p in [to, from] { + if let Some(k) = self.region_at(p).and_then(|i| out.iter().position(|&j| j == i)) { + let i = out.remove(k); + out.insert(0, i); + } + } + out + } + /// What a gesture at `p` touches: with `piece_tol` (erasing), the line piece or edge that /// close, else the region under it. pub fn hit(&self, p: Point, piece_tol: Option) -> Option { @@ -261,6 +336,49 @@ impl BuilderMap { piece.or_else(|| self.region_at(p).map(Hit::Region)) } + /// The stroke a click at `p` splits with In Merge Mode, Clicking Stroke Splits the Path: the + /// line piece or edge nearest `p` within `tol` (as [`BuilderMap::hit`] finds it), from the + /// anchor or crossing point before `p` to the one after it on the path it lies on. `outlines` + /// are the inputs' outlines as they are drawn (a filled open path without the edge that closes + /// it in the map). Returns the input's index and the run, going the way the path goes; None + /// when no piece is that close, when the run is not on the outline as drawn, when it would be a + /// whole closed subpath, when it is too short to cut out (see [`po::cut_out`]), or when it is + /// all of the path (a lone line) or leaves no more of it than [`outline_snap`], which leaves + /// nothing to split off. + pub fn stroke_at(&self, outlines: &[PathData], p: Point, tol: f64) -> Option<(usize, SubPath)> { + use kurbo::ParamCurve as _; + let piece = match self.hit(p, Some(tol))? { + Hit::Line(i) => self.lines.get(i), + Hit::Edge(i) => self.edges.get(i), + Hit::Region(_) => None, + }?; + let i = piece.0.key as usize; + let drawn = outlines.get(i)?; + let stops: Vec = drawn.anchors().map(|(.., a)| a.p).collect(); + let snap = outline_snap(drawn); + let mut run = po::run_around(piece.0.path.subpaths.first()?, &stops, p, snap)?; + let n = run.segment_count(); + if n == 0 { + return None; + } + let mid = run.segment(n / 2).eval(0.5); + drawn.nearest(mid).filter(|n| n.4 <= snap)?; + // A run that cannot be cut out, or that is all of the path (such as a lone line) or all + // but a speck of it, leaves nothing to split off. + if !po::cut_out(drawn, &run, false, snap).is_some_and(|rest| rest.length() > snap) { + return None; + } + // Reverse the run when the path's parameter goes down along it. + let seg = run.segment(n / 2); + if let (Some(a), Some(b)) = (drawn.nearest(seg.eval(0.25)), drawn.nearest(seg.eval(0.75))) + && a.0 == b.0 + && (b.1 as f64 + b.2) < (a.1 as f64 + a.2) + { + run.reverse(); + } + Some((i, run)) + } + /// The regions just either side of the middle of `piece` (None: outside every region). pub fn sides(&self, piece: &po::Shape) -> [Option; 2] { let Some(sp) = piece.path.subpaths.first() else { return [None; 2] }; @@ -407,24 +525,30 @@ pub fn hatch(path: &BezPath, gap: f64) -> BezPath { out } -/// The planar map of the current selection, cached by document identity and selection. +/// The planar map of the current selection, cached by document identity, selection and whether +/// filled open paths count as closed. #[derive(Default)] struct RegionCache { - key: Option<(usize, Vec)>, + key: Option<(usize, Vec, bool)>, map: BuilderMap, /// Live Paint faces (every path an edge, filled or not) rather than Shape Builder regions. live: bool, /// The map has the Shape Builder's outline edges. edges: bool, + /// Gap Detection distance; changing tool options invalidates the cache. + gap: f64, + /// The outlines of the Shape Builder's inputs as they are drawn (see [`BuilderMap::stroke_at`]). + outlines: Vec, } impl RegionCache { fn live_paint() -> Self { Self { live: true, ..Default::default() } } - /// The map, with the outline edges when `edges` (built the first time they're wanted). - fn get(&mut self, cx: &ToolContext, edges: bool) -> &BuilderMap { - let key = (cx.doc as *const Document as usize, cx.selection.objects.clone()); + /// The map, with the outline edges when `edges` (built the first time they're wanted), and + /// filled open paths closed when `open_filled_as_closed` (Shape Builder only). + fn get(&mut self, cx: &ToolContext, edges: bool, open_filled_as_closed: bool) -> &BuilderMap { + let key = (cx.doc as *const Document as usize, cx.selection.objects.clone(), open_filled_as_closed); if self.key.as_ref() != Some(&key) || (edges && !self.edges) { let roots = sorted_roots(cx.doc, &cx.selection.objects); self.map = if self.live { @@ -432,7 +556,9 @@ impl RegionCache { let regions = if shapes.is_empty() { vec![] } else { faces_of(po::live_paint(&shapes).0) }; BuilderMap { regions, ..Default::default() } } else { - BuilderMap::new(cx.doc, &roots, edges).0 + let (map, leaves) = BuilderMap::with_gap(cx.doc, &roots, edges, open_filled_as_closed, self.gap); + self.outlines = drawn_outlines(cx.doc, &leaves); + map }; self.key = Some(key); self.edges = edges; @@ -440,16 +566,114 @@ impl RegionCache { &self.map } fn at(&mut self, cx: &ToolContext, p: Point) -> Option { - self.get(cx, false).region_at(p) + self.get(cx, false, true).region_at(p) } } // ---------- Shape Builder ---------- +pub use po::SHAPE_BUILDER_MAX_GAP; +/// Small, Medium and Large gap lengths in points. +pub const GAP_LENGTHS: [f64; 3] = [3.0, 6.0, 12.0]; + +/// Shape Builder Tool Options (double-click the tool; `tool.setOption`), kept across tool switches +/// and saved with the preferences. +#[derive(Clone, Debug, PartialEq)] +pub struct ShapeBuilderOptions { + /// Consider Open Filled Paths as Closed (`openFilledAsClosed`, on): a filled open path bounds a + /// region as if an invisible edge closed it. When it is off, the path cuts the regions it crosses + /// as a line does. + pub open_filled_as_closed: bool, + /// Pick Color From Color Swatches (`pickColorFrom: "swatches"`; off: `"artwork"`): a merge + /// fills the new shape with the current fill, and the Left and Right arrow keys step the current + /// fill through the document's color swatches. With Artwork, the shape takes the look of the + /// object where the drag starts, else of the one where it ends, else of the front-most selected + /// object. + pub swatches: bool, + /// Cursor Swatch Preview (`cursorSwatchPreview`, off): with Color Swatches, the current fill + /// and the swatches before and after it show as chips above the pointer while no drag is on. + pub cursor_swatch_preview: bool, + /// Selection Straight Line (`selection: "straightLine"`; off: `"freeform"`): a drag touches + /// what the straight line from where it started to the pointer crosses, not what the pointer + /// passed over. + pub straight_line: bool, + /// Highlight Fill (`highlightFill`, on): in merge mode, the regions under the pointer or + /// touched by the drag are shaded with a gray mesh. When it is off, they are not highlighted. + pub highlight_fill: bool, + /// Close gaps up to `gap_length` while finding regions. + pub gap_detection: bool, + /// Gap Length in points (at most [`SHAPE_BUILDER_MAX_GAP`]). + pub gap_length: f64, + /// In Merge Mode, Clicking Stroke Splits the Path (`clickStrokeSplitsPath`, off): in merge mode, + /// a click on a stroke splits its path into the run under the pointer + /// ([`BuilderMap::stroke_at`]) and the rest, and the pointer shows scissors over such a stroke. + pub click_stroke_splits: bool, + /// Highlight Stroke When Editable (`highlightStroke`, on): with `click_stroke_splits`, the run + /// of stroke a click would split is drawn in `stroke_color` while the pointer is over it. + pub highlight_stroke: bool, + /// The color of that highlight (`highlightColor`: `#rrggbb` or a layer color's name; the layer + /// color Red by default). + pub stroke_color: [u8; 3], +} + +/// The color Highlight Stroke When Editable draws in by default: the layer color Red. +pub const STROKE_HIGHLIGHT: [u8; 3] = [0xff, 0x4f, 0x4f]; + +impl Default for ShapeBuilderOptions { + fn default() -> Self { + Self { + open_filled_as_closed: true, + swatches: false, + cursor_swatch_preview: false, + straight_line: false, + highlight_fill: true, + gap_detection: false, + gap_length: GAP_LENGTHS[0], + click_stroke_splits: false, + highlight_stroke: true, + stroke_color: STROKE_HIGHLIGHT, + } + } +} + +/// The document's color swatches in the Swatches panel's order (the swatches outside groups, then +/// each group's): the solid colors, without None. +pub fn color_swatches(doc: &Document) -> Vec<&Swatch> { + let groups = doc.swatch_groups.iter().flat_map(|g| &g.swatches); + doc.swatches.iter().chain(groups).filter(|s| matches!(s.paint, Paint::Solid { .. })).collect() +} + +/// Is `fill` swatch `s`: linked to it by name, or the same paint? +fn is_swatch(s: &Swatch, fill: &Paint) -> bool { + s.paint == *fill || matches!(fill, Paint::Solid { swatch: Some(name), tint, .. } if *tint >= 1.0 && *name == s.name) +} + +/// The index in `list` of the swatch `fill` is: `last` (the swatch picked last) while `fill` is still +/// that swatch, so swatches of the same color are each reached in turn; otherwise the first swatch +/// it is. +fn swatch_index(list: &[&Swatch], fill: &Paint, last: Option) -> Option { + last.filter(|&i| list.get(i).is_some_and(|s| is_swatch(s, fill))).or_else(|| list.iter().position(|s| is_swatch(s, fill))) +} + +/// The index of the swatch of `list` after (`forward`) or before the one `fill` is (see +/// [`swatch_index`]), wrapping around; the first or the last one when `fill` is none of them. +fn step_swatch(list: &[&Swatch], fill: &Paint, last: Option, forward: bool) -> Option { + let n = list.len(); + match (swatch_index(list, fill, last), forward) { + (Some(i), true) => Some((i + 1) % n), + (Some(i), false) => Some((i + n - 1) % n), + (None, true) => (n > 0).then_some(0), + (None, false) => n.checked_sub(1), + } +} + /// Shape Builder tool: drag across regions to merge them. Holding Alt (Option) switches to erase /// mode while it is held: the pointer shows a minus, what a click or drag would delete highlights in /// red, and the gesture deletes the regions it touches, the pieces of open paths and the edges -/// (outline pieces between the points where paths meet) it passes over. +/// (outline pieces between the points where paths meet) it passes over. With Shift held at the +/// press, the drag draws a rectangular marquee and touches the regions it reaches (see +/// [`BuilderMap::regions_in_marquee`]): they merge, or with Alt they are deleted. Its options are +/// [`ShapeBuilderOptions`]. #[derive(Default)] pub struct ShapeBuilderTool { cache: RegionCache, @@ -457,14 +681,45 @@ pub struct ShapeBuilderTool { points: Vec, touched: Touched, dragging: bool, + /// A Shift-drag's marquee: where the drag started and where the pointer is. + marquee: Option<(Point, Point)>, /// Erase mode: Alt held while hovering, or when the drag started. alt: bool, + /// Where the pointer last moved (the cursor swatch preview sits above it). + at: Option, + /// The index in [`color_swatches`] of the swatch the arrow keys picked last. + picked: Option, + opts: ShapeBuilderOptions, + /// With In Merge Mode, Clicking Stroke Splits the Path: the run of stroke under the pointer, + /// which a click splits off. + stroke: Option, + /// The pointer went more than [`CLICK_TOL_PX`] from the press during the drag, so the gesture + /// is not a click. + far: bool, } /// How close (screen px) the pointer must come to a line piece or an edge to delete it. const LINE_TOL_PX: f64 = 4.0; +/// How far (screen px) the pointer may move between press and release for the gesture to be a +/// click, which with In Merge Mode, Clicking Stroke Splits the Path splits the stroke at the press. +const CLICK_TOL_PX: f64 = 3.0; + impl ShapeBuilderTool { + fn gap(&self) -> f64 { + if self.opts.gap_detection { self.opts.gap_length } else { 0.0 } + } + + /// With In Merge Mode, Clicking Stroke Splits the Path and not in erase mode (`alt`): the run of + /// stroke a click at `p` splits off ([`BuilderMap::stroke_at`]). + fn stroke_at(&mut self, cx: &ToolContext, p: Point, alt: bool) -> Option { + if alt || !self.opts.click_stroke_splits { + return None; + } + self.cache.get(cx, true, self.opts.open_filled_as_closed); + self.cache.map.stroke_at(&self.cache.outlines, p, cx.tol(LINE_TOL_PX)).map(|(_, run)| run) + } + fn line_tol(cx: &ToolContext, alt: bool) -> Option { alt.then(|| cx.tol(LINE_TOL_PX)) } @@ -475,11 +730,56 @@ impl ShapeBuilderTool { None => vec![to], }; let tol = Self::line_tol(cx, self.alt); - let map = self.cache.get(cx, self.alt); + let map = self.cache.get(cx, self.alt, self.opts.open_filled_as_closed); for p in pts { self.touched.add(map.hit(p, tol)); } } + + /// Selection Straight Line: the drag touches what the line from its first point to `p` crosses. + fn straight_to(&mut self, cx: &ToolContext, p: Point) { + let Some(&start) = self.points.first() else { return }; + self.touched = Touched::default(); + if start == p { + self.points = vec![start]; + self.touch(cx, None, start); + } else { + self.points = vec![start, p]; + self.touch(cx, Some(start), p); + } + } + + /// A Shift-drag: the drag touches the regions the marquee from where it started to `p` reaches; + /// while the pointer is where the drag started, what a click there touches. + fn marquee_to(&mut self, cx: &ToolContext, p: Point) { + let Some((start, _)) = self.marquee else { return }; + self.marquee = Some((start, p)); + self.touched = Touched::default(); + if start == p { + self.touch(cx, None, start); + } else { + self.touched.regions = self.cache.get(cx, self.alt, self.opts.open_filled_as_closed).regions_in_marquee(start, p); + } + } + + /// The cursor swatch preview: the swatch before the current fill, the current fill (ringed) + /// and the swatch after it, side by side above the pointer. + fn swatch_chips(&self, cx: &ToolContext) -> Vec { + let Some(at) = self.at else { return vec![] }; + let list = color_swatches(cx.doc); + let fill = &cx.paint.fill; + let rgba = |p: &Paint| match p { + Paint::Solid { color, .. } => Some(color.to_rgba8(1.0)), + _ => None, + }; + let chip = |forward| step_swatch(&list, fill, self.picked, forward).and_then(|i| rgba(&list.get(i)?.paint)); + let (before, after) = (chip(false), chip(true)); + let y = at.y - cx.tol(22.0); + [(before, -16.0, false), (rgba(fill), 0.0, true), (after, 16.0, false)] + .into_iter() + .filter_map(|(color, dx, selected)| Some(Overlay::Swatch { p: Point::new(at.x + cx.tol(dx), y), color: color?, selected })) + .collect() + } } impl Tool for ShapeBuilderTool { @@ -492,7 +792,13 @@ impl Tool for ShapeBuilderTool { match ev.kind { PointerKind::Move => { self.alt = ev.mods.alt; - self.hover = self.cache.get(cx, self.alt).hit(p, Self::line_tol(cx, self.alt)); + self.at = Some(p); + // Over a stroke a click would split, the tool highlights the stroke and not the region. + self.stroke = self.stroke_at(cx, p, self.alt); + self.hover = match self.stroke { + Some(_) => None, + None => self.cache.get(cx, self.alt, self.opts.open_filled_as_closed).hit(p, Self::line_tol(cx, self.alt)), + }; vec![] } PointerKind::Down => { @@ -501,12 +807,26 @@ impl Tool for ShapeBuilderTool { } self.dragging = true; self.alt = ev.mods.alt; + self.marquee = ev.mods.shift.then_some((p, p)); self.points = vec![p]; + self.far = false; self.touched = Touched::default(); self.touch(cx, None, p); vec![] } PointerKind::Drag if self.dragging => { + self.at = Some(p); + if self.points.first().is_some_and(|s| s.distance(p) > cx.tol(CLICK_TOL_PX)) { + self.far = true; + } + if self.marquee.is_some() { + self.marquee_to(cx, p); + return vec![]; + } + if self.opts.straight_line { + self.straight_to(cx, p); + return vec![]; + } let last = self.points.last().copied(); if last.is_none_or(|q| q.distance(p) >= cx.tol(1.0)) { self.touch(cx, last, p); @@ -516,24 +836,52 @@ impl Tool for ShapeBuilderTool { } PointerKind::Up if self.dragging => { self.dragging = false; - if self.points.last().is_none_or(|q| *q != p) { + if self.marquee.is_some() { + self.marquee_to(cx, p); + } else if self.opts.straight_line { + self.straight_to(cx, p); + } else if self.points.last().is_none_or(|q| *q != p) { let last = self.points.last().copied(); self.touch(cx, last, p); self.points.push(p); } let points = std::mem::take(&mut self.points); - if std::mem::take(&mut self.touched).is_empty() { + // The mode the drag started in, which its highlight showed. + let erase = self.alt; + // A marquee of no size is a click. + let marquee = self.marquee.take().filter(|(a, b)| a != b); + // With In Merge Mode, Clicking Stroke Splits the Path, a click on a stroke splits it + // where the press was; a marquee splits nothing. + let press = points.first().copied().filter(|s| marquee.is_none() && !self.far && s.distance(p) <= cx.tol(CLICK_TOL_PX)); + let split = press.filter(|q| self.stroke_at(cx, *q, erase).is_some()); + if std::mem::take(&mut self.touched).is_empty() && split.is_none() { return vec![]; } self.cache.key = None; + self.stroke = None; let roots = sorted_roots(cx.doc, &cx.selection.objects); + let points = split.map_or(points, |q| vec![q]); let pts: Vec = points.iter().map(|q| json!([q.x, q.y])).collect(); - // The mode the drag started in, which its highlight showed. - let erase = self.alt; - let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase }); + let mut params = json!({ "ids": json_ids(&roots), "points": pts, "erase": erase, "gap": self.gap() }); + if let Some((a, b)) = marquee + && let Some(o) = params.as_object_mut() + { + o.remove("points"); + o.insert("rect".into(), json!([a.x, a.y, b.x - a.x, b.y - a.y])); + } if let Some(t) = Self::line_tol(cx, erase) { params["tolerance"] = json!(t); } + if split.is_some() { + params["splitStroke"] = json!(true); + params["tolerance"] = json!(cx.tol(LINE_TOL_PX)); + } + if !self.opts.open_filled_as_closed { + params["openFilledAsClosed"] = json!(false); + } + if self.opts.swatches { + params["pickColorFrom"] = json!("swatches"); + } vec![Action::Exec("shapeBuilder.merge".into(), params)] } _ => vec![], @@ -553,6 +901,8 @@ impl Tool for ShapeBuilderTool { }; for hit in show { match hit { + // Highlight Fill off: merge mode leaves the regions as they are. + Hit::Region(_) if !self.alt && !self.opts.highlight_fill => {} Hit::Region(i) => { if let Some((_, bp, _)) = map.regions.get(i) { out.push(Overlay::Path { path: hatch(bp, cx.tol(4.0)), color, width: 1.0, dashed: false }); @@ -567,6 +917,11 @@ impl Tool for ShapeBuilderTool { } } } + if self.dragging + && let Some((a, b)) = self.marquee.filter(|(a, b)| a != b) + { + out.push(Overlay::Marquee(Rect::from_points(a, b))); + } if self.dragging && self.points.len() > 1 { let mut bp = BezPath::new(); bp.move_to(self.points[0]); @@ -575,18 +930,94 @@ impl Tool for ShapeBuilderTool { } out.push(Overlay::Path { path: bp, color, width: 1.0, dashed: false }); } + if self.opts.swatches && self.opts.cursor_swatch_preview && !self.dragging { + out.extend(self.swatch_chips(cx)); + } + // Highlight Stroke When Editable draws the stroke a click would split. + if let Some(run) = self.stroke.as_ref().filter(|_| self.opts.highlight_stroke && !self.dragging) { + let mut path = BezPath::new(); + run.to_bezpath_into(&mut path); + out.push(Overlay::Path { path, color: self.opts.stroke_color, width: 3.0, dashed: false }); + } out } fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor { // A drag keeps the mode it started in; hovering follows the key. let erase = if self.dragging { self.alt } else { m.alt }; - if erase { Cursor::ShapeBuilderErase } else { Cursor::ShapeBuilder } + if erase { + Cursor::ShapeBuilderErase + } else if self.stroke.is_some() && !self.dragging { + // Over a stroke a click splits. + Cursor::Scissors + } else { + Cursor::ShapeBuilder + } } fn busy(&self) -> bool { self.dragging } + + /// With Pick Color From Color Swatches, the Left and Right arrow keys pick the color swatch + /// before or after the current fill. + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + self.opts.swatches && !self.dragging && matches!(key, ToolKey::Left | ToolKey::Right) + } + + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + if !self.claims_key(cx, key) { + return vec![]; + } + let list = color_swatches(cx.doc); + let Some((i, s)) = step_swatch(&list, &cx.paint.fill, self.picked, key == ToolKey::Right).and_then(|i| Some((i, list.get(i)?))) else { + return vec![]; + }; + self.picked = Some(i); + // The current fill only (no `ids`): the selected art keeps its own. + vec![Action::Exec("paint.setFill".into(), json!({ "swatch": s.name, "ids": [] }))] + } + + fn options(&self) -> Value { + let o = &self.opts; + let [r, g, b] = o.stroke_color; + json!({ + "openFilledAsClosed": o.open_filled_as_closed, + "pickColorFrom": if o.swatches { "swatches" } else { "artwork" }, + "cursorSwatchPreview": o.cursor_swatch_preview, + "selection": if o.straight_line { "straightLine" } else { "freeform" }, + "highlightFill": o.highlight_fill, + "gapDetection": o.gap_detection, + "gapLength": o.gap_length, + "clickStrokeSplitsPath": o.click_stroke_splits, + "highlightStroke": o.highlight_stroke, + "highlightColor": format!("#{r:02x}{g:02x}{b:02x}"), + }) + } + + fn set_option(&mut self, key: &str, v: &Value) { + let o = &mut self.opts; + match key { + "openFilledAsClosed" => o.open_filled_as_closed = v.as_bool().unwrap_or(o.open_filled_as_closed), + "pickColorFrom" => o.swatches = v.as_str().map_or(o.swatches, |s| s == "swatches"), + "cursorSwatchPreview" => o.cursor_swatch_preview = v.as_bool().unwrap_or(o.cursor_swatch_preview), + "selection" => o.straight_line = v.as_str().map_or(o.straight_line, |s| s == "straightLine"), + "highlightFill" => o.highlight_fill = v.as_bool().unwrap_or(o.highlight_fill), + "gapDetection" => o.gap_detection = v.as_bool().unwrap_or(o.gap_detection), + "gapLength" => { + if let Some(n) = v.as_f64().filter(|n| n.is_finite()) { + o.gap_length = n.clamp(0.0, SHAPE_BUILDER_MAX_GAP); + } + } + "clickStrokeSplitsPath" => o.click_stroke_splits = v.as_bool().unwrap_or(o.click_stroke_splits), + "highlightStroke" => o.highlight_stroke = v.as_bool().unwrap_or(o.highlight_stroke), + "highlightColor" => o.stroke_color = lp_color(v).unwrap_or(o.stroke_color), + _ => {} + } + self.stroke = None; + self.cache.key = None; + self.cache.gap = self.gap(); + } } // ---------- Live Paint Bucket ---------- @@ -602,24 +1033,35 @@ enum LpHover { Region(usize), } -fn lp_hover(cx: &ToolContext, cache: &mut RegionCache, p: Point, edge: bool) -> LpHover { - if let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) { - let part = if edge { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) }; - return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); +/// The Live Paint group and part the Live Paint tools hover and click at `p`. Inside a visible face +/// of a visible Live Paint group ([`live_paint_at`]) it is that group with its visible face under +/// `p`, or with `edge` its visible edge nearest `p` within `tol` (None when there is none). Outside +/// every visible face, with `edge`, it is the visible edge nearest `p` within `tol` among the +/// visible Live Paint groups (the front-most group's on a tie). None when no group is found. +fn lp_part_at(doc: &Document, p: Point, tol: f64, edge: bool) -> Option<(NodeId, Option<&Arc>)> { + if let Some(g) = live_paint_at(doc, p).and_then(|id| doc.node(id)) { + return Some((g.id, if edge { visible_edge_near(g, p, tol) } else { visible_face_at(g, p) })); } - if edge { - // Edges of every Live Paint group near the pointer. - let mut found = None; - cx.doc.walk(|n| { - if is_live_paint(n) - && let Some(e) = edge_near(n, p, cx.tol(4.0)) - { - found = e.path_data().map(|pd| pd.to_bezpath()); - } - }); - if let Some(bp) = found { - return LpHover::Part(bp); + if !edge { + return None; + } + let mut best: Option<(f64, NodeId, &Arc)> = None; + doc.walk(|n| { + if is_live_paint(n) + && let Some(e) = visible_edge_near(n, p, tol) + && let Some((.., d)) = e.path_data().and_then(|pd| pd.nearest(p)) + && best.is_none_or(|b| d <= b.0) + && doc.is_visible(n.id) + { + best = Some((d, n.id, e)); } + }); + best.map(|(_, g, e)| (g, Some(e))) +} + +fn lp_hover(cx: &ToolContext, cache: &mut RegionCache, p: Point, edge: bool) -> LpHover { + if let Some((_, part)) = lp_part_at(cx.doc, p, cx.tol(4.0), edge) { + return part.and_then(|n| n.path_data()).map(|pd| LpHover::Part(pd.to_bezpath())).unwrap_or_default(); } let sel_is_live = cx.selection.objects.iter().any(|id| cx.doc.node(*id).is_some_and(is_live_paint)); if !sel_is_live && let Some(i) = cache.at(cx, p) { @@ -669,19 +1111,11 @@ impl Tool for LivePaintBucketTool { self.hover = LpHover::None; let point = json!([p.x, p.y]); if ev.mods.shift { - let mut group = live_paint_at(cx.doc, p); - if group.is_none() { - cx.doc.walk(|n| { - if is_live_paint(n) && edge_near(n, p, cx.tol(4.0)).is_some() { - group = Some(n.id); - } - }); - } - return match group { - Some(g) => { + return match lp_part_at(cx.doc, p, cx.tol(4.0), true) { + Some((g, Some(_))) => { vec![Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": point, "tolerance": cx.tol(4.0) }))] } - None => vec![], + _ => vec![], }; } if let Some(g) = live_paint_at(cx.doc, p) { @@ -774,13 +1208,7 @@ impl Tool for LivePaintSelectionTool { vec![] } PointerKind::Down => { - let mut hit = None; - if let Some(edge) = edge - && let Some(g) = live_paint_at(cx.doc, p).and_then(|id| cx.doc.node(id)) - { - let part = if edge { edge_near(g, p, cx.tol(4.0)) } else { face_at(g, p) }; - hit = part.map(|n| n.id); - } + let hit = edge.and_then(|e| lp_part_at(cx.doc, p, cx.tol(4.0), e)).and_then(|(_, part)| part).map(|n| n.id); match (hit, ev.mods.shift) { (Some(id), true) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], (Some(id), false) => vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))], @@ -995,6 +1423,220 @@ mod tests { assert_eq!(colors(&t.overlays(&cx)), [HIGHLIGHT_RED; 5]); } + /// A 3×3 grid of touching 100 pt squares from (0, 0) to (300, 300), selected. + fn grid() -> (Document, Selection) { + let mut d = Document::new(500.0, 500.0); + let l = d.layers[0].id; + let mut s = Selection::default(); + for k in 0..9 { + let (x, y) = ((k % 3) as f64 * 100.0, (k / 3) as f64 * 100.0); + let id = d.alloc_id(); + d.insert(Some(l), k, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 100.0, y + 100.0)), Appearance::default_art())).unwrap(); + s.add(id); + } + (d, s) + } + + /// The paths of an overlay list (a shaded region draws two: its mesh and its outline), and its + /// marquees. + fn paths_and_marquees(ov: &[Overlay]) -> (usize, Vec) { + let paths = ov.iter().filter(|o| matches!(o, Overlay::Path { .. })).count(); + (paths, ov.iter().filter_map(|o| if let Overlay::Marquee(r) = o { Some(*r) } else { None }).collect()) + } + + #[test] + fn shape_builder_shift_drag_highlights_and_merges_every_region_in_its_marquee() { + let (d, s) = grid(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let shift = Mods { shift: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0).with_mods(shift)); + // Shift may be let go once the drag is on. + t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); + assert_eq!(t.touched.regions.len(), 9, "{:?}", t.touched); + assert_eq!(t.touched.regions[0], t.cache.map.region_at(Point::new(250.0, 250.0)).unwrap(), "where the drag started comes first"); + assert_eq!(paths_and_marquees(&t.overlays(&cx)), (18, vec![Rect::new(50.0, 50.0, 250.0, 250.0)]), "nine regions, no trail"); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)); + let Action::Exec(id, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((id.as_str(), &v["rect"], &v["erase"]), ("shapeBuilder.merge", &json!([250.0, 250.0, -200.0, -200.0]), &json!(false))); + assert!(v.get("points").is_none(), "{v}"); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Marquee(_))), "the marquee goes with the drag"); + } + + #[test] + fn shape_builder_alt_shift_drag_marks_the_regions_in_its_marquee_for_deleting() { + let (d, s) = grid(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let alt_shift = Mods { alt: true, shift: true, ..Default::default() }; + // A narrow marquee down the middle column. + t.pointer(&cx, &ev(PointerKind::Down, 140.0, 50.0).with_mods(alt_shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 250.0).with_mods(alt_shift)); + assert_eq!(t.touched.regions.len(), 3); + let ov = t.overlays(&cx); + assert!(ov.iter().all(|o| matches!(o, Overlay::Marquee(_) | Overlay::Path { color: HIGHLIGHT_RED, .. })), "{ov:?}"); + assert_eq!(paths_and_marquees(&ov).0, 6, "three regions"); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 160.0, 250.0).with_mods(alt_shift)); + let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((&v["rect"], &v["erase"]), (&json!([140.0, 50.0, 20.0, 200.0]), &json!(true))); + } + + /// Without Shift a drag still touches what the pointer passes over; a Shift-click that does not + /// move is a click. + #[test] + fn shape_builder_drag_without_shift_and_shift_click_send_points() { + let (d, s) = grid(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 250.0, 250.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 150.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 50.0, 50.0)); + assert_eq!(t.touched.regions.len(), 3, "the diagonal"); + let (paths, marquees) = paths_and_marquees(&t.overlays(&cx)); + assert_eq!((paths, marquees.len()), (7, 0), "three regions and the trail"); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)); + let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!(v["points"], json!([[250.0, 250.0], [150.0, 150.0], [50.0, 50.0]])); + assert!(v.get("rect").is_none(), "{v}"); + let shift = Mods { shift: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0).with_mods(shift)); + assert_eq!(t.touched.regions.len(), 1); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0).with_mods(shift)); + let Action::Exec(_, v) = &acts[0] else { panic!("{acts:?}") }; + assert_eq!((&v["points"], v.get("rect")), (&json!([[150.0, 150.0]]), None)); + } + + #[test] + fn marquee_regions_touch_crossed_outlines_held_anchors_and_the_region_around_it() { + let (d, s) = grid(); + let (map, _) = BuilderMap::new(&d, &sorted_roots(&d, &s.objects), false, true); + let at = |x: f64, y: f64| map.region_at(Point::new(x, y)).unwrap(); + // Wholly inside the middle square, then crossing into the one to its right. + assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(180.0, 180.0)), [at(150.0, 150.0)]); + assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(220.0, 180.0)), [at(150.0, 150.0), at(250.0, 150.0)]); + // Ending on a shared side does not reach the square past it. + assert_eq!(map.regions_in_marquee(Point::new(120.0, 120.0), Point::new(200.0, 180.0)), [at(150.0, 150.0)]); + // Around the whole grid, from outside it: every square, with no region under either corner. + assert_eq!(map.regions_in_marquee(Point::new(-10.0, -10.0), Point::new(310.0, 310.0)).len(), 9); + assert!(map.regions_in_marquee(Point::new(400.0, 400.0), Point::new(450.0, 450.0)).is_empty()); + // Where the drag started first, then where it ends, whichever way it runs. + for (a, b) in [((50.0, 50.0), (250.0, 250.0)), ((250.0, 250.0), (50.0, 50.0)), ((250.0, 50.0), (50.0, 250.0))] { + let got = map.regions_in_marquee(a.into(), b.into()); + assert_eq!((got.len(), got[0], got[1]), (9, at(a.0, a.1), at(b.0, b.1)), "{a:?} to {b:?}"); + } + } + + /// With In Merge Mode, Clicking Stroke Splits the Path on, a Shift-drag that starts on a stroke + /// sends its marquee and no split, however little it moves; a Shift-click there splits the + /// stroke as a click does. + #[test] + fn shape_builder_shift_drag_on_a_stroke_merges_and_a_shift_click_splits() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + t.set_option("clickStrokeSplitsPath", &json!(true)); + let shift = Mods { shift: true, ..Default::default() }; + // 1 pt outside A's left side, a move of less than 3 pixels, and one across the art. + for (to, rect) in [((0.5, 26.0), json!([-1.0, 25.0, 1.5, 1.0])), ((75.0, 75.0), json!([-1.0, 25.0, 76.0, 50.0]))] { + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); + t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, to.0, to.1).with_mods(shift)); + let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((&v["rect"], v.get("points"), v.get("splitStroke")), (&rect, None, None), "{v}"); + } + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0).with_mods(shift)); + let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((&v["splitStroke"], &v["points"], v.get("rect")), (&json!(true), &json!([[-1.0, 25.0]]), None), "{v}"); + } + + /// While a Shift-drag that started over a stroke a click would split draws its marquee, the + /// pointer shows the Shape Builder, not the scissors, and the stroke is not highlighted. + #[test] + fn shape_builder_shift_drag_shows_no_scissors_or_stroke_highlight() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + t.set_option("clickStrokeSplitsPath", &json!(true)); + let shift = Mods { shift: true, ..Default::default() }; + let highlighted = |ov: &[Overlay]| ov.iter().any(|o| matches!(o, Overlay::Path { color: STROKE_HIGHLIGHT, width: 3.0, .. })); + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), shift), Cursor::Scissors); + assert!(highlighted(&t.overlays(&cx))); + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0).with_mods(shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0).with_mods(shift)); + let ov = t.overlays(&cx); + assert!(ov.iter().any(|o| matches!(o, Overlay::Marquee(_))) && !highlighted(&ov), "{ov:?}"); + assert_eq!(t.cursor(&cx, Point::new(75.0, 75.0), shift), Cursor::ShapeBuilder); + } + + #[test] + fn shape_builder_stroke_options_round_trip_and_pick_the_side_under_the_pointer() { + let (d, s) = corner_rects(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = ShapeBuilderTool::default(); + let o = t.options(); + assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(false), &json!(true), &json!("#ff4f4f"))); + // With the option off, the pointer 1 pt outside A's left side highlights nothing. + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + assert!(t.overlays(&cx).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::ShapeBuilder); + t.set_option("clickStrokeSplitsPath", &json!(true)); + t.set_option("highlightColor", &json!("#00ff00")); + // A value that is not a color or not a switch is ignored. + t.set_option("highlightColor", &json!("nope")); + t.set_option("highlightStroke", &json!("off")); + let o = t.options(); + assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(true), &json!("#00ff00"))); + // On, the tool draws A's left side from corner to corner in the highlight color and shows the scissors. + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0)); + let ov = t.overlays(&cx); + let [Overlay::Path { path, color: [0, 0xff, 0], width: 3.0, .. }] = ov.as_slice() else { panic!("{ov:?}") }; + assert_eq!(path.bounding_box(), Rect::new(0.0, 0.0, 0.0, 100.0)); + assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), Mods::default()), Cursor::Scissors); + // With Alt the tool is in erase mode, which deletes edges and does not split them. + let alt = Mods { alt: true, ..Default::default() }; + t.pointer(&cx, &ev(PointerKind::Move, -1.0, 25.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, Point::new(-1.0, 25.0), alt), Cursor::ShapeBuilderErase); + assert!(t.stroke.is_none()); + // A click splits; the command gets the point, the switch and the tolerance. + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, -1.0, 25.0)); + let [Action::Exec(id, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((id.as_str(), &v["splitStroke"], &v["erase"], &v["tolerance"]), ("shapeBuilder.merge", &json!(true), &json!(false), &json!(4.0))); + // A release up to 3 pixels from the press is still a click, which splits where the press was. + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Drag, -1.5, 26.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 0.5, 27.0)); + let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert_eq!((&v["splitStroke"], &v["points"]), (&json!(true), &json!([[-1.0, 25.0]]))); + // A release farther away is a drag, which splits nothing. + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); + assert!(t.pointer(&cx, &ev(PointerKind::Up, -1.0, 30.0)).is_empty()); + // So is a Straight Line drag that goes away and ends back near the press. + t.set_option("selection", &json!("straightLine")); + t.pointer(&cx, &ev(PointerKind::Down, -1.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 30.0, 25.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, -0.5, 25.5)); + assert!(!acts.iter().any(|a| matches!(a, Action::Exec(_, v) if v.get("splitStroke").is_some())), "{acts:?}"); + t.set_option("selection", &json!("freeform")); + // A drag merges as before and sends no switch. + t.pointer(&cx, &ev(PointerKind::Down, 25.0, 25.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 75.0, 75.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Up, 75.0, 75.0)); + let [Action::Exec(_, v)] = acts.as_slice() else { panic!("{acts:?}") }; + assert!(v.get("splitStroke").is_none(), "{v}"); + } + #[test] fn hatch_stays_inside_region() { let bp = shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)).to_bezpath(); @@ -1023,6 +1665,29 @@ mod tests { (d, gid, f2, e) } + /// Adds a Live Paint group of one face `r` with its four sides as edges (left, top, right, + /// bottom) in front of the layer's art; returns the group and the edges. + fn live_square(d: &mut Document, r: Rect) -> (NodeId, [NodeId; 4]) { + let l = d.layers[0].id; + let mut face = Node::path(d.alloc_id(), shapes::rectangle(r), Appearance::default()); + face.name = Some(FACE_NAME.into()); + let (a, b, c, e) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)); + let mut kids = vec![Arc::new(face)]; + let mut ids = [NodeId(0); 4]; + for (k, (from, to)) in [(e, a), (a, b), (b, c), (c, e)].into_iter().enumerate() { + ids[k] = d.alloc_id(); + let mut edge = Node::path(ids[k], shapes::line(from, to), Appearance::default()); + edge.name = Some(EDGE_NAME.into()); + kids.push(Arc::new(edge)); + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, kids); + g.name = Some(LIVE_PAINT_NAME.into()); + let at = d.layers[0].children().map_or(0, |c| c.len()); + d.insert(Some(l), at, g).unwrap(); + (gid, ids) + } + #[test] fn bucket_click_fills_face_and_shift_click_strokes_edge() { let (d, gid, f2, _e) = live_group_doc(); @@ -1112,6 +1777,156 @@ mod tests { assert!(t.pointer(&cx, &ev(PointerKind::Down, 75.0, 25.0).with_mods(shift)).is_empty()); } + #[test] + fn live_paint_selection_click_outside_the_faces_selects_the_edge_it_highlights() { + let mut d = Document::new(500.0, 500.0); + let (_, [_, _, right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = LivePaintSelectionTool::default(); + // Alt held, and Select Fills off without Alt: 2 pt right of the square, the pointer + // highlights the square's right side, and a click selects that side (Shift-click adds it). + for (fills, m) in [(true, alt), (false, Mods::default())] { + t.set_option("selectFills", &json!(fills)); + t.pointer(&cx, &ev(PointerKind::Move, 102.0, 50.0).with_mods(m)); + let ov = t.overlays(&cx); + let [Overlay::Path { path, .. }] = ov.as_slice() else { panic!("{ov:?}") }; + assert_eq!(path.bounding_box(), Rect::new(100.0, 0.0, 100.0, 100.0)); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(m)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [right.0] }))]); + let acts = t.pointer(&cx, &ev(PointerKind::Down, 102.0, 50.0).with_mods(Mods { shift: true, ..m })); + assert_eq!(acts, vec![Action::Exec("select.toggle".into(), json!({ "id": right.0 }))]); + } + // Farther than 4 pixels from every edge: no highlight, and a click selects nothing. + t.pointer(&cx, &ev(PointerKind::Move, 106.0, 50.0)); + assert!(t.overlays(&cx).is_empty()); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 106.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + } + + #[test] + fn live_paint_edges_outside_the_faces_come_from_the_nearest_visible_group() { + let mut d = Document::new(500.0, 500.0); + let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0)); + let s = Selection::default(); + let p = paint(); + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + // 1.5 pt from the back group's right side and 2.5 pt from the front group's left side. + let (x, y) = (101.5, 50.0); + let edge_x = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box().x0), + _ => panic!("{ov:?}"), + }; + // Live Paint Selection (Alt) and Live Paint Bucket (Shift) highlight and pick the same edge. + let check = |d: &Document, want: Option<(NodeId, NodeId, f64)>| { + let cx = cx(d, &s, &p); + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(alt)); + assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2)); + let picked = match want { + Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), + None => Action::Exec("select.none".into(), json!({})), + }; + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(alt)), vec![picked]); + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, x, y).with_mods(shift)); + assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2)); + let stroked = want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, y], "tolerance": 4.0 }))); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, y).with_mods(shift)), stroked.into_iter().collect::>()); + }; + check(&d, Some((back, back_right, 100.0))); + // A hidden group's edges are not highlighted or picked. + d.node_mut(back).unwrap().visible = false; + check(&d, Some((front, front_left, 104.0))); + d.node_mut(front).unwrap().visible = false; + check(&d, None); + } + + #[test] + fn live_paint_tools_skip_hidden_edges() { + let mut d = Document::new(500.0, 500.0); + let (_, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, [front_left, ..]) = live_square(&mut d, Rect::new(104.0, 0.0, 204.0, 100.0)); + // The back group's right side is hidden on its own; both groups stay visible. + d.node_mut(back_right).unwrap().visible = false; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let shift = Mods { shift: true, ..Default::default() }; + let edge_x = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box().x0), + _ => panic!("{ov:?}"), + }; + // Outside the faces, 1.5 pt from the hidden side and 2.5 pt from the front group's left + // side, both tools highlight and pick the visible side. Inside the back face, 1.5 pt from + // the hidden side, no edge is in reach, so neither tool highlights or picks one. + for (x, want) in [(101.5, Some((front, front_left, 104.0))), (98.5, None)] { + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(alt)); + assert_eq!(edge_x(&sel.overlays(&cx)), want.map(|w| w.2), "x {x}"); + let picked = match want { + Some((_, e, _)) => Action::Exec("select.set".into(), json!({ "ids": [e.0] })), + None => Action::Exec("select.none".into(), json!({})), + }; + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(alt)), vec![picked], "x {x}"); + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, x, 50.0).with_mods(shift)); + assert_eq!(edge_x(&bucket.overlays(&cx)), want.map(|w| w.2), "x {x}"); + let stroked = + want.map(|(g, ..)| Action::Exec("livePaint.strokeEdge".into(), json!({ "group": g.0, "point": [x, 50.0], "tolerance": 4.0 }))); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, x, 50.0).with_mods(shift)), stroked.into_iter().collect::>(), "x {x}"); + } + } + + #[test] + fn live_paint_tools_skip_hidden_faces() { + let mut d = Document::new(500.0, 500.0); + let (back, [_, _, back_right, _]) = live_square(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0)); + let (front, _) = live_square(&mut d, Rect::new(50.0, 0.0, 150.0, 100.0)); + let face = |g: NodeId| faces(d.node(g).unwrap())[0].id; + let (back_face, hidden) = (face(back), face(front)); + // The front group's face is hidden on its own; both groups stay visible. + d.node_mut(hidden).unwrap().visible = false; + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let part = |ov: &[Overlay]| match ov { + [] => None, + [Overlay::Path { path, .. }] => Some(path.bounding_box()), + _ => panic!("{ov:?}"), + }; + // Where the faces overlap, both tools take the back group's face under the hidden one. + let mut bucket = LivePaintBucketTool::default(); + bucket.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&bucket.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + let fill = Action::Exec("livePaint.fill".into(), json!({ "group": back.0, "point": [75.0, 50.0] })); + assert_eq!(bucket.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![fill]); + let mut sel = LivePaintSelectionTool::default(); + sel.pointer(&cx, &ev(PointerKind::Move, 75.0, 50.0)); + assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(0.0, 0.0, 100.0, 100.0))); + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 75.0, 50.0)), vec![Action::Exec("select.set".into(), json!({ "ids": [back_face.0] }))]); + // Over the hidden face alone, neither tool highlights or picks a face. + bucket.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&bucket.overlays(&cx)), None); + assert!(bucket.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)).is_empty()); + sel.pointer(&cx, &ev(PointerKind::Move, 125.0, 50.0)); + assert_eq!(part(&sel.overlays(&cx)), None); + assert_eq!(sel.pointer(&cx, &ev(PointerKind::Down, 125.0, 50.0)), vec![Action::Exec("select.none".into(), json!({}))]); + // Inside the hidden face, 1.5 pt from the back group's right side, Alt-click takes that side + // as it does outside every face. + sel.pointer(&cx, &ev(PointerKind::Move, 101.5, 50.0).with_mods(alt)); + assert_eq!(part(&sel.overlays(&cx)), Some(Rect::new(100.0, 0.0, 100.0, 100.0))); + let picked = sel.pointer(&cx, &ev(PointerKind::Down, 101.5, 50.0).with_mods(alt)); + assert_eq!(picked, vec![Action::Exec("select.set".into(), json!({ "ids": [back_right.0] }))]); + } + #[test] fn live_paint_selection_options_set_the_highlight() { let (d, ..) = live_group_doc(); diff --git a/crates/tools/src/corners.rs b/crates/tools/src/corners.rs index b710fc7c7..c9281fade 100644 --- a/crates/tools/src/corners.rs +++ b/crates/tools/src/corners.rs @@ -54,9 +54,10 @@ struct PathWidgets { impl PathWidgets { /// The widgets of path `id` at `zoom` (screen px per document point), if it has any: any path - /// with Direct Selection (`any_path`), live rectangles and polygons with Selection. + /// with Direct Selection (`any_path`), live rectangles and polygons with Selection. `id` is + /// editable ([`Document::is_editable`]). fn of(doc: &Document, selection: &Selection, id: NodeId, zoom: f64, any_path: bool) -> Option { - if !doc.is_editable(id) || selection.segments.contains_key(&id) { + if selection.segments.contains_key(&id) { return None; } let NodeKind::Path { path, live, .. } = &doc.node(id)?.kind else { return None }; @@ -103,7 +104,9 @@ impl CornerWidgets { /// The widgets at `zoom` (screen px per document point), if the selection has them: on any /// path with Direct Selection (`any_path`), on live rectangles and polygons with Selection. pub fn of(doc: &Document, selection: &Selection, zoom: f64, any_path: bool) -> Option { - let paths: Vec<_> = selection.objects.iter().filter_map(|id| PathWidgets::of(doc, selection, *id, zoom, any_path)).collect(); + let editable = doc.editable(); + let paths: Vec<_> = + selection.objects.iter().filter(|id| editable(**id)).filter_map(|id| PathWidgets::of(doc, selection, *id, zoom, any_path)).collect(); (!paths.is_empty()).then_some(Self { paths }) } @@ -399,7 +402,7 @@ mod tests { /// A live `w` × `h` rectangle with corner radius `r`. fn live_shape(w: f64, h: f64, r: f64, xf: Affine) -> (Document, NodeId) { - let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf }; + let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf }; doc_with(live.to_path(), Some(live)) } @@ -482,7 +485,14 @@ mod tests { #[test] fn widgets_of_several_selected_paths_drag_together() { let (mut d, a) = live_rect(0.0, Affine::translate((100.0, 100.0))); - let live = LiveShape::Rectangle { w: 100.0, h: 100.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((300.0, 100.0)) }; + let live = LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [0.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::translate((300.0, 100.0)), + }; let b = d.alloc_id(); let mut n = Node::path(b, live.to_path(), Appearance::default_art()); if let NodeKind::Path { live: slot, .. } = &mut n.kind { @@ -638,6 +648,7 @@ mod tests { h: 100.0, radii: [0.0, top_right, 0.0, 0.0], kinds: Default::default(), + radii_relative: false, xf: Affine::translate((100.0, 100.0)), }; let (d, id) = doc_with(live.to_path(), Some(live)); @@ -788,7 +799,8 @@ mod tests { /// cuts outline in red. #[test] fn a_live_polygon_rounds_with_either_tool() { - let live = LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![] }; + let live = + LiveShape::Polygon { radius: 80.0, sides: 6, xf: Affine::translate((200.0, 200.0)), radii: vec![], kinds: vec![], radii_relative: false }; let (d, id) = doc_with(live.to_path(), Some(live)); let s = selected(id); let p = paint(); diff --git a/crates/tools/src/cropimage.rs b/crates/tools/src/cropimage.rs index 512f4140a..52f347e91 100644 --- a/crates/tools/src/cropimage.rs +++ b/crates/tools/src/cropimage.rs @@ -285,6 +285,7 @@ mod tests { xf: Affine::translate(at) * Affine::scale(2.0), link: None, placement: Default::default(), + pixels: None, }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let mut s = Selection::default(); diff --git a/crates/tools/src/cursors.toml b/crates/tools/src/cursors.toml index b2ee35ecd..1c0f707b1 100644 --- a/crates/tools/src/cursors.toml +++ b/crates/tools/src/cursors.toml @@ -130,7 +130,7 @@ look = "Arrow with a type badge (a letter T)." id = "LoadedText" group = "Selection" shown_when = [ - "The Selection tool after a click on the red out port of selected area type whose text overflows: the pointer carries that text until a click threads it into a closed path, a click on empty canvas makes a new frame of the same shape there, or a drag draws the new frame. Escape or another tool drops it.", + "The Selection tool after a click on the out port or the in port of selected area type or type on a path, threaded, overflowing or not: the pointer carries that text until a click threads it into a closed path, area type or type on a path, a click on empty canvas makes a new frame of the same kind and shape there, or a drag draws the new frame; from the in port the frame placed comes before the clicked one. Escape or another tool drops it.", ] hotspot = "Where the new frame's top-left corner goes (the click), or the path clicked." look = "A small block of text lines: the overflow text the pointer carries. Until it is drawn it borrows the I-beam (Text)." @@ -533,12 +533,26 @@ group = "Drawing and editing tools" shown_when = ["The Pencil tool: draws a freeform path; starting near the end of a selected open path continues it; Alt while drawing closes the path."] hotspot = "The lead tip." +[[cursor]] +id = "PencilClose" +group = "Drawing and editing tools" +shown_when = ["The Pencil tool with Alt held, before a press and while drawing, when Alt key toggles to Smooth Tool is off: releasing the mouse button with Alt still held closes the path."] +hotspot = "The lead tip." +look = "The Pencil cursor with a ring badge (closing the loop), like PenClose." + [[cursor]] id = "Paintbrush" group = "Drawing and editing tools" shown_when = ["The Paintbrush tool: draws a brushed freeform path (with pen pressure), continuing a selected path from its end; Alt closes it."] hotspot = "The bristle tip." +[[cursor]] +id = "PaintbrushClose" +group = "Drawing and editing tools" +shown_when = ["The Paintbrush tool with Alt held, before a press and while drawing: releasing the mouse button with Alt still held closes the path."] +hotspot = "The bristle tip." +look = "The Paintbrush cursor with a ring badge (closing the loop), like PenClose." + [[cursor]] id = "BlobBrush" group = "Drawing and editing tools" @@ -554,7 +568,10 @@ hotspot = "The eraser's centre." [[cursor]] id = "Smooth" group = "Drawing and editing tools" -shown_when = ["The Smooth tool: a drag along a selected path smooths it."] +shown_when = [ + "The Smooth tool: a drag along a selected path smooths it.", + "The Pencil tool with Alt key toggles to Smooth Tool on: with Alt held before a stroke begins to draw, and through a drag that smooths.", +] hotspot = "The tip." [[cursor]] diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 19f3da4b2..ca39112f6 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -43,24 +43,27 @@ use crate::guides::{HandleSnap, Leave, PointSnap, Targets}; use crate::meshedit::MeshEdit; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::rulerguide::GuideEdit; -use crate::select::{MoveSnap, is_area_type, matrix_json, release_guide_at}; +use crate::select::{MoveSnap, hold_point, is_area_type, matrix_json, release_guide_at}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Debug)] enum State { Idle, /// Dragging the selected anchors, which move alike: `grab` is where the anchor pressed on (or - /// the pointer on a segment) was, the point that snaps. + /// the point of a straight segment nearest the pointer pressed on it) was, the point that + /// snaps. MoveAnchors { start: Point, grab: Point, began: bool, }, /// Moving (Alt: copying) the selected objects; `grab`: the anchor an Alt-press picked the - /// path by, which snaps as a dragged anchor does. + /// path by, which snaps as a dragged anchor does; else `hold`, where the press took hold of + /// the object ([`hold_point`]), snaps as the Selection tool's does. MoveObject { start: Point, grab: Option, + hold: Point, began: bool, /// Group Selection: Shift-click on a selected object toggles it off on release. deselect: Option, @@ -231,7 +234,8 @@ pub(crate) fn spines(cx: &ToolContext) -> Vec<(NodeId, PathData)> { } }); v.reverse(); - v.retain(|(id, _)| cx.doc.is_editable(*id)); + let editable = cx.doc.editable(); + v.retain(|(id, _)| editable(*id)); v } @@ -288,18 +292,59 @@ fn anchor_owners(cx: &ToolContext, f: fn(&Node) -> bool) -> Vec { } }); v.reverse(); - v.retain(|id| cx.doc.is_editable(*id)); + let editable = cx.doc.editable(); + v.retain(|id| editable(*id)); v } /// 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 -/// [`hovered_anchor`] marks it. +/// [`hovered_anchor`] marks it. In normal viewmode, an opaque fill in front occludes unselected +/// objects behind it (#3405). fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, Point)> { let mut owners = anchor_owners(cx, |n| matches!(n.kind, NodeKind::Path { .. }) || is_area_type(n)); owners.sort_by_key(|id| !cx.selection.contains(*id)); - owners.into_iter().find_map(|id| { + + // 1. Check selected objects first. Selected objects are NEVER occluded (#1508). + let mut unselected_idx = owners.len(); + for (i, &id) in owners.iter().enumerate() { + if !cx.selection.contains(id) { + unselected_idx = i; + break; + } + if let Some(pd) = cx.doc.node(id).and_then(editable_path) + && let Some((si, ai, a)) = nearest_anchor(&pd, p, tol) + { + return Some((id, si, ai, a)); + } + } + + // 2. For unselected objects: in normal viewmode, an opaque fill in front occludes + // unselected objects behind it (#3405). + let unselected = &owners[unselected_idx..]; + if unselected.is_empty() { + return None; + } + + let filter: Option bool>> = if !cx.outline + && let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) + && matches!(h.kind, HitKind::Fill) + { + let leaf = h.leaf; + let ancestry = h.ancestry.clone(); + let part = cx.doc.node(leaf).and_then(|n| compound_part_at(n, p, tol)); + Some(Box::new(move |id| id == leaf || ancestry.contains(&id) || part == Some(id))) + } else { + None + }; + + unselected.iter().copied().find_map(|id| { + if let Some(f) = &filter + && !f(id) + { + return None; + } let pd = cx.doc.node(id).and_then(editable_path)?; nearest_anchor(&pd, p, tol).map(|(si, ai, a)| (id, si, ai, a)) }) @@ -437,7 +482,7 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; return out; } - let Some(mut h) = hit_test(cx.doc, p, cx.hit_options()) else { + let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) else { self.state = State::Marquee { start: p, cur: p, toggle: ev.mods.shift, enclosed: false }; return vec![]; }; @@ -471,7 +516,7 @@ impl Tool for DirectSelectionTool { out.push(Action::Exec("select.set".into(), json!({"ids": [target.0]}))); } } - self.state = State::MoveObject { start: p, grab: None, began: false, deselect }; + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect }; out } (PointerKind::DoubleClick, _) if self.group => release_guide_at(cx, p, ev.mods).into_iter().collect(), @@ -486,6 +531,7 @@ impl Tool for DirectSelectionTool { if crate::rulerguide::guide_at(cx, p).is_some() { return vec![]; } + // A double-click edits what is drawn: not a clipping path's outline. if hit_test(cx.doc, p, cx.hit_options()).and_then(|h| cx.doc.node(h.leaf)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) { return vec![Action::SwitchTool("type".into())]; } @@ -535,7 +581,7 @@ impl Tool for DirectSelectionTool { } // Alt picks the whole path (and Alt-drag copies it), as on a segment. if ev.mods.alt { - self.state = State::MoveObject { start: p, grab: Some(grab), began: false, deselect: None }; + self.state = State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None }; let whole = cx.selection.contains(id) && cx.selection.partial(id).is_none(); return if whole { vec![] } else { vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))] }; } @@ -546,7 +592,7 @@ impl Tool for DirectSelectionTool { // Paths all selected whole move as objects, held by this anchor, so Alt can turn // the copy on and off during the drag as with the Selection tool. self.state = if already && cx.selection.anchors.is_empty() { - State::MoveObject { start: p, grab: Some(grab), began: false, deselect: None } + State::MoveObject { start: p, grab: Some(grab), hold: grab, began: false, deselect: None } } else { State::MoveAnchors { start: p, grab, began: false } }; @@ -573,25 +619,36 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; return out; } - if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { + if let Some(mut h) = hit_test(cx.doc, p, cx.selection_hit_options()) { + if let Some(part) = cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)) { + h.ancestry.push(part); + h.leaf = part; + } // A segment pick keeps its endpoints available for movement without selecting // the anchors themselves. Dragging reshapes a curve, else moves it. Alt picks // the whole path (and Alt-drag copies it), as with Group Selection. let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); - if !ev.mods.shift - && !ev.mods.alt + if !ev.mods.alt && matches!(h.kind, HitKind::Stroke | HitKind::Outline) && let Some(s) = segment_at(cx, h.leaf, p) { - if whole { + if whole && !ev.mods.shift { self.click_segment = Some((h.leaf, s.si, s.seg)); } else { let selected = cx.selection.segments.get(&h.leaf).is_some_and(|sel| sel.contains(&(s.si, s.seg))); - self.state = if s.curved { + self.state = if s.curved && !ev.mods.shift { State::Segment { id: h.leaf, si: s.si, seg: s.seg, t: s.t, start: p, began: false } } else { - State::MoveAnchors { start: p, grab: p, began: false } + // Held by the segment itself, so the segment is what lands on a + // ruler guide or lines up, not the pressed spot beside it (#3701). + State::MoveAnchors { start: p, grab: hold_point(cx, &h, p), began: false } }; + if ev.mods.shift { + return vec![Action::Exec( + "select.segments".into(), + json!({"id": h.leaf.0, "segments": [[s.si, s.seg]], "mode": "toggle"}), + )]; + } if selected { return vec![]; } @@ -599,7 +656,7 @@ impl Tool for DirectSelectionTool { } } // Clicking the fill selects the whole leaf path (all anchors). - self.state = State::MoveObject { start: p, grab: None, began: false, deselect: None }; + self.state = State::MoveObject { start: p, grab: None, hold: hold_point(cx, &h, p), began: false, deselect: None }; if ev.mods.shift { return vec![Action::Exec("select.toggle".into(), json!({"id": h.leaf.0}))]; } @@ -621,7 +678,7 @@ impl Tool for DirectSelectionTool { self.state = State::MoveAnchors { start, grab, began: true }; self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); } - let mut d = move_delta(start, p, ev.mods.shift); + let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let unsnapped = d; // The grabbed anchor snaps and the others follow it. Shift keeps the move at its // angle, and the anchor slides along it onto what Smart Guides find there (#886). @@ -651,14 +708,14 @@ impl Tool for DirectSelectionTool { )); out } - (PointerKind::Drag, State::MoveObject { start, grab, began, .. }) => { + (PointerKind::Drag, State::MoveObject { start, grab, hold, began, .. }) => { let mut out = vec![]; if !began { if p.distance(start) < cx.tol(3.0) { return out; } out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); - self.state = State::MoveObject { start, grab, began: true, deselect: None }; + self.state = State::MoveObject { start, grab, hold, began: true, deselect: None }; if grab.is_some() { // Alt can go down or up at any time: gather both while nothing has moved. self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); @@ -667,7 +724,7 @@ impl Tool for DirectSelectionTool { self.move_snap = Some(MoveSnap::new(cx)); } } - let mut d = move_delta(start, p, ev.mods.shift); + let mut d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); let unsnapped = d; self.guides.clear(); let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; @@ -676,7 +733,7 @@ impl Tool for DirectSelectionTool { let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); (d, self.guides) = (q - grab, guides); } else if let Some(snap) = &self.move_snap { - (d, self.guides) = if ev.mods.shift { snap.snap_along(cx, start, d, ev.mods.alt) } else { snap.snap(cx, start, d, ev.mods.alt) }; + (d, self.guides) = if ev.mods.shift { snap.snap_along(cx, hold, d, ev.mods.alt) } else { snap.snap(cx, hold, d, ev.mods.alt) }; } self.snapped = snapped(d, unsnapped, ev.mods.shift, &self.guides); out.push(Action::Preview( @@ -693,7 +750,7 @@ impl Tool for DirectSelectionTool { } out.push(Action::Begin("Reshape Type Area".into())); } - let d = move_delta(start, p, ev.mods.shift); + let d = move_delta(start, p, ev.mods.shift, cx.constrain_angle); out.push(Action::Preview("text.reshapeArea".into(), json!({"id": id.0, "anchors": anchors_json(&anchors), "dx": d.x, "dy": d.y}))); self.state = State::TypeArea { id, anchors, start, began: true }; out @@ -716,7 +773,7 @@ impl Tool for DirectSelectionTool { out.push(Action::Begin("Reshape Spine".into())); self.state = State::SpinePoint { id, anchor, from, start, began: true }; } - let q = from + move_delta(start, p, ev.mods.shift); + let q = from + move_delta(start, p, ev.mods.shift, cx.constrain_angle); out.push(Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": anchor, "x": q.x, "y": q.y}))); out } @@ -810,7 +867,8 @@ impl Tool for DirectSelectionTool { // (members of groups too), each whole, as a click selects one. if enclosed { let mut touched = vectorcraft_doc::hit::marquee(cx.doc, r, cx.isolation, true); - touched.retain(|id| cx.doc.is_editable(*id)); + let editable = cx.doc.editable(); + touched.retain(|id| editable(*id)); let ids: Vec = crate::select::inside(cx, r, touched).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(); @@ -833,7 +891,8 @@ impl Tool for DirectSelectionTool { sel.push((id, vec![])); } } - sel.retain(|(id, _)| cx.doc.is_editable(*id)); + let editable = cx.doc.editable(); + sel.retain(|(id, _)| editable(*id)); let items: Vec = sel.iter().map(|(id, v)| json!({"id": id.0, "anchors": anchors_json(v)})).collect(); let art = Action::Exec("select.anchorsMany".into(), json!({"items": items, "mode": if toggle { "toggle" } else { "set" }})); std::iter::once(art).chain(crate::rulerguide::marquee_select(cx, r, false)).collect() @@ -942,7 +1001,7 @@ impl DirectSelectionTool { if m.shift || self.guide.cursor(cx, p).is_some() { return None; } - let h = hit_test(cx.doc, p, cx.hit_options())?; + let h = hit_test(cx.doc, p, cx.selection_hit_options())?; let whole = cx.selection.contains(h.leaf) && cx.selection.partial(h.leaf).is_none(); let curved = matches!(h.kind, HitKind::Stroke | HitKind::Outline) && segment_at(cx, h.leaf, p).is_some_and(|s| s.curved); (!whole && curved).then_some(Cursor::ReshapeSegment) @@ -952,8 +1011,9 @@ impl DirectSelectionTool { /// The anchor within a press's reach of `p` ([`ToolContext::point_tol`]), of a selected path or the /// path under `p`: what Highlight anchors on mouse over marks (a press there picks it). fn hovered_anchor(cx: &ToolContext, p: Point) -> Option<(NodeId, Point)> { - let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.leaf); let tol = cx.point_tol(); + let under = + hit_test(cx.doc, p, cx.selection_hit_options()).map(|h| cx.doc.node(h.leaf).and_then(|n| compound_part_at(n, p, tol)).unwrap_or(h.leaf)); cx.selection.objects.iter().copied().filter(|id| in_scope(cx.doc, cx.isolation, *id)).chain(under).find_map(|id| { let path = cx.doc.node(id).and_then(editable_path)?; nearest_anchor(&path, p, tol).map(|(.., a)| (id, a)) @@ -1077,6 +1137,27 @@ mod tests { assert_ne!(press(&zoomed, 102.6, 100.2), corner); } + /// A press on a clip group's clipping path, where the art it clips lies over its edge, takes + /// the clipping path's segment, so the mask can be reshaped on the canvas; inside the mask the + /// art is taken, as before. + #[test] + fn a_press_on_a_clipping_path_takes_its_segment_over_the_art() { + let (d, _, clip, art) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let press = |x: f64, y: f64| DirectSelectionTool::new(false).pointer(&c, &PointerEvent::new(PointerKind::Down, x, y)); + let names = |actions: &[Action], id: NodeId| { + actions.iter().any(|a| matches!(a, Action::Exec(_, v) if v["id"] == json!(id.0) || v["ids"] == json!([id.0]))) + }; + // The middle of the right edge, on it and just inside it. + for x in [500.0, 498.5] { + let a = press(x, 450.0); + assert!(names(&a, clip), "at {x}: {a:?}"); + } + let a = press(450.0, 450.0); + assert!(names(&a, art) && !names(&a, clip), "{a:?}"); + } + /// A blend of two 20 pt squares centred on (110, 310) and (210, 310), selected. fn blend_doc() -> (vectorcraft_doc::Document, NodeId, NodeId) { let (mut d, _) = doc_with_rect(); @@ -1944,7 +2025,7 @@ mod tests { assert!(near(&v, (-100.0, -100.0)) && labels == ["anchor"], "{v} {labels:?}"); // …and with nothing in reach, at the pointer's distance along its angle. let (v, labels) = drag(&mut t, &c, (300.0, 300.0), (380.0, 302.0), shift); - let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true); + let s = move_delta(Point::new(300.0, 300.0), Point::new(380.0, 302.0), true, 0.0); assert!(near(&v, (s.x, s.y)) && labels.is_empty(), "{v} {labels:?}"); // Two anchors: the one pressed on (B's top-right) lands on A's corner. let two = anchors_of(b, &[(0, 0), (0, 1)]); @@ -1998,6 +2079,29 @@ mod tests { } } + /// #1816: Group Selection and Direct Selection move a whole path with Snap to Grid as the + /// Selection tool does, landing an edge on the grid; the center does not snap. A 45 pt square at + /// 90–135 on the 9 pt grid, dragged right by 22 to 112–157, lands its left edge on the gridline + /// 108 and moves by 18. Its center, at 134.5 after the drag, is nearer the gridline 135 and + /// does not snap. + #[test] + fn a_move_of_objects_lands_an_edge_on_the_grid() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(90.0, 90.0, 135.0, 135.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let p = paint(); + let mut s = Selection::default(); + s.set([id]); + let c = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + for group in [true, false] { + let mut t = DirectSelectionTool::new(group); + let (v, _) = drag(&mut t, &c, (110.0, 110.0), (132.0, 110.0), Mods::default()); + assert_eq!(v["matrix"], json!([1.0, 0.0, 0.0, 1.0, 18.0, 0.0]), "group: {group}"); + } + } + /// Group Selection and Direct Selection on a whole path keep a Shift-constrained move on its /// line when it snaps, as the Selection tool does: level by 13.04 with Snap to Grid on, B's /// right side (413.04) lands on the gridline 414, and its top stays at 300, between 297 and @@ -2018,6 +2122,16 @@ mod tests { } } + /// Direct Selection's double-click on type where a layer's clipping mask edge runs through it + /// edits the type, though a press there takes the mask's segment. + #[test] + fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() { + let (d, _, at) = doc_with_type_on_a_layer_masks_edge(); + let (s, p) = (Selection::default(), paint()); + let acts = DirectSelectionTool::new(false).pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::DoubleClick, at.x, at.y)); + assert_eq!(acts, vec![Action::SwitchTool("type".into())]); + } + #[test] fn double_clicking_type_switches_to_type_tool() { use std::sync::Arc; @@ -2049,4 +2163,138 @@ mod tests { let acts_group_text = t.pointer(&c2, &PointerEvent::new(PointerKind::DoubleClick, in_group_b.center().x, in_group_b.center().y)); assert_eq!(acts_group_text, vec![Action::SwitchTool("type".into())]); } + + #[test] + fn direct_selection_click_and_drag_member_of_compound_path() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + let (outer, hole, compound) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let rect = |id, a: f64, b: f64| { + std::sync::Arc::new(vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(a, a, b, b)), Default::default())) + }; + let mut c = vectorcraft_doc::Node::new( + compound, + vectorcraft_doc::NodeKind::Compound { + children: vec![rect(outer, 0.0, 200.0), rect(hole, 50.0, 150.0)], + rule: vectorcraft_geom::FillRule::EvenOdd, + }, + ); + c.appearance = vectorcraft_doc::Appearance::default_art().into(); + d.insert(Some(l), 0, c).unwrap(); + let p = paint(); + let none = Selection::default(); + let mut t = DirectSelectionTool::new(false); + let ctx = cx(&d, &none, &p); + let down = t.pointer(&ctx, &PointerEvent::new(PointerKind::Down, 25.0, 100.0)); + assert_eq!( + down, + vec![Action::Exec("select.set".into(), json!({"ids": [outer.0]}))], + "Direct Selection selects child member, not compound parent" + ); + let mut s = Selection::default(); + s.set([outer]); + let ctx_sel = cx(&d, &s, &p); + let drag = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Drag, 35.0, 110.0)); + assert!(drag.iter().any(|a| matches!(a, Action::Preview(cmd, _) if cmd == "object.transform")), "drags the selected child member: {drag:?}"); + let up = t.pointer(&ctx_sel, &PointerEvent::new(PointerKind::Up, 35.0, 110.0)); + assert_eq!(up, vec![Action::Commit]); + } + + #[test] + fn shift_clicking_anchor_after_segment_pick_toggles_or_adds() { + let (d, id) = doc_with_rect(); + let p = paint(); + let mut s = Selection::default(); + s.set([id]); + s.segments.insert(id, [(0, 0)].into()); + s.anchors.insert(id, [(0, 0), (0, 1)].into()); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let shift = Mods { shift: true, ..Default::default() }; + // Shift-click on anchor (0, 0) (top-left at 100, 100) toggles it off + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); + // Shift-click on anchor (0, 2) (bottom-right at 200, 200) toggles it on (adds it) + let a2 = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 200.0).with_mods(shift)); + assert_eq!(a2, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 2]], "mode": "toggle"}))]); + } + + #[test] + fn shift_clicking_segment_toggles_segment() { + let (d, id) = doc_with_rect(); + let p = paint(); + let s = Selection::default(); + let cx = cx(&d, &s, &p); + let mut t = DirectSelectionTool::new(false); + let shift = Mods { shift: true, ..Default::default() }; + // The rectangle is at 100..200. Segment 0 is the top edge (y=100, x between 100 and 200). + // Shift-click at (150, 100) selects segment 0 with mode "toggle". + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 100.0).with_mods(shift)); + assert_eq!(a, vec![Action::Exec("select.segments".into(), json!({"id": id.0, "segments": [[0, 0]], "mode": "toggle"}))]); + } + + /// In normal viewmode, clicking the fill of an open path in front selects the open path's shape + /// and anchors, and NEVER clicks through to select or grab a path behind it (even if the path + /// behind has an anchor directly under the click). + #[test] + fn clicking_fill_of_open_path_in_front_never_selects_path_behind() { + use vectorcraft_geom::{Anchor, SubPath}; + let (mut d, _behind) = doc_with_rect(); + // `behind` has an anchor at (100.0, 100.0). + // Create an open path in front (id `front`), with a fill, covering (100.0, 100.0). + let front = d.alloc_id(); + let l = d.layers[0].id; + // Triangle from (50, 50) to (150, 50) to (150, 150), open (implicit closing edge from (150, 150) to (50, 50)). + // (100.0, 100.0) is inside this filled triangle, but `front` has NO anchor at (100.0, 100.0). + let anchors = vec![Anchor::corner(Point::new(50.0, 50.0)), Anchor::corner(Point::new(150.0, 50.0)), Anchor::corner(Point::new(150.0, 150.0))]; + let open_path = PathData::single(SubPath::new(anchors, false)); + let ap = vectorcraft_doc::Appearance::default_art(); // has solid white fill + d.insert(Some(l), 1, vectorcraft_doc::Node::path(front, open_path, ap)).unwrap(); + + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + + // Hovering over (100.0, 100.0) does NOT hover the hidden anchor of `behind`. + assert_eq!(hovered_anchor(&c, Point::new(100.0, 100.0)), None); + + // Clicking at (100.0, 100.0) selects `front` (the open path in front), NOT `behind`. + let mut t = DirectSelectionTool::new(false); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [front.0]}))]); + } + + /// A selected path's Bézier direction handle positioned over a background filled rectangle + /// can be hit and dragged without being occluded by the background fill. + #[test] + fn selected_path_handle_over_background_rect_is_draggable() { + use vectorcraft_geom::{Anchor, SubPath}; + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + let l = d.layers[0].id; + // Background rectangle covering 0..300 with default white fill + let bg = d.alloc_id(); + let bg_rect = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 300.0, 300.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(bg, bg_rect, vectorcraft_doc::Appearance::default_art())).unwrap(); + + // Selected path with a Bézier curve whose out-handle is at (150, 150) + let fg = d.alloc_id(); + let mut a0 = Anchor::corner(Point::new(100.0, 150.0)); + a0.h_out = Point::new(150.0, 150.0); // handle over the background rect + let a1 = Anchor::corner(Point::new(200.0, 150.0)); + let curve = PathData::single(SubPath::new(vec![a0, a1], false)); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(fg, curve, vectorcraft_doc::Appearance::default_art())).unwrap(); + + let mut s = Selection::default(); + s.set([fg]); + s.anchors.insert(fg, [(0, 0)].into()); + let p = paint(); + let c = cx(&d, &s, &p); + + // hit_handle on the handle at (150, 150) must find the handle of `fg` + assert_eq!(hit_handle(&c, Point::new(150.0, 150.0), c.point_tol()), Some((fg, 0, 0, true))); + + // Direct Selection tool clicking on the handle initiates Reshape + let mut t = DirectSelectionTool::new(false); + let acts = t.pointer(&c, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!(acts, vec![Action::Begin("Reshape".into())]); + } } diff --git a/crates/tools/src/distort/liquify.rs b/crates/tools/src/distort/liquify.rs index de40eef35..1e917ffc4 100644 --- a/crates/tools/src/distort/liquify.rs +++ b/crates/tools/src/distort/liquify.rs @@ -388,7 +388,8 @@ fn subdivide(sp: &mut SubPath, prm: &LiquifyParams, c: Point, spacing: f64) { continue; } let len = cub.arclen(1e-3); - let k = (len / spacing).ceil() as usize; + // (No more pieces than the anchors left under the ceiling.) + let k = ((len / spacing).ceil() as usize).min(MAX_ANCHORS.saturating_sub(sp.anchors.len()) + 1); if k <= 1 { seg += 1; continue; diff --git a/crates/tools/src/distort/perspective.rs b/crates/tools/src/distort/perspective.rs index 4f172760b..f7bcacf43 100644 --- a/crates/tools/src/distort/perspective.rs +++ b/crates/tools/src/distort/perspective.rs @@ -549,6 +549,11 @@ impl PerspectiveGrid { let Some(h) = self.homography(plane) else { return vec![] }; let d = self.domain(plane); let mut out = vec![]; + // A stored grid is not checked: no spacing, or an extent that isn't a number, has no lines. + let (w, h_len) = (d.width(), d.height()); + if !(self.cell.is_finite() && self.cell > 0.0 && w.is_finite() && h_len.is_finite()) { + return out; + } let step = |len: f64| { let mut s = self.cell; while len / s > 120.0 { @@ -556,20 +561,22 @@ impl PerspectiveGrid { } s }; - let (su, sv) = (step(d.width()), step(d.height())); - let mut u = 0.0; - while u <= d.width() + 1e-9 { - if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, d.height()))) { + let (su, sv) = (step(w), step(h_len)); + // Counted, not summed: a count in whole lines (at most 121 a way). The fixed 1e-9 + // tolerance can inflate the count when the whole domain is far smaller than it, so the + // bound is capped on its own, before any loop runs. + let count = |len: f64, s: f64| ((len + 1e-9) / s).floor().clamp(0.0, 120.0) as usize; + for i in 0..=count(w, su) { + let u = i as f64 * su; + if let (Some(a), Some(b)) = (h.apply(Point::new(u, 0.0)), h.apply(Point::new(u, h_len))) { out.push((a, b)); } - u += su; } - let mut v = 0.0; - while v <= d.height() + 1e-9 { - if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(d.width(), v))) { + for i in 0..=count(h_len, sv) { + let v = i as f64 * sv; + if let (Some(a), Some(b)) = (h.apply(Point::new(0.0, v)), h.apply(Point::new(w, v))) { out.push((a, b)); } - v += sv; } out } diff --git a/crates/tools/src/distort/perspective/view.rs b/crates/tools/src/distort/perspective/view.rs index 676736cad..62523b501 100644 --- a/crates/tools/src/distort/perspective/view.rs +++ b/crates/tools/src/distort/perspective/view.rs @@ -123,6 +123,16 @@ mod tests { assert_eq!(g.snap_offset(b, Vec2::new(18.0, 0.0)), Vec2::new(18.0, 0.0), "nothing within reach"); } + #[test] + fn a_tiny_stored_grid_draws_a_bounded_number_of_lines() { + // A stored grid can carry extreme values: the loop bound must not depend on a fixed + // tolerance swallowing the whole domain. + let g = PerspectiveGrid { cell: 1e-18, extent: 1e-18, height: 1e-18, ..PerspectiveGrid::normal(2, Rect::new(0.0, 0.0, 800.0, 600.0)) }; + for pl in Plane::ALL { + assert!(g.lines(pl).len() <= 121 + 121, "at most 121 lines a way on {pl:?}"); + } + } + #[test] fn colours_and_rulers_come_from_the_definition() { let mut g = grid(); diff --git a/crates/tools/src/distort/width.rs b/crates/tools/src/distort/width.rs index ebe7d7ab5..aa43a46e1 100644 --- a/crates/tools/src/distort/width.rs +++ b/crates/tools/src/distort/width.rs @@ -137,8 +137,9 @@ pub fn sides_at(st: &StrokeLayer, t: f64) -> (f64, f64) { impl WidthTool { /// The stroked path nearest to `p` within reach (stroke half-width + a few pixels). fn spot_at(cx: &ToolContext, p: Point) -> Option { + let editable = cx.doc.editable(); let consider = |id: NodeId| -> Option<(f64, Spot)> { - if !cx.doc.is_editable(id) { + if !editable(id) { return None; } let n = cx.doc.node(id)?; diff --git a/crates/tools/src/draw2.rs b/crates/tools/src/draw2.rs index 9e28e8a3d..8d84eaf4d 100644 --- a/crates/tools/src/draw2.rs +++ b/crates/tools/src/draw2.rs @@ -243,17 +243,19 @@ fn candidate_paths(cx: &ToolContext) -> Vec { v.push(id); } } - v.retain(|id| cx.doc.is_editable(*id)); + let editable = cx.doc.editable(); + v.retain(|id| editable(*id)); v } /// The selected paths the Pen and the Curvature tool edit (not guides, nor locked or hidden ones). pub(crate) fn editable_paths<'a>(cx: &'a ToolContext) -> impl Iterator + 'a { + let editable = cx.doc.editable(); cx.selection .objects .iter() .copied() - .filter(|&id| cx.doc.is_editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. }))) + .filter(move |&id| editable(id) && cx.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Path { guide: false, .. }))) } /// The paths whose anchors show with the Pen: the selected ones, and those inside selected groups @@ -275,7 +277,8 @@ pub(crate) fn shown_paths(cx: &ToolContext) -> Vec { for n in cx.selection.objects.iter().filter_map(|id| cx.doc.node(*id)) { inside(n, &mut v); } - v.retain(|id| cx.doc.is_editable(*id)); + let editable = cx.doc.editable(); + v.retain(|id| editable(*id)); v } diff --git a/crates/tools/src/draw2/anchor.rs b/crates/tools/src/draw2/anchor.rs index 5ac491c61..e3813d38e 100644 --- a/crates/tools/src/draw2/anchor.rs +++ b/crates/tools/src/draw2/anchor.rs @@ -89,6 +89,8 @@ pub struct AnchorTool { /// Smart guides of the handle being dragged, and what it snaps to. guides: Vec, snap: HandleSnap, + /// Move offset while Shift-dragging an anchor or spine point (pointer, delta). + measure: Option<(Point, f64, f64)>, } impl AnchorTool { @@ -99,7 +101,7 @@ impl AnchorTool { "scissors" => "scissors", _ => "addAnchor", }; - Self { id, state: State::Idle, guides: vec![], snap: HandleSnap::default() } + Self { id, state: State::Idle, guides: vec![], snap: HandleSnap::default(), measure: None } } /// Nearest point of this eligible spine, in document coordinates. @@ -315,11 +317,15 @@ impl Tool for AnchorTool { json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}), ), State::Move { id, si, ai, from, start, .. } => { - let q = from + Self::move_delta(cx, start, p, ev.mods.shift); + let d = Self::move_delta(cx, start, p, ev.mods.shift); + self.measure = Some((p, d.x, d.y)); + let q = from + d; Action::Preview("path.moveAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "x": q.x, "y": q.y})) } State::SpineMove { id, ai, from, start, .. } => { - let q = from + Self::move_delta(cx, start, p, ev.mods.shift); + let d = Self::move_delta(cx, start, p, ev.mods.shift); + self.measure = Some((p, d.x, d.y)); + let q = from + d; Action::Preview("object.blend.spine.moveAnchor".into(), json!({"id": id.0, "anchor": ai, "x": q.x, "y": q.y})) } State::SpineConvert { id, ai, .. } => Action::Preview( @@ -369,6 +375,7 @@ impl Tool for AnchorTool { } ("anchorPoint", PointerKind::Up) => { self.guides.clear(); + self.measure = None; let st = std::mem::replace(&mut self.state, State::Idle); match st { State::Convert { began: true, .. } @@ -434,6 +441,7 @@ impl Tool for AnchorTool { } } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.measure = None; let st = std::mem::replace(&mut self.state, State::Idle); match st { State::Convert { began: true, .. } @@ -451,6 +459,11 @@ impl Tool for AnchorTool { if matches!(self.state, State::Handle { .. }) { out.extend(self.guides.iter().cloned()); } + if cx.measurement_labels + && let Some((p, dx, dy)) = self.measure + { + out.push(Overlay::Measure { p, text: cx.offset_label(dx, dy) }); + } out } fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { @@ -970,4 +983,26 @@ mod tests { t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(shift)); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0, 300.0).with_mods(shift)).is_empty()); } + + #[test] + fn anchor_point_shift_drag_honors_measurement_labels() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let sp = vectorcraft_geom::SubPath::polyline(&[Point::new(100.0, 300.0), Point::new(200.0, 300.0)], false); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, vectorcraft_geom::PathData::single(sp), vectorcraft_doc::Appearance::default_art())) + .unwrap(); + let s = Selection::default(); + let p = paint(); + let base = cx(&d, &s, &p); + let on = ToolContext { measurement_labels: true, ..base }; + let off = ToolContext { measurement_labels: false, ..base }; + let shift = Mods { shift: true, ..Mods::default() }; + let mut t = AnchorTool::new("anchorPoint"); + t.pointer(&on, &PointerEvent::new(PointerKind::Down, 200.0, 300.0).with_mods(shift)); + t.pointer(&on, &PointerEvent::new(PointerKind::Drag, 230.0, 340.0)); + let want = on.offset_label(30.0, 40.0); + assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); + assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); + } } diff --git a/crates/tools/src/draw2/curvature.rs b/crates/tools/src/draw2/curvature.rs index d2503be65..d81dc03e5 100644 --- a/crates/tools/src/draw2/curvature.rs +++ b/crates/tools/src/draw2/curvature.rs @@ -376,8 +376,8 @@ impl CurvatureTool { /// and the segment to it: the path being drawn, or the selected one whose end is current. fn leave(&self, cx: &ToolContext) -> Option<(NodeId, Leave)> { match self.active(cx) { - Some(id) => self.pts.last().map(|(p, _)| (id, Leave::segment(cx, *p, false))), - None => current_end(cx).map(|e| (e.id, Leave::segment(cx, e.p, false))), + Some(id) => self.pts.last().map(|(p, _)| (id, Leave::segment(cx, *p, false).without_contact())), + None => current_end(cx).map(|e| (e.id, Leave::segment(cx, e.p, false).without_contact())), } } @@ -420,7 +420,7 @@ impl CurvatureTool { return Some(out); } if let Some(e) = end { - let q = self.snap.press(cx, p, &[e.id], Some(&Leave::segment(cx, e.p, false))); + let q = self.snap.press(cx, p, &[e.id], Some(&Leave::segment(cx, e.p, false).without_contact())); let how = self.how(&e); self.placed = Some(Placed { id: e.id, si: e.si, start: e.start, anchors: e.anchors + 1, corner: ev.mods.alt }); let v = json!({"id": e.id.0, "subpath": e.si, "op": "extend", "end": e.name(), "x": q.x, "y": q.y, "from": how.name(), "corner": ev.mods.alt}); diff --git a/crates/tools/src/draw2/family.rs b/crates/tools/src/draw2/family.rs index 9b068bc05..82870b694 100644 --- a/crates/tools/src/draw2/family.rs +++ b/crates/tools/src/draw2/family.rs @@ -9,8 +9,8 @@ use serde_json::{Value, json}; use vectorcraft_geom::Point; -use crate::guides::{DrawSnap, Leave, square}; -use crate::shape::drag_rect; +use crate::guides::{DrawSnap, Leave}; +use crate::shape::{box_params, drag_rect, turned_box}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; pub struct FamilyTool { @@ -20,6 +20,12 @@ pub struct FamilyTool { mods: Mods, began: bool, pub closed: bool, + length_x: f64, + length_y: f64, + reference: usize, + base_along: &'static str, + slope: f64, + fill_arc: bool, pub decay: f64, pub segments: u32, pub clockwise: bool, @@ -35,6 +41,9 @@ pub struct FamilyTool { pub compound: bool, /// The grids take the current fill. pub fill: bool, + /// The grids' Default Size (width, height): the size a click's dialog opens with (a drag sets + /// its own). The Reference Point is `reference`. + pub grid_size: (f64, f64), snap: DrawSnap, } @@ -53,6 +62,12 @@ impl FamilyTool { mods: Mods::default(), began: false, closed: false, + length_x: 100.0, + length_y: 100.0, + reference: 0, + base_along: "x", + slope: 50.0, + fill_arc: false, decay: 80.0, segments: 10, clockwise: true, @@ -62,6 +77,7 @@ impl FamilyTool { frame: true, compound: false, fill: false, + grid_size: (100.0, 100.0), snap: DrawSnap::default(), } } @@ -76,13 +92,39 @@ impl FamilyTool { } /// How the dragged point keeps to the start: a spiral's radius goes anywhere, the arc's and - /// the grids' corner keeps to a diagonal with Shift. - fn leave(&self, start: Point, m: Mods) -> Option { - (self.id != "spiral").then(|| Leave::diagonal(start, m.shift)) + /// the grids' corner keeps to a diagonal with Shift (a grid's square is taken along the + /// Constrain Angle's axes instead when it turns them, [`Self::command_at`]). + fn leave(&self, cx: &ToolContext, start: Point, m: Mods) -> Option { + let turned = self.is_grid() && vectorcraft_geom::constrain_axes(cx.constrain_angle).is_some(); + (self.id != "spiral").then(|| Leave::diagonal(start, m.shift && !turned)) + } + + fn is_grid(&self) -> bool { + matches!(self.id, "rectangularGrid" | "polarGrid") + } + + /// A grid's options a drag draws with: its dividers, skews and switches (the Default Size and + /// Reference Point are for a click's dialog). + fn grid_options(&self) -> Value { + if self.id == "polarGrid" { + json!({ + "concentric": self.rows, "radial": self.columns, "concentricSkew": self.skew[0], "radialSkew": self.skew[1], "compound": self.compound, "fill": self.fill, + }) + } else { + json!({ + "rows": self.rows, "columns": self.columns, "rowSkew": self.skew[0], "columnSkew": self.skew[1], "frame": self.frame, "fill": self.fill, + }) + } } /// The command for a drag from `start` to `p`. pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) { + self.command_at(start, p, m, 0.0) + } + + /// [`Self::command`] with General › Constrain Angle `base` (degrees, counter-clockwise): the + /// grids are drawn along the x and y axes turned by it, as the rectangle is. + pub fn command_at(&self, start: Point, p: Point, m: Mods, base: f64) -> (String, Value) { match self.id { "spiral" => { let r = p.distance(start).max(0.01); @@ -91,19 +133,33 @@ impl FamilyTool { json!({"cx": start.x, "cy": start.y, "radius": r, "decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}), ) } - // The grids: the dragged box with the tool's options. + // The grids: the dragged box (along the Constrain Angle's axes) with the tool's options. "rectangularGrid" | "polarGrid" => { - let r = drag_rect(start, p, m); - let mut params = self.options(); - if let Some(o) = params.as_object_mut() { - o.extend([("x", r.x0), ("y", r.y0), ("width", r.width()), ("height", r.height())].map(|(k, v)| (k.to_string(), json!(v)))); + let mut params = self.grid_options(); + if let (Some(o), Value::Object(b)) = (params.as_object_mut(), box_params(start, p, m, base)) { + o.extend(b); } (format!("shape.{}", self.id), params) } _ => { - let d = if m.shift { square(p - start) } else { p - start }; - let (a, b) = if m.alt { (start - d, start + d) } else { (start, start + d) }; - ("shape.arc".into(), json!({"x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y, "closed": self.closed})) + let r = drag_rect(start, p, m).abs(); + let (w, h) = (r.width().max(1e-9), r.height().max(1e-9)); + let rp = vectorcraft_geom::reference_point(vectorcraft_geom::Rect::new(0.0, 0.0, w, h), self.reference.min(8)); + let anchor = Point::new(r.x0 + rp.x, r.y0 + rp.y); + ( + "shape.arc".into(), + json!({ + "x": anchor.x, + "y": anchor.y, + "lengthX": w, + "lengthY": h, + "reference": self.reference, + "baseAlong": self.base_along, + "slope": self.slope, + "closed": self.closed, + "fillArc": self.fill_arc, + }), + ) } } } @@ -131,7 +187,7 @@ impl Tool for FamilyTool { } PointerKind::Drag => { let Some(mut s) = self.start else { return vec![] }; - let pos = self.snap.drag(cx, ev.pos, self.leave(s, ev.mods).as_ref()); + let pos = self.snap.drag(cx, ev.pos, self.leave(cx, s, ev.mods).as_ref()); let ev = &PointerEvent { pos, ..*ev }; crate::shape::space_moves(&mut s, &mut self.last, ev, self.began); self.start = Some(s); @@ -144,7 +200,7 @@ impl Tool for FamilyTool { self.began = true; out.push(Action::Begin(self.label().into())); } - let (c, v) = self.command(s, ev.pos, ev.mods); + let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle); out.push(Action::Preview(c, v)); out } @@ -156,7 +212,7 @@ impl Tool for FamilyTool { _ => vec![], } } - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { + fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec { let Some(s) = self.start.filter(|_| self.began) else { return vec![] }; let changed = match (self.id, key) { ("spiral", ToolKey::Up) => { @@ -186,7 +242,7 @@ impl Tool for FamilyTool { _ => false, }; if changed { - let (c, v) = self.command(s, self.last, self.mods); + let (c, v) = self.command_at(s, self.last, self.mods, cx.constrain_angle); vec![Action::Preview(c, v)] } else if key == ToolKey::Escape { self.start = None; @@ -201,7 +257,8 @@ impl Tool for FamilyTool { let mut o = self.snap.guides().to_vec(); if let Some(s) = self.start.filter(|_| self.began && cx.measurement_labels) { // The size as drawn (from the centre with Alt, square with Shift). - let r = drag_rect(s, self.last, self.mods); + // A grid's along the Constrain Angle's axes, as it is drawn. + let r = if self.is_grid() { turned_box(s, self.last, self.mods, cx.constrain_angle).0 } else { drag_rect(s, self.last, self.mods) }; let (w, h) = if self.id == "spiral" { ((self.last - s).x.abs(), (self.last - s).y.abs()) } else { (r.width(), r.height()) }; o.push(Overlay::Measure { p: self.last, text: cx.size_label(w, h) }); } @@ -213,18 +270,37 @@ impl Tool for FamilyTool { fn options(&self) -> Value { match self.id { "spiral" => json!({"decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}), - "rectangularGrid" => json!({ - "rows": self.rows, "columns": self.columns, "rowSkew": self.skew[0], "columnSkew": self.skew[1], "frame": self.frame, "fill": self.fill, + "rectangularGrid" | "polarGrid" => { + let mut o = self.grid_options(); + o["width"] = json!(self.grid_size.0); + o["height"] = json!(self.grid_size.1); + o["reference"] = json!(self.reference); + o + } + _ => json!({ + "closed": self.closed, + "lengthX": self.length_x, + "lengthY": self.length_y, + "reference": self.reference, + "baseAlong": self.base_along, + "slope": self.slope, + "fillArc": self.fill_arc, }), - "polarGrid" => json!({ - "concentric": self.rows, "radial": self.columns, "concentricSkew": self.skew[0], "radialSkew": self.skew[1], "compound": self.compound, "fill": self.fill, - }), - _ => json!({"closed": self.closed}), } } fn set_option(&mut self, key: &str, v: &Value) { match key { "closed" => self.closed = v.as_bool().unwrap_or(self.closed), + "lengthX" => self.length_x = arc_length(v, self.length_x), + "lengthY" => self.length_y = arc_length(v, self.length_y), + "reference" => self.reference = v.as_u64().unwrap_or(self.reference as u64).min(8) as usize, + "baseAlong" => { + if let Some(s) = v.as_str() { + self.base_along = if s.eq_ignore_ascii_case("y") { "y" } else { "x" }; + } + } + "slope" => self.slope = v.as_f64().filter(|n| n.is_finite()).map_or(self.slope, |n| n.clamp(-100.0, 100.0)), + "fillArc" => self.fill_arc = v.as_bool().unwrap_or(self.fill_arc), "decay" => self.decay = v.as_f64().unwrap_or(self.decay).clamp(5.0, 150.0), "segments" => self.segments = v.as_u64().unwrap_or(10).clamp(2, 1000) as u32, "clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise), @@ -235,11 +311,24 @@ impl Tool for FamilyTool { "frame" => self.frame = v.as_bool().unwrap_or(self.frame), "compound" => self.compound = v.as_bool().unwrap_or(self.compound), "fill" => self.fill = v.as_bool().unwrap_or(self.fill), + "width" => self.grid_size.0 = grid_length(v, self.grid_size.0), + "height" => self.grid_size.1 = grid_length(v, self.grid_size.1), _ => {} } } } +/// Arc segment length from a dialog or preference value. +fn arc_length(v: &Value, current: f64) -> f64 { + v.as_f64().filter(|n| n.is_finite()).map_or(current, |n| n.abs().max(1e-9)) +} + +/// A grid's Default Size side from option value `v`: a finite positive length (at most 1e6 pt), +/// else `current`. +fn grid_length(v: &Value, current: f64) -> f64 { + v.as_f64().filter(|n| n.is_finite() && *n > 0.0).map_or(current, |n| n.min(1e6)) +} + /// A grid divider skew from option value `v`: a finite number within the range, else `current`. fn grid_skew(v: &Value, current: f64) -> f64 { let max = vectorcraft_geom::shapes::GRID_SKEW_MAX; @@ -299,7 +388,9 @@ mod tests { t.set_option(key, &value); } let options = json!({"concentric": 2, "radial": 5, "concentricSkew": -40.0, "radialSkew": 500.0, "compound": true, "fill": true}); - assert_eq!(t.options(), options); + let mut reported = options.clone(); + reported.as_object_mut().unwrap().extend(json!({"width": 100.0, "height": 100.0, "reference": 0}).as_object().unwrap().clone()); + assert_eq!(t.options(), reported); let (command, params) = t.command(Point::new(10.0, 20.0), Point::new(40.0, 80.0), Mods::default()); assert_eq!(command, "shape.polarGrid"); let mut want = options; @@ -308,7 +399,10 @@ mod tests { let mut t = FamilyTool::new("rectangularGrid"); t.set_option("frame", &json!(false)); t.set_option("rowSkew", &json!(12.5)); - assert_eq!(t.options(), json!({"rows": 5, "columns": 5, "rowSkew": 12.5, "columnSkew": 0.0, "frame": false, "fill": false})); + assert_eq!( + t.options(), + json!({"rows": 5, "columns": 5, "rowSkew": 12.5, "columnSkew": 0.0, "frame": false, "fill": false, "width": 100.0, "height": 100.0, "reference": 0}) + ); } #[test] @@ -327,7 +421,72 @@ mod tests { let t = FamilyTool::new("arc"); let (c, v) = t.command(Point::new(0.0, 0.0), Point::new(10.0, 4.0), Mods { shift: true, ..Default::default() }); assert_eq!(c, "shape.arc"); - assert_eq!(v, json!({"x1": 0.0, "y1": 0.0, "x2": 10.0, "y2": 10.0, "closed": false})); + assert_eq!( + v, + json!({ + "x": 0.0, + "y": 0.0, + "lengthX": 10.0, + "lengthY": 10.0, + "reference": 0, + "baseAlong": "x", + "slope": 50.0, + "closed": false, + "fillArc": false, + }) + ); + } + + #[test] + fn arc_drag_uses_stored_segment_options() { + let mut t = FamilyTool::new("arc"); + for (key, value) in [ + ("lengthX", json!(80.0)), + ("lengthY", json!(40.0)), + ("reference", json!(4)), + ("baseAlong", json!("y")), + ("slope", json!(-25.0)), + ("closed", json!(true)), + ("fillArc", json!(true)), + ] { + t.set_option(key, &value); + } + let (command, params) = t.command(Point::new(10.0, 20.0), Point::new(50.0, 70.0), Mods::default()); + assert_eq!(command, "shape.arc"); + assert_eq!( + params, + json!({ + "x": 30.0, + "y": 45.0, + "lengthX": 40.0, + "lengthY": 50.0, + "reference": 4, + "baseAlong": "y", + "slope": -25.0, + "closed": true, + "fillArc": true, + }) + ); + } + + /// General › Constrain Angle: grids are drawn along the turned axes, Shift's square too; at 0° + /// and whole turns as before. + #[test] + fn grids_follow_the_constrain_angle() { + let t = FamilyTool::new("polarGrid"); + let (a, b) = (Point::new(100.0, 100.0), Point::new(160.0, 120.0)); + let flat = t.command(a, b, Mods::default()); + assert_eq!(t.command_at(a, b, Mods::default(), 360.0), flat); + assert_eq!(t.command_at(a, b, Mods::default(), -720.0), flat); + let (_, v) = t.command_at(a, b, Mods::default(), 90.0); + assert_eq!( + v, + json!({"x": 120.0, "y": 80.0, "width": 20.0, "height": 60.0, "rotation": -90.0, "concentric": 5, "radial": 5, "concentricSkew": 0.0, "radialSkew": 0.0, "compound": false, "fill": false}) + ); + let shift = Mods { shift: true, ..Mods::default() }; + let (_, v) = FamilyTool::new("rectangularGrid").command_at(a, b, shift, 30.0); + assert!((v["width"].as_f64().unwrap() - v["height"].as_f64().unwrap()).abs() < 1e-9, "a square along the turned axes: {v}"); + assert_eq!(v["rotation"], json!(-30.0)); } /// The start and the dragged corner snap to Smart Guides (#506), Shift sliding the corner of @@ -348,4 +507,21 @@ mod tests { ); assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "align"))); } + + /// A grid's Default Size and Reference Point are options for a click's dialog; the drag + /// draws the box it is dragged to and leaves them out of its command. + #[test] + fn grid_default_size_is_an_option_the_drag_leaves_out() { + for id in ["rectangularGrid", "polarGrid"] { + let mut t = FamilyTool::new(id); + for (k, v) in [("width", json!(150.0)), ("height", json!(-3.0)), ("reference", json!(4))] { + t.set_option(k, &v); + } + let o = t.options(); + assert_eq!((o["width"].clone(), o["height"].clone(), o["reference"].clone()), (json!(150.0), json!(100.0), json!(4)), "{id}"); + let (_, p) = t.command(Point::new(0.0, 0.0), Point::new(40.0, 30.0), Mods::default()); + assert!(p.get("reference").is_none(), "{id}: {p}"); + assert_eq!((p["width"].clone(), p["height"].clone()), (json!(40.0), json!(30.0)), "{id}"); + } + } } diff --git a/crates/tools/src/draw2/gesture.rs b/crates/tools/src/draw2/gesture.rs index c0630d997..03f4c8779 100644 --- a/crates/tools/src/draw2/gesture.rs +++ b/crates/tools/src/draw2/gesture.rs @@ -3,12 +3,14 @@ //! //! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot) //! and commit on release; Alt on release (or ending near the start) closes the path, and starting -//! near an end of a selected open path continues it. The Pencil's new path starts and ends on +//! near an end of a selected open path continues it. Shift held at the press draws a straight +//! Pencil line at 45° steps from the Constrain Angle instead. The Pencil's new path starts and ends on //! Smart Guides ([`DrawSnap`]: an anchor, a path, in line with the art), hovering too; the points //! between follow the hand. The Paintbrush records the pen pressure of each point (a stroke a pen //! pressed less than fully on sends `[x, y, pressure]` points) for pressure-sensitive brushes. The //! other tools collect the drag polyline (shown as an overlay) and run -//! one command on release. +//! one command on release. With Shift held, the Blob Brush's points stay on the horizontal or +//! vertical line through the press point. use serde_json::{Value, json}; use vectorcraft_doc::NodeId; @@ -40,10 +42,18 @@ pub struct GestureTool { /// or merges into (on for the Pencil and Paintbrush, off for the Blob Brush). pub keep_selected: bool, /// "Alt key toggles to Smooth Tool": holding Alt with the Pencil tool smooths selected paths - /// instead of closing the path (Pencil only, on by default). + /// instead of closing the path (Pencil only, off by default). pub alt_smooth: bool, /// Active drag is smoothing with the Pencil tool. smoothing: bool, + /// Active Pencil drag draws a straight line (Shift held at the press): the stroke is the + /// press point and the pointer constrained to 45° steps from it. + straight: bool, + /// Where the pointer was for each Blob Brush point dragged since Shift went down: the last + /// `shift_raw.len()` of `points`. + shift_raw: Vec, + /// Where the pointer was for the Shift-constrained point the next [`Self::push`] adds. + shift_next: Option, /// "Merge only with selection": a Blob Brush stroke merges only with selected paths. pub merge_only_selection: bool, /// "Edit selected paths within N pixels". @@ -82,6 +92,9 @@ impl GestureTool { // Off, as Pencil Tool Options ships it: Alt keeps drawing a closed path until it is on. alt_smooth: false, smoothing: false, + straight: false, + shift_raw: vec![], + shift_next: None, merge_only_selection: false, edit_within: 12.0, close_within: 15.0, @@ -100,12 +113,50 @@ impl GestureTool { /// Add a stroke sample. fn push(&mut self, p: Point, pressure: f32) { + let raw = self.shift_next.take(); + if self.points.is_empty() { + self.shift_raw.clear(); + } else if let Some(raw) = raw { + self.shift_raw.push(raw); + } self.points.push(p); if self.id == "paintbrush" { self.pressures.push(pressure); } } + /// Where the stroke takes a pointer at `p`. For the Blob Brush with Shift held, that is the + /// horizontal or vertical line through the press point that is nearer in angle to `p` (90° + /// steps from the Constrain Angle), at `p`'s distance from the press point. The points dragged + /// since Shift went down move to that line too, each at its pointer position's distance and on + /// that position's side of the press point; a pointer at the press point leaves them on their + /// line. Without Shift the stroke takes `p`. + fn constrained(&mut self, cx: &ToolContext, p: Point, m: Mods) -> Point { + self.shift_next = None; + if self.straight + && let Some(&start) = self.points.first() + { + return start + vectorcraft_geom::constrain_angle_from(p - start, 45.0, cx.constrain_angle); + } + let Some(&start) = self.points.first().filter(|_| self.id == "blobBrush" && m.shift) else { + self.shift_raw.clear(); + return p; + }; + self.shift_next = Some(p); + // The press point is on both lines: the points dragged before stay on theirs. + if (p - start).hypot() < vectorcraft_geom::EPS { + return start; + } + let d = vectorcraft_geom::constrain_angle_from(p - start, 90.0, cx.constrain_angle); + // `line` is the line's angle, measured counterclockwise on screen as the Constrain Angle is measured. + let line = (-d.y).atan2(d.x).to_degrees(); + let on_line = |q: Point| start + vectorcraft_geom::constrain_angle_from(q - start, 180.0, line); + for (q, raw) in self.points.iter_mut().skip(1).rev().zip(self.shift_raw.iter().rev()) { + *q = on_line(*raw); + } + on_line(p) + } + /// Forget the stroke. fn clear(&mut self) { self.points.clear(); @@ -135,21 +186,26 @@ impl GestureTool { } } - /// The selected open path whose end is near `p`. - fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool)> { + /// The selected open path whose end is near `p` (Edit Selected Paths, off at 0 pixels), and + /// that end. + fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool, Point)> { + if self.edit_within <= 0.0 { + return None; + } let tol = cx.tol(self.edit_within); + let editable = cx.doc.editable(); for id in &cx.selection.objects { let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue }; - if !cx.doc.is_editable(*id) { + if !editable(*id) { continue; } let Some(sp) = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty()) else { continue }; let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p); if p.distance(last) <= tol { - return Some((*id, false)); + return Some((*id, false, last)); } if p.distance(first) <= tol { - return Some((*id, true)); + return Some((*id, true, first)); } } None @@ -200,8 +256,10 @@ impl GestureTool { if !self.began { return vec![]; } - let near = pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) <= cx.tol(self.close_within); - let closed = (m.alt && !self.alt_smooth) || (self.extend.is_none() && near); + // Close Paths When Ends Are Within is off at 0 pixels. + let near = self.close_within > 0.0 && pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) <= cx.tol(self.close_within); + // A straight line has two points: closing it would draw it back over itself. + let closed = !self.straight && ((m.alt && !self.alt_smooth) || (self.extend.is_none() && near)); vec![Action::Preview("path.freehand".into(), self.freehand_params(closed)), Action::Commit] } "blobBrush" => vec![Action::Exec( @@ -243,10 +301,15 @@ impl Tool for GestureTool { self.active = true; self.began = false; self.smoothing = self.id == "pencil" && self.alt_smooth && ev.mods.alt; - self.extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None }; + self.straight = self.id == "pencil" && !self.smoothing && ev.mods.shift; + let extend = if self.draws_path() && !self.smoothing { self.find_extend(cx, p) } else { None }; + self.extend = extend.map(|(id, at_start, _)| (id, at_start)); self.redraw = if self.draws_path() && !self.smoothing && self.extend.is_none() { self.find_redraw(cx, p) } else { None }; // A path continued starts at its end, one redrawn on it; a new one where it snaps. - let start = if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() { + // A straight line is constrained from the end it continues, so it starts there. + let start = if let Some((_, _, end)) = extend.filter(|_| self.straight) { + end + } else if self.snaps() && !self.smoothing && self.extend.is_none() && self.redraw.is_none() { self.snap.press(cx, p, &[], None) } else { p @@ -259,14 +322,25 @@ impl Tool for GestureTool { if !self.active { return vec![]; } - if !self.began && self.id == "pencil" && self.alt_smooth && ev.mods.alt { + let p = self.constrained(cx, p, ev.mods); + if !self.began && !self.straight && self.id == "pencil" && self.alt_smooth && ev.mods.alt { self.smoothing = true; } - if self.snaps() && !self.smoothing { + if self.snaps() && !self.smoothing && !self.straight { // Where the stroke would end: shown, taken on release. self.snap.drag(cx, p, None); } - if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) { + if self.straight { + // The press's Smart Guides would stay on screen: the line's end doesn't snap. + self.snap.clear(); + // The line runs from the press point to the pointer, wherever the drag went. + if self.points.first().is_some_and(|s| s.distance(p) < cx.tol(1.0)) { + // Back at the press point there is no line: the one shown goes. + self.points.truncate(1); + return if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }; + } + self.points.truncate(1); + } else if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) { return vec![]; } self.push(p, ev.pressure); @@ -285,9 +359,18 @@ impl Tool for GestureTool { if !self.active { return vec![]; } - let end = if self.snaps() && !self.smoothing { self.snap.drag(cx, p, None) } else { p }; + let p = self.constrained(cx, p, ev.mods); + let end = if self.snaps() && !self.smoothing && !self.straight { self.snap.drag(cx, p, None) } else { p }; self.snap.clear(); - if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) { + let mut cancel = false; + if self.straight { + self.points.truncate(1); + if self.points.first().is_some_and(|s| s.distance(p) >= cx.tol(1.0)) { + self.push(end, ev.pressure); + } else { + cancel = self.began; + } + } else if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) { self.push(end, ev.pressure); } else if self.snaps() && !self.smoothing @@ -297,10 +380,11 @@ impl Tool for GestureTool { // The last step was where the button went up: it moves where that snaps. *last = end; } - let out = self.finish(cx, ev.mods); + let out = if cancel { vec![Action::Cancel] } else { self.finish(cx, ev.mods) }; self.active = false; self.began = false; self.smoothing = false; + self.straight = false; self.clear(); self.extend = None; self.redraw = None; @@ -321,6 +405,7 @@ impl Tool for GestureTool { self.active = false; self.began = false; self.smoothing = false; + self.straight = false; self.clear(); if began { vec![Action::Cancel] } else { vec![] } } @@ -340,6 +425,7 @@ impl Tool for GestureTool { self.snap.clear(); self.active = false; self.began = false; + self.straight = false; self.clear(); if began { vec![Action::Commit] } else { vec![] } } @@ -357,9 +443,16 @@ impl Tool for GestureTool { o } fn cursor(&self, _cx: &ToolContext, _p: Point, m: Mods) -> Cursor { - if self.id == "pencil" && self.alt_smooth && m.alt { + // A drag that smooths, or Alt before a Pencil stroke has begun to draw (a stroke that + // has begun keeps drawing with Alt). + if self.smoothing || (self.id == "pencil" && self.alt_smooth && m.alt && !self.began) { return Cursor::Smooth; } + // Alt held at the release closes the path (`finish`). + // A straight line stays open, so its release has nothing to close. + if self.draws_path() && !self.alt_smooth && m.alt && !self.straight { + return if self.id == "paintbrush" { Cursor::PaintbrushClose } else { Cursor::PencilClose }; + } match self.id { "paintbrush" => Cursor::Paintbrush, "blobBrush" => Cursor::BlobBrush, @@ -518,6 +611,52 @@ mod tests { } } + /// Holding Alt shows the Pencil's and the Paintbrush's closing cursor, before the press and while + /// drawing; a release with Alt still held closes the path, and Alt let go before the release + /// leaves it open. + #[test] + fn alt_shows_the_closing_cursor_and_a_release_with_alt_closes() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let at = Point::new(10.0, 5.0); + for (id, own, closing, style) in + [("pencil", Cursor::Pencil, Cursor::PencilClose, "pencil"), ("paintbrush", Cursor::Paintbrush, Cursor::PaintbrushClose, "brush")] + { + let mut t = GestureTool::new(id); + assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id}"); + assert_eq!(t.cursor(&cx, at, alt), closing, "{id}"); + for alt_at_release in [true, false] { + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, alt), closing, "{id} while drawing"); + assert_eq!(t.cursor(&cx, at, Mods::default()), own, "{id} while drawing"); + let up = Mods { alt: alt_at_release, ..Default::default() }; + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(up)); + assert!( + matches!(&acts[0], Action::Preview(_, v) if v["closed"] == alt_at_release && v["style"] == style), + "{id}, Alt at the release {alt_at_release}: {acts:?}" + ); + } + } + // With Alt key toggles to Smooth Tool on, Alt lends the Pencil the Smooth tool instead, until + // a stroke has begun to draw: Alt then neither smooths nor closes, and the cursor stays the Pencil's. + let mut t = GestureTool::new("pencil"); + t.set_option("altSmooth", &json!(true)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::Smooth); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0)); + assert_eq!(t.cursor(&cx, at, alt), Cursor::Pencil, "a stroke that has begun"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 20.0, 0.0).with_mods(alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false), "{acts:?}"); + // A smoothing drag keeps the Smooth cursor when Alt is let go. + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(alt)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 10.0, 5.0).with_mods(alt)); + assert_eq!(t.cursor(&cx, at, Mods::default()), Cursor::Smooth, "a smoothing drag"); + } + /// The Blob Brush has Keep selected and Merge only with selection, both off by default, and its /// stroke sends both. #[test] @@ -541,6 +680,199 @@ mod tests { assert_eq!(GestureTool::new("eraser").options(), json!({"size": 10.0})); } + /// Shift keeps a Blob Brush stroke on the horizontal or vertical line through the press point, + /// whichever is nearer to the pointer, at 90° steps from the Constrain Angle; the points + /// dragged with Shift held move to the other line together, and each point follows the Shift + /// state of its own event. + #[test] + fn blob_brush_shift_keeps_the_stroke_horizontal_or_vertical() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let blob = |acts: &[Vec]| -> Vec<(f64, f64)> { + let Some(Action::Exec(c, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(c, "path.blob"); + v["points"].as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect() + }; + let mut t = GestureTool::new("blobBrush"); + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], shift)); + assert_eq!(pts.len(), 3); + assert!(pts.iter().all(|q| q.1 == 0.0 && q.0 >= 0.0), "{pts:?}"); + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (5.0, 30.0), (8.0, 50.0)], shift)); + assert!(pts.iter().all(|q| q.0.abs() < 1e-9 && q.1 >= 0.0), "{pts:?}"); + // A pointer that crosses to the vertical line takes the points before it along, each on + // its own side of the press point and at its own distance. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (-40.0, -2.0), (100.0, 99.0), (90.0, 120.0)], shift)); + assert_eq!(pts.len(), 4, "{pts:?}"); + assert!(pts.iter().all(|q| q.0.abs() < 1e-9), "{pts:?}"); + assert!((pts[1].1 + 1604f64.sqrt()).abs() < 1e-9 && (pts[2].1 - 19801f64.sqrt()).abs() < 1e-9 && (pts[3].1 - 150.0).abs() < 1e-9, "{pts:?}"); + // Without Shift the stroke follows the pointer. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)], Mods::default())); + assert_eq!(pts, vec![(0.0, 0.0), (30.0, 5.0), (50.0, 8.0)]); + // Shift pressed and let go mid-stroke constrains only the points dragged while it is down, + // and a later press of Shift leaves the points of the earlier one where they are. + let ev = |k, x, y, m| PointerEvent::new(k, x, y).with_mods(m); + t.pointer(&cx, &ev(PointerKind::Down, 0.0, 0.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Drag, 20.0, 10.0, Mods::default())); + t.pointer(&cx, &ev(PointerKind::Drag, 40.0, 10.0, shift)); + t.pointer(&cx, &ev(PointerKind::Drag, 60.0, 10.0, Mods::default())); + let pts = blob(&[t.pointer(&cx, &ev(PointerKind::Up, 10.0, 80.0, shift))]); + assert_eq!((pts.len(), pts[1], pts[3]), (5, (20.0, 10.0), (60.0, 10.0)), "{pts:?}"); + assert!(pts[2].1 == 0.0 && (pts[2].0 - 1700f64.sqrt()).abs() < 1e-9, "{pts:?}"); + assert!(pts[4].0.abs() < 1e-9 && (pts[4].1 - 6500f64.sqrt()).abs() < 1e-9, "{pts:?}"); + // The axes turn with the Constrain Angle. + cx.constrain_angle = 30.0; + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0)], shift)); + let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); + assert!((a - 30.0).abs() < 1e-9, "{pts:?}"); + // A pointer back at the press point leaves the stroke on its line. + let pts = blob(&drag_held(&mut t, &cx, &[(0.0, 0.0), (40.0, -20.0), (0.0, 0.0)], shift)); + let a = (-pts[1].1).atan2(pts[1].0).to_degrees(); + assert!(pts.len() == 3 && pts[2] == (0.0, 0.0) && (a - 30.0).abs() < 1e-9, "{pts:?}"); + } + + /// #3799: Shift held at the press draws a straight Pencil line from the press point, at 45° + /// steps from the Constrain Angle, previewed as it is dragged; the Paintbrush keeps following + /// the hand. + #[test] + fn pencil_shift_draws_a_straight_line_at_45_degree_steps() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let pts = |acts: &[Vec]| -> Vec> { + acts.iter() + .filter_map(|a| a.iter().find_map(|a| if let Action::Preview(_, v) = a { Some(v["points"].clone()) } else { None })) + .map(|v| v.as_array().unwrap().iter().map(|q| (q[0].as_f64().unwrap(), q[1].as_f64().unwrap())).collect()) + .collect() + }; + let angle = |q: (f64, f64), from: (f64, f64)| (-(q.1 - from.1)).atan2(q.0 - from.0).to_degrees(); + let mut t = GestureTool::new("pencil"); + // A wavy drag heading up and right at about 40°: a line at 45°, as long as the drag. + let acts = drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0), (100.0, -84.0)], shift); + let all = pts(&acts); + assert!(all.iter().all(|v| v.len() == 2 && v[0] == (0.0, 0.0)), "{all:?}"); + let end = *all.last().unwrap().last().unwrap(); + assert!((angle(end, (0.0, 0.0)) - 45.0).abs() < 1e-9, "{end:?}"); + assert!((end.0.hypot(end.1) - 100f64.hypot(84.0)).abs() < 1e-9, "{end:?}"); + let last = acts.last().unwrap(); + assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false) && last[1] == Action::Commit, "{last:?}"); + // The steps turn with the Constrain Angle: at 30°, a drag at 40° gives 30°, one at 60° gives 75°. + cx.constrain_angle = 30.0; + for (to, want) in [((100.0, -84.0), 30.0), ((50.0, -86.6), 75.0)] { + let all = pts(&drag_held(&mut t, &cx, &[(10.0, 10.0), (to.0 + 10.0, to.1 + 10.0)], shift)); + let end = *all.last().unwrap().last().unwrap(); + assert!((angle(end, (10.0, 10.0)) - want).abs() < 1e-9, "{to:?}: {all:?}"); + } + // Without Shift, and with the Paintbrush, the stroke follows the hand. + for (id, m) in [("pencil", Mods::default()), ("paintbrush", shift)] { + let mut t = GestureTool::new(id); + let all = pts(&drag_held(&mut t, &cx, &[(0.0, 0.0), (30.0, -10.0), (50.0, -60.0)], m)); + assert_eq!(all.last().unwrap().len(), 3, "{id}: {all:?}"); + } + } + + /// #3799: a Shift line leaves no Smart Guides of its press on screen while it is dragged + /// (its end doesn't snap), and Alt at its release leaves it open. + #[test] + fn pencil_shift_line_drops_the_press_guides_and_stays_open_with_alt() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 102.0, 98.0).with_mods(shift)); + assert!(!t.overlays(&cx).is_empty(), "the press snaps to the corner and shows it"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 300.0, 140.0).with_mods(shift)); + assert!(t.overlays(&cx).is_empty(), "{:?}", t.overlays(&cx)); + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 300.0, 140.0).with_mods(shift_alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["closed"] == false && v["points"].as_array().unwrap().len() == 2), "{acts:?}"); + } + + /// #3799: a Shift line started on an end of a selected open path continues the path from that + /// end, constrained from the end itself. + #[test] + fn pencil_shift_line_continues_a_selected_path_from_its_end() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = GestureTool::new("pencil"); + // Pressed 5 pt off the end, released 100 pt right and 4 pt above it. + let acts = drag_held(&mut t, &cx, &[(405.0, 303.0), (450.0, 290.0), (500.0, 296.0)], Mods { shift: true, ..Default::default() }); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{acts:?}") }; + assert_eq!(v["extend"], json!({"id": id.0, "end": "end"})); + assert_eq!(v["points"], json!([[400.0, 300.0], [400.0 + 100f64.hypot(4.0), 300.0]])); + } + + /// #3846: Alt during a Shift line doesn't promise a close the release can't do, and with Alt + /// Toggles to Smooth, Alt pressed after the press doesn't turn the line into a smoothing drag. + #[test] + fn pencil_shift_line_ignores_alt_for_cursor_and_smoothing() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let shift_alt = Mods { shift: true, alt: true, ..Default::default() }; + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + assert_eq!(t.cursor(&cx, Point::new(0.0, 0.0), shift_alt), Cursor::Pencil); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.0, 0.0).with_mods(shift)); + + let mut t = GestureTool::new("pencil"); + t.alt_smooth = true; + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift_alt)); + assert!(matches!(acts.as_slice(), [Action::Begin(_), Action::Preview(..)]), "{acts:?}"); + let acts = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 0.0).with_mods(shift_alt)); + assert!(matches!(&acts[0], Action::Preview(_, v) if v["points"].as_array().unwrap().len() == 2), "{acts:?}"); + } + + /// #3846: a Shift line dragged back to its press point has no line: the preview is cancelled + /// and the release commits nothing (not the previous end of the line). + #[test] + fn pencil_shift_line_back_at_the_press_point_draws_nothing() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + // Released back at the press point, and dragged back there first. + for back_in_drag in [false, true] { + let mut t = GestureTool::new("pencil"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 0.0, 0.0).with_mods(shift)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 0.0).with_mods(shift)); + let mut acts = vec![]; + if back_in_drag { + // The shown line goes as soon as the pointer is back, not only at the release. + let back = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 0.2, 0.0).with_mods(shift)); + assert_eq!(back, vec![Action::Cancel]); + acts.extend(back); + } + acts.extend(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 0.2, 0.0).with_mods(shift))); + assert!(acts.contains(&Action::Cancel), "{back_in_drag}: {acts:?}"); + assert!(!acts.contains(&Action::Commit) && !acts.iter().any(|a| matches!(a, Action::Preview(..))), "{back_in_drag}: {acts:?}"); + } + } + #[test] fn pencil_alt_closes_and_continues_selected_path() { let (d, id) = doc_with_rect(); @@ -558,6 +890,59 @@ mod tests { assert!(matches!(&acts[1][1], Action::Preview(_, v) if v.get("extend").is_none())); } + /// Edit selected paths within 0 turns editing off: a stroke that starts exactly on an end of a + /// selected open path draws a new path; at 12 px it continues the path from that end. + #[test] + fn edit_selected_paths_within_0_draws_a_new_path_from_an_end() { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::BezPath::from_vec(vec![ + vectorcraft_geom::PathEl::MoveTo(Point::new(300.0, 300.0)), + vectorcraft_geom::PathEl::LineTo(Point::new(400.0, 300.0)), + ])); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(id, line, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + for tool in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(tool); + for (within, extends) in [(12.0, true), (0.0, false)] { + t.set_option("editWithin", &json!(within)); + for (x, end) in [(400.0, "end"), (300.0, "start")] { + let acts = drag(&mut t, &cx, &[(x, 300.0), (x + 20.0, 350.0), (x, 380.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; + if extends { + assert_eq!(v["extend"], json!({"id": id.0, "end": end}), "{tool} from the {end}"); + } else { + assert!(v.get("extend").is_none() && v.get("redraw").is_none(), "{tool} from the {end}: {v}"); + } + } + } + } + } + + /// Close paths when ends are within 0 turns closing off: a stroke that ends exactly where it + /// started stays open; at 15 px it is closed. + #[test] + fn close_paths_within_0_leaves_a_stroke_that_ends_at_its_start_open() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + for tool in ["pencil", "paintbrush"] { + let mut t = GestureTool::new(tool); + for (within, closes) in [(15.0, true), (0.0, false)] { + t.set_option("closeWithin", &json!(within)); + let acts = drag(&mut t, &cx, &[(310.0, 330.0), (360.0, 350.0), (330.0, 390.0), (310.0, 330.0)], Mods::default()); + let Some(Action::Preview(_, v)) = acts.last().and_then(|a| a.first()) else { panic!("{tool}: {acts:?}") }; + assert_eq!(v["points"][0], v["points"][3], "{tool}"); + assert_eq!(v["closed"], json!(closes), "{tool} at {within} px"); + } + } + } + #[test] fn eraser_and_knife_exec_on_release() { let (d, _) = doc_with_rect(); @@ -620,7 +1005,7 @@ mod tests { // When altSmooth is disabled: t.set_option("altSmooth", &json!(false)); assert_eq!(t.options()["altSmooth"], json!(false)); - assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::Pencil); + assert_eq!(t.cursor(&cx, Point::new(50.0, 50.0), alt_mod), Cursor::PencilClose); // Alt-drag draws a closed pencil path. let empty_sel = Selection::default(); diff --git a/crates/tools/src/extra.rs b/crates/tools/src/extra.rs index 86d4ae4a3..518237d3f 100644 --- a/crates/tools/src/extra.rs +++ b/crates/tools/src/extra.rs @@ -240,13 +240,16 @@ pub struct ReshapeTool { /// (path, grab point) while dragging. grab: Option<(vectorcraft_doc::NodeId, Point)>, began: bool, + /// Pointer and offset readout while a reshape drag is in progress. + measure: Option<(Point, String)>, } impl ReshapeTool { /// The selected path under `p` (Reshape works on selected paths only, like Illustrator). fn hit(cx: &ToolContext, p: Point) -> Option<(vectorcraft_doc::NodeId, Point)> { let tol = cx.tol(4.0); - cx.selection.objects.iter().copied().filter(|id| cx.doc.is_editable(*id)).find_map(|id| { + let editable = cx.doc.editable(); + cx.selection.objects.iter().copied().filter(|id| editable(*id)).find_map(|id| { let pd = cx.doc.node(id)?.path_data()?; let (_, _, _, q, d) = pd.nearest(p)?; (d <= tol).then_some((id, q)) @@ -266,13 +269,14 @@ impl Tool for ReshapeTool { PointerKind::Down => { self.grab = Self::hit(cx, ev.pos); self.began = false; + self.measure = None; vec![] } PointerKind::Drag => { let Some((id, at)) = self.grab else { return vec![] }; let mut d: Vec2 = ev.pos - at; if ev.mods.shift { - d = vectorcraft_geom::constrain_angle(d, 45.0); + d = vectorcraft_geom::constrain_angle_from(d, 45.0, cx.constrain_angle); } let mut out = vec![]; if !self.began { @@ -282,6 +286,7 @@ impl Tool for ReshapeTool { self.began = true; out.push(Action::Begin("Reshape".into())); } + self.measure = cx.measurement_labels.then(|| (ev.pos + Vec2::new(cx.tol(12.0), cx.tol(12.0)), cx.offset_label(d.x, d.y))); out.push(Action::Preview( "path.reshape".into(), json!({ "id": id.0, "x": at.x, "y": at.y, "dx": d.x, "dy": d.y, "tol": cx.tol(4.0) }), @@ -290,17 +295,24 @@ impl Tool for ReshapeTool { } PointerKind::Up => { self.grab = None; + self.measure = None; if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![] } } _ => vec![], } } fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { - if key == ToolKey::Escape && self.grab.take().is_some() && std::mem::take(&mut self.began) { - return vec![Action::Cancel]; + if key == ToolKey::Escape && self.grab.take().is_some() { + self.measure = None; + if std::mem::take(&mut self.began) { + return vec![Action::Cancel]; + } } vec![] } + fn overlays(&self, _cx: &ToolContext) -> Vec { + if let Some((p, t)) = &self.measure { vec![Overlay::Measure { p: *p, text: t.clone() }] } else { vec![] } + } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { if self.grab.is_some() || Self::hit(cx, p).is_some() { Cursor::Move } else { Cursor::Arrow } } @@ -520,6 +532,29 @@ mod tests { assert_eq!(t.params(Point::ZERO, 1.0, None)["rays"].as_u64(), Some(0)); } + #[test] + fn reshape_drag_honors_measurement_labels() { + use crate::testutil::*; + use vectorcraft_doc::Selection; + + let (d, id) = doc_with_rect(); + let s = Selection { objects: vec![id], ..Selection::default() }; + let p = paint(); + let mut t = ReshapeTool::default(); + let drag = |t: &mut ReshapeTool, c: &ToolContext| { + t.pointer(c, &PointerEvent::new(PointerKind::Down, 100.0, 150.0)); + t.pointer(c, &PointerEvent::new(PointerKind::Drag, 130.0, 150.0)); + }; + let on = ToolContext { measurement_labels: true, ..cx(&d, &s, &p) }; + drag(&mut t, &on); + let want = on.offset_label(30.0, 0.0); + assert!(t.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); + let off = ToolContext { measurement_labels: false, ..cx(&d, &s, &p) }; + let mut quiet = ReshapeTool::default(); + drag(&mut quiet, &off); + assert!(!quiet.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); + } + #[test] fn graph_plot_measure_labels() { use crate::testutil::*; diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 746554585..56066ab7e 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -13,8 +13,8 @@ use std::sync::Arc; use vectorcraft_doc::hit::{HitOptions, hit_test}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind, OrientedBox, Selection}; -use vectorcraft_geom::kurbo::ParamCurveNearest; -use vectorcraft_geom::{Affine, Line, ParamCurve, PathSeg, Point, Rect, Shape, SubPath, Vec2, constrain_angle_from}; +use vectorcraft_geom::kurbo::{ParamCurveDeriv, ParamCurveNearest}; +use vectorcraft_geom::{Affine, BezPath, Line, ParamCurve, PathSeg, Point, Rect, Shape, SubPath, Vec2, constrain_angle_from}; use vectorcraft_text::{FontDb, layout as layout_text}; use crate::bbox::Handle; @@ -89,6 +89,10 @@ pub struct Targets { /// it ends, as the end that stays put and the unit direction towards the dragged one /// ([`Self::for_anchor_drag`]). extensions: Vec<(Point, Vec2)>, + /// Snap to Tangent and Snap to Perpendicular: a drawn line from an anchor lands where it + /// grazes a curve, or meets a segment square ([`Self::touching`]). + tangent: bool, + perpendicular: bool, /// How the guides these targets produce look. style: GuideStyle, } @@ -560,6 +564,11 @@ impl Targets { if let Some(q) = self.crossing(p, tol) { return (q, self.label(q, "intersect").into_iter().collect()); } + if let Some(f) = from.filter(|f| f.contact) + && let Some((q, perpendicular, dir)) = self.touching(f.at, p, tol) + { + return (q, self.touch_guides(f.at, q, perpendicular, dir, tol)); + } if let Some(q) = self.on_segment(p, tol) { return (q, self.label(q, Kind::Edge.label()).into_iter().collect()); } @@ -602,6 +611,52 @@ impl Targets { (out, ov) } + /// Where a line from `at` through a point near `p` touches a target segment: square to it + /// (`perpendicular`, the foot of the perpendicular from `at`) or just grazing a curve + /// (`tangent`), within `tol` of `p`. The point, whether it is the perpendicular one, and the + /// segment's direction there. Targets gather segments for drawn points and dragged anchors only. + fn touching(&self, at: Point, p: Point, tol: f64) -> Option<(Point, bool, Vec2)> { + if !(self.tangent || self.perpendicular) || ![at.x, at.y, p.x, p.y, tol].iter().all(|v| v.is_finite()) { + return None; + } + let mut best: Option<(f64, Point, bool, Vec2)> = None; + // Every segment in reach is tried: their order is the document's, not the distance. + for (_, seg) in self.segments.iter().filter(|(b, _)| b.inflate(tol, tol).contains(p)) { + let curved = !matches!(seg, PathSeg::Line(_)); + for (perpendicular, wanted) in [(true, self.perpendicular && !curved), (false, self.tangent && curved)] { + if !wanted { + continue; + } + for t in touch_params(seg, at, perpendicular) { + let (q, d) = (seg.eval(t), seg_direction(seg, t)); + let dist = q.distance(p); + if dist <= tol && q.distance(at) > tol && best.is_none_or(|b| dist < b.0) { + best = Some((dist, q, perpendicular, d)); + } + } + } + } + best.map(|(_, q, perpendicular, d)| (q, perpendicular, d)) + } + + /// The guides of a point landed where the line from `at` touches a segment at `q` running + /// along `dir`: the line itself, a square corner mark for the perpendicular, and the label. + fn touch_guides(&self, at: Point, q: Point, perpendicular: bool, dir: Vec2, tol: f64) -> Vec { + let mut out: Vec = self.line(at, q).into_iter().collect(); + let (u, v) = (at - q, dir); + if perpendicular && self.style.lines && u.hypot2() > 1e-18 && v.hypot2() > 1e-18 { + let (u, v) = (u / u.hypot(), v / v.hypot()); + let s = tol * 1.5; + let mut mark = BezPath::new(); + mark.move_to(q + u * s); + mark.line_to(q + u * s + v * s); + mark.line_to(q + v * s); + out.push(Overlay::Path { path: mark, color: self.style.color, width: 1.0, dashed: false }); + } + out.extend(self.label(q, if perpendicular { "perpendicular" } else { "tangent" })); + out + } + /// The segment extension nearest `p`, within `tol` of it (and `p` farther than that from the /// end it starts at): that end, its direction and where `p` lands on it. Where `p` is nearer /// the horizontal or the vertical through that end there is none: lining up with it ("align") @@ -1123,6 +1178,71 @@ fn meeting(a: &PathSeg, b: &PathSeg, p: Point) -> Option { None } +/// The direction of `seg` at `t` (not normalised; zero in a cusp). +fn seg_direction(seg: &PathSeg, t: f64) -> Vec2 { + match seg { + PathSeg::Line(l) => l.p1 - l.p0, + PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(), + PathSeg::Cubic(c) => c.deriv().eval(t).to_vec2(), + } +} + +/// The parameters in (0, 1) where the line from `at` to `seg` is square to it (`perpendicular`) +/// or grazes it, found by sampling the curve for a change of sign and bisecting. +fn touch_params(seg: &PathSeg, at: Point, perpendicular: bool) -> Vec { + const STEPS: usize = 24; + let f = |t: f64| { + let (v, d) = (seg.eval(t) - at, seg_direction(seg, t)); + if perpendicular { v.dot(d) } else { v.cross(d) } + }; + let mut out = vec![]; + let mut prev = (0.0, f(0.0)); + for i in 1..=STEPS { + let t = i as f64 / STEPS as f64; + let cur = (t, f(t)); + if prev.1.is_finite() && cur.1.is_finite() && prev.1 * cur.1 < 0.0 { + let (mut lo, mut hi) = (prev, cur); + for _ in 0..40 { + let mid = 0.5 * (lo.0 + hi.0); + let m = (mid, f(mid)); + if lo.1 * m.1 <= 0.0 { + hi = m; + } else { + lo = m; + } + } + let t = 0.5 * (lo.0 + hi.0); + // A jump through a cusp changes sign without crossing zero: the line must really + // run along (or square to) the curve there. + let (v, d) = (seg.eval(t) - at, seg_direction(seg, t)); + let scale = v.hypot() * d.hypot(); + if scale > 1e-12 && f(t).abs() <= 1e-3 * scale { + out.push(t); + } + } + prev = cur; + } + out +} + +/// Where a press at `p` takes hold of `path` by its outline: the point of the path nearest `p`, +/// when it is within `reach`, and on a straight segment exactly on its line. A path dragged by a +/// press on its stroke or beside its edge snaps by that point of the path, so it is the path that +/// lands on a ruler guide or lines up, not the pressed spot a few pixels beside it (#3701). +pub(crate) fn hold_on_path(path: &vectorcraft_geom::PathData, p: Point, reach: f64) -> Option { + let (si, seg, _, q, d) = path.nearest(p)?; + if d > reach { + return None; + } + let sp = path.subpaths.get(si)?; + if !sp.segment_is_line(seg) { + return Some(q); + } + let c = sp.segment(seg); + let line = Line::new(c.p0, c.p3); + Some(line.eval(line.nearest(p, 0.0).t)) +} + /// Snap `p` for a drawing tool when smart guides (or grid snapping) are on. pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec) { snap_with(cx, p, || Targets::collect(cx.doc, exclude, None)) @@ -1148,7 +1268,8 @@ impl PointSnap { None } else if cx.smart_guides { // Snap to Point keeps pulling points onto the ruler guides with Smart Guides on (#812). - let t = targets().styled(cx); + let mut t = targets().styled(cx); + (t.tangent, t.perpendicular) = (cx.snap_tangent, cx.snap_perpendicular); Some((if cx.snap_to_point { t.with_rulers(cx) } else { t }, cx.snapping_tolerance)) } else if cx.snap_to_point { Some((targets().for_snap_to_point(cx), cx.snap_tolerance)) @@ -1247,6 +1368,8 @@ pub struct Leave { shift: Option, /// The angles of the construction guides through `at` (none with them off). angles: &'static [f64], + /// A drawn segment: it may land square to a segment or grazing a curve (a box does not). + contact: bool, } impl Leave { @@ -1257,13 +1380,21 @@ impl Leave { at, shift: shift.then_some(Keep::Angle { step: 45.0, base: cx.constrain_angle }), angles: if cx.smart_guides { cx.construction_angles } else { &[] }, + contact: true, } } - /// A box dragged from its corner or centre `at` (Rectangle, Ellipse, Arc, the grids, - /// Artboard): Shift keeps the dragged corner on a diagonal, the box a square. + /// A curved source cannot make a line-to-line perpendicular or line-to-curve tangent contact. + pub fn without_contact(mut self) -> Self { + self.contact = false; + self + } + + /// 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. pub fn diagonal(at: Point, shift: bool) -> Self { - Self { at, shift: shift.then_some(Keep::Square), angles: &[] } + Self { at, shift: shift.then_some(Keep::Square), angles: &[], contact: false } } /// Where Shift puts `p`: none without it. @@ -1323,7 +1454,7 @@ struct SnapKey { revision: (u64, u64), exclude: Vec, screen: Option, - view: [bool; 5], + view: [bool; 7], snap_to_glyph: bool, style: GuideStyle, tolerances: [f64; 2], @@ -1335,7 +1466,7 @@ impl SnapKey { revision: cx.revision, exclude: exclude.to_vec(), screen: cx.screen, - view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides], + view: [cx.smart_guides, cx.snap_to_point, cx.snap_to_pixel, cx.snap_to_grid, cx.guides, cx.snap_tangent, cx.snap_perpendicular], snap_to_glyph: cx.snap_to_glyph && cx.alignment_guides, style: GuideStyle::of(cx), tolerances: [cx.snapping_tolerance, cx.snap_tolerance], @@ -1443,6 +1574,49 @@ pub fn snap_pick(cx: &ToolContext, p: Point) -> (Point, Vec) { Targets::points_near(cx.doc, &roots, near).styled(cx).snap_point(p, tol) } +/// Distance Guides (Smart Guides → Invoke Distance Guides): how far the selection's bounds `from` +/// lie from `to` along each axis, as measured lines ([`Overlay::Distance`]) labelled with their +/// lengths in the General unit. Along an axis on which the two lie apart, one line spans the gap +/// between their facing edges; along one on which they overlap, or one holds the other, one line +/// runs between their left (top) edges and one between their right (bottom) edges. An artboard +/// (`artboard`) is always measured edge to edge: the selection's distance from its left, right, +/// top and bottom edges. Each line runs across the middle of where the two overlap on the other +/// axis, else across the selection's middle, with a dashed line from `to`'s edge out to it when it +/// passes beside `to`. Lines of no length are left out. +pub fn distance_guides(cx: &ToolContext, from: Rect, to: Rect, artboard: bool) -> Vec { + let finite = |r: &Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()); + if !finite(&from) || !finite(&to) { + return vec![]; + } + let color = cx.smart_guide_color; + let mut out = Vec::new(); + for x in [true, false] { + let ((f0, f1), (t0, t1)) = (span(&from, x), span(&to, x)); + let ((g0, g1), (u0, u1)) = (span(&from, !x), span(&to, !x)); + let (lo, hi) = (g0.max(u0), g1.min(u1)); + let across = if lo <= hi { (lo + hi) / 2.0 } else { (g0 + g1) / 2.0 }; + let pt = |u: f64, c: f64| if x { Point::new(u, c) } else { Point::new(c, u) }; + // Each measured line: from the selection's edge to `to`'s. + let lines: Vec<(f64, f64)> = match (artboard, f1 <= t0, t1 <= f0) { + (false, true, _) => vec![(f1, t0)], + (false, _, true) => vec![(f0, t1)], + _ => vec![(f0, t0), (f1, t1)], + }; + for (fu, tu) in lines { + let length = (tu - fu).abs(); + if length <= 1e-9 { + continue; + } + out.push(Overlay::Distance { a: pt(fu, across), b: pt(tu, across), text: cx.len(length), color }); + let on_edge = across.clamp(u0, u1); + if on_edge != across { + out.push(Overlay::Line { a: pt(tu, on_edge), b: pt(tu, across), color, dashed: true }); + } + } + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -1867,6 +2041,104 @@ mod tests { assert_eq!(labels(&t.snap_from(Point::new(170.0, 201.0), None, 4.0).1), ["path"]); } + #[test] + fn perpendicular_snapping_rejects_a_curved_target() { + let (mut d, _) = doc_with_rect(); + let layer = d.layers[0].id; + let id = d.alloc_id(); + d.insert( + Some(layer), + 1, + vectorcraft_doc::Node::path( + id, + vectorcraft_geom::shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), + vectorcraft_doc::Appearance::default_art(), + ), + ) + .unwrap(); + let targets = Targets { tangent: false, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) }; + assert!(targets.touching(Point::new(400.0, 500.0), Point::new(450.0, 500.0), 4.0).is_none()); + assert!(targets.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), 4.0).is_some()); + } + + /// A line drawn from an anchor lands square to a segment (the foot of its perpendicular) and + /// just grazing a curve, with a guide and a label; the options switch each off, and the + /// result is the same each time and survives non-finite input. + #[test] + fn drawn_lines_land_perpendicular_to_segments_and_tangent_to_curves() { + use vectorcraft_doc::{Appearance, Node}; + use vectorcraft_geom::shapes; + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let id = d.alloc_id(); + d.insert(Some(l), 1, Node::path(id, shapes::ellipse(Rect::new(450.0, 450.0, 550.0, 550.0)), Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let snap = |c: &ToolContext, from: Point, to: Point| { + let leave = Leave::segment(c, from, false); + PointSnap::new(c, || Targets::for_drawing(&d, &[], None, false)).snap_from(c, to, Some(&leave)) + }; + // From (300, 160) the rect's right side (x = 200) is square at (200, 160). + let (q, ov) = snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)); + assert!((q - Point::new(200.0, 160.0)).hypot() < 1e-6, "{q:?}"); + assert_eq!(labels(&ov), ["perpendicular"]); + assert_eq!(lines(&ov), vec![(Point::new(300.0, 160.0), q)]); + assert!(ov.iter().any(|o| matches!(o, Overlay::Path { .. })), "the square corner mark: {ov:?}"); + assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(202.0, 163.0)).0, q, "deterministic"); + // Off, the point only lands on the path, where the pointer is. + let curved = Leave::segment(&c, Point::new(300.0, 160.0), false).without_contact(); + let (_, ov) = PointSnap::new(&c, || Targets::for_drawing(&d, &[], None, false)).snap_from(&c, Point::new(202.0, 163.0), Some(&curved)); + assert!(!labels(&ov).contains(&"perpendicular")); + let off = ToolContext { snap_perpendicular: false, ..cx(&d, &s, &p) }; + let (q, ov) = snap(&off, Point::new(300.0, 160.0), Point::new(202.0, 163.0)); + assert_eq!((q, labels(&ov)), (Point::new(200.0, 163.0), vec!["path"])); + // The circle (centre (500, 500), radius 50) seen from (400, 500): the tangent points are 60° + // round from the way back, at (475, 456.7) and (475, 543.3). + let (q, ov) = snap(&c, Point::new(400.0, 500.0), Point::new(477.0, 459.0)); + assert_eq!(labels(&ov), ["tangent"]); + assert!((q - Point::new(475.0, 500.0 - 1875f64.sqrt())).hypot() < 0.1, "{q:?}"); + assert!(((q - Point::new(400.0, 500.0)).dot(q - Point::new(500.0, 500.0))).abs() < 20.0, "{q:?}"); + let none = ToolContext { snap_tangent: false, ..cx(&d, &s, &p) }; + assert!(!labels(&snap(&none, Point::new(400.0, 500.0), Point::new(477.0, 459.0)).1).contains(&"tangent")); + // Out of reach, or with nothing finite to go on: left alone, no panic. + assert_eq!(snap(&c, Point::new(300.0, 160.0), Point::new(250.0, 175.0)).0, Point::new(250.0, 175.0)); + let t = Targets::for_drawing(&d, &[], None, false); + let t = Targets { tangent: true, perpendicular: true, ..t }; + for bad in [f64::NAN, f64::INFINITY] { + assert!(t.touching(Point::new(bad, 1.0), Point::new(200.0, 160.0), 4.0).is_none()); + assert!(t.touching(Point::new(300.0, 160.0), Point::new(bad, 1.0), 4.0).is_none()); + assert!(t.touching(Point::new(300.0, 160.0), Point::new(200.0, 160.0), bad).is_none()); + } + } + + /// A box dragged from a corner (Rectangle, Ellipse, ...) never snaps square to a segment, and + /// a busy spot does not hide the contact: every segment in reach is tried. + #[test] + fn contact_snaps_only_for_segments_and_in_busy_spots() { + use vectorcraft_geom::kurbo::Line; + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let snap = |t: &Targets, leave: &Leave| t.snap_from(Point::new(202.0, 163.0), Some(leave), 4.0); + let t = Targets { tangent: true, perpendicular: true, ..Targets::for_drawing(&d, &[], None, false) }; + let (_, ov) = snap(&t, &Leave::segment(&c, Point::new(300.0, 160.0), false)); + assert_eq!(labels(&ov), ["perpendicular"]); + let (_, ov) = snap(&t, &Leave::diagonal(Point::new(300.0, 160.0), false)); + assert!(!labels(&ov).contains(&"perpendicular"), "{ov:?}"); + // 70 short segments near the pointer, none square to the leave point, then the rect's side. + let mut busy = t; + let mut segments: Vec<(Rect, PathSeg)> = (0..70) + .map(|i| { + let y = 160.0 + f64::from(i) * 0.01; + (Rect::new(199.0, y, 205.0, y + 0.001), PathSeg::Line(Line::new((199.0, y), (205.0, y + 0.001)))) + }) + .collect(); + segments.extend(busy.segments.iter().cloned()); + busy.segments = segments; + let (_, ov) = snap(&busy, &Leave::segment(&c, Point::new(300.0, 160.0), false)); + assert_eq!(labels(&ov), ["perpendicular"]); + } + /// The angle sets of Preferences › Smart Guides › Construction Guides. #[test] fn construction_angle_sets() { diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 81e6f6a54..c9b0700a7 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -252,6 +252,11 @@ pub struct ToolContext<'a> { /// points (a transform's reference point) land on anchors and ruler guides within /// [`Self::snap_tolerance`]. pub snap_to_point: bool, + /// View → Snapping → Snap to Tangent: with Smart Guides on, a line drawn + /// from an anchor lands where it touches a nearby curve. + pub snap_tangent: bool, + /// View → Snapping → Snap to Perpendicular: likewise where it meets a nearby segment square. + pub snap_perpendicular: bool, /// View → Snap to Glyph: drawn points snap to live-text baselines and glyph bounds (with Smart Guides). pub snap_to_glyph: bool, /// View → Show Corner Widget: paths show draggable Live Corners widgets in their corners. @@ -275,6 +280,9 @@ pub struct ToolContext<'a> { pub raster_sample: u32, /// General → Use Preview Bounds: the bounding box measures visual bounds (strokes included). pub preview_bounds: bool, + /// The session's memo of the selection tools' measurements for the document as it is + /// ([`select::PreviewMemo`]); without one they measure on every call. + pub preview_memo: Option<&'a select::PreviewMemo>, /// Units ▸ General: the unit measurement labels show lengths in. pub unit: Unit, /// Units ▸ Stroke: the unit measurement labels show stroke widths in. @@ -344,6 +352,10 @@ pub struct ToolContext<'a> { /// two other objects in its row or column, or evenly between its two neighbours, and the /// equal gaps show. pub spacing_guides: bool, + /// Smart Guides → Invoke Distance Guides: with the Selection tool, Alt/Option held over another + /// object shows how far the selection is from it, and over an empty part of an artboard how + /// far it is from the artboard's edges ([`guides::distance_guides`]). + pub distance_guides: bool, /// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the /// pointer, a dragged edge or a drawn point. pub snapping_tolerance: f64, @@ -413,8 +425,19 @@ impl ToolContext<'_> { type_path_only: self.type_path_only, scope: self.isolation, skip_guides: !self.guides, + clip_outlines: false, } } + + /// [`Self::hit_options`] for the selection tools (Selection, Direct Selection, Group + /// Selection): a clipping path is also hit on its outline ([`HitOptions::clip_outlines`]). + /// Only for what a press or a hover selects: a double-click, loaded text or a guide's release + /// acts on what is drawn and takes [`Self::hit_options`]. + /// + /// [`HitOptions::clip_outlines`]: vectorcraft_doc::hit::HitOptions::clip_outlines + pub fn selection_hit_options(&self) -> vectorcraft_doc::hit::HitOptions { + vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..self.hit_options() } + } } /// Visual feedback drawn by the UI in screen space (coordinates here are document space). @@ -436,6 +459,9 @@ pub enum Overlay { Label { p: Point, text: String, color: [u8; 3] }, /// Measurement pill near the cursor (grey box with white text). Measure { p: Point, text: String }, + /// A Distance Guides measurement: a line from `a` to `b` with a tick across each end, and + /// `text` (its length) in a pill of `color` on its middle. + Distance { a: Point, b: Point, text: String, color: [u8; 3] }, /// Translucent filled quad (text selection highlight), RGBA. Highlight { quad: [Point; 4], color: [u8; 4] }, /// A colour chip of fixed screen size (a gradient stop), RGBA; ringed when selected. @@ -535,8 +561,8 @@ pub enum Cursor { /// Over the type widget of selected type (a double-click converts point type to area type and /// back): the arrow with a type badge. TypeWidget, - /// The Selection tool loaded with the text of an area type's out port (a click threads it - /// into a path or a new frame, a drag draws the new frame). + /// The Selection tool loaded with the text of area type or type on a path by a click on its out + /// or in port (a click threads it into a path or a new frame, a drag draws the new frame). LoadedText, /// The Shape Builder: a crosshair with a plus (merge mode)... ShapeBuilder, @@ -548,8 +574,14 @@ pub enum Cursor { MagicWand, /// The Pencil tool. Pencil, + /// The Pencil tool with Alt held while Alt key toggles to Smooth Tool is off: releasing the + /// mouse button with Alt still held closes the path. A crosshair with a ring. + PencilClose, /// The Paintbrush tool. Paintbrush, + /// The Paintbrush tool with Alt held: releasing the mouse button with Alt still held closes + /// the path. A crosshair with a ring. + PaintbrushClose, /// The Blob Brush tool. BlobBrush, /// The Eraser tool. @@ -660,7 +692,9 @@ impl Cursor { | Cursor::Lasso | Cursor::MagicWand | Cursor::Pencil + | Cursor::PencilClose | Cursor::Paintbrush + | Cursor::PaintbrushClose | Cursor::BlobBrush | Cursor::Eraser | Cursor::Smooth @@ -779,6 +813,17 @@ pub trait Tool: Send { None } fn notify(&mut self, _cx: &ToolContext, _what: &str) {} + /// The pointer stopped hovering for the tool while it holds no press: it left the canvas (for a + /// panel or out of the window), went under a floating panel, menu, popup or dialog, or a press + /// started panning, zooming or turning the view. Drop what shows only for the hover (the + /// Selection tool's Distance Guides). + fn hover_ended(&mut self, _cx: &ToolContext) {} + /// Whether what the tool shows for the hover depends on what lies under a still pointer: then + /// the canvas hovers it again where the pointer is when the view, the document or the + /// selection changes (the Selection tool while Alt shows Distance Guides). + fn wants_rehover(&self) -> bool { + false + } /// Called when the user switches away (finish pending work). fn deactivate(&mut self, _cx: &ToolContext) -> Vec { vec![] @@ -860,6 +905,51 @@ pub(crate) mod testutil { (d, id) } + /// [`doc_with_rect`] plus a clip group: a filled 200 × 200 square (350, 350)–(550, 550) + /// clipped to an unpainted rectangle (400, 400)–(500, 500), so the art lies over the mask's + /// edges. The group, the clipping path and the art. + pub fn doc_with_masked_square() -> (Document, NodeId, NodeId, NodeId) { + let (mut d, _) = doc_with_rect(); + let l = d.layers[0].id; + let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id()); + let mut path = Node::path(clip, shapes::rectangle(Rect::new(400.0, 400.0, 500.0, 500.0)), Default::default()); + if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + let picture = Node::path(art, shapes::rectangle(Rect::new(350.0, 350.0, 550.0, 550.0)), Appearance::default_art()); + let group = + Node::new(g, vectorcraft_doc::NodeKind::Group { children: vec![std::sync::Arc::new(path), std::sync::Arc::new(picture)], clip: true }); + d.insert(Some(l), 1, group).unwrap(); + (d, g, clip, art) + } + + /// A new layer of `d`, above the others, with the unpainted rectangle `mask` as its clipping + /// mask and `art` clipped by it: the layer and the clipping mask. + pub fn add_masked_layer(d: &mut Document, mask: Rect, art: Node) -> (NodeId, NodeId) { + let l = d.add_layer(Some("Masked")); + let clip = d.alloc_id(); + let mut path = Node::path(clip, shapes::rectangle(mask), Default::default()); + if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + d.insert(Some(l), 0, path).unwrap(); + d.insert(Some(l), 1, art).unwrap(); + d.node_mut(l).unwrap().set_clips(true); + (l, clip) + } + + /// A layer clipped to (250, 250)–(360, 400), its mask's right edge through 120 × 40 area type + /// at (300, 300): the type, and a point on it just inside the mask's edge, within the + /// selection tolerance, where the selection tools take the mask. + pub fn doc_with_type_on_a_layer_masks_edge() -> (Document, NodeId, Point) { + let (mut d, _) = doc_with_rect(); + let id = d.alloc_id(); + let mut t = vectorcraft_doc::TextObject::point(Point::new(300.0, 300.0), "Some words", Default::default()); + t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 40.0)) }; + add_masked_layer(&mut d, Rect::new(250.0, 250.0, 360.0, 400.0), Node::new(id, vectorcraft_doc::NodeKind::Text(Box::new(t)))); + (d, id, Point::new(358.5, 320.0)) + } + /// [`doc_with_rect`] plus 120 × 40 area type at (300, 300). pub fn doc_with_area_type() -> (Document, NodeId) { let (mut d, _) = doc_with_rect(); @@ -936,6 +1026,8 @@ pub(crate) mod testutil { show_bbox: true, snap_to_pixel: false, snap_to_point: true, + snap_tangent: true, + snap_perpendicular: true, snap_to_glyph: false, corner_widgets: true, fill_active: true, @@ -946,6 +1038,7 @@ pub(crate) mod testutil { freeform_point: None, raster_sample: 1, preview_bounds: false, + preview_memo: None, unit: Unit::Points, stroke_unit: Unit::Points, paste_plain_text: false, @@ -972,6 +1065,7 @@ pub(crate) mod testutil { measurement_labels: true, transform_tools_guides: true, spacing_guides: true, + distance_guides: true, snapping_tolerance: 4.0, construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES), screen: None, diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index 2b3127529..230c92ea2 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -162,7 +162,8 @@ impl MeshTool { v.push(id); } } - v.retain(|id| cx.doc.is_editable(*id)); + let editable = cx.doc.editable(); + v.retain(|id| editable(*id)); v } diff --git a/crates/tools/src/meshedit.rs b/crates/tools/src/meshedit.rs index d9fbd5cc8..9037968ae 100644 --- a/crates/tools/src/meshedit.rs +++ b/crates/tools/src/meshedit.rs @@ -65,7 +65,8 @@ impl MeshEdit { { return Some(MeshGrab { id, index, handle: Some(h) }); } - ids.iter().filter(|id| cx.doc.is_editable(**id)).find_map(|id| { + let editable = cx.doc.editable(); + ids.iter().filter(|id| editable(**id)).find_map(|id| { let index = grid_of(cx.doc.node(*id)?)?.point_near(p, tol)?; Some(MeshGrab { id: *id, index, handle: None }) }) diff --git a/crates/tools/src/objects_on_path.rs b/crates/tools/src/objects_on_path.rs index e7db500df..b699601ab 100644 --- a/crates/tools/src/objects_on_path.rs +++ b/crates/tools/src/objects_on_path.rs @@ -15,7 +15,9 @@ use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, Tool const HIGHLIGHT: [u8; 3] = [0x4f, 0x9d, 0xff]; /// The path a click at `p` attaches the selected objects to: the path object hit there, when it can -/// take objects, is in no Objects on Path group, and something else is selected. +/// take objects, is in no Objects on Path group, is not a clipping path or part of a compound path, +/// blend, envelope, repeat or Intertwine group ([`vectorcraft_doc::objects_on_path::held`]), and +/// something else is selected. pub fn target(cx: &ToolContext, p: Point) -> Option<(NodeId, BezPath)> { let h = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?; let n = cx.doc.node(h.leaf)?; @@ -23,6 +25,9 @@ pub fn target(cx: &ToolContext, p: Point) -> Option<(NodeId, BezPath)> { if h.ancestry.iter().filter(|a| **a != h.leaf).any(|a| cx.doc.node(*a).is_some_and(is_objects_on_path)) { return None; } + if vectorcraft_doc::objects_on_path::held(cx.doc, h.leaf).is_some() { + return None; + } cx.selection.objects.iter().any(|id| *id != h.leaf).then(|| (h.leaf, path.to_bezpath())) } @@ -156,4 +161,38 @@ mod tests { assert!(t.pointer(&cx, &ev(PointerKind::Up, 200.0, 300.0)).is_empty()); assert!(matches!(d.node(line).map(|n| &n.kind), Some(NodeKind::Path { .. }))); } + + #[test] + fn a_clipping_path_or_a_blend_key_is_not_a_target() { + let (mut d, line, square) = doc(); + let l = d.layers[0].id; + let key = d.alloc_id(); + d.insert(Some(l), 2, Node::path(key, shapes::line(Point::new(0.0, 100.0), Point::new(400.0, 100.0)), Appearance::default_art())).unwrap(); + let blend = d.alloc_id(); + d.wrap(&[line, key], |children| Node::new(blend, NodeKind::Blend { children, spec: Default::default() })).unwrap(); + let mask = d.alloc_id(); + let art = d.alloc_id(); + d.insert(Some(l), 2, Node::path(mask, shapes::rectangle(Rect::new(100.0, 380.0, 300.0, 430.0)), Appearance::default_art())).unwrap(); + d.insert(Some(l), 3, Node::path(art, shapes::rectangle(Rect::new(250.0, 390.0, 290.0, 420.0)), Appearance::default_art())).unwrap(); + let clip = d.alloc_id(); + d.wrap(&[mask, art], |children| { + let mut g = Node::group(clip, children); + g.set_clips(true); + g + }) + .unwrap(); + assert_eq!(d.node(clip).and_then(|g| g.children()).and_then(|c| c.first()).map(|c| c.id), Some(mask)); + let mut sel = Selection::default(); + sel.set([square]); + let paint = paint(); + let cx = cx(&d, &sel, &paint); + let mut t = ObjectsOnPathTool::default(); + for (p, what) in [((150.0, 400.0), "a clipping path"), ((200.0, 300.0), "a blend's key path"), ((200.0, 100.0), "a blend's other key path")] { + assert_eq!(t.cursor(&cx, Point::new(p.0, p.1), Mods::default()), Cursor::Arrow, "{what}"); + t.pointer(&cx, &ev(PointerKind::Move, p.0, p.1)); + assert!(t.overlays(&cx).is_empty(), "{what} is not highlighted"); + t.pointer(&cx, &ev(PointerKind::Down, p.0, p.1)); + assert!(t.pointer(&cx, &ev(PointerKind::Up, p.0, p.1)).is_empty(), "a click on {what} attaches nothing"); + } + } } diff --git a/crates/tools/src/params.rs b/crates/tools/src/params.rs index 46e920a6a..8c3565ba2 100644 --- a/crates/tools/src/params.rs +++ b/crates/tools/src/params.rs @@ -69,6 +69,12 @@ pub fn gradient_params(g: &GradientPaint) -> Value { if let Some(f) = &g.freeform { v["freeform"] = freeform_json(f); } + if g.gradient.dither { + v["dither"] = json!(true); + } + if !g.gradient.method.is_classic() { + v["method"] = json!(g.gradient.method.label().to_lowercase()); + } v } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index e1a8eccf5..85168a4b2 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -190,7 +190,10 @@ impl Tool for PenTool { } // The path being drawn moves its bounds as it grows: only its anchors and segments pull. let drawn = active.map(|a| a.0); - let from = active.map(|(_, _, last, _)| Leave::segment(cx, last, ev.mods.shift)); + let from = active.map(|(_, _, last, handle)| { + let leave = Leave::segment(cx, last, ev.mods.shift); + if handle == last { leave } else { leave.without_contact() } + }); let p = match ev.kind { PointerKind::Move => self.snap.hover(cx, ev.pos, drawn.as_slice(), from.as_ref()), PointerKind::Down => self.snap.press(cx, ev.pos, drawn.as_slice(), from.as_ref()), @@ -302,7 +305,7 @@ impl Tool for PenTool { if let Some((id, si, ai, a)) = self.handle { let mut h = ev.pos; if ev.mods.shift { - h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0); + h = a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle); } return vec![set_out_handle(id, si, ai, h)]; } @@ -317,7 +320,7 @@ impl Tool for PenTool { self.last = ev.pos; let mut out_h = ev.pos; if ev.mods.shift { - out_h = a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0); + out_h = a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle); } let alt = ev.mods.alt; let Some(id) = path else { diff --git a/crates/tools/src/pie.rs b/crates/tools/src/pie.rs index e173d0a55..57ef47734 100644 --- a/crates/tools/src/pie.rs +++ b/crates/tools/src/pie.rs @@ -98,7 +98,8 @@ impl PieWidget { if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 { return vec![]; } - let shown = |id: &&NodeId| doc.is_editable(**id) && !selection.segments.contains_key(*id); + let editable = doc.editable(); + let shown = |id: &&NodeId| editable(**id) && !selection.segments.contains_key(*id); let mut out = vec![]; for id in selection.objects.iter().filter(shown) { let Some((e, (start, end))) = Ellipse::of(doc, *id) else { continue }; diff --git a/crates/tools/src/place.rs b/crates/tools/src/place.rs index a885b0ca3..af67b38c0 100644 --- a/crates/tools/src/place.rs +++ b/crates/tools/src/place.rs @@ -124,7 +124,12 @@ impl Tool for PlaceTool { fn overlays(&self, cx: &ToolContext) -> Vec { let Some((a, b)) = self.drag else { return vec![] }; - self.target(a, b, cx.tol(3.0)).map(Overlay::Marquee).into_iter().collect() + let Some(r) = self.target(a, b, cx.tol(3.0)) else { return vec![] }; + let mut o = vec![Overlay::Marquee(r)]; + if cx.measurement_labels { + o.push(Overlay::Measure { p: b, text: cx.size_label(r.width(), r.height()) }); + } + o } fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor { @@ -215,7 +220,8 @@ mod tests { // A drag up and to the left grows the box from the press point, aspect kept. t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0)); t.pointer(&c, &ev(PointerKind::Drag, 20.0, 90.0)); - assert_eq!(t.overlays(&c), vec![Overlay::Marquee(Rect::new(20.0, 60.0, 100.0, 100.0))]); + let c_marquee_only = ToolContext { measurement_labels: false, ..c }; + assert_eq!(t.overlays(&c_marquee_only), vec![Overlay::Marquee(Rect::new(20.0, 60.0, 100.0, 100.0))]); let acts = t.pointer(&c, &ev(PointerKind::Up, 20.0, 90.0)); assert_eq!(acts[0], Action::Exec("file.place".into(), json!({"path": "f1.png", "rect": [20.0, 60.0, 80.0, 40.0]}))); assert_eq!(t.options()["count"], 1); @@ -226,6 +232,29 @@ mod tests { assert!(!t.claims_key(&c, ToolKey::Escape), "an empty cursor takes no keys"); } + #[test] + fn measurement_labels_gate_the_place_size_readout() { + let (d, _) = doc_with_rect(); + let (s, p) = (Default::default(), paint()); + let base = cx(&d, &s, &p); + let mut t = loaded(1); + t.pointer(&base, &ev(PointerKind::Down, 100.0, 100.0)); + t.pointer(&base, &ev(PointerKind::Drag, 20.0, 90.0)); + let r = Rect::new(20.0, 60.0, 100.0, 100.0); + for on in [false, true] { + let c = ToolContext { measurement_labels: on, ..base }; + let o = t.overlays(&c); + assert!(o.iter().any(|x| matches!(x, Overlay::Marquee(_)))); + let measures = o.iter().filter(|x| matches!(x, Overlay::Measure { .. })).count(); + assert_eq!(measures, usize::from(on), "measurement labels {on}"); + if on { + assert!(o.iter().any( + |x| matches!(x, Overlay::Measure { p, text, .. } if *p == Point::new(20.0, 90.0) && text == &c.size_label(r.width(), r.height())) + )); + } + } + } + #[test] fn arrows_cycle_and_escape_discards() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/rulerguide.rs b/crates/tools/src/rulerguide.rs index faa6ab9c9..5473d620f 100644 --- a/crates/tools/src/rulerguide.rs +++ b/crates/tools/src/rulerguide.rs @@ -599,8 +599,12 @@ mod tests { id, name: "B".into(), rect: vectorcraft_geom::Rect::new(600.0, 0.0, 900.0, 300.0), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); d.guides = vec![Guide { artboard: Some(id), ..Guide::new(false, 150.0) }]; let (s, p) = (Selection::default(), paint()); @@ -911,8 +915,12 @@ mod tests { id: 2, name: "Artboard 2".into(), rect: Rect::new(150.0, 200.0, 350.0, 400.0), + locked: false, show_center_mark: false, show_cross_hairs: false, + show_video_safe_areas: false, + video_ruler_pixel_aspect_ratio: 1.0, + color: None, }); doc.ruler_origin = Point::new(5.0, 5.0); let (paint, selection) = (paint(), Selection::default()); diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index be3f08da1..07a6c0822 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -17,19 +17,28 @@ //! scaling its type: the text reflows at its size. //! E pressed during a marquee drag makes the marquee take only the objects entirely inside it, and //! E pressed again makes it take the objects it touches. -//! A click on the red out port of selected area type whose text overflows loads the pointer with -//! that text ([`Cursor::LoadedText`]): the next click threads it into the closed path or area type -//! under the pointer, or into a new frame of the same shape and size at the click on empty canvas, -//! and a drag draws a rectangle for the new frame (`text.thread.add`). Escape drops the loaded text. +//! Selected area type and type on a path show an in port and an out port. A click on the out port +//! loads the pointer with the text ([`Cursor::LoadedText`]): the next click threads it into the +//! closed path, area type or type on a path under the pointer, or into a new frame of the same +//! kind, shape and size at the click on empty canvas, and a drag draws a rectangle for the new +//! frame (`text.thread.add`), which comes after the clicked one in its thread. A click on the in +//! port does the same with the new frame put before it: the story then starts there and flows on +//! into the clicked one. Escape drops the loaded text. +//! With Smart Guides and Invoke Distance Guides on, Alt/Option held over another object shows how +//! far the selection is from it, and over an empty part of an artboard how far it is from the +//! artboard's edges ([`crate::guides::distance_guides`]). + +use std::cell::RefCell; +use std::collections::HashMap; use serde_json::{Value, json}; -use vectorcraft_doc::hit::{hit_test, marquee, objects_at}; +use vectorcraft_doc::hit::{Hit, HitKind, hit_test, marquee, objects_at}; use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; use crate::corners::{self, CornerDrag, over_widget}; -use crate::guides::{PointSnap, Targets}; +use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; use crate::pathtype::{self, BracketDrag, over_bracket}; use crate::pie::{self, PieDrag}; use crate::rulerguide::GuideEdit; @@ -44,6 +53,12 @@ enum State { /// Pressed on an object; becomes Moving after the drag threshold. Moving { start: Point, + /// Where the pointer went down: the drag threshold counts from there, not from `start`, + /// which may be an anchor or centre the press grabbed a few pixels away (#3716). + press: Point, + /// The point that snaps, moving with the pointer: `start`, or where a press on a path's + /// stroke or beside its edge took hold of the path ([`hold_point`]). + hold: Point, began: bool, /// Shift-pressed on this selected object: released without a drag, it leaves the /// selection. @@ -80,10 +95,12 @@ enum State { Mirror(crate::mirror::MirrorDrag), /// Dragging a bracket of type on a path. Bracket(BracketDrag), - /// Loaded with the text of area type `from` by a click on its out port; `press`: where the - /// next press went down and where the pointer is now. + /// Loaded with the text of area type or type on a path `from` by a click on its out port, or + /// on its in port (`before`: the frame it goes into comes before `from` in its thread); + /// `press`: where the next press went down and where the pointer is now. Loaded { from: NodeId, + before: bool, press: Option<(Point, Point)>, }, } @@ -101,10 +118,47 @@ pub struct SelectionTool { guide: GuideEdit, /// Smart Guides: the centre or anchor under the pointer, and its label ([`Self::hover_point`]). hover: Option<(Point, &'static str)>, + /// Distance Guides: what Alt held over it measures the selection to ([`distance_target`]). + distance: Option, + /// The last hover had Alt held: what it measures depends on what lies under the pointer + /// ([`Tool::wants_rehover`]). + alt_hover: bool, +} + +/// What Distance Guides measure the selection to. +#[derive(Clone, Copy, Debug, PartialEq)] +enum DistanceTo { + Object(NodeId), + /// The artboard at this index. + Artboard(usize), +} + +/// Distance Guides: with Smart Guides and Smart Guides → Invoke Distance Guides on, something +/// selected and Alt held (`m`), the object under `p` unless it is selected, or over no object the +/// artboard `p` is on. +fn distance_target(cx: &ToolContext, p: Point, m: Mods) -> Option { + if !(cx.smart_guides && cx.distance_guides && m.alt) || cx.selection.objects.is_empty() { + return None; + } + match hit_test(cx.doc, p, cx.selection_hit_options()).map(|h| h.top_object(cx.isolation)) { + Some(id) => (!cx.selection.contains(id)).then_some(DistanceTo::Object(id)), + None => cx.doc.artboard_at(p).map(DistanceTo::Artboard), + } +} + +/// The Distance Guides from the selection to `to` ([`distance_guides`]); none once either is gone. +fn distance_overlays(cx: &ToolContext, to: DistanceTo) -> Vec { + let Some(from) = selection_bounds(cx) else { return vec![] }; + match to { + DistanceTo::Object(id) if !cx.selection.contains(id) => object_bounds(cx, id).map(|r| distance_guides(cx, from, r, false)), + DistanceTo::Object(_) => None, + DistanceTo::Artboard(i) => cx.doc.artboards.get(i).map(|a| distance_guides(cx, from, a.rect, true)), + } + .unwrap_or_default() } /// Snapping for the selection moved as a whole, its targets gathered when the move begins: View → -/// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart +/// Snap to Grid lands its edges on the grid (#740), taking over from the rest; otherwise Smart /// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the /// point it was grabbed by (an anchor pressed near, else the pressed point) on an anchor or a ruler /// guide. Snap to Pixel puts its top-left on whole pixels. A copy (Alt, which can go down or up at @@ -246,12 +300,12 @@ impl MoveSnap { } /// The shift that lands `r` on the grid (a gridline every `step`), on each axis by whichever of its -/// two edges and its centre is nearest a gridline, however far that is. +/// two edges is nearest a gridline, however far that is. The center does not snap (#1816). fn grid_pull(r: Rect, step: f64) -> Vec2 { - let pull = |vs: [f64; 3]| { + let pull = |vs: [f64; 2]| { vs.iter().map(|v| vectorcraft_geom::snap::snap_to_grid(*v, step, 0.0) - v).min_by(|a, b| a.abs().total_cmp(&b.abs())).unwrap_or(0.0) }; - Vec2::new(pull([r.x0, r.center().x, r.x1]), pull([r.y0, r.center().y, r.y1])) + Vec2::new(pull([r.x0, r.x1]), pull([r.y0, r.y1])) } pub fn matrix_json(a: Affine) -> Value { @@ -259,16 +313,103 @@ pub fn matrix_json(a: Affine) -> Value { json!([c[0], c[1], c[2], c[3], c[4], c[5]]) } -/// Selection bounds used for the bounding box: visual bounds with Use Preview Bounds, else -/// geometric bounds. +/// The bounds the selection tools measured for one state of an open document: [`selection_bounds`], +/// [`selection_box`] and the objects' bounds that Smart Guides find centers in while hovering +/// ([`object_bounds`]). With Use Preview Bounds on, each measurement evaluates the geometry effects +/// of the objects (Offset Path, Pathfinder, Outline Object, blends), and the canvas asks for the +/// cursor on every hover frame. The session keeps one memo ([`ToolContext::preview_memo`]), as the +/// UI keeps one for `VectorcraftApp::selection_box`. The memo holds the measurements for one key +/// (the document's uid and revision, the selected objects and Use Preview Bounds), and a new key +/// replaces them. +#[derive(Default)] +pub struct PreviewMemo(RefCell); + +#[derive(Default)] +struct MemoState { + entry: Option, + /// How many measurements the memo has made. + measurements: u64, +} + +/// The measurements for one key; a slot holds `None` until it is measured. +struct Measured { + revision: (u64, u64), + objects: Vec, + preview: bool, + bounds: Option>, + oriented: Option>, + by_object: HashMap>>, +} + +impl Measured { + fn is_for(&self, cx: &ToolContext) -> bool { + self.revision == cx.revision && self.preview == cx.preview_bounds && self.objects == cx.selection.objects + } +} + +impl PreviewMemo { + /// How many measurements the memo has made: each one it could not answer from the measurements + /// for the document as it is. + pub fn measurements(&self) -> u64 { + self.0.try_borrow().map_or(0, |st| st.measurements) + } + + /// The measurements for the state `cx` shows, after the ones for any other state are dropped. + fn current<'m>(st: &'m mut MemoState, cx: &ToolContext) -> &'m mut Measured { + let fresh = || Measured { + revision: cx.revision, + objects: cx.selection.objects.clone(), + preview: cx.preview_bounds, + bounds: None, + oriented: None, + by_object: HashMap::new(), + }; + if !st.entry.as_ref().is_some_and(|m| m.is_for(cx)) { + st.entry = Some(fresh()); + } + st.entry.get_or_insert_with(fresh) + } + + /// The value `slot` holds for the state `cx` shows, else `measure`'s, which `slot` then holds. + fn remember(&self, cx: &ToolContext, slot: impl Fn(&mut Measured) -> &mut Option, measure: impl FnOnce() -> T) -> T { + if let Ok(mut st) = self.0.try_borrow_mut() + && let Some(v) = slot(Self::current(&mut st, cx)).clone() + { + return v; + } + let v = measure(); + if let Ok(mut st) = self.0.try_borrow_mut() { + st.measurements += 1; + *slot(Self::current(&mut st, cx)) = Some(v.clone()); + } + v + } +} + +/// `measure`'s value, kept in the session's [`PreviewMemo`] in the slot `slot` picks; measured +/// every time without a memo. +fn remembered(cx: &ToolContext, slot: impl Fn(&mut Measured) -> &mut Option, measure: impl FnOnce() -> T) -> T { + match cx.preview_memo { + Some(memo) => memo.remember(cx, slot, measure), + None => measure(), + } +} + +/// Selection bounds used for the bounding box: visual bounds as drawn with Use Preview Bounds +/// ([`vectorcraft_effects::preview`]), else geometric bounds. pub fn selection_bounds(cx: &ToolContext) -> Option { - cx.doc.bounds_of(&cx.selection.objects, cx.preview_bounds) + remembered(cx, |m| &mut m.bounds, || vectorcraft_effects::preview::bounds_of(cx.doc, &cx.selection.objects, cx.preview_bounds)) } /// [`selection_bounds`] square to the selection's own angle: the bounding box the Selection tool /// shows and drags (rotated after a rotation). pub fn selection_box(cx: &ToolContext) -> Option { - cx.doc.oriented_bounds(&cx.selection.objects, cx.preview_bounds) + remembered(cx, |m| &mut m.oriented, || vectorcraft_effects::preview::oriented_bounds(cx.doc, &cx.selection.objects, cx.preview_bounds)) +} + +/// The bounds of object `id`, measured as [`selection_bounds`] measures the selection. +fn object_bounds(cx: &ToolContext, id: NodeId) -> Option { + remembered(cx, |m| m.by_object.entry(id).or_insert(None), || vectorcraft_effects::preview::bounds_of(cx.doc, &[id], cx.preview_bounds)) } /// The objects of `ids` that lie entirely inside marquee `r`, edges included, measured by their @@ -306,6 +447,14 @@ pub(crate) fn bbox_handle_at(cx: &ToolContext, p: Point) -> bool { /// to label it while hovering. const HOVER_REACH: f64 = 6.0; +/// Where a press at `p` holds the object it hit (`h`) for a move: the nearest point of the path +/// hit, when the press is on its stroke or outline or within [`HOVER_REACH`] of it, else `p` +/// ([`hold_on_path`]). That point snaps, so the path itself lands on a ruler guide (#3701). +pub(crate) fn hold_point(cx: &ToolContext, h: &Hit, p: Point) -> Point { + let reach = if matches!(h.kind, HitKind::Stroke | HitKind::Outline) { f64::INFINITY } else { cx.tol(HOVER_REACH) }; + cx.doc.node(h.leaf).and_then(|n| n.path_data()).and_then(|pd| hold_on_path(pd, p, reach)).unwrap_or(p) +} + impl SelectionTool { fn drag_threshold(cx: &ToolContext) -> f64 { cx.tol(3.0) @@ -321,7 +470,7 @@ impl SelectionTool { return None; } let reach = cx.tol(HOVER_REACH); - let under = hit_test(cx.doc, p, cx.hit_options()).map(|h| h.top_object(cx.isolation)); + let under = hit_test(cx.doc, p, cx.selection_hit_options()).map(|h| h.top_object(cx.isolation)); let nearest = |points: &mut dyn Iterator| { points.filter(|q| q.distance(p) <= reach).min_by(|a, b| a.distance(p).total_cmp(&b.distance(p))) }; @@ -334,27 +483,32 @@ impl SelectionTool { if !cx.smart_guides { return None; } - let c = cx.doc.bounds_of(&[id], cx.preview_bounds)?.center(); + let c = object_bounds(cx, id)?.center(); (c.distance(p) <= reach).then_some((c, "center")) }) }) } - /// The pointer loaded with the text of area type `from` ([`State::Loaded`]): a press starts - /// placing it, and its release threads it (`text.thread.add`). None when `from` is no longer - /// area type: the press is an ordinary one. - fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, press: Option<(Point, Point)>) -> Option> { + /// The pointer loaded with the text of area type or type on a path `from` ([`State::Loaded`]): + /// a press starts placing it, and its release threads it (`text.thread.add`), into a frame put + /// `before` `from` or after it. None when `from` can no longer thread: the press is an + /// ordinary one. + fn loaded(&mut self, cx: &ToolContext, ev: &PointerEvent, from: NodeId, before: bool, press: Option<(Point, Point)>) -> Option> { let p = ev.pos; match (ev.kind, press) { - (PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_area_type) => { + (PointerKind::Down, _) if !cx.doc.node(from).is_some_and(is_threadable) => { self.state = State::Idle; return None; } - (PointerKind::Down, _) => self.state = State::Loaded { from, press: Some((p, p)) }, - (PointerKind::Drag, Some((start, _))) => self.state = State::Loaded { from, press: Some((start, p)) }, + (PointerKind::Down, _) => self.state = State::Loaded { from, before, press: Some((p, p)) }, + (PointerKind::Drag, Some((start, _))) => self.state = State::Loaded { from, before, press: Some((start, p)) }, (PointerKind::Up, Some((start, _))) => { self.state = State::Idle; - return Some(vec![Action::Exec("text.thread.add".into(), thread_params(cx, from, start, p))]); + let mut params = thread_params(cx, from, start, p); + if before { + params["before"] = json!(true); + } + return Some(vec![Action::Exec("text.thread.add".into(), params)]); } _ => {} } @@ -363,15 +517,17 @@ impl SelectionTool { } /// Where the text of `from` goes for a press at `start` released at `end`: a dragged rectangle, -/// else the closed path or area type pressed on, else a new frame with its top-left at `start`. +/// else the closed path, area type or type on a path pressed on, else a new frame with its +/// top-left at `start`. fn thread_params(cx: &ToolContext, from: NodeId, start: Point, end: Point) -> Value { let r = Rect::from_points(start, end); if r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0) { return json!({ "from": from.0, "rect": [r.x0, r.y0, r.width(), r.height()] }); } let frame = |n: &vectorcraft_doc::Node| { - is_area_type(n) || matches!(&n.kind, vectorcraft_doc::NodeKind::Path { path, .. } if path.subpaths.first().is_some_and(|s| s.closed)) + is_threadable(n) || matches!(&n.kind, vectorcraft_doc::NodeKind::Path { path, .. } if path.subpaths.first().is_some_and(|s| s.closed)) }; + // The art the text flows into: what is drawn, not a clipping path's outline. let to = hit_test(cx.doc, start, cx.hit_options()).map(|h| h.top_object(cx.isolation)).filter(|id| *id != from && cx.doc.node(*id).is_some_and(frame)); match to { @@ -419,6 +575,17 @@ impl Tool for SelectionTool { } } + /// The pointer hovers nothing for the tool: its Smart Guides label and its Distance Guides go. + fn hover_ended(&mut self, _cx: &ToolContext) { + self.hover = None; + self.distance = None; + self.alt_hover = false; + } + + fn wants_rehover(&self) -> bool { + self.alt_hover + } + fn transforming(&self) -> bool { match &self.state { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, @@ -431,8 +598,11 @@ impl Tool for SelectionTool { } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - if let State::Loaded { from, press } = self.state - && let Some(out) = self.loaded(cx, ev, from, press) + let hovering = ev.kind == PointerKind::Move && matches!(self.state, State::Idle); + self.alt_hover = hovering && ev.mods.alt; + self.distance = hovering.then(|| distance_target(cx, ev.pos, ev.mods)).flatten(); + if let State::Loaded { from, before, press } = self.state + && let Some(out) = self.loaded(cx, ev, from, before, press) { return out; } @@ -467,7 +637,8 @@ impl Tool for SelectionTool { let m = ev.mods; // A press near a centre or an anchor grabs the object by it. self.hover = None; - let grab = if matches!(ev.kind, PointerKind::Down) { Self::hover_point(cx, p).map_or(p, |(q, _)| q) } else { p }; + let grabbed = if matches!(ev.kind, PointerKind::Down) { Self::hover_point(cx, p).map(|(q, _)| q) } else { None }; + let grab = grabbed.unwrap_or(p); if matches!(ev.kind, PointerKind::Move) && matches!(self.state, State::Idle) { self.hover = Self::hover_point(cx, p); } @@ -488,6 +659,7 @@ impl Tool for SelectionTool { if crate::rulerguide::guide_at(cx, p).is_some() { return vec![]; } + // A double-click edits what is drawn: not a clipping path's outline. if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { let top = h.top_object(cx.isolation); if cx.doc.node(top).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Text(_))) { @@ -503,9 +675,10 @@ impl Tool for SelectionTool { vec![] } (PointerKind::Down, _) => { - // 0. The out port of selected area type: the pointer takes its text. - if let Some(from) = crate::text::out_port_at(cx, p) { - self.state = State::Loaded { from, press: None }; + // 0. A port of selected area type or type on a path: the pointer takes its text, + // to thread on from the out port or back from the in port. + if let Some((from, port)) = crate::text::port_at(cx, p) { + self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return vec![]; } // 1. Live Corners widgets, pie widgets and type on a path's brackets, then the @@ -564,10 +737,10 @@ impl Tool for SelectionTool { && cx.select_behind && let Some(behind) = object_behind(cx, p) { - self.state = State::Moving { start: p, began: false, deselect: None, key: None }; + self.state = State::Moving { start: p, press: p, hold: p, began: false, deselect: None, key: None }; return vec![Action::Exec("select.set".into(), json!({ "ids": [behind.0] }))]; } - match hit_test(cx.doc, p, cx.hit_options()) { + match hit_test(cx.doc, p, cx.selection_hit_options()) { Some(h) => { let top = h.top_object(cx.isolation); let mut out = vec![]; @@ -585,7 +758,10 @@ impl Tool for SelectionTool { } else if (cx.selection.objects.len() > 1 || !cx.selection.guides.is_empty()) && !m.cmd && !m.alt { key = Some(top); } - self.state = State::Moving { start: grab, began: false, deselect, key }; + // Grabbed by no anchor or centre, a press on a path's stroke or beside its + // edge holds the path there, and that point of it snaps (#3701). + let hold = if grabbed.is_some() { grab } else { hold_point(cx, &h, p) }; + self.state = State::Moving { start: grab, press: p, hold, began: false, deselect, key }; out } None => { @@ -594,23 +770,23 @@ impl Tool for SelectionTool { } } } - (PointerKind::Drag, State::Moving { start, began, .. }) => { + (PointerKind::Drag, State::Moving { start, press, hold, began, .. }) => { let mut out = vec![]; if !began { - if p.distance(start) < Self::drag_threshold(cx) { + if p.distance(press) < Self::drag_threshold(cx) { return out; } out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); self.moving = Some(MoveSnap::new(cx)); } - let mut d = move_delta(start, p, m.shift); + let mut d = move_delta(start, p, m.shift, cx.constrain_angle); let unsnapped = d; if let Some(snap) = &self.moving { - (d, self.guides) = if m.shift { snap.snap_along(cx, start, d, m.alt) } else { snap.snap(cx, start, d, m.alt) }; + (d, self.guides) = if m.shift { snap.snap_along(cx, hold, d, m.alt) } else { snap.snap(cx, hold, d, m.alt) }; } // Shift has already constrained `d`: any change is a snap. self.snapped = (d - unsnapped).hypot() > 1e-9; - self.state = State::Moving { start, began: true, deselect: None, key: None }; + self.state = State::Moving { start, press, hold, began: true, deselect: None, key: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out @@ -624,11 +800,21 @@ impl Tool for SelectionTool { } let nr = a.transform_rect_bbox(bx.rect); self.measure = cx.transform_tools_guides.then(|| (p, cx.size_label(nr.width(), nr.height()))); - let mut params = json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false }); + // With Use Preview Bounds the box is the visual box, whose margins around the art + // (strokes, geometry effects) scale with it or stay. `object.setBounds` then gives the + // visual box the dragged size and keeps its opposite handle (with Alt, its center) + // where it is. A drag that flips the box scales by the matrix. + let [sx, _, _, sy, _, _] = a.as_coeffs(); + let (cmd, mut params) = if cx.preview_bounds && cx.selection.anchors.is_empty() && sx > 0.0 && sy > 0.0 { + let reference = if m.alt { 4 } else { handle.opposite().reference() }; + ("object.setBounds", json!({ "width": nr.width(), "height": nr.height(), "reference": reference, "copy": false })) + } else { + ("object.transform", json!({ "matrix": matrix_json(bx.conjugate(a)), "copy": false })) + }; if areas { params["typeAreas"] = json!(true); } - vec![Action::Preview("object.transform".into(), params)] + vec![Action::Preview(cmd.into(), params)] } (PointerKind::Drag, State::Rotating { center, start, .. }) => { let (a, deg) = rotate_for_drag(center, start, p, m.shift); @@ -726,6 +912,9 @@ impl Tool for SelectionTool { o.push(Overlay::Label { p: q, text: label.into(), color: style.color }); } } + if let (Some(to), State::Idle, true, true) = (self.distance, &self.state, cx.smart_guides, cx.distance_guides) { + o.extend(distance_overlays(cx, to)); + } if let Some((p, t)) = &self.measure { o.push(Overlay::Measure { p: *p, text: t.clone() }); } @@ -789,7 +978,7 @@ impl Tool for SelectionTool { if cx.snap_to_point && matches!(Self::hover_point(cx, p), Some((_, "anchor"))) { return Cursor::ArrowHollow; } - if let Some(h) = hit_test(cx.doc, p, cx.hit_options()) { + if let Some(h) = hit_test(cx.doc, p, cx.selection_hit_options()) { if cx.selection.contains(h.top_object(cx.isolation)) || m.alt { return Cursor::Move; } @@ -802,7 +991,7 @@ impl Tool for SelectionTool { /// Select Behind: of the objects under `p` (topmost first), the one below the lowest selected /// one, back to the topmost after the bottom one; the topmost when none is selected. fn object_behind(cx: &ToolContext, p: Point) -> Option { - let stack = objects_at(cx.doc, p, cx.hit_options(), cx.isolation); + let stack = objects_at(cx.doc, p, cx.selection_hit_options(), cx.isolation); let next = stack.iter().rposition(|id| cx.selection.contains(*id)).map_or(0, |i| i + 1); stack.get(next).or(stack.first()).copied() } @@ -825,6 +1014,11 @@ pub(crate) fn is_area_type(n: &vectorcraft_doc::Node) -> bool { && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })) } +/// Can `n` be a frame of a thread: area type or type on a path, outside perspective? +pub(crate) fn is_threadable(n: &vectorcraft_doc::Node) -> bool { + n.extras().perspective.is_none() && matches!(&n.kind, vectorcraft_doc::NodeKind::Text(t) if !matches!(t.kind, vectorcraft_doc::TextKind::Point)) +} + /// Does a double-click or the Control bar's Isolate Selected Object button isolate an object of /// this kind? A group, a compound path or a single path, image, mesh or placed file does. Blends, /// envelopes, repeats and symbol instances do not; they have editing modes of their own. @@ -873,6 +1067,21 @@ mod tests { PointerEvent::new(kind, x, y) } + /// A Selection tool press on a clip group's mask edge, where the art it clips lies over it, + /// selects the clip group, as a press on the art inside does. + #[test] + fn a_press_on_a_masks_edge_selects_its_clip_group() { + let (d, g, _, _) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + for x in [500.0, 501.5, 450.0] { + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, x, 450.0)); + assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([g.0]))), "at {x}: {a:?}"); + } + // Just outside the mask, beyond the tolerance, the clipped-away art isn't there. + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 520.0, 450.0)); + assert!(!a.iter().any(|a| matches!(a, Action::Exec(c, _) if c == "select.set")), "{a:?}"); + } + /// #955: grabbed by an anchor and dropped near another object's anchor, the anchor lands /// exactly on it with Smart Guides, even where lining the bounds up would leave it beside it. #[test] @@ -898,6 +1107,31 @@ mod tests { assert!((200.0 + x - 350.3).abs() < 1e-9 && (200.0 + y - 250.0).abs() < 1e-9, "the corner lands at ({}, {})", 200.0 + x, 200.0 + y); } + /// #3716: a press a few pixels from an anchor grabs the object by that anchor, but the drag + /// threshold counts from where the pointer went down. A click that wobbles by a pixel selects + /// the object without moving it, instead of jumping the anchor onto the pointer. + #[test] + fn a_click_near_an_anchor_selects_without_moving() { + let (d, id) = doc_with_rect(); + let (none, mut picked) = (Selection::default(), Selection::default()); + picked.add(id); + let p = paint(); + let mut t = SelectionTool::default(); + // 5.7 px from the corner (200, 200): within reach of it, beyond the drag threshold. + let a = t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 196.0, 196.0)); + assert!(a.iter().any(|a| matches!(a, Action::Exec(c, v) if c == "select.set" && v["ids"] == json!([id.0]))), "{a:?}"); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 197.0, 196.0)); + assert!(a.is_empty(), "a 1 px wobble moves the object: {a:?}"); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Up, 197.0, 196.0)); + assert!(!a.iter().any(|a| matches!(a, Action::Commit)), "{a:?}"); + // A drag past the threshold from the press still moves it by the corner (#812). + let mut t = SelectionTool::default(); + t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 196.0, 196.0)); + let a = t.pointer(&cx(&d, &picked, &p), &ev(PointerKind::Drag, 230.0, 196.0)); + let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; + assert_eq!((v["matrix"][4].as_f64(), v["matrix"][5].as_f64()), (Some(30.0), Some(-4.0)), "the corner follows the pointer: {a:?}"); + } + /// A corner-radius drag is a transform, so the sharp selection box hides while the radius changes. #[test] fn a_corner_drag_counts_as_transforming_once_it_moves() { @@ -907,6 +1141,7 @@ mod tests { h: 100.0, radii: [0.0; 4], kinds: Default::default(), + radii_relative: false, xf: vectorcraft_geom::Affine::translate((100.0, 100.0)), }; let mut d = vectorcraft_doc::Document::new(500.0, 500.0); @@ -977,6 +1212,159 @@ mod tests { assert!(!t.overlays(&off).iter().any(|o| matches!(o, Overlay::Label { .. }))); } + /// [`doc_with_rect`] (100, 100)–(200, 200), selected, and a second rectangle `other`. + fn two_rects(other: Rect) -> (vectorcraft_doc::Document, NodeId, NodeId) { + let (mut d, a) = doc_with_rect(); + let l = d.layers[0].id; + let b = d.alloc_id(); + let path = vectorcraft_geom::shapes::rectangle(other); + d.insert(Some(l), 1, vectorcraft_doc::Node::path(b, path, vectorcraft_doc::Appearance::default_art())).unwrap(); + (d, a, b) + } + + fn alt(kind: PointerKind, x: f64, y: f64) -> PointerEvent { + ev(kind, x, y).with_mods(Mods { alt: true, ..Mods::default() }) + } + + /// A Distance Guides measured line: its ends and its label. + type DistanceLine = (Point, Point, String); + + /// The Distance Guides overlays: each measured line, and the dashed lines out to it, in order. + fn distances(t: &SelectionTool, cx: &ToolContext) -> (Vec, Vec<(Point, Point)>) { + let o = t.overlays(cx); + let lines = o.iter().filter_map(|o| if let Overlay::Distance { a, b, text, .. } = o { Some((*a, *b, text.clone())) } else { None }); + let dashed = o.iter().filter_map(|o| if let Overlay::Line { a, b, dashed: true, .. } = o { Some((*a, *b)) } else { None }); + (lines.collect(), dashed.collect()) + } + + fn line(a: (f64, f64), b: (f64, f64), text: &str) -> DistanceLine { + (Point::new(a.0, a.1), Point::new(b.0, b.1), text.to_string()) + } + + /// Alt held over an object beside the selection shows the horizontal gap between their + /// facing edges across the middle of where they overlap, and the vertical offsets of their + /// top and bottom edges across the selection's middle, with dashed lines out to the other + /// object's edges (helpx: Use Distance Guides for accurate placement). + #[test] + fn alt_over_an_object_beside_the_selection_measures_the_gap_and_the_edges() { + let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); + let s = Selection { objects: vec![a], ..Default::default() }; + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); + let (lines, dashed) = distances(&t, &cx); + assert_eq!( + lines, + vec![ + line((200.0, 150.0), (300.0, 150.0), "100.00 pt"), + line((150.0, 100.0), (150.0, 120.0), "20.00 pt"), + line((150.0, 200.0), (150.0, 180.0), "20.00 pt"), + ] + ); + let p = Point::new; + assert_eq!(dashed, vec![(p(300.0, 120.0), p(150.0, 120.0)), (p(300.0, 180.0), p(150.0, 180.0))]); + assert!(t.overlays(&cx).iter().all(|o| !matches!(o, Overlay::Distance { color, .. } if *color != cx.smart_guide_color))); + } + + /// Below the selection: the vertical gap, and the left and right edges' offsets. + #[test] + fn alt_over_an_object_below_the_selection_measures_the_vertical_gap() { + let (d, a, _) = two_rects(Rect::new(120.0, 300.0, 180.0, 400.0)); + let s = Selection { objects: vec![a], ..Default::default() }; + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &alt(PointerKind::Move, 150.0, 350.0)); + let (lines, dashed) = distances(&t, &cx); + assert_eq!( + lines, + vec![ + line((100.0, 150.0), (120.0, 150.0), "20.00 pt"), + line((200.0, 150.0), (180.0, 150.0), "20.00 pt"), + line((150.0, 200.0), (150.0, 300.0), "100.00 pt"), + ] + ); + assert_eq!(dashed.len(), 2, "out to the left and right edges of the object below"); + } + + /// Alt held over an empty part of the artboard: the selection's distance from the artboard's + /// left, right, top and bottom edges, in the General unit. + #[test] + fn alt_over_the_artboard_measures_to_its_four_edges_in_the_general_unit() { + let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); + let s = Selection { objects: vec![a], ..Default::default() }; + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + t.pointer(&cx, &alt(PointerKind::Move, 50.0, 450.0)); + let (lines, dashed) = distances(&t, &cx); + assert_eq!( + lines, + vec![ + line((100.0, 150.0), (0.0, 150.0), "100.00 pt"), + line((200.0, 150.0), (500.0, 150.0), "300.00 pt"), + line((150.0, 100.0), (150.0, 0.0), "100.00 pt"), + line((150.0, 200.0), (150.0, 500.0), "300.00 pt"), + ] + ); + assert!(dashed.is_empty()); + let mm = ToolContext { unit: vectorcraft_doc::Unit::Millimeters, ..cx }; + t.pointer(&mm, &alt(PointerKind::Move, 50.0, 450.0)); + let texts: Vec = distances(&t, &mm).0.into_iter().map(|l| l.2).collect(); + assert_eq!(texts, ["35.28 mm", "105.83 mm", "35.28 mm", "105.83 mm"]); + // Off the artboard, over nothing: no distances. + t.pointer(&cx, &alt(PointerKind::Move, 600.0, 600.0)); + assert!(distances(&t, &cx).0.is_empty()); + } + + /// No Distance Guides without Alt, with nothing selected, over the selection itself, with + /// Smart Guides or Invoke Distance Guides off, or once Alt is let go. + #[test] + fn distance_guides_need_alt_a_selection_and_smart_guides() { + let (d, a, _) = two_rects(Rect::new(300.0, 120.0, 400.0, 180.0)); + let s = Selection { objects: vec![a], ..Default::default() }; + let none = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let shown = |cx: &ToolContext, e: PointerEvent| { + let mut t = SelectionTool::default(); + t.pointer(cx, &e); + !distances(&t, cx).0.is_empty() + }; + assert!(shown(&cx, alt(PointerKind::Move, 350.0, 150.0))); + assert!(!shown(&cx, ev(PointerKind::Move, 350.0, 150.0)), "without Alt"); + assert!(!shown(&cx, ev(PointerKind::Move, 50.0, 450.0)), "without Alt, over the artboard"); + assert!(!shown(&crate::testutil::cx(&d, &none, &p), alt(PointerKind::Move, 350.0, 150.0)), "nothing selected"); + assert!(!shown(&cx, alt(PointerKind::Move, 150.0, 150.0)), "over the selection"); + assert!( + !shown(&ToolContext { smart_guides: false, ..crate::testutil::cx(&d, &s, &p) }, alt(PointerKind::Move, 350.0, 150.0)), + "View › Smart Guides off" + ); + assert!( + !shown(&ToolContext { distance_guides: false, ..crate::testutil::cx(&d, &s, &p) }, alt(PointerKind::Move, 350.0, 150.0)), + "the preference off" + ); + // Letting Alt go (the canvas sends a hover with the new modifiers) takes them away. + let mut t = SelectionTool::default(); + t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); + t.pointer(&cx, &ev(PointerKind::Move, 350.0, 150.0)); + assert!(distances(&t, &cx).0.is_empty()); + // So does the hover ending (the pointer leaving the canvas) with Alt still held. + t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); + assert!(!distances(&t, &cx).0.is_empty()); + assert!(t.wants_rehover(), "an Alt hover is hovered again when what lies under it changes"); + t.hover_ended(&cx); + assert!(distances(&t, &cx).0.is_empty()); + assert!(!t.wants_rehover()); + t.pointer(&cx, &ev(PointerKind::Move, 350.0, 150.0)); + assert!(!t.wants_rehover(), "nor without Alt"); + // A press ends them too. + t.pointer(&cx, &alt(PointerKind::Move, 350.0, 150.0)); + t.pointer(&cx, &alt(PointerKind::Down, 350.0, 150.0)); + assert!(distances(&t, &cx).0.is_empty()); + } + #[test] fn click_selects_object() { let (d, id) = doc_with_rect(); @@ -1215,10 +1603,10 @@ mod tests { assert_eq!(t.cursor(&c, Point::new(377.0, 371.0), alt), Cursor::SelectionCopy); } - /// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by - /// whichever of its edges or its centre is nearest a gridline, on each axis. + /// #740, #1816: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) + /// by whichever of its edges is nearest a gridline, on each axis. #[test] - fn a_move_lands_the_nearest_edge_or_centre_on_the_grid() { + fn a_move_lands_the_nearest_edge_on_the_grid() { let (d, id) = doc_with_rect(); let mut s = Selection::default(); s.add(id); @@ -1233,9 +1621,10 @@ mod tests { other => panic!("{other:?}"), } }; - // Bounds 100–200 moved by (13, 4): the centres (163, 154) are nearest gridlines (162, 153). + // Bounds 100–200 moved by (13, 4): the right and bottom edges (213, 204) are nearest + // gridlines (216, 207). let (x, y) = moved((163.0, 154.0)); - assert!((x - 12.0).abs() < 1e-9 && (y - 3.0).abs() < 1e-9, "{x} {y}"); + assert!((x - 16.0).abs() < 1e-9 && (y - 7.0).abs() < 1e-9, "{x} {y}"); // Moved by (7, 0): the right edge is on a gridline (207) and the top goes up to one (99). let (x, y) = moved((157.0, 150.0)); assert!((x - 7.0).abs() < 1e-9 && (y + 1.0).abs() < 1e-9, "{x} {y}"); @@ -1260,7 +1649,7 @@ mod tests { /// A Shift-constrained move snapped to the grid stays on its line: a level or upright one lands /// on the grid along its own axis only, and a diagonal one slides along the diagonal until an - /// edge or the center is on a gridline on the axis that needs the shorter way there. + /// edge is on a gridline on the axis that needs the shorter way there. #[test] fn a_shift_move_snapped_to_the_grid_stays_on_its_line() { let (d, id) = doc_with_rect(); @@ -1268,16 +1657,47 @@ mod tests { s.add(id); let p = paint(); let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; - // Level by 13.04: the center (163.04) lands on the gridline 162, and the top stays at 100, - // between the gridlines 99 and 108. + // Level by 13.04: the right edge (213.04) lands on the gridline 216, and the top stays at + // 100, between the gridlines 99 and 108. let (m, _) = shift_moved(&cx, (163.0, 151.0)); - assert!(moved_by(m, (12.0, 0.0)), "{m:?}"); + assert!(moved_by(m, (16.0, 0.0)), "{m:?}"); let (m, _) = shift_moved(&cx, (151.0, 163.0)); - assert!(moved_by(m, (0.0, 12.0)), "{m:?}"); - // Up and to the right by 13 on each axis: the center (163, 137) is 1 from the gridline 162 - // and 2 from 135, so the move goes back along the diagonal by 1 on each axis. + assert!(moved_by(m, (0.0, 16.0)), "{m:?}"); + // Up and to the right by 13 on each axis: the bottom edge (187) is 2 from the gridline 189, + // and the right edge (213) is 3 from 216, so the move goes back along the diagonal by 2 on + // each axis. let (m, _) = shift_moved(&cx, (163.0, 137.0)); - assert!(moved_by(m, (12.0, -12.0)), "{m:?}"); + assert!(moved_by(m, (11.0, -11.0)), "{m:?}"); + } + + /// #1816: on a 20 pt grid (gridlines every 100, 5 subdivisions), the 100 pt square at 100–200 + /// sits on the grid with its center half a step off. A move by (8, 8) brings the left and top + /// edges back onto the gridlines at 100, so the square stays where it was; a move by (31, 29) + /// lands its edges on the nearest gridlines (140, 120). The center does not snap. + #[test] + fn a_move_keeps_an_object_on_the_grid_when_its_center_is_off_it() { + let (mut d, id) = doc_with_rect(); + d.grid = vectorcraft_doc::GridPrefs { spacing: 100.0, subdivisions: 5 }; + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = ToolContext { snap_to_grid: true, ..cx(&d, &s, &p) }; + let moved = |to: (f64, f64)| { + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + let a = t.pointer(&cx, &ev(PointerKind::Drag, to.0, to.1)); + let Some(Action::Preview(_, v)) = a.get(1) else { panic!("{a:?}") }; + Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()) + }; + let m = moved((158.0, 158.0)); + assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); + let m = moved((181.0, 179.0)); + assert!(moved_by(m, (40.0, 20.0)), "{m:?}"); + // With Shift, level and diagonal moves by 8 come back to where they started too. + let (m, _) = shift_moved(&cx, (158.0, 150.0)); + assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); + let (m, _) = shift_moved(&cx, (158.0, 158.0)); + assert!(moved_by(m, (0.0, 0.0)), "{m:?}"); } /// A Shift-constrained move with Snap to Pixel on puts the top-left on a whole pixel on the @@ -2038,6 +2458,37 @@ mod tests { (d, id, rect, port) } + /// Type on a path whose text overflows has its out port just past its end bracket, along the + /// path: a click there loads the pointer with its text, as area type's does. + #[test] + fn the_out_port_of_type_on_a_path_loads_its_text() { + let (mut d, id) = doc_with_area_type(); + let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") }; + let line = vectorcraft_geom::kurbo::Line::new((0.0, 200.0), (100.0, 200.0)); + t.kind = vectorcraft_doc::TextKind::OnPath { + path: vectorcraft_geom::PathData::from_bezpath(&vectorcraft_geom::kurbo::Shape::to_path(&line, 0.1)), + start: 0.0, + end: Some(0.8), + }; + t.xf = vectorcraft_geom::Affine::IDENTITY; + t.runs = vec![vectorcraft_doc::TextRun::new("Words that run past the end of the path. ".repeat(5), vectorcraft_doc::CharStyle::default())]; + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + // The end bracket stands at 80, 200; the port past it, along the path. + let port = Point::new(80.0 + crate::text::PORT_RISE_PX, 200.0); + assert!(t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + assert!(t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)).is_empty()); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), + vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))] + ); + } + /// #2572: a click on the out port loads the pointer with the text; a click on empty canvas then /// threads it into a new frame there, a click on a closed path into the path, a drag into the /// rectangle it draws. @@ -2072,10 +2523,65 @@ mod tests { assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]); } - /// Escape drops the loaded text, and the next click is an ordinary one; type that fits its - /// frame shows no out port to click. + /// Loaded text clicked just inside a layer's clipping mask, where art is drawn over the mask's + /// edge, threads into that art, not into the unpainted mask: placing text acts on what is drawn, + /// so it takes the ordinary hit, not the selection tools' clipping-path outline. #[test] - fn escape_drops_the_loaded_text_and_type_that_fits_has_no_port() { + fn loaded_text_threads_into_the_art_on_a_layer_masks_edge() { + let (mut d, id, _, port) = overflowing_area_type(); + let art = d.alloc_id(); + let picture = vectorcraft_doc::Node::path( + art, + vectorcraft_geom::shapes::rectangle(Rect::new(350.0, 350.0, 550.0, 550.0)), + vectorcraft_doc::Appearance::default_art(), + ); + add_masked_layer(&mut d, Rect::new(400.0, 400.0, 500.0, 500.0), picture); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + assert!(cx.selection_hit_options().clip_outlines, "the selection tools would take the mask here"); + let mut t = SelectionTool::default(); + t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); + t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); + t.pointer(&cx, &ev(PointerKind::Down, 498.5, 450.0)); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Up, 498.5, 450.0)), + vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "to": art.0}))] + ); + } + + /// A double-click on type where a layer's clipping mask edge runs through it edits the type, + /// though a press there selects the mask: what a double-click edits is what is drawn. + #[test] + fn a_double_click_on_type_at_a_layer_masks_edge_edits_it() { + let (d, _, at) = doc_with_type_on_a_layer_masks_edge(); + let (s, p) = (Selection::default(), paint()); + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::DoubleClick, at.x, at.y)); + assert_eq!(a, vec![Action::SwitchTool("type".into())]); + } + + /// A Cmd/Ctrl+Shift double-click releases a guide path where it crosses a layer's clipping + /// mask edge (its left side, x = 450, just inside the mask's bottom): the mask, unpainted and + /// never a guide, doesn't stand in front of it. + #[test] + fn a_guide_on_a_layer_masks_edge_is_released() { + let (mut d, _) = doc_with_rect(); + let id = d.alloc_id(); + let mut guide_path = + vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(450.0, 350.0, 550.0, 550.0)), Default::default()); + let vectorcraft_doc::NodeKind::Path { guide, .. } = &mut guide_path.kind else { panic!("a path") }; + *guide = true; + add_masked_layer(&mut d, Rect::new(400.0, 400.0, 500.0, 500.0), guide_path); + let (s, p) = (Selection::default(), paint()); + let both = Mods { cmd: true, shift: true, ..Mods::default() }; + let a = SelectionTool::default().pointer(&cx(&d, &s, &p), &ev(PointerKind::DoubleClick, 450.0, 498.5).with_mods(both)); + assert_eq!(a, vec![Action::Exec("view.guides.release".into(), json!({"ids": [id.0]}))]); + } + + /// Escape drops the loaded text, and the next click is an ordinary one. + #[test] + fn escape_drops_the_loaded_text() { let (d, id, _, port) = overflowing_area_type(); let mut s = Selection::default(); s.set([id]); @@ -2090,14 +2596,117 @@ mod tests { assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), vec![Action::Exec("select.none".into(), json!({}))]); + } + /// [`doc_with_area_type`], its text fitting its frame; where its in and out ports are. + fn fitting_area_type() -> (vectorcraft_doc::Document, NodeId, Point, Point) { let (d, id) = doc_with_area_type(); + let Some(vectorcraft_doc::NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") }; + assert!(!vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).overflow, "the text fits"); + // On the left side below the top-left corner, on the right side above the bottom-right one. + let (input, output) = (t.xf * Point::new(0.0, crate::text::PORT_RISE_PX), t.xf * Point::new(120.0, 40.0 - crate::text::PORT_RISE_PX)); + (d, id, input, output) + } + + /// #2572: selected area type has both ports to click whether its text fits or not; other + /// points of its frame, and type that isn't selected, have none. + #[test] + fn every_selected_area_type_has_an_in_and_an_out_port() { + use crate::text::{Port, port_at}; + let (d, id, input, output) = fitting_area_type(); + let p = paint(); + let mut s = Selection::default(); + assert_eq!(port_at(&cx(&d, &s, &p), output), None, "not selected"); + s.set([id]); + let cx = cx(&d, &s, &p); + assert_eq!(port_at(&cx, output), Some((id, Port::Out))); + assert_eq!(port_at(&cx, input), Some((id, Port::In))); + assert_eq!(port_at(&cx, Point::new(input.x + 4.0, input.y + 4.0)), Some((id, Port::In)), "the box around it"); + assert_eq!(port_at(&cx, Point::new(input.x + 12.0, input.y)), None); + assert_eq!(port_at(&cx, Point::new(output.x, output.y + crate::text::PORT_RISE_PX)), None, "the corner"); + } + + /// #2572: a click on the out port of type that fits loads its text as well; the next click + /// threads it into a new frame after it. + #[test] + fn a_click_on_the_out_port_of_type_that_fits_starts_threading() { + let (d, id, _, output) = fitting_area_type(); let mut s = Selection::default(); s.set([id]); - let cx = crate::testutil::cx(&d, &s, &p); + let p = paint(); + let cx = cx(&d, &s, &p); let mut t = SelectionTool::default(); - t.pointer(&cx, &ev(PointerKind::Down, port.x, port.y)); - t.pointer(&cx, &ev(PointerKind::Up, port.x, port.y)); - assert_ne!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + assert!(t.pointer(&cx, &ev(PointerKind::Down, output.x, output.y)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, output.x, output.y)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); + assert_eq!( + t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), + vec![Action::Exec("text.thread.add".into(), json!({"from": id.0, "at": [30.0, 420.0]}))] + ); + } + + /// #2572: a click on the in port loads the text to thread backwards: the next click on empty + /// canvas, on a closed path or a drag makes the frame the story flows from into this one + /// (`before`). The corner handle above the in port still resizes the frame. + #[test] + fn a_click_on_the_in_port_threads_into_a_frame_before_it() { + let (d, id, input, _) = fitting_area_type(); + let rect = d.children(Some(d.layers[0].id)).unwrap()[0].id; + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let thread = |params: Value| vec![Action::Exec("text.thread.add".into(), params)]; + let load = |t: &mut SelectionTool| { + assert!(t.pointer(&cx, &ev(PointerKind::Down, input.x, input.y)).is_empty()); + assert!(t.pointer(&cx, &ev(PointerKind::Up, input.x, input.y)).is_empty()); + assert_eq!(t.cursor(&cx, Point::new(30.0, 420.0), Mods::default()), Cursor::LoadedText); + }; + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 30.0, 420.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 30.0, 420.0)), thread(json!({"from": id.0, "at": [30.0, 420.0], "before": true}))); + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 150.0, 150.0)), thread(json!({"from": id.0, "to": rect.0, "before": true}))); + load(&mut t); + t.pointer(&cx, &ev(PointerKind::Down, 20.0, 20.0)); + t.pointer(&cx, &ev(PointerKind::Drag, 80.0, 90.0)); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 80.0, 90.0)), thread(json!({"from": id.0, "rect": [20.0, 20.0, 60.0, 70.0], "before": true}))); + let corner = Point::new(input.x, input.y - crate::text::PORT_RISE_PX); + assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, corner.x, corner.y)), vec![Action::Begin("Resize Type Area".into())]); + } + + /// The memo answers for the revision, the selected objects and Use Preview Bounds it measured + /// for, and measures again when any of them changes. + #[test] + fn the_preview_memo_measures_once_per_revision_selection_and_preview_bounds() { + let (d, id) = doc_with_rect(); + let mut moved = d.clone(); + if let Some(n) = moved.node_mut(id) { + n.transform(Affine::translate((50.0, 0.0)), false); + } + let (mut s, p, memo) = (Selection::default(), paint(), PreviewMemo::default()); + s.set([id]); + let at = |d, s, revision, preview_bounds| { + let c = ToolContext { revision, preview_bounds, preview_memo: Some(&memo), ..cx(d, s, &p) }; + (selection_bounds(&c).map(|r| r.x1), object_bounds(&c, id).map(|r| r.x1)) + }; + assert_eq!(at(&d, &s, (1, 1), false), (Some(200.0), Some(200.0))); + assert_eq!(memo.measurements(), 2); + // The same revision with the moved square: the memo answers with what it measured. + assert_eq!(at(&moved, &s, (1, 1), false), (Some(200.0), Some(200.0))); + assert_eq!(memo.measurements(), 2); + assert_eq!(at(&moved, &s, (1, 2), false), (Some(250.0), Some(250.0)), "a new revision"); + assert_eq!(memo.measurements(), 4); + assert_eq!(at(&moved, &s, (1, 2), true), (Some(250.5), Some(250.5)), "Use Preview Bounds counts the stroke"); + assert_eq!(memo.measurements(), 6); + let none = Selection::default(); + assert_eq!(at(&moved, &none, (1, 2), true), (None, Some(250.5)), "another selection"); + assert_eq!(memo.measurements(), 8); + // Without a memo, every call measures. + let c = cx(&moved, &s, &p); + assert_eq!(selection_bounds(&c).map(|r| r.x1), Some(250.0)); } } diff --git a/crates/tools/src/settings.rs b/crates/tools/src/settings.rs index e46bd15dc..a682e1204 100644 --- a/crates/tools/src/settings.rs +++ b/crates/tools/src/settings.rs @@ -26,10 +26,14 @@ const ROWS: &[Row] = &[ (SYMBOLISM, Some("symbolism"), &["diameter", "intensity", "density"]), (&["mirrorCut"], None, &["axis", "keep"]), (&["puppetWarp"], None, &["showMesh", "expand"]), - (&["arc"], None, &["closed"]), + (&["arc"], None, &["closed", "lengthX", "lengthY", "reference", "baseAlong", "slope", "fillArc"]), (&["spiral"], None, &["decay", "segments", "clockwise"]), - (&["rectangularGrid"], None, &["rows", "columns", "rowSkew", "columnSkew", "frame", "fill"]), - (&["polarGrid"], None, &["concentric", "radial", "concentricSkew", "radialSkew", "compound", "fill"]), + // The grids' Tool Options: the dividers and switches the drag uses, the Default Size and + // Reference Point a click's dialog opens with. + (&["rectangularGrid"], None, &["rows", "columns", "rowSkew", "columnSkew", "frame", "fill", "width", "height", "reference"]), + (&["polarGrid"], None, &["concentric", "radial", "concentricSkew", "radialSkew", "compound", "fill", "width", "height", "reference"]), + // Line Segment Tool Options: the Length and Angle a click's dialog opens with, Fill Line. + (&["lineSegment"], None, &["length", "angle", "fillLine"]), (&["pencil", "paintbrush"], None, &["fidelity", "fill", "keepSelected", "editWithin", "closeWithin"]), // Pencil Tool Options' Alt key toggles to Smooth Tool (the Paintbrush has no such option). (&["pencil"], None, &["altSmooth"]), @@ -41,6 +45,23 @@ const ROWS: &[Row] = &[ (&["polygon"], None, &["sides"]), (&["star"], None, &["points"]), (&["freeTransform"], None, &["constrain"]), + // Shape Builder Tool Options (`builder::ShapeBuilderOptions`). + ( + &["shapeBuilder"], + None, + &[ + "openFilledAsClosed", + "pickColorFrom", + "cursorSwatchPreview", + "selection", + "highlightFill", + "gapDetection", + "gapLength", + "clickStrokeSplitsPath", + "highlightStroke", + "highlightColor", + ], + ), (&["artboard"], None, &["moveArt", "scaleArt"]), // Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes). ( diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index 07593a72b..47ddf7a84 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -8,7 +8,7 @@ //! the dragged corner snap to Smart Guides ([`DrawSnap`]), hovering before the press too. use serde_json::{Value, json}; -use vectorcraft_geom::{Point, Rect, Vec2}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::guides::{DrawSnap, Leave, square}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -26,6 +26,11 @@ pub struct ShapeTool { pub star_ratio: f64, /// The star's inner radius held while Ctrl/Cmd is down in a drag. inner: Option, + /// Line Segment Tool Options: the length and angle (degrees) a click's dialog opens with, and + /// Fill Line (new lines take the current fill). + pub line_length: f64, + pub line_angle: f64, + pub fill_line: bool, snap: DrawSnap, } @@ -59,6 +64,9 @@ impl ShapeTool { star_ratio: 0.5, inner: None, snap: DrawSnap::default(), + line_length: 100.0, + line_angle: 0.0, + fill_line: false, } } @@ -96,18 +104,29 @@ impl ShapeTool { match self.id { "polygon" | "star" => None, "lineSegment" => Some(Leave::segment(cx, start, m.shift)), - _ => Some(Leave::diagonal(start, m.shift)), + // With a Constrain Angle, Shift's square is taken along the rotated axes + // ([`Self::command_at`]), not the page's diagonal. + _ => Some(Leave::diagonal(start, m.shift && vectorcraft_geom::constrain_axes(cx.constrain_angle).is_none())), } } /// The command for a drag from `start` to `p` (snapped: a line's end already keeps Shift's /// angle). pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) { + self.command_at(start, p, m, 0.0) + } + + /// [`Self::command`] with General › Constrain Angle `base` (degrees, counter-clockwise): + /// rectangles, rounded rectangles and ellipses are drawn along the x and y axes turned by it, + /// as the reference app draws new objects along the rotated axes + /// (). + pub fn command_at(&self, start: Point, p: Point, m: Mods, base: f64) -> (String, Value) { match self.id { "polygon" | "star" => { let v = p - start; let r = v.hypot().max(0.01); - let rot = if m.shift { 0.0 } else { (v.atan2().to_degrees() + 90.0) % 360.0 }; + // Shift keeps the base orientation: upright, turned by the Constrain Angle. + let rot = if m.shift { vectorcraft_geom::constrain_rotation(base).unwrap_or(0.0) } else { (v.atan2().to_degrees() + 90.0) % 360.0 }; if self.id == "polygon" { ("shape.polygon".into(), json!({ "cx": start.x, "cy": start.y, "radius": r, "sides": self.sides, "rotation": rot })) } else { @@ -120,12 +139,15 @@ impl ShapeTool { } "lineSegment" => { let (a, b) = if m.alt { (start - (p - start), p) } else { (start, p) }; - ("shape.line".into(), json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y })) + let mut v = json!({ "x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y }); + if self.fill_line { + v["fill"] = json!(true); + } + ("shape.line".into(), v) } _ => { - let r = drag_rect(start, p, m); let cmd = if self.id == "ellipse" { "shape.ellipse" } else { "shape.rectangle" }; - let mut v = json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }); + let mut v = box_params(start, p, m, base); if self.id == "roundedRectangle" { v["radius"] = json!(self.corner_radius); } @@ -135,6 +157,34 @@ impl ShapeTool { } } +/// The box a drag from `start` to `p` draws (from its centre with Alt, square with Shift), +/// measured along the page axes turned by Constrain Angle `base`: the box in those axes, and the +/// axes (`None` for the page's own: 0° or a whole turn, where the box is on the page exactly). +/// The drawn shape and its size label both come from it. +pub(crate) fn turned_box(start: Point, p: Point, m: Mods, base: f64) -> (Rect, Option) { + match vectorcraft_geom::constrain_axes(base) { + None => (drag_rect(start, p, m), None), + Some(axes) => { + let back = axes.inverse(); + (drag_rect(back * start, back * p, m), Some(axes)) + } + } +} + +/// The `{x, y, width, height, rotation?}` params of the box a drag from `start` to `p` draws along +/// Constrain Angle `base` ([`turned_box`]): the box's page position at 0° or a whole turn, else +/// the turned box's centre out on the page again with the rotation that turns it about it. +pub(crate) fn box_params(start: Point, p: Point, m: Mods, base: f64) -> Value { + match turned_box(start, p, m, base) { + (r, None) => json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() }), + (r, Some(axes)) => { + let c = axes * r.center(); + let rotation = -vectorcraft_geom::normalize_deg(base); + json!({ "x": c.x - r.width() / 2.0, "y": c.y - r.height() / 2.0, "width": r.width(), "height": r.height(), "rotation": rotation }) + } + } +} + /// Space held once a shape drag has begun moves the shape being drawn instead of sizing it: `start` /// moves as far as the pointer did since `last`, which becomes the pointer. pub(crate) fn space_moves(start: &mut Point, last: &mut Point, ev: &PointerEvent, began: bool) { @@ -189,7 +239,7 @@ impl Tool for ShapeTool { self.began = true; out.push(Action::Begin(self.label().into())); } - let (c, v) = self.command(s, ev.pos, ev.mods); + let (c, v) = self.command_at(s, ev.pos, ev.mods, cx.constrain_angle); out.push(Action::Preview(c, v)); out } @@ -201,13 +251,15 @@ impl Tool for ShapeTool { self.began = false; vec![Action::Commit] } else { - vec![Action::Dialog(self.id.into(), json!({ "x": s.x, "y": s.y }))] + // Alt (draw from centre) is passed on, so the size dialog can centre the shape on the click. + let args = if ev.mods.alt { json!({ "x": s.x, "y": s.y, "alt": true }) } else { json!({ "x": s.x, "y": s.y }) }; + vec![Action::Dialog(self.id.into(), args)] } } _ => vec![], } } - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { let Some(s) = self.start.filter(|_| self.began) else { return vec![] }; let changed = match (self.id, key) { ("polygon", ToolKey::Up) => { @@ -237,7 +289,7 @@ impl Tool for ShapeTool { _ => false, }; if changed { - let (c, v) = self.command(s, self.last, self.mods); + let (c, v) = self.command_at(s, self.last, self.mods, cx.constrain_angle); vec![Action::Preview(c, v)] } else if key == ToolKey::Escape { self.start = None; @@ -259,7 +311,8 @@ impl Tool for ShapeTool { let (w, h) = match self.id { "polygon" | "star" => ((self.last - s).x.abs(), (self.last - s).y.abs()), _ => { - let r = drag_rect(s, self.last, self.mods); + // Along the Constrain Angle's axes, as the box is drawn (`command_at`). + let (r, _) = turned_box(s, self.last, self.mods, cx.constrain_angle); (r.width(), r.height()) } }; @@ -273,14 +326,26 @@ impl Tool for ShapeTool { Cursor::Crosshair } fn options(&self) -> Value { - json!({ "sides": self.sides, "points": self.points, "cornerRadius": self.corner_radius, "starRatio": self.star_ratio }) + json!({ + "sides": self.sides, + "points": self.points, + "cornerRadius": self.corner_radius, + "starRatio": self.star_ratio, + "length": self.line_length, + "angle": self.line_angle, + "fillLine": self.fill_line, + }) } fn set_option(&mut self, key: &str, v: &Value) { + let finite = v.as_f64().filter(|n| n.is_finite()); match key { "sides" => self.sides = v.as_u64().unwrap_or(6).clamp(3, 1000) as u32, "points" => self.points = v.as_u64().unwrap_or(5).clamp(3, 1000) as u32, "cornerRadius" => self.corner_radius = v.as_f64().unwrap_or(12.0).max(0.0), "starRatio" => self.star_ratio = v.as_f64().unwrap_or(0.5).clamp(0.01, 1.0), + "length" => self.line_length = finite.map_or(self.line_length, |n| n.abs().min(1e6)), + "angle" => self.line_angle = finite.map_or(self.line_angle, |n| n.rem_euclid(360.0)), + "fillLine" => self.fill_line = v.as_bool().unwrap_or(self.fill_line), _ => {} } } @@ -371,6 +436,50 @@ mod tests { assert_eq!(v, json!({"x": -10.0, "y": -10.0, "width": 20.0, "height": 20.0})); } + /// General › Constrain Angle: a Shift-drag squares along the turned axes, and the shape turns + /// by the angle about its centre. + #[test] + fn constrain_angle_squares_along_the_turned_axes() { + let t = ShapeTool::new("rectangle"); + let shift = Mods { shift: true, ..Default::default() }; + // 45°: the drag straight right is a diagonal of the turned axes, so a square on it. + let (_, v) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 0.0), shift, 45.0); + let side = 20.0 / 2f64.sqrt(); + let near = |k: &str, want: f64| (v[k].as_f64().unwrap() - want).abs() < 1e-9; + assert!(near("width", side) && near("height", side) && near("rotation", -45.0), "{v}"); + assert!(near("x", 10.0 - side / 2.0) && near("y", -side / 2.0), "centred on the drag's middle: {v}"); + // Whole turns are the page's axes exactly: the same box as 0°, unturned. + let (a, b) = (Point::new(3.0, 4.0), Point::new(41.0, 17.0)); + let flat = t.command_at(a, b, Mods::default(), 0.0); + for turn in [360.0, -360.0, 720.0] { + assert_eq!(t.command_at(a, b, Mods::default(), turn), flat, "{turn}°"); + } + // A quarter turn is exact (no floating-point noise in the box). + let (_, v) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 10.0), Mods::default(), 90.0); + assert_eq!((v["width"].as_f64(), v["height"].as_f64(), v["rotation"].as_f64()), (Some(10.0), Some(20.0), Some(-90.0)), "{v}"); + let (_, w) = t.command_at(Point::new(0.0, 0.0), Point::new(20.0, 10.0), Mods::default(), 450.0); + assert_eq!(v, w, "450° is 90°"); + } + + /// The size label gives the box as drawn: measured along the Constrain Angle's axes. + #[test] + fn constrain_angle_size_label_measures_the_turned_box() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.constrain_angle = 45.0; + let mut t = ShapeTool::new("rectangle"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 340.0, 300.0)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let (w, h) = (v["width"].as_f64().unwrap(), v["height"].as_f64().unwrap()); + assert!((w - h).abs() < 1e-9 && (w - 40.0 / 2f64.sqrt()).abs() < 1e-9, "{v}"); + let o = t.overlays(&cx); + assert!(o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(w, h))), "{o:?}"); + assert!(!o.iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.size_label(40.0, 0.0))), "not the page box"); + } + #[test] fn click_opens_dialog() { let (d, _) = doc_with_rect(); @@ -385,6 +494,21 @@ mod tests { ); } + #[test] + fn alt_click_passes_draw_from_centre_to_the_dialog() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let alt = Mods { alt: true, ..Default::default() }; + let mut t = ShapeTool::new("rectangle"); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0).with_mods(alt)); + assert_eq!( + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0).with_mods(alt)), + vec![Action::Dialog("rectangle".into(), json!({"x": 10.0, "y": 10.0, "alt": true}))] + ); + } + #[test] fn arrow_keys_change_sides() { let (d, _) = doc_with_rect(); @@ -432,4 +556,22 @@ mod tests { let to = Point::new(200.0, 300.0); assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &cx.segment_measure_label(from, to)))); } + + /// Line Segment Tool Options: Length and Angle are options (for a click's dialog), and Fill + /// Line makes the drag's line take the current fill. + #[test] + fn line_segment_options_and_fill_line() { + let mut t = ShapeTool::new("lineSegment"); + let (a, b) = (Point::new(0.0, 0.0), Point::new(30.0, 40.0)); + assert!(t.command(a, b, Mods::default()).1.get("fill").is_none()); + for (k, v) in [("length", json!(-80.0)), ("angle", json!(-90.0)), ("fillLine", json!(true))] { + t.set_option(k, &v); + } + t.set_option("length", &json!(f64::NAN)); + assert_eq!( + (t.options()["length"].clone(), t.options()["angle"].clone(), t.options()["fillLine"].clone()), + (json!(80.0), json!(270.0), json!(true)) + ); + assert_eq!(t.command(a, b, Mods::default()).1["fill"], json!(true)); + } } diff --git a/crates/tools/src/slice.rs b/crates/tools/src/slice.rs index 9e05f2072..6d2a07b4a 100644 --- a/crates/tools/src/slice.rs +++ b/crates/tools/src/slice.rs @@ -259,7 +259,7 @@ impl Tool for SliceSelectionTool { } out.push(Action::Begin("Move Slice".into())); } - let d = move_delta(start, p, m.shift); + let d = move_delta(start, p, m.shift, cx.constrain_angle); out.push(Action::Preview("object.slice.move".into(), json!({ "slices": json_ids(&ids), "dx": d.x, "dy": d.y }))); self.drag = Some(Drag::Move { start, ids, began: true }); out diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index db0e6e3f3..16418ae8f 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -283,7 +283,17 @@ impl TypeTool { let was_editing = self.editing.is_some_and(|id| Self::text(cx, id).is_some()); let mut out = self.finish(cx); let drag = self.drag.take(); - let area = drag.map(|d| Rect::from_points(at, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0)); + // New type is set along General › Constrain Angle's axes (none at 0° or a whole turn): an + // area's box measured along them, turned about its top-left corner, point type about its + // first baseline's start. + let axes = vectorcraft_geom::constrain_axes(cx.constrain_angle); + let back = axes.map(|a| a.inverse()); + let area = drag + .map(|d| match back { + Some(b) => Rect::from_points(b * at, b * d), + None => Rect::from_points(at, d), + }) + .filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0)); if was_editing && area.is_none() { return out; } @@ -301,94 +311,203 @@ impl TypeTool { } } let mut params = match area { - Some(r) => json!({"x": r.x0, "y": r.y0, "text": "", "area": {"width": r.width(), "height": r.height()}}), + Some(r) => { + let o = axes.map_or(r.origin(), |a| a * r.origin()); + json!({"x": o.x, "y": o.y, "text": "", "area": {"width": r.width(), "height": r.height()}}) + } None => json!({"x": at.x, "y": at.y, "text": ""}), }; + if let Some(rotation) = vectorcraft_geom::constrain_rotation(cx.constrain_angle) { + params["rotation"] = json!(rotation); + } params["vertical"] = json!(self.vertical); params["placeholder"] = json!(cx.placeholder_text); out.push(Action::Exec("text.create".into(), params)); out.push(Action::Notify("text.editNew".into())); out } - - fn overflow_marker(t: &TextObject, lay: &TextLayout, cx: &ToolContext, o: &mut Vec) { - if lay.overflow { - overflow_port(t, cx, o); - } - } } -/// How far (screen px) the out port of area type sits above the bottom-right corner of its frame, -/// on its right side: clear of the bounding box's corner handle, so both can be grabbed. +/// How far (screen px) the ports of area type sit from the frame's corners, along its sides: the +/// out port above the bottom-right corner on the right side, the in port below the top-left corner +/// on the left side, clear of the bounding box's corner handles, so both can be grabbed. pub const PORT_RISE_PX: f64 = 12.0; -/// Half the out port's side (screen px). +/// Half a port's side (screen px). const PORT_HALF_PX: f64 = 4.5; +/// The red of the out port of text that doesn't fit. +const OVERFLOW_RED: [u8; 3] = [230, 30, 30]; -/// Centre of the out port of `t`: on the right side of an area type frame just above its -/// bottom-right corner ([`PORT_RISE_PX`]), at the end of the path for type on a path. -fn out_port(t: &TextObject, cx: &ToolContext) -> Option { +/// One of the two ports of area type or type on a path: its story flows in through the in port +/// and on through the out port. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Port { + In, + Out, +} + +/// What a port shows: empty when the frame is not threaded on that side (and, for the out port, +/// all its text is seen), an arrow when it is threaded to another frame there, a red "+" in the out +/// port when its text doesn't fit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PortState { + Empty, + Threaded, + Overflow, +} + +/// Centre of `port` of `t`, `px` document units to the screen pixel: area type's on its frame's +/// sides by [`PORT_RISE_PX`] from the corners; type on a path's along its path, as far past its end +/// bracket (out) or before its start bracket (in), clear of the brackets, which drag. +pub fn port_centre(t: &TextObject, port: Port, px: f64) -> Option { + let rise = PORT_RISE_PX * px; match &t.kind { TextKind::Area { frame } => { let b = frame.bounds()?; - let corner = t.xf * Point::new(b.x1, b.y1); - let up = t.xf * Point::new(b.x1, b.y0) - corner; - let len = up.hypot(); - Some(if len > 1e-9 && len.is_finite() { corner + up * (cx.tol(PORT_RISE_PX).min(len / 2.0) / len) } else { corner }) + // The corner the port sits by, and the far end of the side it sits on. + let (corner, far) = match port { + Port::Out => (Point::new(b.x1, b.y1), Point::new(b.x1, b.y0)), + Port::In => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)), + }; + let (corner, side) = (t.xf * corner, t.xf * far - t.xf * corner); + let len = side.hypot(); + Some(if len > 1e-9 && len.is_finite() { corner + side * (rise.min(len / 2.0) / len) } else { corner }) + } + TextKind::OnPath { path, .. } => { + let arc = vectorcraft_geom::ArcPath::new(path); + let (start, end) = t.kind.path_span()?; + if arc.is_empty() { + return None; + } + let (at, away) = match port { + Port::Out => (end, rise), + Port::In => (start, -rise), + }; + let (p, dir) = arc.at(at * arc.len()); + let (at, ahead) = (t.xf * p, t.xf * (p + dir) - t.xf * p); + let len = ahead.hypot(); + Some(if len > 1e-9 && len.is_finite() { at + ahead * (away / len) } else { at }) } - TextKind::OnPath { path, .. } => path.to_bezpath().segments().last().map(|s| t.xf * vectorcraft_geom::ParamCurve::end(&s)), TextKind::Point => None, } } -/// The red "+" out port of `t` (area type and type on a path whose text doesn't fit), at [`out_port`]. -fn overflow_port(t: &TextObject, cx: &ToolContext, o: &mut Vec) { - let Some(c) = out_port(t, cx) else { return }; - let h = cx.tol(PORT_HALF_PX); - let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); - let red = [230, 30, 30]; - o.push(Overlay::Path { path: r.to_path(0.1), color: red, width: 1.0, dashed: false }); - let (k, m) = (cx.tol(2.5), r.center()); - o.push(Overlay::Line { a: Point::new(m.x - k, m.y), b: Point::new(m.x + k, m.y), color: red, dashed: false }); - o.push(Overlay::Line { a: Point::new(m.x, m.y - k), b: Point::new(m.x, m.y + k), color: red, dashed: false }); +/// What the in and out ports of frame `id` of `d` show, its text `overflow`ing or not: an arrow +/// where a frame of its thread comes before (in) or after (out) it, else a red "+" in the out port +/// of text that doesn't fit, else nothing. +pub fn port_states(d: &vectorcraft_doc::Document, id: NodeId, overflow: bool) -> (PortState, PortState) { + let thread = d.text_threads.iter().find(|t| t.contains(&id)); + let at = thread.and_then(|t| t.iter().position(|x| *x == id)); + let input = if at.is_some_and(|i| i > 0) { PortState::Threaded } else { PortState::Empty }; + let output = match (thread, at) { + (Some(t), Some(i)) if i + 1 < t.len() => PortState::Threaded, + _ if overflow => PortState::Overflow, + _ => PortState::Empty, + }; + (input, output) } -/// The selected area type whose red out port is under `p` (only text that overflows shows one): -/// a click there loads the Selection tool with its text, to thread into another frame. -pub(crate) fn out_port_at(cx: &ToolContext, p: Point) -> Option { +/// A port at `c` showing `state`, `px` document units to the screen pixel: a white box outlined in +/// `color` (the frame's layer colour; red for text that doesn't fit), with a red "+" or an arrow +/// pointing the way the story flows. +pub fn push_port(o: &mut Vec, c: Point, state: PortState, color: [u8; 3], px: f64) { + let h = PORT_HALF_PX * px; + let r = Rect::new(c.x - h, c.y - h, c.x + h, c.y + h); + let color = if state == PortState::Overflow { OVERFLOW_RED } else { color }; + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + o.push(Overlay::Highlight { quad: corners, color: [255, 255, 255, 255] }); + o.push(Overlay::Path { path: r.to_path(0.1), color, width: 1.0, dashed: false }); + let k = 2.5 * px; + let line = |a: Point, b: Point| Overlay::Line { a, b, color, dashed: false }; + match state { + PortState::Empty => {} + PortState::Overflow => { + o.push(line(Point::new(c.x - k, c.y), Point::new(c.x + k, c.y))); + o.push(line(Point::new(c.x, c.y - k), Point::new(c.x, c.y + k))); + } + PortState::Threaded => { + let tip = Point::new(c.x + k, c.y); + o.push(line(Point::new(c.x - k, c.y), tip)); + o.push(line(Point::new(c.x, c.y - k), tip)); + o.push(line(Point::new(c.x, c.y + k), tip)); + } + } +} + +/// The in and out ports of area type or type on a path `t`, frame `id`, its text `overflow`ing or +/// not ([`port_states`]). +fn ports(cx: &ToolContext, id: NodeId, t: &TextObject, overflow: bool, o: &mut Vec) { + let (input, output) = port_states(cx.doc, id, overflow); + let (color, px) = (cx.doc.layer_color(id), cx.tol(1.0)); + for (port, state) in [(Port::In, input), (Port::Out, output)] { + if let Some(c) = port_centre(t, port, px) { + push_port(o, c, state, color, px); + } + } +} + +/// The most selected area type and type on a path whose ports are drawn and take clicks: selecting +/// all of an opened PDF can select tens of thousands of type objects, whose ports would only bury +/// the canvas (and the GPU's buffers) in boxes. +pub const PORTS_MAX: usize = 1_000; + +/// The text of `id` in `d` when it has ports: area type or type on a path a thread can hold, not in +/// perspective ([`crate::select::is_threadable`]). The one test for drawing ports, clicking them and +/// counting them toward [`PORTS_MAX`]. +pub fn ported(d: &vectorcraft_doc::Document, id: NodeId) -> Option<&TextObject> { + match d.node(id).filter(|n| crate::select::is_threadable(n)).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t), + _ => None, + } +} + +/// Are at most [`PORTS_MAX`] of `selected` [`ported`] (counting stops past it), so that their +/// ports show? +pub fn ports_shown(d: &vectorcraft_doc::Document, selected: &[NodeId]) -> bool { + selected.iter().filter(|id| ported(d, **id).is_some()).take(PORTS_MAX + 1).count() <= PORTS_MAX +} + +/// The selected area type or type on a path with a port under `p`, and which port: a click on the +/// out port loads the Selection tool with its text to thread into another frame after it, a click +/// on the in port to thread into one before it. Every such frame has both ports, threaded, +/// overflowing or not, while their ports show ([`PORTS_MAX`]). +pub(crate) fn port_at(cx: &ToolContext, p: Point) -> Option<(NodeId, Port)> { + if !ports_shown(cx.doc, &cx.selection.objects) { + return None; + } let h = cx.tol(PORT_HALF_PX + 1.0); - let near = |t: &TextObject| out_port(t, cx).is_some_and(|c| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h); - cx.selection.objects.iter().copied().find(|id| { - cx.doc - .node(*id) - .filter(|n| crate::select::is_area_type(n)) - .and_then(|_| TypeTool::text(cx, *id)) - .is_some_and(|t| near(t) && vectorcraft_text::layout(FontDb::global(), t).overflow) + let near = |c: Point| (c.x - p.x).abs() <= h && (c.y - p.y).abs() <= h; + cx.selection.objects.iter().find_map(|id| { + let t = ported(cx.doc, *id)?; + [Port::Out, Port::In].into_iter().find(|port| port_centre(t, *port, cx.tol(1.0)).is_some_and(near)).map(|port| (*id, port)) }) } -/// Which selected text overflows, for [`overflow_markers`]: each text object's layout verdict kept +/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept /// with the object and the fonts it was made from, as overlays run every frame. #[derive(Default)] -pub struct OverflowMarks { +pub struct PortMarks { seen: std::collections::HashMap, } -/// The overflow markers of the selected area type and type on a path, for every tool but the Type -/// tools (they draw their own): an out port with a red "+" tells that text is hidden by the size of -/// its frame, whatever tool is active. -pub fn overflow_markers(marks: &mut OverflowMarks, cx: &ToolContext) -> Vec { +/// The in and out ports of the selected area type and type on a path, for every tool but the Type +/// tools (they draw their own): each tells whether the frame is threaded on that side and the out +/// port whether text is hidden by the size of its frame, whatever tool is active. None when more +/// than [`PORTS_MAX`] of them are selected. +pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec { let fonts = FontDb::global().generation(); let mut o = Vec::new(); let mut kept = std::collections::HashMap::new(); + if !ports_shown(cx.doc, &cx.selection.objects) { + marks.seen = kept; + return o; + } for id in &cx.selection.objects { - let Some(t) = TypeTool::text(cx, *id).filter(|t| !matches!(t.kind, TextKind::Point)) else { continue }; + let Some(t) = ported(cx.doc, *id) else { continue }; let over = match marks.seen.remove(id) { Some(((g, was), over)) if g == fonts && &was == t => over, _ => vectorcraft_text::layout(FontDb::global(), t).overflow, }; - if over { - overflow_port(t, cx, &mut o); - } + ports(cx, *id, t, over, &mut o); kept.insert(*id, ((fonts, t.clone()), over)); } marks.seen = kept; @@ -399,7 +518,7 @@ pub fn overflow_markers(marks: &mut OverflowMarks, cx: &ToolContext) -> Vec Option<(&'static str, f64)> { +pub fn step_key(key: ToolKey, shift: bool, caret: bool) -> Option<(&'static str, f64)> { let sign = |up: bool| if up { 1.0 } else { -1.0 }; Some(match (key, shift) { (ToolKey::Left | ToolKey::Right, false) => (if caret { "kerning" } else { "tracking" }, sign(key == ToolKey::Right)), @@ -791,6 +910,18 @@ impl Tool for TypeTool { /// `commitTyping` (the caller commits the engine interaction). fn set_option(&mut self, key: &str, v: &Value) { match key { + // Transient selection transferred between Type and Eyedropper; never a saved setting. + "textSelection" => { + if let (Some(id), Some(a), Some(b)) = ( + v.get("editing").and_then(Value::as_u64), + v.get("anchor").or_else(|| v.get("start")).and_then(Value::as_u64), + v.get("caret").or_else(|| v.get("end")).and_then(Value::as_u64), + ) && let (Ok(a), Ok(b)) = (usize::try_from(a), usize::try_from(b)) + { + self.start_editing(NodeId(id), b); + self.anchor = a; + } + } "prevTool" | "previousTool" => { self.prev_tool = v.as_str().map(String::from); } @@ -826,16 +957,36 @@ impl Tool for TypeTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = self.snap.guides().to_vec(); if let (Some((_, s)), Some(d)) = (self.press, self.drag) { - o.push(Overlay::Marquee(Rect::from_points(s, d))); - } - // Overflow markers on selected text. - for id in &cx.selection.objects { - if Some(*id) == self.editing { - continue; + // The area being drawn (along the Constrain Angle's axes when one is set, as `on_up` + // makes it) and its size. + let size = match vectorcraft_geom::constrain_axes(cx.constrain_angle) { + None => { + let r = Rect::from_points(s, d); + o.push(Overlay::Marquee(r)); + r.size() + } + Some(axes) => { + let back = axes.inverse(); + let r = Rect::from_points(back * s, back * d); + let mut path = r.to_path(0.1); + path.apply_affine(axes); + o.push(Overlay::Path { path, color: [79, 128, 255], width: 1.0, dashed: true }); + r.size() + } + }; + if cx.measurement_labels { + o.push(Overlay::Measure { p: d, text: cx.size_label(size.width, size.height) }); } - if let Some(t) = Self::text(cx, *id) { - let lay = self.layout(t); - Self::overflow_marker(t, &lay, cx, &mut o); + } + // The ports of selected area type and type on a path, as many as show ([`PORTS_MAX`]), the + // edited one's included. + let shown = ports_shown(cx.doc, &cx.selection.objects); + if shown { + for id in cx.selection.objects.iter().filter(|id| Some(**id) != self.editing) { + if let Some(t) = ported(cx.doc, *id) { + let lay = self.layout(t); + ports(cx, *id, t, lay.overflow, &mut o); + } } } let Some(t) = self.current(cx) else { return o }; @@ -877,7 +1028,9 @@ impl Tool for TypeTool { o.push(Overlay::Highlight { quad: q.map(|p| t.xf * p), color: [60, 120, 255, 90] }); } } - Self::overflow_marker(&t, &lay, cx, &mut o); + if let Some(id) = self.editing.filter(|id| shown && ported(cx.doc, *id).is_some()) { + ports(cx, id, &t, lay.overflow, &mut o); + } o } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { diff --git a/crates/tools/src/text_tests.rs b/crates/tools/src/text_tests.rs index 2ca919654..f96d391f2 100644 --- a/crates/tools/src/text_tests.rs +++ b/crates/tools/src/text_tests.rs @@ -419,6 +419,36 @@ fn area_and_path_tools_convert_clicked_paths() { assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, p) if cmd == "text.createInPath" && p["mode"] == "onPath")), "{acts:?}"); } +/// Area Type and Type on a Path follow the reference app: click the path outline, not the fill interior +/// (helpx: add-text-work-with-type-objects, creating-type-path). +#[test] +fn area_and_path_tools_hit_path_outline_not_fill_interior() { + let (mut d, rect) = crate::testutil::doc_with_rect(); + if let Some(n) = d.node_mut(rect) { + n.appearance.set_stroke(vectorcraft_color::Paint::None); + } + let (sel, p) = (Selection::default(), paint()); + let c = cx(&d, &sel, &p); + let edge = (100.0, 150.0); + let interior = (150.0, 150.0); + for tool_id in ["areaType", "typeOnPath"] { + let mut tool = TypeTool::new(tool_id); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, edge.0, edge.1)); + let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, edge.0, edge.1)); + assert!( + acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.createInPath" && v["path"] == json!(rect.0))), + "{tool_id} on outline: {acts:?}" + ); + let mut tool = TypeTool::new(tool_id); + tool.pointer(&c, &PointerEvent::new(PointerKind::Down, interior.0, interior.1)); + let acts = tool.pointer(&c, &PointerEvent::new(PointerKind::Up, interior.0, interior.1)); + assert!( + !acts.iter().any(|a| matches!(a, Action::Exec(cmd, _) if cmd == "text.createInPath")), + "{tool_id} in fill interior should not attach to path: {acts:?}" + ); + } +} + #[test] fn vertical_tools_preserve_their_ids_and_create_vertical_text() { for id in ["verticalType", "verticalAreaType", "verticalTypeOnPath"] { @@ -559,6 +589,20 @@ fn new_type_snaps_to_smart_guides() { assert!(acts.iter().any(|a| matches!(a, Action::Exec(cmd, v) if cmd == "text.create" && v["x"] == 100.0 && v["area"] == area)), "{acts:?}"); } +#[test] +fn area_type_drag_measure_labels_honor_preference() { + let (d, _) = crate::testutil::doc_with_rect(); + let (sel, p) = (Selection::default(), paint()); + let on = ToolContext { measurement_labels: true, ..cx(&d, &sel, &p) }; + let off = ToolContext { measurement_labels: false, ..cx(&d, &sel, &p) }; + let mut tool = TypeTool::new("type"); + tool.pointer(&on, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)); + tool.pointer(&on, &PointerEvent::new(PointerKind::Drag, 200.0, 150.0)); + let want = on.size_label(100.0, 50.0); + assert!(tool.overlays(&on).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == &want))); + assert!(!tool.overlays(&off).iter().any(|o| matches!(o, Overlay::Measure { .. }))); +} + #[test] fn layout_cache_follows_fonts_added_later() { // Other tests can load system fonts; this assertion needs its own process-wide database. @@ -702,20 +746,135 @@ fn a_drag_or_a_click_on_other_type_while_editing_acts_at_once() { assert_eq!(tool.editing, Some(other)); } -/// Selected area type whose text doesn't fit its frame shows the red out port with any tool, not -/// only the Type tools (#2323); text that fits shows none. +/// #2572: selected area type shows its in and out ports with any tool, not only the Type tools +/// (#2323): empty while its text fits and it isn't threaded, the out port's red "+" when the text +/// doesn't fit, an arrow in the port on the side where it is threaded. #[test] -fn overflowing_selected_type_shows_its_marker_without_the_type_tool() { +fn selected_type_shows_its_ports_in_each_state() { let (mut d, id) = crate::testutil::doc_with_area_type(); let (mut sel, p) = (Selection::default(), paint()); sel.set([id]); - let mut marks = OverflowMarks::default(); - assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "the text fits"); + let mut marks = PortMarks::default(); + // (white boxes, outlines, glyph strokes, red outlines) + let count = |ov: &[Overlay]| { + let n = |f: &dyn Fn(&Overlay) -> bool| ov.iter().filter(|o| f(o)).count(); + ( + n(&|o| matches!(o, Overlay::Highlight { color: [255, 255, 255, 255], .. })), + n(&|o| matches!(o, Overlay::Path { .. })), + n(&|o| matches!(o, Overlay::Line { .. })), + n(&|o| matches!(o, Overlay::Path { color: [230, 30, 30], .. })), + ) + }; + assert_eq!(count(&thread_ports(&mut marks, &cx(&d, &sel, &p))), (2, 2, 0, 0), "the text fits: two empty ports"); + let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") }; + let (input, output) = (t.xf * Point::new(0.0, PORT_RISE_PX), t.xf * Point::new(120.0, 40.0 - PORT_RISE_PX)); + let near = |a: Option, b: Point| a.is_some_and(|a| a.distance(b) < 1e-9); + assert!(near(port_centre(t, Port::In, 1.0), input), "on the left side, below the top-left corner"); + assert!(near(port_centre(t, Port::Out, 1.0), output), "on the right side, above the bottom-right corner"); + let ov = thread_ports(&mut marks, &cx(&d, &sel, &p)); + let box_at = |c: Point| ov.iter().any(|o| matches!(o, Overlay::Highlight { quad, .. } if quad[0].midpoint(quad[2]).distance(c) < 1e-9)); + assert!(box_at(input) && box_at(output), "{ov:?}"); if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { t.runs = vec![TextRun::new("Words that fill more than the frame holds. ".repeat(20), CharStyle { size: 20.0, ..Default::default() })]; } - let ov = overflow_markers(&mut marks, &cx(&d, &sel, &p)); - assert_eq!(ov.len(), 3, "a box and a plus: {ov:?}"); + assert_eq!(count(&thread_ports(&mut marks, &cx(&d, &sel, &p))), (2, 2, 2, 1), "an empty in port and the red plus"); + assert_eq!(port_states(&d, id, true), (PortState::Empty, PortState::Overflow)); + // Threaded on both sides: arrows (three strokes each), the out port's in place of the plus. + let (a, b) = (d.alloc_id(), d.alloc_id()); + d.text_threads = vec![vec![a, id, b]]; + assert_eq!(port_states(&d, id, true), (PortState::Threaded, PortState::Threaded)); + assert_eq!(count(&thread_ports(&mut marks, &cx(&d, &sel, &p))), (2, 2, 6, 0)); + d.text_threads = vec![vec![id, a]]; + assert_eq!(port_states(&d, id, false), (PortState::Empty, PortState::Threaded)); + d.text_threads = vec![vec![a, id]]; + assert_eq!(port_states(&d, id, false), (PortState::Threaded, PortState::Empty)); + assert_eq!(port_states(&d, id, true), (PortState::Threaded, PortState::Overflow), "the last frame's text doesn't fit"); sel.clear(); - assert!(overflow_markers(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows it"); + assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows them"); +} + +/// Past [`PORTS_MAX`] selected frames (select all of an opened PDF) no ports are drawn or clicked: +/// they would bury the canvas in boxes; up to it every frame has its two. +#[test] +fn a_huge_selection_of_type_shows_no_ports() { + let (mut d, id) = crate::testutil::doc_with_area_type(); + let node = d.node(id).unwrap().clone(); + let l = d.layers[0].id; + let mut sel = Selection::default(); + sel.set([id]); + for _ in 0..PORTS_MAX { + let copy = d.alloc_id(); + d.insert(Some(l), 1, Node::new(copy, node.kind.clone())).unwrap(); + sel.add(copy); + } + let p = paint(); + let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") }; + let out = port_centre(t, Port::Out, 1.0).unwrap(); + let mut marks = PortMarks::default(); + assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty()); + assert_eq!(port_at(&cx(&d, &sel, &p), out), None); + sel.remove(id); + let ports = thread_ports(&mut marks, &cx(&d, &sel, &p)); + assert_eq!(ports.iter().filter(|o| matches!(o, Overlay::Highlight { .. })).count(), 2 * PORTS_MAX, "at the limit"); +} + +/// Type in perspective has no ports, as it can't thread ([`crate::select::is_threadable`]): none +/// are drawn with any tool, the Type tools included, and it doesn't count toward [`PORTS_MAX`]. +#[test] +fn type_in_perspective_has_no_ports_and_doesnt_count() { + let (mut d, id) = crate::testutil::doc_with_area_type(); + d.node_mut(id).unwrap().extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment::new("left", 0.0))); + let (mut sel, p) = (Selection::default(), paint()); + sel.set([id]); + let mut marks = PortMarks::default(); + assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty(), "no ports on type in perspective"); + let boxes = |o: &[Overlay]| o.iter().filter(|o| matches!(o, Overlay::Highlight { color: [255, 255, 255, 255], .. })).count(); + assert_eq!(boxes(&TypeTool::new("type").overlays(&cx(&d, &sel, &p))), 0, "nor with the Type tool"); + // With PORTS_MAX frames that have ports selected besides it, theirs still show. + let mut node = d.node(id).unwrap().clone(); + node.extras_mut().perspective = None; + let l = d.layers[0].id; + for _ in 0..PORTS_MAX { + let copy = d.alloc_id(); + d.insert(Some(l), 1, Node::new(copy, node.kind.clone())).unwrap(); + sel.add(copy); + } + assert_eq!(boxes(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 2 * PORTS_MAX); +} + +/// The Type tool draws the ports of the type it edits as of the rest of the selection: none past +/// [`PORTS_MAX`] selected frames, where they can't be clicked either. +#[test] +fn the_edited_types_ports_follow_the_limit_too() { + let (mut d, id) = crate::testutil::doc_with_area_type(); + let node = d.node(id).unwrap().clone(); + let l = d.layers[0].id; + let mut sel = Selection::default(); + sel.set([id]); + for _ in 1..PORTS_MAX { + let copy = d.alloc_id(); + d.insert(Some(l), 1, Node::new(copy, node.kind.clone())).unwrap(); + sel.add(copy); + } + let p = paint(); + let mut tool = TypeTool::new("type"); + tool.start_editing(id, 0); + let boxes = |o: &[Overlay]| o.iter().filter(|o| matches!(o, Overlay::Highlight { color: [255, 255, 255, 255], .. })).count(); + assert_eq!(boxes(&tool.overlays(&cx(&d, &sel, &p))), 2 * PORTS_MAX, "at the limit, the edited one's included"); + let copy = d.alloc_id(); + d.insert(Some(l), 1, Node::new(copy, node.kind.clone())).unwrap(); + sel.add(copy); + assert_eq!(boxes(&tool.overlays(&cx(&d, &sel, &p))), 0, "past it, not even the edited one's"); +} + +/// Type on a path has its in port just before its start bracket and its out port just past its +/// end bracket, along the path, clear of the brackets. +#[test] +fn type_on_a_path_has_its_ports_beyond_its_brackets() { + let (d, id) = crate::testutil::doc_with_path_type(); + let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { panic!("not text") }; + let near = |a: Option, b: Point| a.is_some_and(|a| a.distance(b) < 1e-6); + assert!(near(port_centre(t, Port::In, 1.0), Point::new(160.0 - PORT_RISE_PX, 300.0)), "{:?}", port_centre(t, Port::In, 1.0)); + assert!(near(port_centre(t, Port::Out, 1.0), Point::new(400.0 + PORT_RISE_PX, 300.0)), "{:?}", port_centre(t, Port::Out, 1.0)); + assert!(near(port_centre(t, Port::In, 0.5), Point::new(160.0 - PORT_RISE_PX / 2.0, 300.0)), "screen pixels at 200%"); } diff --git a/crates/tools/src/touchtype.rs b/crates/tools/src/touchtype.rs index c60ba2bfc..51d4079ab 100644 --- a/crates/tools/src/touchtype.rs +++ b/crates/tools/src/touchtype.rs @@ -152,10 +152,17 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { } let m = g.xf; let (pen, ex, ey) = (m * Point::ZERO, unit(linear(m, Vec2::new(1.0, 0.0))), unit(linear(m, Vec2::new(0.0, -1.0)))); - // The corners of its glyphs' boxes in the first glyph's font units (y down). + // The corners of its glyphs' boxes in the first glyph's font units (y down). A list marker's + // glyphs (length 0) stand for no character. let to_glyph = m.inverse(); - let corners: Vec = - lay.glyphs.iter().filter(|h| h.byte == g.byte && h.line == g.line).filter_map(glyph_quad).flatten().map(|p| to_glyph * p).collect(); + let corners: Vec = lay + .glyphs + .iter() + .filter(|h| h.byte == g.byte && h.len > 0 && h.line == g.line) + .filter_map(glyph_quad) + .flatten() + .map(|p| to_glyph * p) + .collect(); let (mut x0, mut x1, mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::NEG_INFINITY); for p in corners { (x0, x1, y0, y1) = (x0.min(p.x), x1.max(p.x), y0.min(p.y), y1.max(p.y)); @@ -174,7 +181,7 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option { along: if g.rtl { -d } else { d }, ex, ey, - kerned: !g.rtl && lay.glyphs.iter().any(|h| h.line == g.line && h.byte + h.len == g.byte), + kerned: !g.rtl && lay.glyphs.iter().any(|h| h.line == g.line && h.len > 0 && h.byte + h.len == g.byte), style: t.effective_char_style(&style)?, }) } @@ -342,7 +349,9 @@ impl Tool for TouchTypeTool { let Some(t) = text_of(cx, id) else { return out }; let lay = self.layout(t); let start = self.target.map_or(0, |(_, s)| s); - let Some(b) = lay.glyphs.iter().position(|g| g.byte == start).and_then(|gi| char_box(t, &lay, gi)) else { return out }; + let Some(b) = lay.glyphs.iter().position(|g| g.byte == start && g.len > 0).and_then(|gi| char_box(t, &lay, gi)) else { + return out; + }; (Part::Move, b) } }; @@ -535,6 +544,37 @@ mod tests { assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); } + /// In a list item, the first character's box holds that character and not the bullet before + /// it, and a drag of the box moves that character. + #[test] + fn the_first_character_of_a_list_item_has_its_own_box_and_drags() { + let (mut d, id, s) = doc_with_type(); + if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) { + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + t.para.left_indent = 18.0; + t.para.first_line_indent = -18.0; + } + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = TouchTypeTool::default(); + let t_object = text_of(&cx, id).unwrap().clone(); + let lay = vectorcraft_text::layout(FontDb::global(), &t_object); + let first = lay.glyphs.iter().find(|g| g.byte == 0 && g.len > 0).unwrap(); + let at = t_object.xf * (first.origin + Vec2::new(first.advance / 2.0, -10.0)); + drag(&mut t, &cx, at, at); + assert_eq!(t.options(), json!({"id": id.0, "start": 0})); + let (q, pen) = (quad(&t, &cx), t_object.xf * first.origin); + assert!(q[0].x > pen.x - 5.0, "the box starts at the T, 18 pt past the bullet: {q:?}, the T at {pen:?}"); + let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -4.0)); + assert_eq!(preview(&acts[1])["start"].as_u64(), Some(0)); + // A drag on the character once the type is deselected selects the type and moves it. + let none = Selection::default(); + let cx = crate::testutil::cx(&d, &none, &p); + let acts = drag(&mut t, &cx, at, at + Vec2::new(0.0, -4.0)); + assert_eq!(acts[0], vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]); + assert_eq!(preview(&acts[1])["start"].as_u64(), Some(0)); + } + #[test] fn dragging_the_box_moves_the_character_along_its_line_and_up() { let (d, id, s) = doc_with_type(); diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 104ccc814..9c029ae7e 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -6,22 +6,27 @@ //! Double-click an empty part of an artboard to open Artboard Options. Click an empty //! part of an artboard to make it active (dashed bounds, 8 handles and its name); Shift-click adds //! artboards to the selection or takes them out (dashed bounds), and a drag or Delete then acts on -//! all of them (#1139). Drag inside moves -//! it (with its artwork when the `moveArt` option is on, Shift constrains; Alt moves a copy and +//! all of them (#1139). Shift-drag inside an artboard draws a new artboard there, and Shift-drag on +//! the pasteboard is a marquee: the artboards it touches leave the selection or join it. Drag inside +//! moves it (with its artwork when the `moveArt` option is on; Alt moves a copy and //! leaves the artboard where it was), drag a handle resizes it //! (Shift proportional, Alt from centre; with the `scaleArt` option on, Scale Artwork with Artboard, //! always proportional, and the art fully inside scales with it), drag on the pasteboard draws a new artboard, Delete removes -//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`) and Escape returns to -//! the Selection tool. Moving and resizing snap like drawing +//! the active one (Copy, Cut and Paste take it with its art: `artboard.copy`), the arrow keys move the +//! selected artboards by the keyboard increment (Shift ten times as far; with their artwork when +//! `moveArt` is on) and Escape returns to +//! the Selection tool (during a drag it only cancels the drag). Moving and resizing snap like drawing //! does: to whole pixels, to the grid, or with Smart Guides to other artboards, their bleed and //! objects (never to the dragged artboard or the art moving or scaling with it); the artboard's own bleed edges -//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]). +//! snap too. A new artboard's corners snap as drawn points do ([`DrawSnap`]). A locked artboard (the +//! Artboards panel's lock column) is selected by a click, but a drag or an arrow key doesn't move it +//! or copy it, it shows no handles, and Delete leaves it. use serde_json::{Value, json}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use super::{BLUE, polygon, rect_corners}; -use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag}; +use crate::bbox::{Handle, hit_handle, scale_for_drag}; use crate::guides::{DrawSnap, Leave, Targets}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -50,6 +55,13 @@ enum Drag { Create { start: Point, cur: Point, + /// A Shift-press inside an artboard (its index and the pressed point): released without + /// being dragged, it toggles that artboard. + toggle: Option<(usize, Point)>, + }, + Marquee { + start: Point, + cur: Point, }, } @@ -108,6 +120,21 @@ impl ArtboardTool { cx.doc.artboards.get(self.active).map(|a| a.rect) } + /// The active artboard's rectangle when it can be resized: it isn't locked. + fn resizable_rect(&self, cx: &ToolContext) -> Option { + cx.doc.artboards.get(self.active).filter(|a| !a.locked).map(|a| a.rect) + } + + /// The artboards a drag from artboard `index` moves: with `many`, the selected ones that + /// aren't locked, else `index`; none when `index` is locked. + fn movable(&self, cx: &ToolContext, index: usize, many: bool) -> Vec { + let unlocked = |i: &usize| cx.doc.artboards.get(*i).is_some_and(|a| !a.locked); + if !unlocked(&index) { + return vec![]; + } + if many { self.selected(cx).into_iter().filter(unlocked).collect() } else { vec![index] } + } + /// The selected artboards that are still in the document: the active one and the others, /// in order. fn selected(&self, cx: &ToolContext) -> Vec { @@ -133,15 +160,36 @@ impl ArtboardTool { self.others = sel.into_iter().filter(|j| *j != self.active).collect(); } + /// A Shift-drag marquee `r`: the artboards it touches leave the selection when they are in + /// it and join it otherwise, the last one joining becoming active. One artboard always stays + /// selected. + fn toggle_touched(&mut self, cx: &ToolContext, r: Rect) { + let touched: Vec = cx + .doc + .artboards + .iter() + .enumerate() + .filter(|(_, a)| r.x0 <= a.rect.x1 && a.rect.x0 <= r.x1 && r.y0 <= a.rect.y1 && a.rect.y0 <= r.y1) + .map(|(i, _)| i) + .collect(); + let sel = self.selected(cx); + let added: Vec = touched.iter().copied().filter(|i| !sel.contains(i)).collect(); + let kept: Vec = sel.into_iter().filter(|i| !touched.contains(i)).chain(added.iter().copied()).collect(); + let Some(&first) = kept.first() else { return }; + self.active = match added.last() { + Some(i) => *i, + None if kept.contains(&self.active) => self.active, + None => first, + }; + self.others = kept.into_iter().filter(|j| *j != self.active).collect(); + } + /// Gather the Smart Guide targets for dragging artboard `index` (call before the first preview, /// while the document still shows where everything started). fn begin_snapping(&mut self, cx: &ToolContext, index: usize, art_moves: bool) { self.guides.clear(); self.targets = cx.smart_guides.then(|| { - let art = match cx.doc.artboards.get(index) { - Some(a) if art_moves => cx.doc.art_on_artboard(a.rect, cx.move_locked_with_artboard), - _ => vec![], - }; + let art = if art_moves { cx.doc.art_moving_with(index, cx.move_locked_with_artboard) } else { vec![] }; Targets::for_artboard(cx.doc, index, &art).styled(cx) }); // Nothing to skip: no artboard has this index. @@ -257,7 +305,7 @@ impl Tool for ArtboardTool { match (ev.kind, self.drag) { (PointerKind::Down, _) => { self.click_art = None; - if let Some(r) = self.active_rect(cx) + if let Some(r) = self.resizable_rect(cx) && let Some(handle) = hit_handle(r, p, cx.tol(5.0)) { self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, grab: handle.pos(r) - p, began: false }); @@ -268,12 +316,23 @@ impl Tool for ArtboardTool { // artboard command's minimum side or pass non-finite bounds to it. self.click_art = fittable_bounds(cx, p).map(|r| (p, r)); } + if m.shift { + // Shift-drag inside an artboard draws a new one there, and a Shift-click + // toggles the artboard on release. Shift-drag outside every artboard is a + // marquee: the artboards it touches toggle on release. + match cx.doc.artboard_at(p) { + Some(i) => { + let start = self.draw.press(cx, p, &[], None); + self.drag = Some(Drag::Create { start, cur: start, toggle: Some((i, p)) }); + } + None => self.drag = Some(Drag::Marquee { start: p, cur: p }), + } + return vec![]; + } if let Some(i) = cx.doc.artboard_at(p) && let Some(a) = cx.doc.artboards.get(i) { - if m.shift { - self.toggle(cx, i); - } else if !self.selected(cx).contains(&i) { + if !self.selected(cx).contains(&i) { // A plain click on an artboard that isn't selected selects just it; on a // selected one, the selection stays, so the drag moves them all. self.others.clear(); @@ -284,15 +343,17 @@ impl Tool for ArtboardTool { } else { self.others.clear(); let p = self.draw.press(cx, p, &[], None); - self.drag = Some(Drag::Create { start: p, cur: p }); + self.drag = Some(Drag::Create { start: p, cur: p, toggle: None }); } vec![] } (PointerKind::Drag, Some(Drag::Move { index, many, start, rect, began, copy })) => { let mut out = vec![]; let label = |copy: bool| if copy { "Duplicate Artboard" } else { "Move Artboard" }; + let moving = self.movable(cx, index, many); if !began { - if p.distance(start) < cx.tol(3.0) { + // A locked artboard doesn't move, and nor is it copied by an Alt-drag. + if p.distance(start) < cx.tol(3.0) || moving.is_empty() { return out; } out.push(Action::Begin(label(m.alt).into())); @@ -303,11 +364,11 @@ impl Tool for ArtboardTool { out.push(Action::Begin(label(m.alt).into())); } self.drag = Some(Drag::Move { index, many, start, rect, began: true, copy: m.alt }); - let d = self.snap_move(cx, rect, move_delta(start, p, m.shift), m.alt); + let d = self.snap_move(cx, rect, p - start, m.alt); self.preview = Some(rect + d); let mut v = json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art, "copy": m.alt }); if many { - v["indices"] = json!(self.selected(cx)); + v["indices"] = json!(moving); } out.push(Action::Preview("artboard.move".into(), v)); out @@ -333,9 +394,13 @@ impl Tool for ArtboardTool { out.push(Action::Preview("artboard.setProps".into(), v)); out } - (PointerKind::Drag, Some(Drag::Create { start, .. })) => { - let cur = self.draw.drag(cx, p, Some(&Leave::diagonal(start, m.shift))); - self.drag = Some(Drag::Create { start, cur }); + (PointerKind::Drag, Some(Drag::Create { start, toggle, .. })) => { + let cur = self.draw.drag(cx, p, Some(&Leave::diagonal(start, false))); + self.drag = Some(Drag::Create { start, cur, toggle }); + vec![] + } + (PointerKind::Drag, Some(Drag::Marquee { start, .. })) => { + self.drag = Some(Drag::Marquee { start, cur: p }); vec![] } (PointerKind::Up, Some(d)) => { @@ -349,19 +414,30 @@ impl Tool for ArtboardTool { return create_artboard(r); } match d { - Drag::Move { began: true, copy, many, .. } => { + Drag::Move { index, began: true, copy, many, .. } => { // The copies, last in the previewed document, become the selection (the // last one active). if copy { let n = cx.doc.artboards.len(); - let k = if many { self.selected(cx).len().min(n) } else { 1 }; + let k = if many { self.movable(cx, index, many).len().min(n) } else { 1 }; self.active = n.saturating_sub(1); self.others = (n.saturating_sub(k)..n.saturating_sub(1)).collect(); } vec![Action::Commit] } Drag::Resize { began: true, .. } => vec![Action::Commit], - Drag::Create { start, cur } => { + Drag::Create { toggle: Some((i, at)), .. } if p.distance(at) < cx.tol(3.0) => { + self.toggle(cx, i); + vec![] + } + Drag::Marquee { start, .. } => { + let r = Rect::from_points(start, p); + if r.width() >= cx.tol(3.0) || r.height() >= cx.tol(3.0) { + self.toggle_touched(cx, r); + } + vec![] + } + Drag::Create { start, cur, .. } => { let r = Rect::from_points(start, cur); if r.width() < cx.tol(3.0) || r.height() < cx.tol(3.0) { return vec![]; @@ -383,6 +459,10 @@ impl Tool for ArtboardTool { Some(a) => { let mut v = rect_json(self.active, a.rect); v["name"] = json!(a.name); + v["showCenterMark"] = json!(a.show_center_mark); + v["showCrossHairs"] = json!(a.show_cross_hairs); + v["showVideoSafeAreas"] = json!(a.show_video_safe_areas); + v["videoRulerPixelAspectRatio"] = json!(a.video_ruler_pixel_aspect_ratio); vec![Action::Dialog("artboardOptions".into(), v)] } None => vec![], @@ -395,16 +475,18 @@ impl Tool for ArtboardTool { /// Escape owns the request to return to Selection (or cancel an active drag), so the UI /// applies the tool's request instead of falling through to its general Escape behaviour. /// Delete/Backspace belong to the artboards even when deletion is protected or a drag is - /// active: falling through to Clear would delete selected artwork instead. + /// active: falling through to Clear would delete selected artwork instead. So do the arrows: + /// they nudge the artboards, never the selected art on its own (#2103). fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { - matches!(key, ToolKey::Escape | ToolKey::Delete | ToolKey::Backspace) + matches!(key, ToolKey::Escape | ToolKey::Delete | ToolKey::Backspace | ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down) } - fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { match key { ToolKey::Delete | ToolKey::Backspace => { - let mut sel = self.selected(cx); - if self.drag.is_some() || cx.doc.artboards.len() <= 1 || self.active >= cx.doc.artboards.len() { + // A locked artboard isn't deleted. + let mut sel: Vec = self.selected(cx).into_iter().filter(|i| cx.doc.artboards.get(*i).is_some_and(|a| !a.locked)).collect(); + if self.drag.is_some() || cx.doc.artboards.len() <= 1 || self.active >= cx.doc.artboards.len() || sel.is_empty() { return vec![]; } // With every artboard selected, keep the first one in the current order. @@ -420,9 +502,40 @@ impl Tool for ArtboardTool { vec![Action::Exec("artboard.delete".into(), params)] } ToolKey::Escape => { - let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. })); + // During any drag (a move, a resize, a new artboard or a marquee) Escape only + // cancels the gesture; the next one leaves the tool. + let dragging = self.drag.is_some(); + let previewed = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. })); self.end_drag(); - if busy { vec![Action::Cancel] } else { vec![Action::SwitchTool("selection".into())] } + if previewed { + vec![Action::Cancel] + } else if dragging { + vec![] + } else { + vec![Action::SwitchTool("selection".into())] + } + } + ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down => { + // The selected artboards that aren't locked move by the keyboard increment (Shift: + // ten times as far, as object.nudge moves art), with their art when Move Artwork + // with Artboard is on, as a drag moves them: one step per press. + let moving: Vec = self.selected(cx).into_iter().filter(|i| cx.doc.artboards.get(*i).is_some_and(|a| !a.locked)).collect(); + if self.drag.is_some() || moving.is_empty() { + return vec![]; + } + let step = cx.keyboard_increment * if mods.shift { 10.0 } else { 1.0 }; + let (dx, dy) = match key { + ToolKey::Left => (-step, 0.0), + ToolKey::Right => (step, 0.0), + ToolKey::Up => (0.0, -step), + _ => (0.0, step), + }; + let mut v = json!({ "dx": dx, "dy": dy, "moveArt": self.move_art }); + match moving.as_slice() { + [i] => v["index"] = json!(i), + _ => v["indices"] = json!(moving), + } + vec![Action::Exec("artboard.move".into(), v)] } _ => vec![], } @@ -437,7 +550,7 @@ impl Tool for ArtboardTool { fn overlays(&self, cx: &ToolContext) -> Vec { let mut o = vec![]; - if let Some(Drag::Create { start, cur }) = self.drag { + if let Some(Drag::Create { start, cur, .. }) = self.drag { o.extend_from_slice(self.draw.guides()); let r = Rect::from_points(start, cur); o.push(Overlay::Marquee(r)); @@ -446,6 +559,9 @@ impl Tool for ArtboardTool { } return o; } + if let Some(Drag::Marquee { start, cur }) = self.drag { + o.push(Overlay::Marquee(Rect::from_points(start, cur))); + } // The other selected artboards: dashed bounds (in a drag the document shows them moved). for r in self.others.iter().filter_map(|i| cx.doc.artboards.get(*i)).map(|a| a.rect) { o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true }); @@ -453,8 +569,11 @@ impl Tool for ArtboardTool { let Some(ab) = cx.doc.artboards.get(self.active) else { return o }; let r = self.preview.unwrap_or(ab.rect); o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true }); - for h in Handle::ALL { - o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 }); + // A locked artboard has no handles: it can't be resized. + if !ab.locked { + for h in Handle::ALL { + o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 }); + } } // Where the canvas names the other artboards: on the top edge, flush with the left one // (labels are drawn 8 px right of and 14 px above their point). @@ -472,8 +591,8 @@ impl Tool for ArtboardTool { o } - fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { - if let Some(r) = self.active_rect(cx) + fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor { + if let Some(r) = self.resizable_rect(cx) && let Some(h) = hit_handle(r, p, cx.tol(5.0)) { return match h { @@ -484,7 +603,17 @@ impl Tool for ArtboardTool { } .turned(cx.view_angle()); } - if cx.doc.artboard_at(p).is_some() { Cursor::Move } else { Cursor::Crosshair } + // With Shift a drag draws or selects, never moves. + if m.shift { + return Cursor::Crosshair; + } + // The artboard a press would move (the topmost under the pointer, as `artboard_at` finds + // it): a locked one shows the arrow. + match cx.doc.artboard_at(p).and_then(|i| cx.doc.artboards.get(i)) { + Some(a) if a.locked => Cursor::Arrow, + Some(_) => Cursor::Move, + None => Cursor::Crosshair, + } } fn options(&self) -> Value { @@ -614,6 +743,26 @@ mod tests { assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); } + /// Art that is also on a locked artboard stays behind when its artboard moves, so the move + /// snaps to it, as to any art that stays. + #[test] + fn moving_snaps_to_the_art_a_locked_artboard_keeps() { + let mut d = two_boards(); + let mut locked = d.artboards[0].clone(); + locked.rect = Rect::new(640.0, 40.0, 700.0, 70.0); + locked.locked = true; + d.artboards.push(locked); + let l = d.layers[0].id; + let id = d.alloc_id(); + let art = vectorcraft_geom::shapes::rectangle(Rect::new(650.0, 50.0, 660.0, 60.0)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, art, vectorcraft_doc::Appearance::default_art())).unwrap(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let mut t = ArtboardTool::default(); + // Artboard 2's left edge, 600 + 52, snaps to the rectangle's at 650. + assert_eq!(drag_board2(&c, &mut t, 52.0, 0.0)["dx"], 50.0); + } + #[test] fn moving_snaps_to_grid_and_pixels() { let d = two_boards(); @@ -703,6 +852,200 @@ mod tests { assert_eq!(t.options()["selected"], json!([2])); } + /// The arrow keys move the selected artboards by the keyboard increment, Shift ten times as far, + /// with their art when Move Artwork with Artboard is on (#2103): the tool claims them, so they + /// no longer fall through to object.nudge and move only the selected art. A locked artboard + /// stays, and during a drag the arrows do nothing. + #[test] + fn arrow_keys_nudge_the_selected_artboards() { + let mut d = two_boards(); + let mut third = d.artboards[0].clone(); + third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); + third.locked = true; + d.artboards.push(third); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.keyboard_increment = 2.0; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool { active: 1, ..Default::default() }; + let moved = |p: Value| vec![Action::Exec("artboard.move".into(), p)]; + for key in [ToolKey::Left, ToolKey::Right, ToolKey::Up, ToolKey::Down] { + assert!(t.claims_key(&c, key), "{key:?}"); + } + assert_eq!(t.key(&c, ToolKey::Right, Mods::default()), moved(json!({"index": 1, "dx": 2.0, "dy": 0.0, "moveArt": true}))); + assert_eq!(t.key(&c, ToolKey::Up, shift), moved(json!({"index": 1, "dx": 0.0, "dy": -20.0, "moveArt": true}))); + t.move_art = false; + t.set_option("selected", &json!([0, 1, 2])); + assert_eq!( + t.key(&c, ToolKey::Left, Mods::default()), + moved(json!({"indices": [0, 1], "dx": -2.0, "dy": 0.0, "moveArt": false})), + "the locked artboard stays" + ); + t.set_option("selected", &json!([2])); + assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "only a locked artboard selected"); + t.set_option("selected", &json!([1])); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + assert_eq!(t.key(&c, ToolKey::Down, Mods::default()), vec![], "not during a drag"); + } + + /// A click selects a locked artboard, but a drag neither moves it nor copies it (Alt), a press + /// on its edge doesn't resize it, it shows no handles, the pointer over it is the arrow, and + /// Delete leaves it. With an unlocked artboard selected too, a drag and Delete act on that one. + /// With Shift the pointer over it is the crosshair, a Shift-click toggles it, and a Shift-drag + /// inside it draws a new artboard, as over any artboard. + #[test] + fn a_locked_artboard_is_not_moved_resized_or_deleted() { + let mut d = two_boards(); + d.artboards[1].locked = true; + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let mut t = ArtboardTool::default(); + for m in [Mods::default(), Mods { alt: true, ..Default::default() }] { + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(m)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 750.0, 100.0).with_mods(m)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, 750.0, 100.0).with_mods(m)).is_empty()); + } + assert_eq!(t.options()["selected"], json!([1]), "the click selects it"); + assert!(!t.overlays(&c).iter().any(|o| matches!(o, Overlay::Anchor { .. })), "no handles"); + // A press on its left edge, where a handle would be. + t.pointer(&c, &ev(PointerKind::Down, 600.0, 100.0)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 550.0, 100.0)).is_empty()); + t.pointer(&c, &ev(PointerKind::Up, 550.0, 100.0)); + for x in [600.0, 700.0] { + assert_eq!(t.cursor(&c, Point::new(x, 100.0), Mods::default()), Cursor::Arrow); + } + assert!(t.key(&c, ToolKey::Delete, Mods::default()).is_empty()); + t.set_option("selected", &json!([0, 1])); + t.pointer(&c, &ev(PointerKind::Down, 450.0, 450.0)); + assert_eq!(preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 470.0, 450.0)))["indices"], json!([0])); + t.pointer(&c, &ev(PointerKind::Up, 470.0, 450.0)); + t.set_option("selected", &json!([0, 1])); + assert_eq!(t.key(&c, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 0}))]); + let shift = Mods { shift: true, ..Default::default() }; + assert_eq!(t.cursor(&c, Point::new(700.0, 100.0), shift), Cursor::Crosshair); + t.set_option("selected", &json!([0, 1])); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)); + assert!(t.pointer(&c, &ev(PointerKind::Up, 700.0, 100.0).with_mods(shift)).is_empty()); + assert_eq!(t.options()["selected"], json!([0]), "the Shift-click takes it out"); + t.pointer(&c, &ev(PointerKind::Down, 650.0, 50.0).with_mods(shift)); + assert!(t.pointer(&c, &ev(PointerKind::Drag, 690.0, 80.0).with_mods(shift)).is_empty(), "no move preview"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 690.0, 80.0).with_mods(shift)), create_artboard(Rect::new(650.0, 50.0, 690.0, 80.0))); + } + + /// A Shift-click toggles its artboard when the button comes up, so the press alone leaves the + /// selection as it was. + #[test] + fn a_shift_click_toggles_its_artboard_on_release() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0).with_mods(shift)), vec![]); + assert_eq!(t.options()["selected"], json!([0]), "the press alone selects nothing"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 701.0, 100.0).with_mods(shift)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 0])); + for kind in [PointerKind::Down, PointerKind::Up] { + t.pointer(&c, &ev(kind, 700.0, 100.0).with_mods(shift)); + } + assert_eq!(t.options()["selected"], json!([0]), "a second Shift-click takes it out"); + } + + /// Shift-drag inside an artboard draws a new artboard there, neither square nor moving the + /// artboard it starts in, and the cursor shows a crosshair while Shift is held. + #[test] + fn a_shift_drag_inside_an_artboard_draws_a_new_one() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), Mods::default()), Cursor::Move); + assert_eq!(t.cursor(&c, Point::new(300.0, 300.0), shift), Cursor::Crosshair); + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, 300.0, 300.0).with_mods(shift)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, 420.0, 380.0).with_mods(shift)), vec![], "no move preview"); + assert!(matches!(t.overlays(&c)[0], Overlay::Marquee(_))); + let a = t.pointer(&c, &ev(PointerKind::Up, 420.0, 380.0).with_mods(shift)); + assert_eq!(a, create_artboard(Rect::new(300.0, 300.0, 420.0, 380.0))); + assert_eq!(t.options()["selected"], json!([0]), "the artboard drawn in is not toggled"); + } + + /// Shift-drag on the pasteboard is a marquee: the artboards it touches leave the selection + /// or join it, the last one joining becoming active, and no artboard is drawn. + #[test] + fn a_shift_drag_on_the_pasteboard_selects_the_artboards_it_touches() { + let mut d = two_boards(); + let mut third = d.artboards[0].clone(); + third.rect = Rect::new(0.0, 600.0, 200.0, 800.0); + d.artboards.push(third); + let (s, p) = (Selection::default(), paint()); + let c = cx(&d, &s, &p); + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + let marquee = |t: &mut ArtboardTool, from: (f64, f64), to: (f64, f64)| { + assert_eq!(t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(shift)), vec![]); + assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(shift)), vec![]); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Marquee(_)))); + t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(shift)) + }; + // From between the artboards across Artboard 1 (selected) and Artboard 3: 1 leaves, 3 joins. + assert_eq!(marquee(&mut t, (550.0, 150.0), (150.0, 650.0)), vec![]); + assert_eq!(t.options()["selected"], json!([2])); + // Right of Artboard 2 across its corner: it joins and becomes active. + assert_eq!(marquee(&mut t, (850.0, -50.0), (700.0, 100.0)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 2])); + // A Shift-click on the pasteboard changes nothing. + assert_eq!(marquee(&mut t, (550.0, 150.0), (551.0, 150.0)), vec![]); + assert_eq!(t.options()["selected"], json!([1, 2])); + } + + /// A drag begun without Shift still moves the artboard, and on the pasteboard still draws one; + /// Shift pressed during either no longer keeps the move at 45° or the new artboard square. + #[test] + fn a_plain_drag_still_moves_and_shift_no_longer_constrains_it() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 700.0, 100.0)); + let v = preview_params(&t.pointer(&c, &ev(PointerKind::Drag, 730.0, 110.0).with_mods(shift))); + assert_eq!((v["index"].as_u64(), v["dx"].as_f64(), v["dy"].as_f64()), (Some(1), Some(30.0), Some(10.0))); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 730.0, 110.0).with_mods(shift)), vec![Action::Commit]); + t.pointer(&c, &ev(PointerKind::Down, 1000.0, 1000.0)); + t.pointer(&c, &ev(PointerKind::Drag, 1100.0, 1050.0).with_mods(shift)); + let a = t.pointer(&c, &ev(PointerKind::Up, 1100.0, 1050.0).with_mods(shift)); + assert_eq!(a, create_artboard(Rect::new(1000.0, 1000.0, 1100.0, 1050.0))); + } + + /// Escape during a marquee or while a new artboard is being drawn ends the gesture without + /// selecting or drawing anything and keeps the tool; the next Escape leaves it. + #[test] + fn escape_cancels_a_marquee_or_a_new_artboard_and_keeps_the_tool() { + let d = two_boards(); + let (s, p) = (Selection::default(), paint()); + let mut c = cx(&d, &s, &p); + c.smart_guides = false; + let shift = Mods { shift: true, ..Default::default() }; + let mut t = ArtboardTool::default(); + // A Shift-drag marquee on the pasteboard, a Shift-drag inside Artboard 1 and a plain drag on + // the pasteboard. + let drags = + [((850.0, -50.0), (700.0, 100.0), shift), ((300.0, 300.0), (420.0, 380.0), shift), ((1000.0, 1000.0), (1100.0, 1050.0), Mods::default())]; + for (from, to, mods) in drags { + t.pointer(&c, &ev(PointerKind::Down, from.0, from.1).with_mods(mods)); + t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(mods)); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![], "Escape from {from:?} only ends the gesture"); + assert!(!t.busy()); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, to.0, to.1).with_mods(mods)), vec![], "the release draws nothing"); + assert_eq!(t.options()["selected"], json!([0]), "the release selects nothing"); + } + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]); + } + /// Scale Artwork with Artboard (#602): the resize asks for the art to scale, keeps the artboard's /// proportions, and never snaps to the art scaling with it. #[test] diff --git a/crates/tools/src/xform/eyedropper.rs b/crates/tools/src/xform/eyedropper.rs index 20248ad6e..40778a6c0 100644 --- a/crates/tools/src/xform/eyedropper.rs +++ b/crates/tools/src/xform/eyedropper.rs @@ -11,6 +11,9 @@ //! The Gradient panel's eyedropper sets the `stop` option (the tool to return to): the next click //! samples the colour under the cursor into the selected gradient stop (`paint.sampleColor //! {stop}`) and switches back to that tool. +//! +//! Brush Options' Key Color eyedropper sets `brushKeyColor` and `stop`: the next click samples into +//! the open dialog (`brushKeyColor` UI request) and switches back. use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; @@ -20,12 +23,16 @@ use vectorcraft_geom::Point; use super::paint_owner; use crate::params::color_json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; #[derive(Default)] pub struct EyedropperTool { /// Sampling for the selected gradient stop: the tool to switch back to afterwards. stop: Option, + /// The Type tool's highlighted range while sampling, including its selection overlay. + text_selection: crate::text::TypeTool, + /// Brush Options › Key Color (Hue Shift): sample into the dialog, then [`Self::stop`]. + brush_key_color: bool, } /// The colour of `paint` at `p` (gradients are sampled along their vector, radial ones honouring @@ -83,6 +90,19 @@ impl Tool for EyedropperTool { // A click inside a fill or among characters samples it too, whatever Object and Type // Object Selection by Path Only say. let opts = vectorcraft_doc::hit::HitOptions { path_only: false, type_path_only: false, ..cx.hit_options() }; + if self.brush_key_color { + let h = hit_test_visible(cx.doc, ev.pos, opts); + let Some(h) = h else { return vec![] }; + let src = paint_owner(cx.doc, h.leaf); + let Some(n) = cx.doc.node(src) else { return vec![] }; + let image = matches!(n.kind, NodeKind::Image(_)); + let sampled = if image { image_color_at(cx, n, ev.pos) } else { paint_sample(n, h.kind == HitKind::Stroke, ev.pos) }; + let Some(c) = sampled else { return vec![] }; + let back = self.stop.clone().unwrap_or_else(|| "selection".to_string()); + self.brush_key_color = false; + self.stop = None; + return vec![Action::Dialog("brushKeyColor".into(), json!({ "color": color_json(&c) })), Action::SwitchTool(back)]; + } let stop = self.stop.as_ref().zip(cx.gradient_stop); // Reading a source is independent of its editability. Only Alt's reverse operation // writes to the clicked object; Shift+Alt appends its appearance to the selection. @@ -107,16 +127,37 @@ impl Tool for EyedropperTool { out } + /// Expose a transient text range alongside gradient-stop and brush-key sampling state. fn options(&self) -> Value { - json!({ "stop": self.stop }) + let selected = self.text_selection.options(); + let mut options = if selected.get("editing").is_some_and(|v| !v.is_null()) { selected } else { json!({}) }; + if let Some(o) = options.as_object_mut() { + o.insert("stop".into(), json!(self.stop)); + o.insert("brushKeyColor".into(), json!(self.brush_key_color)); + } + options } + /// Accept a stop target or the selection transferred from the Type tool. fn set_option(&mut self, key: &str, value: &Value) { - if key == "stop" { - self.stop = value.as_str().map(str::to_string); + match key { + "stop" => self.stop = value.as_str().map(str::to_string), + "brushKeyColor" => self.brush_key_color = value.as_bool().unwrap_or(false), + "textSelection" => self.text_selection.set_option(key, value), + _ => {} } } + /// Drop the range when its text is removed or no longer selected. + fn after_command(&mut self, cx: &ToolContext) -> Vec { + self.text_selection.after_command(cx) + } + + /// Keep the sampled character range highlighted without activating text input. + fn overlays(&self, cx: &ToolContext) -> Vec { + if self.text_selection.options().get("editing").is_some_and(|v| !v.is_null()) { self.text_selection.overlays(cx) } else { vec![] } + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { Cursor::Eyedropper } @@ -128,6 +169,22 @@ mod tests { use crate::testutil::*; use vectorcraft_doc::Selection; + /// Upstream brush-key sampling and highlighted-text transfer keep independent state. + #[test] + fn text_range_and_brush_sampling_options_coexist() { + let mut tool = EyedropperTool::default(); + tool.set_option("textSelection", &json!({"editing": 1, "anchor": 0, "caret": 4})); + tool.set_option("stop", &json!("selection")); + tool.set_option("brushKeyColor", &json!(true)); + let options = tool.options(); + assert_eq!((options["editing"].as_u64(), options["start"].as_u64(), options["end"].as_u64()), (Some(1), Some(0), Some(4))); + assert_eq!(options["stop"], "selection"); + assert_eq!(options["brushKeyColor"], true); + tool.set_option("brushKeyColor", &json!(false)); + assert_eq!(tool.options()["editing"], 1); + assert_eq!(tool.options()["brushKeyColor"], false); + } + #[test] fn click_copies_appearance_shift_samples_color() { let (d, id) = doc_with_rect(); @@ -146,6 +203,27 @@ mod tests { assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty()); } + /// On a clip group's mask edge, where the art it clips lies over it, the eyedropper copies the + /// art's appearance, not the unpainted clipping path's (the selection tools take the path there). + #[test] + fn on_a_masks_edge_the_eyedropper_copies_the_art() { + let (d, _, _, art) = doc_with_masked_square(); + let (s, p) = (Selection::default(), paint()); + let a = EyedropperTool::default().pointer(&cx(&d, &s, &p), &PointerEvent::new(PointerKind::Down, 498.5, 450.0)); + assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": art.0}))]); + } + + /// The tools that act on what is drawn hit with options that don't take a clipping path on + /// its outline; only the selection tools' do. + #[test] + fn only_the_selection_options_take_clipping_paths_on_their_outline() { + let (d, _) = doc_with_rect(); + let (s, p) = (Selection::default(), paint()); + let cx = cx(&d, &s, &p); + assert!(!cx.hit_options().clip_outlines); + assert!(cx.selection_hit_options().clip_outlines); + } + #[test] fn the_stop_option_samples_into_the_selected_stop_then_switches_back() { let (d, _) = doc_with_rect(); @@ -154,7 +232,7 @@ mod tests { let mut cx = cx(&d, &s, &p); let mut t = EyedropperTool::default(); t.set_option("stop", &json!("gradient")); - assert_eq!(t.options(), json!({"stop": "gradient"})); + assert_eq!(t.options(), json!({"stop": "gradient", "brushKeyColor": false})); // Without a selected stop the click copies the appearance as usual. let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); assert!(matches!(&a[0], Action::Exec(c, _) if c == "appearance.copyFrom")); @@ -169,7 +247,24 @@ mod tests { Action::SwitchTool("gradient".into()) ] ); - assert_eq!(t.options(), json!({"stop": null}), "one sample only"); + assert_eq!(t.options(), json!({"stop": null, "brushKeyColor": false}), "one sample only"); + } + + #[test] + fn brush_key_color_samples_into_the_dialog_then_switches_back() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = EyedropperTool::default(); + t.set_option("brushKeyColor", &json!(true)); + t.set_option("stop", &json!("selection")); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0)); + assert_eq!( + a, + vec![Action::Dialog("brushKeyColor".into(), json!({ "color": color_json(&Color::WHITE) })), Action::SwitchTool("selection".into())] + ); + assert_eq!(t.options(), json!({ "stop": null, "brushKeyColor": false })); } /// The hex of the colour a `paint.sampleColor` action samples. @@ -222,6 +317,7 @@ mod tests { xf: vectorcraft_geom::Affine::scale(10.0), link: None, placement: Default::default(), + pixels: None, }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let (s, p) = (Selection::default(), paint()); @@ -258,6 +354,7 @@ mod tests { xf: vectorcraft_geom::Affine::scale(10.0), link: None, placement: Default::default(), + pixels: None, }; d.insert(Some(l), 0, Node::new(id, NodeKind::Image(Box::new(im)))).unwrap(); let (s, p) = (Selection::default(), paint()); diff --git a/crates/tools/src/xform/free.rs b/crates/tools/src/xform/free.rs index 550a9baf0..0f111faea 100644 --- a/crates/tools/src/xform/free.rs +++ b/crates/tools/src/xform/free.rs @@ -194,7 +194,7 @@ impl FreeTransformTool { return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] })); } Op::Move => { - let v = move_delta(d.start, p, shift); + let v = move_delta(d.start, p, shift, cx.constrain_angle); self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(v.x, v.y))); Affine::translate(v) } diff --git a/crates/tools/src/xform/gradient.rs b/crates/tools/src/xform/gradient.rs index 2802582b0..cdc733bec 100644 --- a/crates/tools/src/xform/gradient.rs +++ b/crates/tools/src/xform/gradient.rs @@ -97,7 +97,7 @@ impl Annotator { } /// An annotator showing `stops` on `geom` (the state a drag started from). fn with(geom: GradientGeom, stops: &[GradientStop]) -> Self { - Self { geom, gradient: Gradient { kind: GradientKind::Linear, stops: stops.to_vec() } } + Self { geom, gradient: Gradient::new(GradientKind::Linear, stops.to_vec()) } } fn radial(&self) -> bool { self.gradient.kind == GradientKind::Radial @@ -604,7 +604,7 @@ mod tests { /// The test rectangle (100..200) with a horizontal gradient of `stops` from (100,150) to (200,150). fn graded(stops: Vec) -> (Document, Selection) { let (mut d, id) = doc_with_rect(); - let mut g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops }); + let mut g = GradientPaint::new(Gradient::new(GradientKind::Linear, stops)); g.geom = Some(GradientGeom { start: Point::new(100.0, 150.0), end: Point::new(200.0, 150.0), aspect: 1.0, focal: None }); d.node_mut(id).unwrap().appearance.set_fill(Paint::Gradient(Box::new(g))); let mut s = Selection::default(); diff --git a/crates/tools/src/xform/measure.rs b/crates/tools/src/xform/measure.rs index 55835c48c..15b956bae 100644 --- a/crates/tools/src/xform/measure.rs +++ b/crates/tools/src/xform/measure.rs @@ -17,7 +17,7 @@ pub struct MeasureTool { /// (distance, angle in degrees counter-clockwise, dx, dy) pub fn measure(a: Point, b: Point) -> (f64, f64, f64, f64) { let d = b - a; - (d.hypot(), (-d.y).atan2(d.x).to_degrees(), d.x, d.y) + (d.hypot(), (-d.y).atan2(d.x).to_degrees() + 0.0, d.x, d.y) } impl Tool for MeasureTool { @@ -39,7 +39,7 @@ impl Tool for MeasureTool { PointerKind::Drag | PointerKind::Up if self.dragging => { if let Some((a, _)) = self.line { let b = if ev.mods.shift { - a + vectorcraft_geom::constrain_angle(ev.pos - a, 45.0) + a + vectorcraft_geom::constrain_angle_from(ev.pos - a, 45.0, cx.constrain_angle) } else { crate::guides::snap_draw(cx, ev.pos, &[]).0 }; @@ -90,6 +90,23 @@ mod tests { use crate::testutil::*; use vectorcraft_doc::Selection; + #[test] + fn horizontal_drag_reports_positive_zero_angle() { + let (d, _) = doc_with_rect(); + let s = Selection::default(); + let p = paint(); + let mut cx = cx(&d, &s, &p); + cx.smart_guides = false; + let mut t = MeasureTool::default(); + t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, 10.0)); + t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, 10.0)); + let ang = t.options()["angle"].as_f64().unwrap(); + assert!(ang == 0.0 && ang.is_sign_positive(), "angle was {ang:?}"); + let ov = t.overlays(&cx); + assert!(matches!(&ov[1], Overlay::Measure { text, .. } if text.contains("Angle: 0.0°"))); + } + #[test] fn drag_reports_distance_and_angle() { let (d, _) = doc_with_rect(); diff --git a/crates/tools/src/xform/transform.rs b/crates/tools/src/xform/transform.rs index 260d886f8..fbc2f8830 100644 --- a/crates/tools/src/xform/transform.rs +++ b/crates/tools/src/xform/transform.rs @@ -113,7 +113,9 @@ impl TransformTool { return Some(*p); } // Anchors selected with Direct Selection transform alone, about their own centre. - cx.selection.anchor_bounds(cx.doc, cx.preview_bounds).map(|b| b.center()).or_else(|| selection_box(cx).map(|b| b.center())) + vectorcraft_effects::preview::anchor_bounds(cx.selection, cx.doc, cx.preview_bounds) + .map(|b| b.center()) + .or_else(|| selection_box(cx).map(|b| b.center())) } /// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout. diff --git a/crates/tools/src/xform/wand.rs b/crates/tools/src/xform/wand.rs index 5add6fddb..530c9302d 100644 --- a/crates/tools/src/xform/wand.rs +++ b/crates/tools/src/xform/wand.rs @@ -114,7 +114,8 @@ impl Tool for LassoTool { } } }); - hits.retain(|(id, _)| cx.doc.is_editable(*id) && cx.doc.is_visible(*id)); + let editable = cx.doc.editable(); + hits.retain(|(id, _)| editable(*id) && cx.doc.is_visible(*id)); let items: Vec = hits.iter().map(|(id, v)| json!({ "id": id.0, "anchors": anchors_json(v) })).collect(); let mode = if sub { "subtract" diff --git a/crates/trace/src/centerline/mod.rs b/crates/trace/src/centerline/mod.rs index 7999f655f..332c4499c 100644 --- a/crates/trace/src/centerline/mod.rs +++ b/crates/trace/src/centerline/mod.rs @@ -65,6 +65,9 @@ pub(crate) fn lines( let mask = |i: usize| labels.get(i) == Some(&label); let mut seen = vec![false; w * h]; let mut stack = vec![]; + // Thin shapes nested in one another have overlapping boxes: the cells of all of them together + // are bounded, so the work is too (a component past it stays as it is, not a line). + let mut cells_left = n.saturating_mul(8).max(1 << 22); for start in 0..w * h { if !mask(start) || seen.get(start).copied().unwrap_or(false) { continue; @@ -86,6 +89,8 @@ pub(crate) fn lines( } } let Some(g) = Grid::from_component(&comp, w) else { continue }; + let Some(left) = cells_left.checked_sub(g.w.saturating_mul(g.h)) else { break }; + cells_left = left; let Some(dt) = distance::squared(&g.on, g.w, g.h) else { continue }; let widest = 2.0 * f64::from(dt.iter().copied().fold(0.0f32, f32::max)).sqrt() - 1.0; if widest > max_width { diff --git a/crates/trace/src/group.rs b/crates/trace/src/group.rs index d5b615de6..50b165f1a 100644 --- a/crates/trace/src/group.rs +++ b/crates/trace/src/group.rs @@ -73,6 +73,9 @@ fn inside_or_near(p: Point, o: &Outer) -> bool { inside } +/// The most paths grouped. +const MAX_GROUPED_PATHS: usize = 20_000; + /// For each of `paths` (bottom-most first), the path it lies inside, if any: the smallest filled /// path whose outer contour holds all of its own. The path that covers the whole `w` × `h` image /// (the paper) is nobody's parent: everything would be inside it. @@ -81,6 +84,10 @@ fn inside_or_near(p: Point, o: &Outer) -> bool { /// `paths` paints in its own order: where the paths can overlap (`stacked`), a path only gets a parent below it, and only /// if no path that would then come after it instead of before it shares any of its box. pub(crate) fn parents(paths: &[TracedPath], w: usize, h: usize, stacked: bool) -> Vec> { + // Each path is tested against the others (quadratic): past this many, they stay ungrouped. + if paths.len() > MAX_GROUPED_PATHS { + return vec![None; paths.len()]; + } let outers: Vec> = paths.iter().map(outer).collect(); let paper = Rect::new(1.0, 1.0, w as f64 - 1.0, h as f64 - 1.0); let mut parent: Vec> = vec![None; paths.len()]; diff --git a/crates/trace/src/lib.rs b/crates/trace/src/lib.rs index d9dfeab21..1e3849bf4 100644 --- a/crates/trace/src/lib.rs +++ b/crates/trace/src/lib.rs @@ -516,7 +516,7 @@ pub fn trace(img: &Raster, params: &TraceParams) -> TraceResult { /// colour layers are traced in parallel where threads are available. pub fn trace_within(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { let (w, h) = (img.width as usize, img.height as usize); - if w == 0 || h == 0 || img.rgba.len() != w * h * 4 { + if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { return Ok(TraceResult::default()); } let mut out = trace_paths(img, params, max_anchors)?; diff --git a/crates/trace/src/logo/pipeline.rs b/crates/trace/src/logo/pipeline.rs index 887989b5d..6004af7f4 100644 --- a/crates/trace/src/logo/pipeline.rs +++ b/crates/trace/src/logo/pipeline.rs @@ -342,7 +342,7 @@ fn choose_palette(params: &TraceParams, pix: &[Px], w: usize, h: usize) -> Resul /// Trace a flat-colour logo. pub(crate) fn trace_logo(img: &Raster, params: &TraceParams, max_anchors: usize) -> Result { let (w0, h0) = (img.width as usize, img.height as usize); - if w0 == 0 || h0 == 0 || img.rgba.len() != w0 * h0 * 4 { + if w0 == 0 || h0 == 0 || w0.checked_mul(h0).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { return Ok(TraceResult::default()); } let (mut pix, mut w, mut h, mut factor) = working_copy(img, 1); diff --git a/crates/trace/src/mosaic.rs b/crates/trace/src/mosaic.rs index ac2ae3051..b8bd46a37 100644 --- a/crates/trace/src/mosaic.rs +++ b/crates/trace/src/mosaic.rs @@ -2,38 +2,72 @@ use crate::Raster; +/// The most tiles along a side. +const MAX_SIDE: u32 = 4096; + /// Average colour (straight RGBA) of each of `cols` × `rows` tiles covering `img`, row by row. -/// Transparent pixels count by their alpha, so a tile over transparency fades out. +/// Transparent pixels count by their alpha, so a tile over transparency fades out. Tiles over the +/// same pixels (more tiles than pixels) are averaged once, and a raster whose bytes are not +/// `width * height * 4` gives transparent tiles. pub fn mosaic(img: &Raster, cols: u32, rows: u32) -> Vec<[u8; 4]> { - let (cols, rows) = (cols.max(1), rows.max(1)); - let mut out = Vec::with_capacity((cols * rows) as usize); + let (cols, rows) = (cols.clamp(1, MAX_SIDE), rows.clamp(1, MAX_SIDE)); + let tiles = cols as usize * rows as usize; + let (w, h) = (img.width as usize, img.height as usize); + if w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { + return vec![[0; 4]; tiles]; + } + let mut out = Vec::with_capacity(tiles); + // The pixel rows of the last row of tiles, and where its tiles start in `out`. + let mut last_row: Option<((u32, u32), usize)> = None; for r in 0..rows { let y0 = (r as u64 * img.height as u64 / rows as u64) as u32; let y1 = (((r + 1) as u64 * img.height as u64 / rows as u64) as u32).max(y0 + 1).min(img.height); + if let Some((range, start)) = last_row + && range == (y0, y1) + { + out.extend_from_within(start..start + cols as usize); + continue; + } + let start = out.len(); + let mut last: Option<((u32, u32), [u8; 4])> = None; for c in 0..cols { let x0 = (c as u64 * img.width as u64 / cols as u64) as u32; let x1 = (((c + 1) as u64 * img.width as u64 / cols as u64) as u32).max(x0 + 1).min(img.width); - let (mut sr, mut sg, mut sb, mut sa, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64); - for y in y0..y1.max(y0 + 1).min(img.height) { - for x in x0..x1.max(x0 + 1).min(img.width) { - let [pr, pg, pb, pa] = img.pixel(x, y); - let a = pa as u64; - sr += pr as u64 * a; - sg += pg as u64 * a; - sb += pb as u64 * a; - sa += a; - n += 1; - } + if let Some((range, tile)) = last + && range == (x0, x1) + { + out.push(tile); + continue; } - out.push(match (sr.checked_div(sa), sg.checked_div(sa), sb.checked_div(sa)) { - (Some(r), Some(g), Some(b)) => [r as u8, g as u8, b as u8, (sa / n.max(1)) as u8], - _ => [0, 0, 0, 0], - }); + let tile = average(img, (x0, x1), (y0, y1)); + out.push(tile); + last = Some(((x0, x1), tile)); } + last_row = Some(((y0, y1), start)); } out } +/// The average colour of pixels `xs` × `ys` (half-open). +fn average(img: &Raster, (x0, x1): (u32, u32), (y0, y1): (u32, u32)) -> [u8; 4] { + let (mut sr, mut sg, mut sb, mut sa, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64); + for y in y0..y1.max(y0 + 1).min(img.height) { + for x in x0..x1.max(x0 + 1).min(img.width) { + let [pr, pg, pb, pa] = img.pixel(x, y); + let a = pa as u64; + sr += pr as u64 * a; + sg += pg as u64 * a; + sb += pb as u64 * a; + sa += a; + n += 1; + } + } + match (sr.checked_div(sa), sg.checked_div(sa), sb.checked_div(sa)) { + (Some(r), Some(g), Some(b)) => [r as u8, g as u8, b as u8, (sa / n.max(1)) as u8], + _ => [0, 0, 0, 0], + } +} + #[cfg(test)] mod tests { use super::*; @@ -49,6 +83,12 @@ mod tests { assert!((m[0][0] as i32 - 127).abs() <= 1 && (m[0][2] as i32 - 127).abs() <= 1); // More tiles than pixels still works. assert_eq!(mosaic(&img, 20, 8).len(), 160); + // Tiles over the same pixels average the same. + let m = mosaic(&img, 20, 8); + assert!(m.chunks(20).all(|row| row == &m[..20])); + // Bytes that are not the raster's size: transparent. + let short = Raster { width: 4, height: 4, rgba: vec![] }; + assert_eq!(mosaic(&short, 2, 2), vec![[0; 4]; 4]); // Transparency. let clear = Raster::from_fn(2, 2, |_, _| [9, 9, 9, 0]); assert_eq!(mosaic(&clear, 1, 1), vec![[0, 0, 0, 0]]); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 2ad5d60b4..ba650c3b6 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -8,6 +8,17 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap [lints] workspace = true +[features] +default = ["bundled-fonts", "interface-fonts"] +# The fonts vectorcraft-text bundles for documents, through the engine's `bundled-fonts`. Off, for +# an embedder that supplies every document font at run time (`vectorcraft_text::FontDb::add_font`); +# the interface's own faces are `interface-fonts`. +bundled-fonts = ["vectorcraft-engine/bundled-fonts"] +# The interface's own faces (theme.rs: Inter, Source Sans 3, JetBrains Mono, about 2 MB). Off, the +# panels and dialogs draw in egui's built-in fonts, which lack some symbols they show (→ ▾ ● ✓); +# `theme::install_fonts` binds their families either way. +interface-fonts = [] + [dependencies] craft-ui = { workspace = true } vectorcraft-geom = { workspace = true } @@ -26,7 +37,9 @@ vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } vectorcraft-eps = { workspace = true } vectorcraft-format = { workspace = true } -egui = { workspace = true } +egui = { workspace = true, features = ["bytemuck"] } +# With egui's `bytemuck`: copies rendered rasters into egui images whole (`canvas::color_image`). +bytemuck = "1" egui_extras = { workspace = true } image = { workspace = true } serde = { workspace = true } @@ -38,6 +51,12 @@ log = { workspace = true } # MIT OR Apache-2.0 and already in the build through eframe. web-time = "1" +# The current user's id (`geteuid`): Preview in Browser uses its temporary folder only when that +# user owns it (src/dialogs/save_for_web.rs). Already in Cargo.lock (tempfile), so this adds no +# new crate. +[target.'cfg(unix)'.dependencies] +rustix = { workspace = true, features = ["std", "process"] } + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } vectorcraft-testkit = { workspace = true } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8f127071..1b89cae74 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -10,6 +10,7 @@ use vectorcraft_tools::{Cursor, Mods, Overlay, PointerEvent, PointerKind}; use crate::opening; use crate::state::View; +use crate::stylus::StylusFeed; use crate::theme::{self, Tokens}; use crate::{CacheKey, VectorcraftApp, now_ms, widgets}; @@ -91,8 +92,9 @@ enum Drag { start_rot: f64, snaps: bool, }, - /// A Selection tool move dragged off the canvas: the panels get the art - /// ([`widgets::PanelDrag::Art`]). + /// A Selection tool move over a panel or toolbar: the art waits where it last moved on the + /// canvas, and the panels get it ([`widgets::PanelDrag::Art`]) if it is released there. Back + /// on the canvas the move goes on, as in Illustrator (#2826). Art, /// Fingers making a touch gesture ([`crate::touch`]): the first one's press does nothing. Gesture, @@ -100,6 +102,35 @@ enum Drag { Present, } +/// Whether drag `d` is a press the tool holds. Every other drag (panning, zooming, turning the +/// view, a touch gesture, Presentation Mode) never reaches the tool, so its hover ends with it. +fn tool_holds(d: Drag) -> bool { + matches!(d, Drag::Tool | Drag::Art) +} + +/// What a hover depends on besides the pointer's screen position. +#[derive(Clone, Copy, Debug, PartialEq)] +struct HoverContext { + /// The active tool: a tool switched to under a still pointer is hovered at once. + tool: &'static str, + /// The canvas, and the view's centre, zoom and rotation. + canvas: egui::Rect, + center: Point, + zoom: f64, + rotation: f64, + /// The active document's id and revision (which moves with every edit, undo and selection + /// change). + doc: Option<(u64, u64)>, +} + +/// Clear canvas interaction state before a tab transfer takes ownership of the artwork drag. +pub(crate) fn end_document_drag(ctx: &egui::Context) { + ctx.data_mut(|d| { + d.remove::(drag_id()); + d.remove::(drag_pos_id()); + }); +} + fn drag_id() -> egui::Id { egui::Id::new("canvas-drag") } @@ -107,20 +138,35 @@ fn drag_id() -> egui::Id { fn drag_pos_id() -> egui::Id { egui::Id::new("canvas-drag-pos") } -/// The document point last sent to the tool as a drag during [`Drag::Tool`]. +/// The layer the canvas was last drawn on: a release over another layer is over a panel (#2826). +fn drag_layer_id() -> egui::Id { + egui::Id::new("canvas-drag-layer") +} +/// Whether `p` is over the canvas itself, not a panel or a floating window over it. +fn on_canvas(ui: &Ui, resp: &egui::Response, p: Pos2) -> bool { + resp.rect.contains(p) && ui.clip_rect().contains(p) && ui.ctx().layer_id_at(p).is_none_or(|l| l == resp.layer_id) +} +/// The document point last sent to the tool as a drag during [`Drag::Tool`]; none until the press +/// has dragged. fn tool_drag_doc_id() -> egui::Id { egui::Id::new("canvas-tool-drag-doc") } -/// Whether the tool should get another drag event: the pointer moved this frame, or its document -/// position changed without a delta (some Wayland compositors coalesce motion until release, #834). +/// The document point of the press that began [`Drag::Tool`], sent to the tool as its Down. +fn tool_press_doc_id() -> egui::Id { + egui::Id::new("canvas-tool-press-doc") +} +/// Whether the tool should get another drag event, and if so records `p` as the last position +/// sent: the pointer moved this frame, or its document position changed without a delta (#834). +/// egui's frame delta is zero when some Wayland compositors coalesce motion until release, and in +/// the frame the pointer comes back into the window after leaving it (`PointerGone` forgot where +/// it was), though it is somewhere new; judged on the delta alone, the preview stayed behind. fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> bool { - if pointer.delta() != egui::Vec2::ZERO { - return true; - } let at = xf.to_doc(p); let id = tool_drag_doc_id(); - let prev = ui.data(|d| d.get_temp::(id)); - if prev == Some(at) { + // Before the press has dragged, the last position the tool got is the press itself: a press + // held still is not a drag. + let prev = ui.data(|d| d.get_temp::(id).or_else(|| d.get_temp::(tool_press_doc_id()))); + if pointer.delta() == egui::Vec2::ZERO && prev == Some(at) { return false; } ui.data_mut(|d| d.insert_temp(id, at)); @@ -128,9 +174,12 @@ fn tool_drag_moved(ui: &Ui, xf: &Xf, p: Pos2, pointer: &egui::PointerState) -> b } /// The pen pressure (0..1) of the press in progress: the force of this frame's pen or touch -/// input, else the last one seen since the press (`pressed`: a new press, whose mouse has none +/// input, else the pressure in the host's feed while a pen is the pointer that moved last +/// ([`crate::Services::stylus`]: [`StylusFeed::press`] for a new press, [`StylusFeed::held`] +/// after it), else the last one seen since the press (`pressed`: a new press, whose mouse has none /// until a pen reports it). A mouse presses fully (1). -fn pen_pressure(ui: &Ui, pressed: bool) -> f32 { +fn pen_pressure(ui: &Ui, app: &VectorcraftApp, pressed: bool) -> f32 { + let stylus = app.services.stylus.as_ref(); let id = egui::Id::new("canvas-pressure"); let force = ui.input(|i| { i.events.iter().rev().find_map(|e| match e { @@ -138,27 +187,37 @@ fn pen_pressure(ui: &Ui, pressed: bool) -> f32 { _ => None, }) }); + let force = force.or_else(|| stylus.and_then(if pressed { StylusFeed::press } else { StylusFeed::held })); let p = force.or_else(|| if pressed { None } else { ui.data(|d| d.get_temp::(id)) }).unwrap_or(1.0); ui.data_mut(|d| d.insert_temp(id, p)); p } /// The positions this frame's pointer moves passed through before its last one, each with the -/// pen force a touch event gave just before it (if any). The last move is the pointer's position. -/// Only for pens and fingers (frames with touch events): a mouse's moves are left as they were. -fn frame_moves(ui: &Ui) -> Vec<(Pos2, Option)> { +/// pen force a touch event gave just before it, or the pressure the host's feed kept for that move +/// ([`StylusFeed::take_moves`], macOS), if any. The last move is the pointer's position. Only for +/// pens and fingers (frames with touch events or with the feed's moves): a mouse's moves are left +/// as they were. +fn frame_moves(ui: &Ui, stylus: Option<&StylusFeed>) -> Vec<(Pos2, Option)> { + let pressures = stylus.map(StylusFeed::take_moves).unwrap_or_default(); ui.input(|i| { - if !i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) { - return vec![]; - } - let mut force = None; let mut moves = vec![]; - for e in &i.events { - match e { - egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => force = Some(f.clamp(0.0, 1.0)), - egui::Event::PointerMoved(p) => moves.push((*p, force)), - _ => {} + if i.events.iter().any(|e| matches!(e, egui::Event::Touch { .. })) { + let mut force = None; + for e in &i.events { + match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() && *f > 0.0 => force = Some(f.clamp(0.0, 1.0)), + egui::Event::PointerMoved(p) => moves.push((*p, force)), + _ => {} + } } + } else if !pressures.is_empty() { + // The feed's last pressure is the last move's: pair them from the end. A lift's 0 is + // no pressure (the press keeps its last touch). + let points: Vec = i.events.iter().filter_map(|e| if let egui::Event::PointerMoved(p) = e { Some(*p) } else { None }).collect(); + let forces = pressures.iter().rev().map(|f| (*f > 0.0).then_some(*f)).chain(std::iter::repeat(None)); + moves = points.into_iter().rev().zip(forces).collect(); + moves.reverse(); } moves.pop(); // A burst of events (a stalled frame) is thinned to a bounded count. @@ -256,10 +315,22 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::Popup::close_all(ui.ctx()); } else { handle_input(app, ui, &resp, rect); + // `hover_doc` is the document point under the pointer in the view `handle_input` left. The + // wheel, a pan, a Rotate View drag, the Zoom tool and a Presentation Mode click move the view + // there. + let moved = Xf::new(rect, app.view().unwrap_or(&View::default())); + app.hover_doc = ui.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p)).map(|p| moved.to_doc(p)); } let v = *app.view().unwrap_or(&View::default()); let xf = Xf::new(rect, &v); + // While the view pans, zooms or turns under the pointer, the art under it isn't hit-tested. + let wait = hover_wait(&mut app.canvas.view_moved, ViewKey { rect, zoom: v.zoom, center: v.center, rotation: v.rotation }, ui.input(|i| i.time)); + if let Some(secs) = wait { + ui.ctx().request_repaint_after_secs(secs as f32); + } + let hover_still = wait.is_none(); let origin_preview = if app.ui.view.rulers && app.ui.screen_mode < 3 { ruler_input(app, ui, full, rect, &xf) } else { None }; + crate::document_drag::drop_on_canvas(app, &resp, &xf); panel_drop(app, ui, &resp, &xf); // In Presentation Mode a right click or a long touch opens no context menu. if app.ui.screen_mode < 3 { @@ -272,6 +343,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Pasteboard, artboard shadows and paper (Document Setup: the transparency grid's look, the // simulated paper colour; a white Background Contents hides the grid). + // An artboard with a background color draws its paper in that color. With Simulate Colored Paper + // on, the canvas multiplies it with the paper color: each sRGB channel is artboard × paper / 255, + // rounded. The transparency grid, while it shows, covers it as it covers the others. painter.rect_filled(rect, 0.0, t.pasteboard); if app.ui.view.artboards && !app.ui.view.outline { let setup = &doc.setup; @@ -286,7 +360,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { match &grid { Some((tex, cell)) => checker(&painter, &q, ab.rect, xf.zoom, tex.id(), *cell), None => { - painter.add(Shape::convex_polygon(q, paper, Stroke::NONE)); + let fill = ab.color.map_or(paper, |c| crate::panels::c32(&c) * paper); + painter.add(Shape::convex_polygon(q, fill, Stroke::NONE)); } } } @@ -330,6 +405,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { outline: app.ui.view.outline, trim: app.ui.view.trim_view, templates: app.ui.view.templates, + guides: app.ui.view.guides, ppp, hidden: vec![], rot: v.rotation, @@ -360,12 +436,25 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { { upload(app, ui.ctx(), &done.img, done.key.pixel.is_some()); app.canvas.key = Some(done.key); + app.canvas.shown = Some((done.doc, done.opts)); app.perf.render_ms = done.ms; app.canvas.last_ms = done.ms; app.canvas.moved = done.moved > 0; app.canvas.from_worker = true; } - if app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none() { + // With `waits_for_still` set (the web build, which has no render worker), a heavy document is + // not rendered while only the view changes: the frame shown is drawn reprojected below, and the + // first frame after the view has been still for STILL_SECS renders. + let wait = if app.canvas.waits_for_still { + let slow = app.canvas.last_ms > 8.0 && app.canvas.texture.is_some(); + still_wait(&mut app.canvas.still, app.canvas.key.as_ref(), &key, slow, ui.input(|i| i.time)) + } else { + None + }; + if let Some(secs) = wait { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(secs)); + } + if wait.is_none() && (app.canvas.key.as_ref() != Some(&key) || app.canvas.texture.is_none()) { let (w, h, view) = match pixel { Some([x0, y0, x1, y1]) => ((x1 - x0) as u32, (y1 - y0) as u32, Affine::translate((-x0 as f64, -y0 as f64))), None => ( @@ -383,6 +472,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { overprint_preview: vectorcraft_render::proof::overprint_preview_on(), trim: app.ui.view.trim_view, hide_templates: !app.ui.view.templates, + hide_guides: !app.ui.view.guides, tile_edge: vectorcraft_color::Color::from_hex(&app.session.prefs.pattern_tile_edge_color).map_or(opts.tile_edge, |c| { let [r, g, b, _] = c.to_rgba8(1.0); [r, g, b] @@ -421,13 +511,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let settling = app.canvas.moved && !key.interacting; let staying = key.interacting && app.canvas.from_worker; let heavy = (app.canvas.last_ms > 8.0 || settling || staying) && app.canvas.texture.is_some() && same_doc; - match (&mut app.canvas.worker, heavy) { - (Some(worker), true) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), + let changed = changed_pixels(app, &key, &doc, &opts, view, w, h); + match (&mut app.canvas.worker, heavy, changed) { + // Only the document changed since the frame shown, in a part of it: that part is + // rendered again, here, into the texture. `last_ms` stays the whole frame's. + (_, _, Some(region)) => { + let t0 = now_ms(); + if let (Some([x0, y0, x1, y1]), Some(tex)) = (region, &mut app.canvas.texture) { + let part = app.canvas.renderer.render(&doc, x1 - x0, y1 - y0, Affine::translate((-f64::from(x0), -f64::from(y0))) * view, &opts); + let color = color_image(&part); + let options = if pixel.is_some() { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; + tex.set_partial([x0 as usize, y0 as usize], color, options); + app.canvas.moved |= app.canvas.renderer.stats.moved > 0; + } + app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); + app.perf.render_ms = now_ms() - t0; + } + (Some(worker), true, None) => worker.submit(crate::render_worker::Job { key: key.clone(), doc: doc.clone(), w, h, view, opts }), _ => { let t0 = now_ms(); let img = app.canvas.renderer.render(&doc, w, h, view, &opts); upload(app, ui.ctx(), &img, pixel.is_some()); app.canvas.key = Some(key.clone()); + app.canvas.shown = Some((doc.clone(), opts.clone())); app.perf.render_ms = now_ms() - t0; app.canvas.last_ms = app.perf.render_ms; app.canvas.moved = app.canvas.renderer.stats.moved > 0; @@ -490,6 +597,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if doc.setup.has_bleed() { painter.add(Shape::closed_line(xf.quad(doc.setup.bleed_rect(ab.rect)), Stroke::new(1.0, t.bleed))); } + // Show Center Mark, Show Cross Hairs and Show Video Safe Areas (Artboard Options). Only the + // canvas draws them, as it does the edges: exports and print leave them out. + artboard_marks(&painter, &xf, ab, t.artboard_mark); if editing_name != Some(i) && (artboard_tool || doc.artboards.len() > 1) && tool_labelled != u64::try_from(i).ok() { // At the artboard's own top-left corner, which a rotated view turns away from its // bounding box's (#1095). @@ -545,11 +655,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { // Gap Options' Preview shows the gaps it finds. overlays.extend(crate::dialogs::live_paint_gaps::overlays(app)); if app.ui.view.edges && !revolve_edit { - hover_highlight(app, &painter, &xf); + if hover_still { + hover_highlight(app, &painter, &xf); + } selection_overlay(app, &painter, &xf, overlays.iter().any(|o| matches!(o, Overlay::CurvePoint { .. }))); mirror_axis_overlay(app, &painter, &xf); if app.ui.view.text_threads { - thread_overlay(app, &painter, &xf); + // Under the tools' overlays: a selected frame's own ports are drawn over its thread's. + let mut threads = thread_overlays(app, xf.zoom); + threads.append(&mut overlays); + overlays = threads; + } + if app.ui.view.live_paint_gaps { + overlays.extend(live_paint_gap_overlays(app)); } if app.ui.view.hidden_chars { app.canvas.hidden_chars = hidden_chars; @@ -574,7 +692,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { painter.line_segment([pos2(at.x, rect.top()), pos2(at.x, rect.bottom())], Stroke::new(1.0, t.text_dim)); painter.line_segment([pos2(rect.left(), at.y), pos2(rect.right(), at.y)], Stroke::new(1.0, t.text_dim)); } - rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin()), &t); + // View › Rulers › Show Video Rulers counts the active artboard in video pixels. + let video = app.ui.view.video_rulers.then(|| crate::dialogs::artboard_options::active_index(app)); + let scale = match video.and_then(|i| app.session.active()?.doc.artboards.get(i)) { + Some(ab) => RulerScale::video(ab), + None => RulerScale::document(app.session.general_unit(), origin_preview.unwrap_or_else(|| app.ruler_origin())), + }; + rulers(ui, full, &xf, app.hover_doc, scale, &t); } // The task bar is not drawn while the Simplify slider shows. if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 && !crate::dialogs::simplify_slider::showing(app) { @@ -612,8 +736,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { None }; // In Presentation Mode the pointer is the default one, with no tool cursor. - let tool_cursor = - (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| app.hover_doc.map(|p| app.session.cursor(p, mods(m, space), view_info)))); + let tool_cursor = (app.ui.screen_mode < 3).then(|| view_cursor.or_else(|| hover_cursor(app, mods(m, space), view_info, hover_still))); let cur = if let Some(c) = tool_cursor.flatten() { let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor"))); // The loaded place cursor carries the file's thumbnail. @@ -655,6 +778,77 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// The active tool's cursor at the pointer, as `mods` make it. While the view moves (`still` +/// false), the cursor the tool last gave for the same tool and keys stays: asking the tool again +/// would hit-test the art under the pointer at every frame of the move (#2452). +fn hover_cursor(app: &mut VectorcraftApp, mods: Mods, view: vectorcraft_engine::ViewInfo, still: bool) -> Option { + let id = app.session.tool_id(); + if !still + && let Some((was, with, c)) = app.canvas.hover_cursor + && (was, with) == (id, mods) + { + return Some(c); + } + let c = app.session.cursor(app.hover_doc?, mods, view); + app.canvas.hover_cursor = Some((id, mods, c)); + Some(c) +} + +/// How long (seconds) the view must stay still before a canvas with +/// [`crate::CanvasCache::waits_for_still`] set renders a heavy document again. +pub const STILL_SECS: f64 = 0.2; + +/// How long (seconds) the view must stay still before the canvas hit-tests the art under the +/// pointer again, for Object Highlighting and the tool's cursor. While the view pans, zooms or +/// turns under a resting pointer, the document point under it changes every frame, and hit-testing +/// detailed art each time slowed every frame of the move (#2452). +pub const HOVER_STILL_SECS: f64 = 0.15; + +/// The view [`hover_wait`] watches: the canvas's rectangle and the view's zoom, centre and rotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ViewKey { + rect: egui::Rect, + zoom: f64, + center: Point, + rotation: f64, +} + +/// Note the view `key` at time `now` (seconds, egui's input time) → how many more seconds the +/// canvas leaves the art under the pointer alone: the view changed less than [`HOVER_STILL_SECS`] +/// ago. The first view noted counts as still. +fn hover_wait(moved: &mut Option<(ViewKey, f64)>, key: ViewKey, now: f64) -> Option { + match moved { + Some((k, at)) if *k != key => (*k, *at) = (key, now), + Some(_) => {} + None => *moved = Some((key, f64::NEG_INFINITY)), + } + let waited = moved.map_or(f64::INFINITY, |(_, at)| now - at); + // The clock went back (a new egui context): don't wait for it to catch up. + (0.0..HOVER_STILL_SECS).contains(&waited).then_some(HOVER_STILL_SECS - waited) +} + +/// Note that the canvas asks for `key` at time `now` (seconds, egui's input time) → how many more +/// seconds it draws the frame `shown` reprojected instead of rendering `key`: while the last +/// render was `slow`, only the view differs between the two, and the canvas has asked for `key` +/// for less than [`STILL_SECS`]. `still` holds the key last asked for and when the canvas first +/// asked for it. None: the canvas renders `key` now, unless it is the frame shown. +fn still_wait(still: &mut Option<(CacheKey, f64)>, shown: Option<&CacheKey>, key: &CacheKey, slow: bool, now: f64) -> Option { + if still.as_ref().is_none_or(|(k, _)| k != key) { + *still = Some((key.clone(), now)); + } + let waited = still.as_ref().map_or(0.0, |(_, at)| now - at); + // The clock went back (a new egui context): don't wait for it to catch up. + let waiting = (0.0..STILL_SECS).contains(&waited); + (slow && waiting && shown.is_some_and(|s| s != key && only_view_differs(s, key))).then_some(STILL_SECS - waited) +} + +/// Whether two cache keys differ only in the view (zoom, center, rotation, canvas size, pixel +/// density and Pixel Preview's document pixels), with Pixel Preview on in both or off in both. +fn only_view_differs(a: &CacheKey, b: &CacheKey) -> bool { + a.pixel.is_some() == b.pixel.is_some() + && *a == CacheKey { zoom: a.zoom, cx: a.cx, cy: a.cy, w: a.w, h: a.h, ppp: a.ppp, rot: a.rot, pixel: a.pixel, ..b.clone() } +} + fn cursor_icon(c: Cursor) -> egui::CursorIcon { use egui::CursorIcon as C; match c { @@ -700,6 +894,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { Cursor::Width | Cursor::WidthAdd => C::Crosshair, Cursor::WidthPoint => C::Move, Cursor::Blend | Cursor::BlendObject | Cursor::BlendAnchor | Cursor::ShapeBuilder | Cursor::ShapeBuilderErase => C::Crosshair, + Cursor::PencilClose | Cursor::PaintbrushClose => C::Crosshair, Cursor::TypeVertical => C::VerticalText, // Not drawn yet: the cursor each borrows. Cursor::Lasso @@ -733,6 +928,7 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon { } } +/// Dispatch canvas pointer, keyboard and drop events through the active document view. fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) { let line = ui.ctx().options(|o| o.input_options.line_scroll_speed); let wheel_zooms = app.session.prefs.zoom_with_mouse_wheel; @@ -747,33 +943,10 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: if turn.glide != 0.0 { ui.ctx().request_repaint(); } - let v = *app.view().unwrap_or(&View::default()); - let xf = Xf::new(rect, &v); let hover = pointer.hover_pos().filter(|p| rect.contains(*p)); - // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), - // so a tap whose press and lift come in one frame has no hover position: it presses (and - // double-taps) where it was seen last (#491). - let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); - app.hover_doc = hover.map(|p| xf.to_doc(p)); - let view = app.view_info(); - touch_gestures(app, ui, rect, view); - // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the - // tool's press is dropped, as a right-click presses nothing. - if resp.long_touched() { - drop_press(app, ui, view); - } - let drag: Option = ui.data(|d| d.get_temp(drag_id())); - // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows - // without moving the mouse (Alt switches the Shape Builder to erase mode). - let mods_changed = ui.data_mut(|d| { - let id = egui::Id::new("canvas-mods"); - let prev = d.get_temp::(id); - d.insert_temp(id, m); - prev.is_some_and(|prev| prev != m) - }); - // Zoom around the pointer, and scroll ([`wheel`]): fingers pinching on a touch screen may also - // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). + // slide. Then turn the view about the pointer for a trackpad rotation ([`trackpad_turn`]). The + // tool's events below take their document points from the view this moves to. if resp.hovered() { if (factor - 1.0).abs() > 1e-6 && let (Some(p), Some(vm)) = (hover, app.view_mut()) @@ -793,6 +966,58 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: rotate_about(vm, rect, p, deg); } } + let v = *app.view().unwrap_or(&View::default()); + let xf = Xf::new(rect, &v); + // A lifted pen or finger takes the pointer away in the frame it lifts (egui's `PointerGone`), + // so a tap whose press and lift come in one frame has no hover position: it presses (and + // double-taps) where it was seen last (#491). + let at = hover.or_else(|| pointer.interact_pos().filter(|p| rect.contains(*p))); + let view = app.view_info(); + touch_gestures(app, ui, rect, view); + // Pressed and held still, a pen or a finger opens the context menu (egui's long touch): the + // tool's press is dropped, as a right-click presses nothing. + if resp.long_touched() { + drop_press(app, ui, view); + } + let drag: Option = ui.data(|d| d.get_temp(drag_id())); + // A modifier pressed or released over the canvas re-hovers the tool, so what it changes shows + // without moving the mouse (Alt switches the Shape Builder to erase mode). + let mods_changed = ui.data_mut(|d| { + let id = egui::Id::new("canvas-mods"); + let prev = d.get_temp::(id); + d.insert_temp(id, m); + prev.is_some_and(|prev| prev != m) + }); + // Where the tool hovers: the pointer over the canvas itself, not over a floating panel, a menu, + // a popup or a dialog drawn on top of it ([`on_canvas`]). + let hover_tool = hover.filter(|p| on_canvas(ui, resp, *p)); + // Whether the tool hovered last frame: coming onto the canvas re-hovers it, even after a single + // jump egui doesn't count as moving, and leaving it ends the tool's hover (below). + let was_over = ui.data_mut(|d| { + let id = egui::Id::new("canvas-hovered"); + let prev = d.get_temp::(id).unwrap_or(false); + d.insert_temp(id, hover_tool.is_some()); + prev + }); + // What a hover means besides where the pointer is on screen: the tool, the canvas, the view + // (scrolled, zoomed or turned by the wheel, a gesture, keys or a command) and the document and + // selection as they are (another tab, an edit, an undo). Under a still pointer, a tool switched + // to is hovered at once, and when the rest changes, a tool that asks for it + // ([`Session::tool_wants_rehover`]) is hovered again where the pointer is. + let context = HoverContext { + tool: app.session.tool_id(), + canvas: rect, + center: v.center, + zoom: v.zoom, + rotation: v.rotation, + doc: app.session.active().map(|st| (st.uid, st.revision)), + }; + let (tool_switched, context_changed) = ui.data_mut(|d| { + let id = egui::Id::new("canvas-hover-context"); + let prev = d.get_temp::(id); + d.insert_temp(id, context); + prev.map_or((false, false), |prev| (prev.tool != context.tool, prev != context)) + }); // In Presentation Mode a click anywhere goes to the next artboard. The tool receives no press, // drag or hover, and a drag does not pan the view. A drag that began before the mode was turned @@ -815,6 +1040,13 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: return; } + // A pen turned to its eraser end selects the Eraser, and a pen turned back to its tip selects + // the tool before it. The switch runs only while the pointer is in this window. It runs before + // this frame's press, which then uses the new tool; while a press is held, it waits for the + // release. A turn made over another window takes effect when the pointer comes in. + if drag.is_none() && ui.input(|i| i.pointer.has_pointer()) { + crate::stylus::sync_eraser_tool(app); + } let tool = app.session.tool_id(); // Held, Space is the Hand tool for the moment, Space+Shift is the Rotate View tool and // Cmd+Space is the Zoom tool (with Alt, zooming out), whatever the tool. @@ -848,13 +1080,22 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: Drag::RotateView { start_angle: (p.y - c.y).atan2(p.x - c.x) as f64, start_rot: v.rotation, snaps: !space } } else { // Cmd with another tool drags with the selection tool used last (the session lends it). - let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, true) }; + let ev = PointerEvent { kind: PointerKind::Down, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, true) }; dispatch(app, &ev, view); + crate::document_drag::begin(app, ui.ctx(), ev.pos); Drag::Tool }; + // Panning, zooming or turning the view takes the pointer from the tool's hover. + if !tool_holds(d) { + app.session.tool_hover_ended(view); + } ui.data_mut(|dd| { dd.insert_temp(drag_id(), d); dd.insert_temp(drag_pos_id(), p); + dd.insert_temp(drag_layer_id(), resp.layer_id); + // A new press has not dragged yet, whatever an interrupted one left behind. + dd.remove::(tool_drag_doc_id()); + dd.insert_temp(tool_press_doc_id(), xf.to_doc(p)); }); } } else if drag.is_none() @@ -864,6 +1105,7 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: { // Middle-button drag pans the view whatever the tool. ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Pan { start, center: v.center, middle: true })); + app.session.tool_hover_ended(view); } else if let Some(d) = drag { // The pointer gone (a pen lifted with its press), the drag ends where it was last seen. let p = pointer.interact_pos().or_else(|| ui.data(|dd| dd.get_temp(drag_pos_id()))).unwrap_or(rect.center()); @@ -907,19 +1149,34 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::ZoomScrub { start, held, moved })); } + Drag::Art if on_canvas(ui, resp, p) => { + // Back on the canvas: the move goes on from where it left, with the pointer. + egui::DragAndDrop::clear_payload(ui.ctx()); + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + ui.data_mut(|dd| { + dd.insert_temp(drag_id(), Drag::Tool); + dd.insert_temp(drag_pos_id(), p); + }); + } Drag::Art | Drag::Gesture | Drag::Present => {} - Drag::Tool if drag_art_out(app, ui, resp, p, view) => { + Drag::Tool if drag_art_out(app, ui, resp, p) => { ui.data_mut(|dd| dd.insert_temp(drag_id(), Drag::Art)); } Drag::Tool => { if tool_drag_moved(ui, &xf, p, &pointer) { - // Every position the pen or mouse passed this frame, not just the last: a - // fast stroke keeps its curve (a pen reports far more often than frames come). - for (at, force) in frame_moves(ui) { - let pressure = force.unwrap_or_else(|| pen_pressure(ui, false)); + // In a frame with touch events (a pen on Windows or the web, a finger) or a + // macOS pen's moves, every position the pen passed, not just the last: a fast + // stroke keeps its curve (a pen reports far more often than frames come). + // Mouse events, a pen's on X11 among them, give the last position only, with + // the host's latest pressure. + for (at, force) in frame_moves(ui, app.services.stylus.as_ref()) { + let pressure = force.unwrap_or_else(|| pen_pressure(ui, app, false)); dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(at), mods: mods(m, space), pressure }, view); } - let ev = PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; dispatch(app, &ev, view); } // Time held (Twirl, Pucker and Bloat keep applying); a stalled frame counts @@ -932,10 +1189,13 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: } } } else { + // The last position the tool's drag got: none if the press never dragged. + let last_drag: Option = ui.data(|dd| dd.get_temp(tool_drag_doc_id())); ui.data_mut(|dd| { dd.remove::(drag_id()); dd.remove::(drag_pos_id()); dd.remove::(tool_drag_doc_id()); + dd.remove::(tool_press_doc_id()); }); match d { Drag::ZoomBox { start } => { @@ -965,19 +1225,52 @@ fn handle_input(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, rect: if resp.double_clicked() && app.session.tool_id() == "artboard" { app.session.set_tool_option("suppressFitClick", &json!(true)); } - let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, false) }; + // A move that came in the release's own frame shapes the preview first: the + // tools commit the last preview, which should be where the pointer let go + // (#834). Only once the press has dragged, so a click's jitter stays a click + // (the Pen's corner anchor, no nudge with Direct Selection). + if let Some(last) = last_drag + && last != xf.to_doc(p) + { + let ev = + PointerEvent { kind: PointerKind::Drag, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; + dispatch(app, &ev, view); + } + let ev = PointerEvent { kind: PointerKind::Up, pos: xf.to_doc(p), mods: mods(m, space), pressure: pen_pressure(ui, app, false) }; dispatch(app, &ev, view); } - Drag::Art | Drag::Gesture | Drag::Present => {} + Drag::Art if on_canvas(ui, resp, p) => { + // Came back to the canvas and released in one frame: finish the move here. + egui::DragAndDrop::clear_payload(ui.ctx()); + let pressure = pen_pressure(ui, app, false); + let pos = xf.to_doc(p); + dispatch(app, &PointerEvent { kind: PointerKind::Drag, pos, mods: mods(m, space), pressure }, view); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos, mods: mods(m, space), pressure }, view); + } + Drag::Art => { + // Released over a panel: the move goes back. (The app ends it before the panels + // see the release, so a panel can take the art; this is the headless path.) + // With no document open there is nothing to roll back; the tool still gets its up. + let _ = app.session.cancel_interaction(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + } + Drag::Gesture | Drag::Present => {} Drag::Pan { .. } | Drag::RotateView { .. } => {} } } - } else if let Some(p) = hover - && (pointer.is_moving() || mods_changed) + } else if let Some(p) = hover_tool + && (pointer.is_moving() || mods_changed || !was_over || tool_switched || (context_changed && app.session.tool_wants_rehover())) { let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; dispatch(app, &ev, view); } + // The pointer left the canvas (for a panel, or out of the window) or went under a floating + // panel, menu, popup or dialog, with no press the tool holds: the tool's hover ends, which no + // hover event would clear (Alt's Distance Guides). + if was_over && hover_tool.is_none() && !drag.is_some_and(tool_holds) { + app.session.tool_hover_ended(view); + ui.ctx().request_repaint(); + } if pointer.primary_released() && let Some(i) = ui.data_mut(|d| d.remove_temp::(name_press_id())) { @@ -1370,10 +1663,13 @@ fn touch_gestures(app: &mut VectorcraftApp, ui: &Ui, rect: egui::Rect, view: vec /// does nothing ([`Drag::Gesture`]). fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewInfo) { match ui.data(|d| d.get_temp::(drag_id())) { - Some(Drag::Tool) => { - // The tool's mouse-up returns it to rest. + Some(Drag::Tool | Drag::Art) => { + // The tool's mouse-up returns it to rest. A move waiting over a panel is still live + // and would otherwise outlive the drag that owns it (#2826). + // Nothing to roll back without a document; the tool still gets its mouse-up. let _ = app.session.cancel_interaction(); dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + egui::DragAndDrop::clear_payload(ui.ctx()); } Some(_) => {} None => return, @@ -1381,26 +1677,47 @@ fn drop_press(app: &mut VectorcraftApp, ui: &Ui, view: vectorcraft_engine::ViewI ui.data_mut(|d| d.insert_temp(drag_id(), Drag::Gesture)); } -/// A Selection tool move dragged off the canvas (to `p`, over a panel) turns into a panel drag of -/// the selected art ([`widgets::PanelDrag::Art`]): the move is dropped, so the art stays where it -/// was, and the panel it is released on takes it (the Graphic Styles panel makes a style of it). -fn drag_art_out(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2, view: vectorcraft_engine::ViewInfo) -> bool { - let off = !ui.clip_rect().contains(p) || ui.ctx().layer_id_at(p).is_some_and(|l| l != resp.layer_id); +/// A Selection tool move dragged off the canvas (to `p`, over a panel) becomes a panel drag of +/// the selected art ([`widgets::PanelDrag::Art`]): the art waits where it last moved on the +/// canvas, and a panel it is released on takes it (the Graphic Styles panel makes a style of it). +/// Back on the canvas, [`Drag::Art`] takes the move up again where the pointer is (#2826). +fn drag_art_out(app: &VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos2) -> bool { + let off = !on_canvas(ui, resp, p); let Some(st) = app.session.active().filter(|_| off && app.session.tool_id() == "selection") else { return false }; // The Selection tool's interaction while it moves (or Alt-copies) the selection. if !st.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy") { return false; } let ids = st.selection.objects.clone(); - if app.session.cancel_interaction().is_err() { - return false; - } - // With nothing left to commit, the tool's mouse-up just returns it to rest. - dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); egui::DragAndDrop::set_payload(ui.ctx(), widgets::PanelDrag::Art(ids)); true } +/// A Selection tool move released over a panel: end it before the panels see the release, so a +/// panel that takes the art ([`widgets::PanelDrag::Art`]) takes it where it was, and no move is +/// left open (#2826). The art stays where it last moved while the pointer is over the panel. +pub(crate) fn release_over_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { + crate::document_drag::cleanup(app, ctx); + if !ctx.input(|i| i.pointer.primary_released()) { + return; + } + let Some(p) = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())) else { return }; + if !matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Art)) { + return; + } + let layer = ctx.data(|d| d.get_temp::(drag_layer_id())); + let on = app.canvas_rect.is_some_and(|r| r.contains(p)) && ctx.layer_id_at(p).is_none_or(|l| Some(l) == layer); + if on { + // The canvas's release finishes the move. + return; + } + // With no document open there is nothing to roll back; the tool still gets its mouse-up. + let _ = app.session.cancel_interaction(); + let view = app.view_info(); + dispatch(app, &PointerEvent { kind: PointerKind::Up, pos: Point::ZERO, mods: Mods::default(), pressure: 1.0 }, view); + ctx.data_mut(|d| d.remove::(drag_id())); +} + /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { let r = app.session.pointer(ev, view); @@ -1434,6 +1751,7 @@ pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result crate::dialogs::open_tool_dialog(app, &kind, p), vectorcraft_engine::UiRequest::SwitchTool(t) => app.select_tool(&t), vectorcraft_engine::UiRequest::Status(msg) => app.status(msg), + vectorcraft_engine::UiRequest::Place(p) => crate::place::place_from_cursor(app, &p), } } } @@ -1695,7 +2013,46 @@ pub(crate) fn ruler_origin_dragging(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::<(Option, bool)>(ruler_origin_drag_id()).is_some()) } -fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, unit: Unit, origin: Point, t: &Tokens) { +/// What the rulers count, and from where: `unit`, from `origin` (document points), with each +/// unit stretched `stretch` times along the document's x and y, which a video ruler's pixel aspect +/// ratio makes differ. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RulerScale { + pub(crate) unit: Unit, + pub(crate) origin: Point, + pub(crate) stretch: [f64; 2], +} + +impl RulerScale { + /// The rulers in `unit` (the General unit), from `origin` (the ruler origin). + pub(crate) fn document(unit: Unit, origin: Point) -> Self { + Self { unit, origin, stretch: [1.0, 1.0] } + } + + /// View › Rulers › Show Video Rulers on artboard `ab`: pixels from its top-left corner, each + /// pixel as many points wide as its Video Ruler Pixel Aspect Ratio and one point tall, so the + /// rulers read the artboard's size in video pixels + /// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). + pub(crate) fn video(ab: &vectorcraft_doc::Artboard) -> Self { + Self { unit: Unit::Pixels, origin: Point::new(ab.rect.x0, ab.rect.y0), stretch: [ab.video_ruler_pixel_aspect_ratio, 1.0] } + } + + /// How far document point `p` lies from the origin along `axis` (a unit direction in the + /// document), in points of this scale: each document coordinate divided by its stretch. + /// Divided by the unit's points, it is the value the ruler reads there. + pub(crate) fn along(&self, p: Point, axis: vectorcraft_geom::Vec2) -> f64 { + let d = p - self.origin; + d.x / self.stretch[0] * axis.x + d.y / self.stretch[1] * axis.y + } + + /// The screen points one point of this scale spans along `axis` at `zoom` (screen points per + /// document point): `zoom` itself when nothing is stretched. + pub(crate) fn zoom_along(&self, zoom: f64, axis: vectorcraft_geom::Vec2) -> f64 { + zoom / (axis.x * axis.x / self.stretch[0] + axis.y * axis.y / self.stretch[1]) + } +} + +fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, scale: RulerScale, t: &Tokens) { let p = ui.painter(); let [top, left, corner] = ruler_rects(full); for r in [top, left, corner] { @@ -1706,43 +2063,40 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, unit: Unit, // Crosshair in the origin box. p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], Stroke::new(1.0, t.ruler_tick)); p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], Stroke::new(1.0, t.ruler_tick)); - // Pick a label step (in `unit`) that gives ≥ 50 px between labels; positions below are in `unit` - // (`per` points each). - let per = unit.points(); - let step = unit.ruler_step(xf.zoom); - let minor = unit.ruler_minor(step); let font = egui::FontId::proportional(9.5); - let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01; - // A tick's length: the labelled ones longest, the halves between them shorter. - let tick = |v: f64| { - if near(v, step) { - RULER - } else if near(v, step / 2.0) { - 7.0 - } else { - 4.0 - } - }; // Each ruler measures along its own edge, in the canvas's coordinates turned with the view // (#826): at 0° the top one reads x and the left one y. `draw` gets each tick's distance - // (screen points) from `from` along the ruler, and its value in `unit`. - let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f64)| { - let along = |s: Pos2| (xf.to_doc(s) - origin).dot(axis); - let (a, b) = (along(from), along(to)); + // (screen points) from `from` along the ruler, its length, and the label of a labelled one. + let ticks = |from: Pos2, to: Pos2, axis: vectorcraft_geom::Vec2, draw: &mut dyn FnMut(f32, f32, Option)| { + // Pick a label step (in the unit) that gives ≥ 50 px between labels on this ruler, at its + // own scale; positions below are in the unit (`per` points of the scale each). + let zoom = scale.zoom_along(xf.zoom, axis); + let (per, step) = (scale.unit.points(), scale.unit.ruler_step(zoom)); + let minor = scale.unit.ruler_minor(step); + let near = |v: f64, every: f64| ((v / every).round() * every - v).abs() < minor * 0.01; + let (a, b) = (scale.along(xf.to_doc(from), axis), scale.along(xf.to_doc(to), axis)); let mut v = (a / per / minor).floor() * minor; while v * per <= b { - draw(((v * per - a) * xf.zoom) as f32, v); + // A tick's length: the labelled ones longest, the halves between them shorter. + let (len, label) = if near(v, step) { + (RULER, Some(ruler_label(v, step))) + } else if near(v, step / 2.0) { + (7.0, None) + } else { + (4.0, None) + }; + draw(((v * per - a) * zoom) as f32, len, label); v += minor; } }; // The canvas's directions along the screen's x and y. let (sn, cs) = xf.rot.sin_cos(); let clip_top = p.with_clip_rect(top); - ticks(top.left_top(), top.right_top(), vectorcraft_geom::Vec2::new(cs, -sn), &mut |d, x| { + ticks(top.left_top(), top.right_top(), vectorcraft_geom::Vec2::new(cs, -sn), &mut |d, len, label| { let sx = top.left() + d; - clip_top.line_segment([pos2(sx, top.bottom() - tick(x)), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); - if near(x, step) { - clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, ruler_label(x, step), font.clone(), t.ruler_tick); + clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick)); + if let Some(text) = label { + clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, text, font.clone(), t.ruler_tick); } }); let clip_left = p.with_clip_rect(left); @@ -1750,12 +2104,11 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option, unit: Unit, // "-0.0625" is seven characters, 60 pt of ruler, where a sixteenth-inch step leaves 54 pt at // zoom 12. The ticks all stay; a label that would run into the one above it is left out. let mut filled_to = f32::NEG_INFINITY; - ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, y| { + ticks(left.left_top(), left.left_bottom(), vectorcraft_geom::Vec2::new(sn, cs), &mut |d, len, label| { let sy = left.top() + d; - clip_left.line_segment([pos2(left.right() - tick(y), sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); - if near(y, step) { + clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick)); + if let Some(text) = label { // Vertical labels read top-to-bottom, one digit per line like Illustrator. - let text = ruler_label(y, step); let top = sy + 2.0; if top < filled_to { return; @@ -1826,13 +2179,22 @@ fn node_outline(n: &Node, symbols: &SymbolBoxes) -> BezPath { walk_drawn(n, &mut |c| match &c.kind { NodeKind::Path { path, .. } => bp.extend(path.to_bezpath()), // Area type shows its frame: the type area Direct Selection reshapes; type on a path its - // path, which the selection tools' brackets sit on. - NodeKind::Text(t) if c.extras().perspective.is_none() && !matches!(t.kind, vectorcraft_doc::TextKind::Point) => { - if let Some(path) = t.area_frame().or_else(|| t.type_path()) { - bp.extend(path.to_bezpath()); + // path, which the selection tools' brackets sit on; point type shows its baseline(s). + NodeKind::Text(t) => { + if c.extras().perspective.is_none() { + if let Some(path) = t.area_frame().or_else(|| t.type_path()) { + bp.extend(path.to_bezpath()); + } else if matches!(t.kind, vectorcraft_doc::TextKind::Point) { + for (a, b) in t.baselines() { + bp.move_to(t.xf * a); + bp.line_to(t.xf * b); + } + } + } else if let Some(b) = c.geometric_bounds_with(symbols) { + bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); } } - NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { + NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => { if let Some(b) = c.geometric_bounds_with(symbols) { bp.extend(vectorcraft_geom::shapes::rectangle(b).to_bezpath()); } @@ -1879,8 +2241,7 @@ fn too_many_anchors<'a>(nodes: impl IntoIterator) -> bool { if n.clips() { return n.children().and_then(|children| children.first()).is_some_and(|clip| over(clip, left)); } - !matches!(n.kind, NodeKind::Envelope { .. }) - && n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())).any(|c| over(c, left)) + !matches!(n.kind, NodeKind::Envelope { .. }) && n.editing_children().iter().any(|c| over(c, left)) } let mut left = OVERLAY_MAX_ANCHORS; nodes.into_iter().any(|n| over(n, &mut left)) @@ -1924,17 +2285,25 @@ fn walk_drawn<'a>(n: &'a Node, f: &mut impl FnMut(&'a Node)) { } return; } - for c in n.children().into_iter().flatten().skip(usize::from(n.extras().shaper.is_some())) { + for c in n.editing_children().iter() { walk_drawn(c, f); } } -/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick it -/// ([`hit_with`]). +/// The topmost editable object under document point `p` at `zoom`, as what is drawn there +/// ([`hit_with`]): a swatch or a symbol dropped on art. fn hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { hit_with(app, p, zoom, vectorcraft_doc::hit::hit_test) } +/// The topmost editable object under document point `p` at `zoom`, as the selection tools pick +/// it: [`hit_at`], and a clipping path on its outline before the art it clips +/// ([`vectorcraft_doc::hit::HitOptions::clip_outlines`]). Object Highlighting and a right-click's +/// selection show and take what a click of those tools would. +fn selection_hit_at(app: &VectorcraftApp, p: Point, zoom: f64) -> Option { + hit_with(app, p, zoom, |doc, p, opt| vectorcraft_doc::hit::hit_test(doc, p, vectorcraft_doc::hit::HitOptions { clip_outlines: true, ..opt })) +} + /// `test` on the active document at document point `p`, with the options the selection tools hit /// objects with at `zoom` (Selection & Anchor Display › Tolerance and Object Selection by Path Only, /// Type › Type Object Selection by Path Only, isolation mode). @@ -1951,6 +2320,7 @@ fn hit_with( path_only: prefs.object_selection_by_path_only, type_path_only: prefs.type_selection_by_path_only, scope: app.session.active().and_then(|st| st.isolation), + skip_guides: !app.ui.view.guides, ..Default::default() }; test(&app.session.active()?.doc, p, opt) @@ -1968,7 +2338,7 @@ fn context_menu(app: &mut VectorcraftApp, resp: &egui::Response, xf: &Xf) { let locked = hit_with(app, p, xf.zoom, |doc, p, opt| Some(vectorcraft_doc::hit::locked_objects_at(doc, p, opt))); app.ui.context_locked = app.session.active().map(|st| st.uid).zip(locked); if let Some(st) = app.session.active() - && let Some(top) = hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation)) + && let Some(top) = selection_hit_at(app, p, xf.zoom).map(|h| h.top_object(st.isolation)) && !st.selection.contains(top) { // A locked or hidden object can't be selected; the menu is then for the selection as is. @@ -2014,6 +2384,14 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) } return; } + // A symbol from a symbol library panel: added to the document's Symbols and placed there. + if let widgets::PanelDrag::LibrarySymbol { library, name } = &*d { + let at = xf.to_doc(pos); + if let Err(e) = app.run("symbol.addFromLibrary", json!({"library": library, "name": name, "place": true, "x": at.x, "y": at.y})) { + app.status(e); + } + return; + } let Some(hit) = hit_at(app, xf.to_doc(pos), xf.zoom) else { return }; let Some(st) = app.session.active() else { return }; let (cmd, params) = match &*d { @@ -2044,7 +2422,10 @@ fn panel_drop(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, xf: &Xf) } // Art dragged back onto the canvas: its move was already dropped. (A symbol was placed // above.) - widgets::PanelDrag::Art(_) | widgets::PanelDrag::Symbol(_) | widgets::PanelDrag::LibraryGraphic { .. } => return, + widgets::PanelDrag::Art(_) + | widgets::PanelDrag::Symbol(_) + | widgets::PanelDrag::LibrarySymbol { .. } + | widgets::PanelDrag::LibraryGraphic { .. } => return, // A Libraries panel style or group only moves within that panel. widgets::PanelDrag::LibraryItem { .. } | widgets::PanelDrag::LibraryGroup { .. } => return, }; @@ -2063,7 +2444,7 @@ fn hover_highlight(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { return; } let Some(st) = app.session.active() else { return }; - let Some(hit) = hit_at(app, h, xf.zoom) else { return }; + let Some(hit) = selection_hit_at(app, h, xf.zoom) else { return }; let id = if app.session.tool_id() == "selection" { hit.top_object(st.isolation) } else { hit.leaf }; if st.selection.contains(id) { return; @@ -2197,7 +2578,8 @@ impl HiddenMarks { } let text = tx.plain_text(); let to = |q: Point| tx.xf * q; - for g in &lay.glyphs { + // A list marker's glyphs (length 0) stand for no character. + for g in lay.glyphs.iter().filter(|g| g.len > 0) { if text.get(g.byte..).is_some_and(|s| s.starts_with(' ')) { marks.spaces.push(to(Point::new(g.origin.x + g.advance / 2.0, g.origin.y - 3.0))); } @@ -2220,28 +2602,52 @@ impl HiddenMarks { } } -/// Text threads of the selected frames: a line from each frame's out port (on its right side, -/// just above the bottom right, where the tools draw the red one) to the next frame's in port (top -/// left), like Illustrator's thread indicators. -fn thread_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { - let Some(st) = app.session.active() else { return }; +/// View › Show Text Threads: for each thread with a selected frame, a line in its first frame's +/// layer colour from each frame's out port to the next frame's in port, both showing the arrow of a +/// threaded port, where the tools draw the ports of selected type ([`vectorcraft_tools::text`]). +/// Only frames with ports ([`vectorcraft_tools::text::ported`]) are joined: a link to or from a +/// frame in perspective, which has none, gets no line. With more than +/// [`vectorcraft_tools::text::PORTS_MAX`] such frames selected the tools draw no ports, and only +/// the lines are drawn here. `zoom`: screen pixels to the document unit. +fn thread_overlays(app: &VectorcraftApp, zoom: f64) -> Vec { + use vectorcraft_tools::text::{Port, PortState, port_centre, ported, ports_shown, push_port}; + let Some(st) = app.session.active() else { return vec![] }; + let px = 1.0 / zoom.max(1e-9); + let boxes = ports_shown(&st.doc, &st.selection.objects); + let mut o = vec![]; for thread in &st.doc.text_threads { if !thread.iter().any(|id| st.selection.objects.contains(id)) { continue; } - let color = c32(st.doc.layer_color(thread[0])); - let frames: Vec = thread.iter().filter_map(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds())).collect(); - for w in frames.windows(2) { - let rise = (vectorcraft_tools::text::PORT_RISE_PX / xf.zoom.max(1e-9)).min(w[0].height() / 2.0); - let (out, inp) = (xf.to_screen(Point::new(w[0].x1, w[0].y1 - rise)), xf.to_screen(Point::new(w[1].x0, w[1].y0))); - p.line_segment([out, inp], Stroke::new(1.0, color)); - for port in [out, inp] { - p.rect_filled(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Color32::WHITE); - p.rect_stroke(egui::Rect::from_center_size(port, egui::vec2(7.0, 7.0)), 0.0, Stroke::new(1.0, color), egui::StrokeKind::Inside); - p.line_segment([port - egui::vec2(2.0, 0.0), port + egui::vec2(2.0, 0.0)], Stroke::new(1.0, color)); + let color = st.doc.layer_color(thread[0]); + for w in thread.windows(2) { + let [a, b] = [w[0], w[1]]; + let (Some(from), Some(to)) = (ported(&st.doc, a), ported(&st.doc, b)) else { continue }; + let (Some(out), Some(input)) = (port_centre(from, Port::Out, px), port_centre(to, Port::In, px)) else { continue }; + o.push(Overlay::Line { a: out, b: input, color, dashed: false }); + if boxes { + push_port(&mut o, out, PortState::Threaded, st.doc.layer_color(a), px); + push_port(&mut o, input, PortState::Threaded, st.doc.layer_color(b), px); } } } + o +} + +/// View → Show Live Paint Gaps: the gaps of the selected Live Paint groups in each group's Gap +/// Preview Color ([`crate::dialogs::live_paint_gaps::selected_gaps`]). Cached per revision. +fn live_paint_gap_overlays(app: &mut VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let key = (st.uid, st.revision); + let gaps = match &app.canvas.live_paint_gaps { + Some((k, gaps)) if *k == key => gaps.clone(), + _ => { + let gaps = std::sync::Arc::new(crate::dialogs::live_paint_gaps::selected_gaps(app)); + app.canvas.live_paint_gaps = Some((key, gaps.clone())); + gaps + } + }; + gaps.to_vec() } /// Simplify's Show Original Path: the paths its preview ran on, as they were before it, in a band @@ -2443,8 +2849,11 @@ fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf, curve let Some(n) = st.doc.node(*id) else { continue }; let color = c32(st.doc.layer_color(*id)); let partial = st.selection.partial(*id); - // Path outlines. - stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color)); + // Path outlines (and area type / placed document / image / mesh / symbol outlines; + // point type baselines and origin are drawn below). + if !matches!(n.kind, NodeKind::Text(ref tx) if matches!(tx.kind, vectorcraft_doc::TextKind::Point)) { + stroke_path(p, &node_outline(n, &symbols), xf, Stroke::new(1.0, color)); + } // Anchors (and handles for selected anchors in direct mode); a gradient mesh's or a mesh // envelope's points. walk_drawn(n, &mut |c| { @@ -2651,6 +3060,39 @@ fn canvas_name_galley(painter: &egui::Painter, corner: Pos2, width: f32, name: & Some((galley, at)) } +/// The length on screen, in points, of each arm of an artboard's center mark (a cross), at any zoom. +const CENTER_MARK_ARM: f32 = 6.0; + +/// Artboard `ab`'s display marks in `color`, each line 1 point wide on screen at any zoom and +/// turned with the view: with Show Cross Hairs, a line from the middle of each side through the +/// center to the middle of the opposite side; with Show Center Mark, a cross at the center whose +/// arms run along the artboard's sides, [`CENTER_MARK_ARM`] long on screen +/// (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/modify-display-settings-of-artboards.html); +/// with Show Video Safe Areas, the outlines of its action-safe and title-safe areas +/// ([`vectorcraft_doc::Artboard::video_safe_areas`], +/// https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). +fn artboard_marks(painter: &egui::Painter, xf: &Xf, ab: &vectorcraft_doc::Artboard, color: Color32) { + let (r, stroke) = (ab.rect, Stroke::new(1.0, color)); + let c = r.center(); + let at = |x: f64, y: f64| xf.to_screen(Point::new(x, y)); + if ab.show_cross_hairs { + painter.line_segment([at(r.x0, c.y), at(r.x1, c.y)], stroke); + painter.line_segment([at(c.x, r.y0), at(c.x, r.y1)], stroke); + } + if ab.show_center_mark { + let mid = at(c.x, c.y); + for side in [at(c.x + 1.0, c.y), at(c.x, c.y + 1.0)] { + let arm = (side - mid).normalized() * CENTER_MARK_ARM; + painter.line_segment([mid - arm, mid + arm], stroke); + } + } + if ab.show_video_safe_areas { + for area in ab.video_safe_areas() { + painter.add(Shape::closed_line(xf.quad(area), stroke)); + } + } +} + /// Artboard `name` above its top-left `corner` on screen, no wider than the artboard (`width` on /// screen): a longer name ends in "…", and an artboard too narrow shows none (#949). fn artboard_label(painter: &egui::Painter, corner: Pos2, width: f32, name: &str, color: Color32) { @@ -2715,8 +3157,10 @@ fn select_named_artboard(app: &mut VectorcraftApp, index: usize) { } } +/// Start editing artboard `index`'s name on the canvas, unless the artboard is locked (the +/// Artboards panel still renames it). fn begin_artboard_name_edit(ui: &Ui, app: &VectorcraftApp, index: usize) { - let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).map(|a| a.name.clone())) else { return }; + let Some(name) = app.session.active().and_then(|st| st.doc.artboards.get(index).filter(|a| !a.locked).map(|a| a.name.clone())) else { return }; ui.data_mut(|d| d.insert_temp(name_edit_id(), ArtboardNameEdit { index, text: name, fresh: true })); } @@ -2835,6 +3279,7 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, l p.rect_filled(r, CornerRadius::same(3), t.measure_bg); p.galley(sp + vec2(6.0, 4.0), galley, Color32::WHITE); } + Overlay::Distance { a, b, text, color } => distance_guide(p, xf.to_screen(*a), xf.to_screen(*b), text, c32(*color)), Overlay::GridLine { a, b, color } => { let c = Color32::from_rgba_unmultiplied(color[0], color[1], color[2], color[3]); p.line_segment([xf.to_screen(*a), xf.to_screen(*b)], Stroke::new(1.0, c)); @@ -2854,6 +3299,28 @@ fn draw_overlays(p: &egui::Painter, xf: &Xf, overlays: &[Overlay], t: &Tokens, l } } +/// A Distance Guides measurement on screen ([`Overlay::Distance`]): the line from `a` to `b` in the +/// smart guide colour `color` with a 4 px tick across each end, and `text` in a pill of that colour +/// on its middle, in white or black, whichever reads on it. +fn distance_guide(p: &egui::Painter, a: Pos2, b: Pos2, text: &str, color: Color32) { + let s = Stroke::new(1.0, color); + p.line_segment([a, b], s); + let along = b - a; + let len = along.length(); + if len > 0.0 && len.is_finite() { + let across = vec2(-along.y, along.x) / len * 4.0; + for end in [a, b] { + p.line_segment([end - across, end + across], s); + } + } + let luma = 0.299 * f32::from(color.r()) + 0.587 * f32::from(color.g()) + 0.114 * f32::from(color.b()); + let ink = if luma > 150.0 { Color32::BLACK } else { Color32::WHITE }; + let galley = p.layout_no_wrap(text.to_owned(), egui::FontId::proportional(11.0), ink); + let r = egui::Rect::from_center_size(a + along / 2.0, galley.size() + vec2(8.0, 4.0)); + p.rect_filled(r, CornerRadius::same(3), color); + p.galley(r.min + vec2(4.0, 2.0), galley, ink); +} + /// The Home screen (`app.home`; with no document open, unless its preference is off). fn home(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { let t = Tokens::get(ui.ctx()); @@ -2928,7 +3395,7 @@ fn recent(app: &mut VectorcraftApp, ui: &mut Ui) { for path in files { if recent_row(ui, &path).clicked() { // A failure is reported to the user there. - let _ = crate::io::open_reporting(app, &path); + let _ = crate::io::open_recent_reporting(app, &path); } } } @@ -2978,9 +3445,68 @@ fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) { vectorcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f); } +/// The rendered raster `img` as an egui image. Both are premultiplied RGBA8, so its bytes are copied +/// over whole instead of pixel by pixel, which costs milliseconds for a canvas on a large screen. +/// A raster whose bytes don't match its size gives an empty image of that size. +pub(crate) fn color_image(img: &vectorcraft_render::Rendered) -> egui::ColorImage { + let size = [img.width as usize, img.height as usize]; + let mut pixels = vec![egui::Color32::TRANSPARENT; size[0] * size[1]]; + let bytes: &mut [u8] = bytemuck::cast_slice_mut(&mut pixels); + if bytes.len() == img.pixels.len() { + bytes.copy_from_slice(&img.pixels); + } + egui::ColorImage::new(size, pixels) +} + +/// The pixels of the `w`×`h` frame through `view` to render again for `doc`, when only the document +/// changed since the frame shown (its key `key` but for the revision, and its render options +/// `opts`), which this thread rendered, and no frame is on its way from the worker: those it +/// changed in (`Some(None)`: none), grown to whole eighths of the frame, as long as they are at +/// most half of it. None: render the whole frame. Frames the worker renders stay +/// whole: its renderer keeps the rasters of the art's effects, this thread's doesn't. +fn changed_pixels( + app: &VectorcraftApp, + key: &CacheKey, + doc: &vectorcraft_doc::Document, + opts: &vectorcraft_render::RenderOptions, + view: Affine, + w: u32, + h: u32, +) -> Option> { + let (shown, _) = app.canvas.shown.as_ref().filter(|(_, o)| o == opts)?; + // A viewed opacity mask shows its coverage wherever its art reaches, past its object's bounds. + if opts.mask_view.is_some() { + return None; + } + let same_view = app.canvas.key.as_ref().is_some_and(|k| *k == CacheKey { revision: k.revision, ..key.clone() }); + if !same_view || app.canvas.texture.is_none() || app.canvas.from_worker || app.canvas.worker.as_ref().is_some_and(|w| w.pending.is_some()) { + return None; + } + match vectorcraft_render::changed(shown, doc) { + vectorcraft_render::Changed::Nothing => Some(None), + vectorcraft_render::Changed::Area(area) => match vectorcraft_render::view_pixels(area, view, w, h) { + None => Some(None), + Some([x0, y0, x1, y1]) => { + let ((x0, x1), (y0, y1)) = (in_steps(x0, x1, w), in_steps(y0, y1, h)); + (u64::from(x1 - x0) * u64::from(y1 - y0) * 2 <= u64::from(w) * u64::from(h)).then_some(Some([x0, y0, x1, y1])) + } + }, + vectorcraft_render::Changed::Everything => None, + } +} + +/// `a..b` of `0..n`, grown to whole eighths of `n` and moved back into `0..n`: the parts of a +/// frame rendered again come in few sizes, which keep the contexts the renderer keeps. +fn in_steps(a: u32, b: u32, n: u32) -> (u32, u32) { + let step = n.div_ceil(8).max(1); + let len = (b - a).max(1).div_ceil(step).saturating_mul(step).min(n); + let a = a.min(n - len); + (a, a + len) +} + /// Upload the art's raster; Pixel Preview's document pixels keep hard edges when magnified. fn upload(app: &mut VectorcraftApp, ctx: &egui::Context, img: &vectorcraft_render::Rendered, pixel: bool) { - let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let color = color_image(img); let options = if pixel { egui::TextureOptions::NEAREST } else { egui::TextureOptions::LINEAR }; match &mut app.canvas.texture { Some(tex) => tex.set(color, options), @@ -3297,6 +3823,23 @@ mod tests { use vectorcraft_engine::Session; use vectorcraft_geom::Shape as _; + /// A rendered raster becomes the image egui's own conversion makes of it, half-transparent + /// edges and all; one whose bytes don't match its size becomes an empty image of that size. + #[test] + fn rendered_rasters_become_the_same_egui_images() { + let mut s = Session::new(); + s.execute("file.new", &serde_json::json!({})).unwrap(); + s.execute("shape.ellipse", &serde_json::json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap(); + s.execute("paint.setFill", &serde_json::json!({"color": "#3366cc"})).unwrap(); + let doc = s.active().unwrap().doc.clone(); + let img = vectorcraft_render::Renderer::new().render(&doc, 97, 61, Affine::IDENTITY, &vectorcraft_render::RenderOptions::default()); + assert!(img.pixels.as_chunks::<4>().0.iter().any(|p| p[3] != 0 && p[3] != 255), "partly covered pixels"); + let egui = egui::ColorImage::from_rgba_premultiplied([97, 61], &img.pixels); + assert_eq!(color_image(&img), egui); + let short = vectorcraft_render::Rendered { width: 3, height: 2, pixels: vec![255; 5] }; + assert_eq!(color_image(&short), egui::ColorImage::new([3, 2], vec![egui::Color32::TRANSPARENT; 6])); + } + /// With VECTORCRAFT_SYSTEM_CURSORS set the glyphs aren't drawn and the system's cursors stand in: those of /// the Pen and the other drawing tools are the system crosshair already, so Use Precise Cursors makes no /// difference to them there. @@ -3334,7 +3877,8 @@ mod tests { let [top, left, _] = ruler_rects(full); for deg in [0.0_f64, 30.0, 90.0, 135.0, 200.0] { let xf = Xf { rect: full, zoom: 1.0, center: Point::new(300.0, 200.0), rot: deg.to_radians() }; - let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, Unit::Points, Point::ZERO, &Tokens::get(ui.ctx()))); + let scale = RulerScale::document(Unit::Points, Point::ZERO); + let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); out.textures_delta.clear(); // The ticks' positions along each ruler. let along = |ruler: egui::Rect, x: bool| -> Vec { @@ -3375,7 +3919,8 @@ mod tests { for deg in [0.0_f64, 30.0, 90.0, 200.0] { for unit in [Unit::Points, Unit::Millimeters] { let xf = Xf { rect: full, zoom: 1.7, center: Point::new(300.0, 200.0), rot: deg.to_radians() }; - let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, unit, origin, &Tokens::get(ui.ctx()))); + let scale = RulerScale::document(unit, origin); + let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); out.textures_delta.clear(); let at = xf.to_screen(origin); for (r, horizontal) in [(top, true), (left, false)] { @@ -3401,6 +3946,66 @@ mod tests { } } + /// View › Rulers › Show Video Rulers counts the artboard in video pixels from its top-left + /// corner, a pixel as wide as the artboard's Video Ruler Pixel Aspect Ratio. At 0.9 a + /// 720 × 480 pt artboard is 800 pixels wide and 480 tall: the top ruler reads 0 at its left + /// edge and 800 at its right, labelled every 100 pixels (90 points on screen at 100%), and the + /// left ruler counts a pixel a point, from 0 at the top edge to a tick at the bottom one. At 1 + /// (square pixels) the artboard is 720 pixels wide. + #[test] + fn video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 720, "height": 480})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 0, "videoRulerPixelAspectRatio": 0.9})).unwrap(); + let ab = s.active().unwrap().doc.artboards[0].clone(); + let r = ab.rect; + let scale = RulerScale::video(&ab); + let (x, y) = (vectorcraft_geom::Vec2::new(1.0, 0.0), vectorcraft_geom::Vec2::new(0.0, 1.0)); + let close = |a: f64, b: f64| (a - b).abs() < 1e-9; + assert_eq!((scale.unit, scale.origin), (Unit::Pixels, Point::new(r.x0, r.y0))); + assert!(close(scale.along(Point::new(r.x1, r.y1), x), 800.0) && close(scale.along(Point::new(r.x1, r.y1), y), 480.0)); + assert!(close(scale.along(Point::new(r.x0 + 9.0, r.y0), x), 10.0), "a pixel is 0.9 pt wide"); + assert!(close(scale.zoom_along(2.0, x), 1.8) && close(scale.zoom_along(2.0, y), 2.0)); + let square = RulerScale { stretch: [1.0, 1.0], ..scale }; + assert!(close(square.along(Point::new(r.x1, r.y0), x), 720.0)); + // Drawn at 100%. + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let full = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let [top, left, _] = ruler_rects(full); + let xf = Xf { rect: full, zoom: 1.0, center: r.center(), rot: 0.0 }; + let mut out = ctx.run_ui(Default::default(), |ui| rulers(ui, full, &xf, None, scale, &Tokens::get(ui.ctx()))); + out.textures_delta.clear(); + let labels = |ruler: egui::Rect| -> Vec<(String, Pos2)> { + out.shapes + .iter() + .filter(|c| c.clip_rect == ruler) + .filter_map(|c| match &c.shape { + Shape::Text(t) => Some((t.galley.text().to_string(), t.pos)), + _ => None, + }) + .collect() + }; + let (l, rgt) = (xf.to_screen(Point::new(r.x0, r.y0)), xf.to_screen(Point::new(r.x1, r.y1))); + let at = |labels: &[(String, Pos2)], text: &str, x: f32| labels.iter().any(|(t, p)| t == text && (p.x - 2.0 - x).abs() < 0.02); + let tops = labels(top); + assert!(at(&tops, "0", l.x) && at(&tops, "800", rgt.x), "{tops:?} from {l:?} to {rgt:?}"); + assert!(at(&tops, "100", l.x + 90.0) && !tops.iter().any(|(t, _)| t == "50"), "{tops:?}"); + assert!(!tops.iter().any(|(t, _)| t == "720"), "points aren't pixels: {tops:?}"); + let lefts = labels(left); + assert!(lefts.iter().any(|(t, p)| t == "0" && (p.y - 2.0 - l.y).abs() < 0.02), "{lefts:?}"); + let ticks: Vec = out + .shapes + .iter() + .filter(|c| c.clip_rect == left) + .filter_map(|c| match &c.shape { + Shape::LineSegment { points: [a, b], .. } if a.y == b.y => Some(a.y), + _ => None, + }) + .collect(); + assert!(ticks.iter().any(|y| (y - rgt.y).abs() < 0.02), "a tick at 480 px: {ticks:?}"); + } + #[test] fn shaper_selection_highlights_only_the_visible_result() { let mut s = Session::new(); @@ -3838,6 +4443,37 @@ mod tests { assert_eq!(edges(&mut app), active(2), "Artboard 3 is the active one of the two selected"); } + // Regression test for #824: with the Artboard tool, the active artboard's name was drawn twice, + // once by the canvas as for every artboard and once, a little higher and in blue, by the tool. + // Now each artboard is named once, and the active one's name sits where the others' do. + #[test] + fn the_artboard_tool_names_its_active_artboard_once() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let drawn = shapes(&mut app, &ctx); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + // Each name drawn: how far its left edge is right of the artboard's corner, and its bottom above it. + let named = |name: &str, board: usize| -> Vec<(f32, f32)> { + let r = app.session.active().unwrap().doc.artboards[board].rect; + let corner = xf.to_screen(Point::new(r.x0, r.y0)); + drawn + .iter() + .filter_map(|s| match s { + Shape::Text(t) if t.galley.text() == name => Some((t.pos.x - corner.x, corner.y - (t.pos.y + t.galley.size().y))), + _ => None, + }) + .collect() + }; + let (active, other) = (named("01 - Artboard 1", 0), named("02 - Artboard 2", 1)); + assert_eq!(other.len(), 1, "the other artboard is named once: {other:?}"); + assert_eq!(active.len(), 1, "the active artboard is named once: {active:?}"); + assert!((active[0].0 - other[0].0).abs() < 0.6, "flush with the artboard's left edge like the other name: {active:?} vs {other:?}"); + assert!((active[0].1 - other[0].1).abs() < 2.0, "on the artboard's top edge like the other name: {active:?} vs {other:?}"); + } + /// Enable Touch Gestures (#585): a two-finger tap undoes and a three-finger one redoes, and the /// first finger's press (the pointer egui makes of it) draws nothing. Off, a tap does nothing. #[test] @@ -3919,6 +4555,37 @@ mod tests { assert_eq!(app.session.active().unwrap().doc.artboards[0].name, "Poster", "an empty name is left as it is"); } + /// Double-clicking the name drawn above a locked artboard doesn't edit it (the Artboards panel + /// still renames the artboard), fit an artboard or open Artboard Options. + #[test] + fn double_clicking_a_locked_artboards_name_leaves_it() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 40, "y": 40, "width": 30, "height": 20})).unwrap(); + app.session.execute("artboard.setProps", &json!({"index": 0, "locked": true})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let xf = Xf::new(rect, app.view().unwrap()); + let mut target = None; + let _ = + ctx.run_ui(egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }, |ui| { + let ab = &app.session.active().unwrap().doc.artboards[0]; + target = Some(tool_name_rect(ui.painter(), &xf, ab.rect, 0, &ab.name).center()); + }); + let at = target.unwrap(); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + for _ in 0..2 { + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + frame(&mut app, &ctx, vec![button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + } + assert!(ctx.data(|d| d.get_temp::(name_edit_id()).is_none()), "the name isn't edited"); + assert_eq!(app.session.active().unwrap().doc.artboards.len(), 1); + assert!(app.ui.dialog.is_none(), "Artboard Options stays closed"); + } + /// #949: an artboard's name stays within the artboard's width on screen, ending in "…" when /// longer, and an artboard too narrow shows none. #[test] @@ -4494,6 +5161,69 @@ mod tests { (v, out.textures_delta) } + /// An edit of one object renders the canvas again only where it changed, into its texture; a + /// change of the view renders all of it. + #[test] + fn an_edit_renders_only_its_part_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let id = app.run("shape.rectangle", json!({"x":50,"y":50,"width":40,"height":30})).unwrap()["id"].as_u64().unwrap(); + app.run("shape.rectangle", json!({"x":250,"y":200,"width":40,"height":30})).unwrap(); + app.run("select.none", json!({})).unwrap(); + let ctx = egui::Context::default(); + // Where the canvas texture changed in a frame, and how much of it. + let canvas = |app: &VectorcraftApp, mut delta: egui::TexturesDelta| { + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let set = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| (d.pos, d.image.size())); + delta.clear(); + set + }; + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "the first frame renders all of it"); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + let (pos, size) = canvas(&app, delta).expect("the edit shows"); + let whole = app.canvas.key.as_ref().map(|k| (k.w as usize, k.h as usize)).unwrap(); + assert!(pos.is_some() && size[0] * size[1] * 10 < whole.0 * whole.1, "a small part of it: {pos:?} {size:?} of {whole:?}"); + // An edit with other render options than the frame shown's renders all of it. + app.session.prefs.pattern_tile_edge_color = "#ff00ff".into(); + app.run("paint.setFill", json!({"color": "#00ff00", "ids": [id]})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "other options render all of it"); + app.run("view.setZoom", json!({"zoom": 2})).unwrap(); + let (_, delta) = frame_output(&mut app, &ctx); + assert!(canvas(&app, delta).is_some_and(|(pos, _)| pos.is_none()), "a new view renders all of it"); + } + + /// While an opacity mask is viewed, an edit renders all of the canvas: the mask view shows the + /// mask's coverage wherever its art reaches, past its object's bounds. + #[test] + fn an_edit_while_a_mask_is_viewed_renders_all_of_the_canvas() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width":400,"height":300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + app.canvas.worker_started = true; // every frame on this thread, however long it takes + let rect = + |app: &mut VectorcraftApp, x: f64| app.run("shape.rectangle", json!({"x": x, "y": 50, "width": 60, "height": 40})).unwrap()["id"].clone(); + let (obj, mask) = (rect(&mut app, 50.0), rect(&mut app, 70.0)); + app.run("select.set", json!({"ids": [obj, mask]})).unwrap(); + app.run("transparency.makeOpacityMask", json!({})).unwrap(); + app.run("transparency.viewOpacityMask", json!({})).unwrap(); + let ctx = egui::Context::default(); + let (_, mut delta) = frame_output(&mut app, &ctx); + delta.clear(); + app.run("paint.setFill", json!({"color": "#ff0000", "ids": [obj]})).unwrap(); + let (_, mut delta) = frame_output(&mut app, &ctx); + let tex = app.canvas.texture.as_ref().map(|t| t.id()); + let pos = delta.set.iter().find(|(t, _)| tex == Some(**t)).and_then(|(_, d)| d.last()).map(|d| d.pos); + delta.clear(); + assert_eq!(pos, Some(None), "all of it"); + } + /// #1278: a segment click marks the edge, not its endpoint anchors or neighboring handles. #[test] fn a_segment_pick_has_hollow_anchors_and_only_its_own_handles() { @@ -5101,7 +5831,7 @@ mod tests { // Deliver the actual replacement and verify that it replaces the cached key. let img = app.canvas.renderer.render(&job.doc, job.w, job.h, job.view, &job.opts); let expected = job.key.clone(); - completed.send(crate::render_worker::Done { key: job.key, img, ms: 1.0, moved: 0 }).unwrap(); + completed.send(crate::render_worker::Done { key: job.key, doc: job.doc, opts: job.opts, img, ms: 1.0, moved: 0 }).unwrap(); frame(&mut app, size, new_ppp); assert_eq!(app.canvas.key.as_ref(), Some(&expected), "the worker's frame arrived"); } @@ -5175,6 +5905,116 @@ mod tests { app.canvas.worker = None; } + /// With the web build's setting (`waits_for_still`, no render worker), a heavy document's canvas + /// is not rendered while the view moves: each frame draws the frame shown moved and scaled with + /// the view and asks for a frame within STILL_SECS. The canvas renders once the view has been + /// still for STILL_SECS, and at once after an edit. With a worker's build settings the same frames render at once, as + /// before. + #[test] + fn without_a_worker_a_heavy_canvas_is_reprojected_until_the_view_is_still() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 100, "y": 50, "width": 200, "height": 200})).unwrap(); + assert!(!app.canvas.waits_for_still, "off the web build"); + app.canvas.worker_started = true; // no worker, as in the web build + app.canvas.waits_for_still = true; + let ctx = egui::Context::default(); + // One frame → (the bounds of the mesh that draws the art's texture, the repaint delay asked for). + let frame = |app: &mut VectorcraftApp| { + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let tex = app.canvas.texture.as_ref().unwrap().id(); + let art = out.shapes.iter().find_map(|c| match &c.shape { + Shape::Mesh(m) if m.texture_id == tex => Some(m.calc_bounds()), + _ => None, + }); + (art.expect("the art's texture is drawn"), out.viewport_output[&egui::ViewportId::ROOT].repaint_delay) + }; + frame(&mut app); // the first frame fits the view to the artboard + frame(&mut app); + let rect = app.canvas_rect.unwrap(); + // A light document renders every change of view at once. + app.canvas.last_ms = 1.0; + app.view_mut().unwrap().center.x += 10.0; + frame(&mut app); + let shown = app.canvas.key.clone().unwrap(); + assert_eq!(shown.cx, app.view().unwrap().center.x, "rendered at once"); + // A heavy one draws the frame shown moved and scaled with the view. + app.canvas.last_ms = 50.0; + let old = Xf::new(rect, app.view().unwrap()); + for (dx, zoom) in [(10.0, 1.0), (10.0, 1.5), (-25.0, 1.5)] { + let v = app.view_mut().unwrap(); + v.center.x += dx; + v.zoom *= zoom; + let (b, delay) = frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&shown), "not rendered while the view moves"); + let new = Xf::new(rect, app.view().unwrap()); + let (a, z) = (new.to_screen(old.to_doc(rect.min)), new.to_screen(old.to_doc(rect.max))); + assert!((b.min - a).length() < 0.01 && (b.max - z).length() < 0.01, "{b:?}: the frame shown at {a:?} to {z:?}"); + assert!(delay <= std::time::Duration::from_secs_f64(STILL_SECS), "a frame within STILL_SECS: {delay:?}"); + } + // The view stays still: frames at 60 fps (egui's input time without a clock) wait until + // STILL_SECS has passed, and the next one renders. + let mut frames = 0; + let b = loop { + frames += 1; + let (b, _) = frame(&mut app); + if app.canvas.key.as_ref() != Some(&shown) || frames == 60 { + break b; + } + }; + assert!((12..=13).contains(&frames), "rendered after about 12 frames: {frames}"); + let now = app.canvas.key.clone().unwrap(); + assert_eq!((now.cx, now.zoom), (app.view().unwrap().center.x, app.view().unwrap().zoom), "rendered for the view"); + assert!((b.min - rect.min).length() < 0.01 && (b.max - rect.max).length() < 0.01, "{b:?} over {rect:?}"); + // An edit renders at once, even while the view moves. + app.canvas.last_ms = 50.0; + app.view_mut().unwrap().center.y += 10.0; + frame(&mut app); + assert_eq!(app.canvas.key.as_ref(), Some(&now), "waiting"); + app.session.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + frame(&mut app); + let k = app.canvas.key.clone().unwrap(); + assert_eq!((k.revision, k.cy), (app.session.active().unwrap().revision, app.view().unwrap().center.y), "rendered after the edit"); + // With a worker's build settings and no worker (VECTORCRAFT_SYNC_RENDER), a heavy document + // renders every change of view at once. + app.canvas.waits_for_still = false; + app.canvas.last_ms = 50.0; + app.view_mut().unwrap().center.x += 10.0; + frame(&mut app); + assert_eq!(app.canvas.key.as_ref().map(|k| k.cx), Some(app.view().unwrap().center.x), "rendered at once"); + } + + /// [`still_wait`] waits while only the view changes on a slow canvas: each change starts + /// STILL_SECS again. It does not wait for a light document, a still view, a clock that went + /// back, an edit, a switch into Pixel Preview or a canvas with no frame shown. + #[test] + fn the_canvas_waits_only_while_the_view_of_a_slow_frame_changes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.canvas.worker_started = true; // render on this thread + shapes(&mut app, &egui::Context::default()); + let shown = app.canvas.key.clone().unwrap(); + let mut still = Some((shown.clone(), 0.0)); + let moved = CacheKey { cx: shown.cx + 5.0, zoom: shown.zoom * 2.0, ..shown.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0), Some(STILL_SECS), "the view moved"); + assert_eq!(still_wait(&mut still, Some(&shown), &moved, true, 10.0625), Some(STILL_SECS - 0.0625), "still for 62.5 ms"); + let turned = CacheKey { rot: 15.0, w: shown.w + 40, ppp: 2.0, ..moved.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.0), Some(STILL_SECS), "turned and resized: again"); + assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 11.25), None, "still for longer than STILL_SECS"); + assert_eq!(still_wait(&mut still, Some(&shown), &turned, false, 11.0), None, "a light document"); + assert_eq!(still_wait(&mut still, Some(&shown), &turned, true, 10.5), None, "the clock went back"); + let edited = CacheKey { revision: shown.revision + 1, ..turned.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown), &edited, true, 12.0), None, "an edit"); + let pixels = CacheKey { pixel: Some([0, 0, 10, 10]), ..moved.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown), &pixels, true, 13.0), None, "into Pixel Preview"); + let shown_pixels = CacheKey { pixel: Some([5, 5, 20, 20]), ..shown.clone() }; + assert_eq!(still_wait(&mut still, Some(&shown_pixels), &pixels, true, 13.0), Some(STILL_SECS), "Pixel Preview, moved"); + assert_eq!(still_wait(&mut still, None, &moved, true, 14.0), None, "no frame shown"); + assert_eq!(still_wait(&mut still, Some(&shown), &shown, true, 15.0), None, "the frame shown"); + } + /// File Handling › Display Bitmaps as Anti-aliased Images in Pixel Preview (#394): off, Pixel /// Preview draws images with each pixel taking its nearest image pixel; on, smoothly, as the /// canvas always does out of Pixel Preview. @@ -5628,8 +6468,43 @@ mod tests { assert_eq!(dots(&mut app).len(), 2, "one anchor still shows them"); } + /// #3219: a selected path made into a guide is deselected when guides are hidden, so the + /// selection overlay cannot reveal the hidden path. + #[test] + fn hide_guides_deselects_selected_guide_paths() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("view.guides.make", json!({})).unwrap(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(id)]); + + app.run("view.guides", json!({})).unwrap(); + + assert!(app.session.active().unwrap().selection.objects.is_empty()); + } + /// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer does not /// show with View › Snap to Grid or View › Pixel Preview on, even with Smart Guides and the option on. + /// #3219: guide paths hidden by View > Hide Guides are not hittable and cannot reveal + /// themselves through Smart Guides object highlighting. + #[test] + fn hidden_guide_paths_have_no_hover_highlight() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("view.guides.make", json!({})).unwrap(); + app.run("select.none", json!({})).unwrap(); + app.select_tool("selection"); + app.ui.view.guides = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let color = c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id))); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(xf.to_screen(Point::new(100.0, 150.0)))]); + assert!(drawn_in(&mut app, &ctx, color, 1.5).is_empty(), "a hidden guide has no hover highlight"); + } + #[test] fn snap_to_grid_and_pixel_preview_hide_the_hover_outline() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -5659,6 +6534,88 @@ mod tests { assert!(outlines(&mut app) > 0, "Pixel Preview off again"); } + /// #2452: while the view scrolls under a resting pointer, the art under it isn't hit-tested: + /// no hover outline, and the tool's last cursor stays. Once the view has been still for + /// [`HOVER_STILL_SECS`], the outline comes back without the pointer moving. + #[test] + fn a_moving_view_leaves_the_art_under_the_pointer_unhighlighted_until_it_stops() { + fn flat(s: &Shape, out: &mut Vec) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| flat(s, out)), + s => out.push(s.clone()), + } + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let layer = egui::epaint::ColorMode::Solid(c32(app.session.active().unwrap().doc.layer_color(vectorcraft_doc::NodeId(id)))); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)); + let mut time = 0.0; + let mut outlines = |app: &mut VectorcraftApp, events: Vec, dt: f64| { + time += dt; + let mut out = + ctx.run_ui(egui::RawInput { time: Some(time), screen_rect: Some(screen), events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + let mut shapes = vec![]; + out.shapes.iter().for_each(|c| flat(&c.shape, &mut shapes)); + shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer)).count() + }; + outlines(&mut app, vec![], 0.0); + let at = app.canvas_rect.unwrap().center(); + assert!(outlines(&mut app, vec![egui::Event::PointerMoved(at)], 0.016) > 0, "the art under the resting pointer is highlighted"); + let wheel = |dy: f32| egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + for k in 0..12 { + let before = app.view().unwrap().center; + let shown = outlines(&mut app, vec![wheel(if k % 2 == 0 { 4.0 } else { -4.0 })], 0.016); + assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view"); + assert_eq!(shown, 0, "frame {k}: no hover outline while the view moves"); + } + assert_eq!(outlines(&mut app, vec![], HOVER_STILL_SECS / 2.0), 0, "the view has only just stopped"); + assert!(outlines(&mut app, vec![], HOVER_STILL_SECS) > 0, "still again: the outline comes back"); + // Selected, the rectangle gives the move cursor near its top edge. Scrolled up past the + // pointer, it stays the move cursor until the view stops, then the arrow. + app.session.execute("select.all", &json!({})).unwrap(); + outlines(&mut app, vec![egui::Event::PointerMoved(at - vec2(0.0, 200.0))], 0.016); + let cursor = |app: &VectorcraftApp| app.canvas.hover_cursor.map(|(_, _, c)| c); + assert_eq!(cursor(&app), Some(Cursor::Move)); + for k in 0..12 { + let before = app.view().unwrap().center; + outlines(&mut app, vec![wheel(-80.0)], 0.016); + assert_ne!(app.view().unwrap().center, before, "frame {k}: the wheel scrolls the view"); + assert_eq!(cursor(&app), Some(Cursor::Move), "frame {k}: the cursor stays while the view moves"); + } + // The wheel's smooth scrolling runs on for some frames; then the view rests. + for _ in 0..200 { + let before = app.view().unwrap().center; + outlines(&mut app, vec![], 0.016); + if app.view().unwrap().center == before { + break; + } + } + assert_eq!(cursor(&app), Some(Cursor::Move), "the view has only just stopped"); + outlines(&mut app, vec![], HOVER_STILL_SECS + 0.01); + assert_eq!(cursor(&app), Some(Cursor::Arrow), "still again: the pointer at {:?} is past the rectangle", app.hover_doc); + } + + /// View › Snap to Glyph toggles through the View menu command (#3124). + #[test] + fn snap_to_glyph_menu_command_toggles_the_view_flag() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(!app.ui.view.snap_to_glyph); + app.run("view.snapToGlyph", json!({})).unwrap(); + assert!(app.ui.view.snap_to_glyph); + app.run("view.snapToGlyph", json!({})).unwrap(); + assert!(!app.ui.view.snap_to_glyph); + } + /// Smart Guides › Object Highlighting (#394): the outline of the object under the pointer /// shows with Smart Guides on and the option on, and not otherwise. #[test] @@ -5803,6 +6760,71 @@ mod tests { assert!(area(&app), "a double-click on the widget converts to area type"); } + /// View → Show Live Paint Gaps draws the gaps of each selected Live Paint group in the group's + /// Gap Preview Color, and nothing for a group or path that is not a selected Live Paint group; + /// Hide Live Paint Gaps and Hide Edges hide them. + #[test] + fn show_live_paint_gaps_draws_the_selected_groups_gaps_in_their_preview_color() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Open paths round a square, each ending 2 pt short of its start: one Small Gap each. + let square = |app: &mut VectorcraftApp, x: i64| { + let anchors = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 2)].map(|(dx, y)| json!({"x": x + dx, "y": y})); + app.session.execute("path.create", &json!({ "anchors": anchors })).unwrap()["id"].as_u64().unwrap() + }; + let mut groups = vec![]; + for (x, color) in [(0, "#123456"), (150, "#654321")] { + let p = square(&mut app, x); + app.session.execute("select.set", &json!({"ids": [p]})).unwrap(); + let g = app.session.execute("livePaint.make", &json!({})).unwrap()["id"].as_u64().unwrap(); + app.session.execute("livePaint.gapOptions", &json!({"id": g, "color": color})).unwrap(); + groups.push(g); + } + let plain = square(&mut app, 300); + let ctx = egui::Context::default(); + let gaps = |app: &mut VectorcraftApp, rgb: [u8; 3]| { + let solid = egui::epaint::ColorMode::Solid(c32(rgb)); + shapes(app, &ctx).iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.color == solid && ps.stroke.width == 2.0)).count() + }; + let (first, second) = ([0x12, 0x34, 0x56], [0x65, 0x43, 0x21]); + // The color the plain path's gaps would be drawn in. + let plain_color = vectorcraft_doc::GapOptions::default().color; + app.session.execute("select.set", &json!({"ids": [groups[0], plain]})).unwrap(); + assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 0), "off until the item is chosen"); + // View → Show Live Paint Gaps runs the command. + let tree = crate::menus::menu_tree(); + let (_, view) = tree.iter().find(|(t, _)| *t == "View").unwrap(); + assert!(view.iter().any(|i| matches!(i, crate::menus::Item::Cmd("Show Live Paint Gaps", "view.livePaintGaps", _))), "{view:?}"); + crate::menus::invoke(&mut app, "view.livePaintGaps", json!({})); + assert!(app.ui.view.live_paint_gaps); + assert_eq!( + (gaps(&mut app, first), gaps(&mut app, second), gaps(&mut app, plain_color)), + (1, 0, 0), + "the selected group only; the plain path shows none" + ); + app.session.execute("select.set", &json!({"ids": [groups[1]]})).unwrap(); + assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (0, 1)); + // The lines follow the group's Gap Preview Color and Paint Stops At size. + app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "color": "#123456"})).unwrap(); + assert_eq!((gaps(&mut app, first), gaps(&mut app, second)), (1, 0)); + app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": 1})).unwrap(); + assert_eq!(gaps(&mut app, first), 0, "a 2 pt gap is wider than 1 pt"); + app.session.execute("livePaint.gapOptions", &json!({"id": groups[1], "size": "small"})).unwrap(); + app.ui.view.edges = false; + assert_eq!(gaps(&mut app, first), 0, "Hide Edges hides them"); + app.ui.view.edges = true; + assert_eq!(gaps(&mut app, first), 1); + app.run("view.livePaintGaps", json!({})).unwrap(); + assert!(!app.ui.view.live_paint_gaps); + assert_eq!(gaps(&mut app, first), 0, "View → Hide Live Paint Gaps"); + // The setting is kept with the interface state; a state saved before it existed loads with it off. + app.ui.view.live_paint_gaps = true; + let mut saved = serde_json::to_value(&app.ui).unwrap(); + assert_eq!(saved["view"]["live_paint_gaps"], json!(true)); + saved["view"].as_object_mut().unwrap().remove("live_paint_gaps"); + assert!(!serde_json::from_value::(saved).unwrap().view.live_paint_gaps); + } + /// #511: a star shows a widget in each of its ten corners with Direct Selection (none with /// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them. #[test] @@ -5981,6 +7003,16 @@ mod tests { .collect() } + /// Show Hidden Characters puts a dot over each space of a list paragraph and none over its + /// bullet, which stands for no character. + #[test] + fn hidden_characters_mark_no_space_at_a_bullet() { + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, " Item two", vectorcraft_doc::CharStyle::default()); + let plain = HiddenMarks::of(&t).spaces.len(); + t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot)); + assert_eq!((plain, HiddenMarks::of(&t).spaces.len()), (2, 2)); + } + /// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before. /// Turning it off drops the marks with their text nodes, and turning it on again draws the /// same marks. @@ -6125,6 +7157,102 @@ mod tests { assert_eq!(app.view().unwrap().zoom / before, done, "and it stops"); } + /// In each frame where a wheel scroll moves the view, the hover point (read by the rulers' + /// pointer marks, the Info panel, the hover highlight and the tool cursor) is the document point + /// under the pointer in the view the canvas draws in that frame. + #[test] + fn the_hover_point_follows_a_wheel_scroll_in_the_frame_it_moves() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let at = rect.center() + vec2(60.0, 40.0); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at)]); + let under = |app: &VectorcraftApp| Xf::new(rect, app.view().unwrap()).to_doc(at); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + let mut events = vec![wheel]; + let mut moved = 0; + for k in 0..30 { + let before = app.view().unwrap().center; + frame(&mut app, &ctx, std::mem::take(&mut events)); + moved += usize::from(app.view().unwrap().center != before); + let hover = app.hover_doc.unwrap(); + assert!(hover.distance(under(&app)) < 1e-9, "frame {k}: {hover:?}, under the pointer {:?}", under(&app)); + } + assert!(moved > 0, "the wheel scrolls the view"); + } + + /// While a middle-button pan or a Rotate View drag moves the view, the hover point is the + /// document point under the pointer in the view the canvas draws in that frame. + #[test] + fn the_hover_point_follows_a_view_drag_in_the_frame_it_moves() { + for (tool, button) in [("rectangle", egui::PointerButton::Middle), ("rotateView", egui::PointerButton::Primary)] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.select_tool(tool); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let press = |pos, pressed| egui::Event::PointerButton { pos, button, pressed, modifiers: Default::default() }; + let a = rect.center() + vec2(100.0, 20.0); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); + frame(&mut app, &ctx, vec![press(a, true)]); + let mut p = a; + for k in 1..=3 { + p = a + vec2(-20.0, 30.0) * k as f32; + let before = *app.view().unwrap(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p)]); + let after = *app.view().unwrap(); + assert!(after.center != before.center || after.rotation != before.rotation, "{tool}: the drag moves the view"); + let under = Xf::new(rect, &after).to_doc(p); + let hover = app.hover_doc.unwrap(); + assert!(hover.distance(under) < 1e-9, "{tool}, step {k}: {hover:?}, under the pointer {under:?}"); + } + frame(&mut app, &ctx, vec![press(p, false)]); + } + } + + /// A tool drag goes on at the document point under the pointer in the view a wheel scroll moved + /// to, in the frame the wheel scrolls. + #[test] + fn a_tool_drag_follows_a_wheel_scroll_in_the_frame_it_moves() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // With Zoom with Mouse Wheel on, Shift-wheel scrolls up and down in the frame it comes in. + app.session.execute("prefs.set", &json!({"key": "zoomWithMouseWheel", "value": true})).unwrap(); + app.ui.view.smart_guides = false; + app.select_tool("rectangle"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let rect = app.canvas_rect.unwrap(); + let (a, b) = (rect.center() - vec2(80.0, 60.0), rect.center() + vec2(40.0, 30.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a)]); + frame(&mut app, &ctx, vec![primary(a, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); + let before = *app.view().unwrap(); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -40.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::SHIFT, + }; + frame(&mut app, &ctx, vec![wheel]); + let after = *app.view().unwrap(); + assert!(after.center != before.center, "Shift-wheel scrolls the view"); + let want = Xf::new(rect, &after).to_doc(b); + let bounds = |app: &VectorcraftApp| app.session.active().unwrap().doc.layers[0].children().unwrap()[0].geometric_bounds().unwrap(); + let r = bounds(&app); + assert!((r.x1 - want.x).abs() < 1e-6 && ((r.y0 - want.y).abs() < 1e-6 || (r.y1 - want.y).abs() < 1e-6), "{r:?} reaches {want:?}"); + frame(&mut app, &ctx, vec![primary(b, false)]); + assert_eq!(bounds(&app), r, "the rectangle drawn"); + } + /// Two fingers dragged together on a touch screen pan the canvas with them, under either /// Zoom with Mouse Wheel setting, and draw nothing (#449). #[test] @@ -6895,6 +8023,39 @@ mod tests { assert!((c - xf.to_doc(at)).hypot() < 1e-6, "{c:?}"); } + #[test] + fn a_library_symbol_dropped_on_the_canvas_is_added_and_placed_there_in_one_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + // Dropped on art too, it is placed, not applied to the art. + app.session.execute("shape.rectangle", &json!({"x": 200, "y": 150, "width": 100, "height": 60})).unwrap(); + app.session.execute("select.none", &json!({})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let undo = app.session.active().unwrap().history.undo.len(); + let at = xf.to_screen(Point::new(250.0, 180.0)); + egui::DragAndDrop::set_payload(&ctx, widgets::PanelDrag::LibrarySymbol { library: "basic-shapes".into(), name: "Star".into() }); + let up = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), up]); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Star"]); + assert_eq!(st.history.undo.len(), undo + 1, "added and placed as one undo step"); + assert_eq!(st.history.undo.last().unwrap().label, "Add to Symbols"); + let placed = st.selection.objects.first().copied().unwrap(); + let n = st.doc.node(placed).unwrap(); + assert!(matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Star")); + // The registration point (the centre) is where it was dropped, in document coordinates + // (the pointer's screen position, which rounds 250, 180 a little). + let size = st.doc.unknown.get(vectorcraft_doc::SYMBOL_SIZES).and_then(|m| m.get("Star")).cloned().unwrap(); + let b = n.geometric_bounds().unwrap(); + assert!((b.width() - size[0].as_f64().unwrap()).abs() < 1e-6, "{b:?} {size}"); + assert!((b.center() - xf.to_doc(at)).hypot() < 1e-6 && (b.center() - Point::new(250.0, 180.0)).hypot() < 0.1, "{b:?}"); + app.run("edit.undo", json!({})).unwrap(); + let st = app.session.active().unwrap(); + assert!(st.doc.symbols.is_empty() && st.doc.node(placed).is_none()); + } + #[test] fn a_brush_dropped_on_a_path_is_applied_to_it() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -7044,7 +8205,7 @@ mod tests { } #[test] - fn art_moved_off_the_canvas_becomes_a_panel_drag_and_stays_put() { + fn art_moved_off_the_canvas_waits_and_drops_on_the_panel() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); @@ -7062,23 +8223,64 @@ mod tests { modifiers: Default::default(), }; let start = xf.to_screen(Point::new(100.0, 100.0)); + let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0; + let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0; frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); // Moving on the canvas moves the art... frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); assert!(app.session.active().unwrap().interaction.is_some()); - // ...until the pointer leaves it: the move is dropped and the panels get the art. + // ...over a panel it waits where it was, and the panels get the art. frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); - assert!(app.session.active().unwrap().interaction.is_none()); - assert_eq!(*app.session.active().unwrap().doc, *before, "the art is back where it was"); + assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels"); + assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved"); assert_eq!(egui::DragAndDrop::payload::(&ctx).as_deref(), Some(&widgets::PanelDrag::Art(vec![id]))); - assert!(!app.session.tool_busy()); - // Released anywhere, nothing moves and nothing is recorded. - frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, false)]); + // Released over the panel, the move goes back and nothing is recorded. + frame(&mut app, &ctx, vec![button(pos2(850.0, 100.0), false)]); let st = app.session.active().unwrap(); + assert!(st.interaction.is_none()); assert_eq!(*st.doc, *before); assert_eq!(st.history.undo.len(), undo); } + /// #2826: a move taken over a panel waits where it left the canvas, and goes on with the + /// pointer when it comes back — it isn't dropped or put back. + #[test] + fn art_moved_off_the_canvas_resumes_when_the_pointer_returns() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let undo = app.session.active().unwrap().history.undo.len(); + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + let left = |app: &VectorcraftApp| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().geometric_bounds().unwrap().x0; + let moved_to = |screen: Pos2| 50.0 + xf.to_doc(screen).x - 100.0; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); + assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the move is live"); + // Over a panel the move waits where it last moved, and stays live. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); + assert!(app.session.active().unwrap().interaction.is_some(), "the move waits, not cancels"); + assert!((left(&app) - moved_to(start + vec2(40.0, 0.0))).abs() < 0.01, "the art stays where it last moved"); + // Back on the canvas it goes on with the pointer, 60 pt right. + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(60.0, 0.0))]); + assert!(app.session.active().unwrap().interaction.is_some(), "the move is live again"); + assert!((left(&app) - moved_to(start + vec2(60.0, 0.0))).abs() < 0.01, "the move resumes with the pointer"); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(70.0, 0.0))]); + frame(&mut app, &ctx, vec![button(start + vec2(70.0, 0.0), false)]); + let st = app.session.active().unwrap(); + assert!((left(&app) - moved_to(start + vec2(70.0, 0.0))).abs() < 0.01, "released where the pointer was"); + assert_eq!(st.history.undo.len(), undo + 1, "one move, one undo step"); + } + /// One frame of keyboard handling and canvas, as the app runs them; returns the IME output. fn typing_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Option { let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }; @@ -7101,6 +8303,50 @@ mod tests { } } + /// #2826: the app ends a move released over a panel before the panels draw, so a panel takes + /// the art as it was, and no move is left open. + #[test] + fn a_move_released_over_a_panel_ends_before_the_panels_draw() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let id = app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let undo = app.session.active().unwrap().history.undo.len(); + let button = |pos: Pos2, pressed: bool| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }; + let start = xf.to_screen(Point::new(100.0, 100.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start), button(start, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(start + vec2(40.0, 0.0))]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); + assert!(app.session.active().unwrap().interaction.is_some()); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events: vec![button(pos2(850.0, 100.0), false)], + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + crate::canvas::release_over_panel(&mut app, ui.ctx()); + assert!(app.session.active().unwrap().interaction.is_none(), "the move is ended"); + assert_eq!(*app.session.active().unwrap().doc, *before, "the art is back where it was"); + assert_eq!( + egui::DragAndDrop::payload::(ui.ctx()).as_deref(), + Some(&widgets::PanelDrag::Art(vec![vectorcraft_doc::NodeId(id)])), + "the panel still gets the art" + ); + assert!(ui.ctx().data(|d| d.get_temp::(drag_id())).is_none(), "no drag is left"); + }); + out.textures_delta.clear(); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo); + } + #[test] fn japanese_ime_composes_on_the_canvas_with_its_window_at_the_caret() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -7148,6 +8394,68 @@ mod tests { assert_eq!(plain(&app, id), ""); } + /// Vietnamese Telex via the Windows IME (TSF): the preedit grows digit by digit, then an empty + /// preedit plus a single Commit lands the amount once, not duplicated. + #[test] + fn vietnamese_telex_digits_commit_once() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let id = app.session.active().unwrap().selection.objects[0].0; + typing_frame(&mut app, &ctx, vec![]); + typing_frame(&mut app, &ctx, vec![preedit("3", 1)]); + typing_frame(&mut app, &ctx, vec![preedit("31", 2)]); + typing_frame(&mut app, &ctx, vec![preedit("316", 3)]); + typing_frame(&mut app, &ctx, vec![preedit("3160", 4)]); + typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("3160".into()))]); + assert_eq!(plain(&app, id), "3160"); + assert!(!app.session.tool_composing()); + } + + /// Keyboard-hook IMEs (UniKey/OpenKey) type Vietnamese by injecting VK_PACKET characters and + /// Backspaces, which egui surfaces as interleaved Event::Text and Event::Key(Backspace). When a + /// tone key fires, the Backspaces and the re-injected characters arrive in one frame; the Type + /// tool must apply them in order to build "tiếng" (captured from a real UniKey session), not + /// insert every character first and delete afterwards. + #[test] + fn hook_ime_backspace_and_reinject_builds_vietnamese() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + let id = app.session.active().unwrap().selection.objects[0].0; + typing_frame(&mut app, &ctx, vec![]); + let txt = |s: &str| egui::Event::Text(s.into()); + let bs = || egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + // When a tone key fires, the Backspaces and the re-injected VK_PACKET characters land + // together in one poll (one egui frame). + typing_frame(&mut app, &ctx, vec![txt("t")]); + typing_frame(&mut app, &ctx, vec![txt("i")]); + typing_frame(&mut app, &ctx, vec![txt("e")]); + typing_frame(&mut app, &ctx, vec![bs(), txt("ê")]); // 2nd "e" -> ê + typing_frame(&mut app, &ctx, vec![txt("n")]); + typing_frame(&mut app, &ctx, vec![txt("g")]); + typing_frame(&mut app, &ctx, vec![bs(), bs(), bs(), txt("ế"), txt("n"), txt("g")]); // "s" tone + assert_eq!(plain(&app, id), "tiếng"); + } + /// Clear History waits while an IME composes, as Undo does: the marked text and the steps stay. #[test] fn clear_history_waits_while_the_ime_composes() { @@ -7282,6 +8590,108 @@ mod tests { assert!(moved, "a new document position runs even when egui reports no delta"); } + /// #834 review: a press held still is not a drag. Before the press has dragged, the first held + /// frame without a delta at the press's own position sends nothing, so a click stays a click; + /// the first move after it does. + #[test] + fn stationary_press_is_not_a_drag() { + let ctx = egui::Context::default(); + let rect = egui::Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0)); + let v = View::default(); + let xf = Xf::new(rect, &v); + let a = pos2(100.0, 100.0); + let b = pos2(150.0, 120.0); + let mut out = + ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(a)], ..Default::default() }, |ui| { + ui.data_mut(|d| { + d.remove::(tool_drag_doc_id()); + d.insert_temp(tool_press_doc_id(), xf.to_doc(a)); + }); + }); + out.textures_delta.clear(); + let mut held = true; + out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { + let pointer = ui.input(|i| i.pointer.clone()); + assert_eq!(pointer.delta(), egui::Vec2::ZERO); + held = tool_drag_moved(ui, &xf, a, &pointer); + }); + out.textures_delta.clear(); + assert!(!held, "a press held still at its own position is not sent as a drag"); + let mut moved = false; + out = ctx.run_ui(egui::RawInput { screen_rect: Some(rect), events: vec![egui::Event::PointerMoved(b)], ..Default::default() }, |ui| { + let pointer = ui.input(|i| i.pointer.clone()); + moved = tool_drag_moved(ui, &xf, b, &pointer); + }); + out.textures_delta.clear(); + assert!(moved, "the first move after the press is a drag"); + } + + /// #834: a bezier drag's preview follows every position the pointer is seen at. egui's frame + /// delta is zero in the frame the pointer comes back into the window after leaving it + /// (`PointerGone` forgets where it was), so a drag gated on it kept the preview where the + /// pointer left until the next move; a move in the release's own frame was lost the same way, + /// and the release committed the stale shape. A click's jitter in its release frame stays a + /// click. + #[test] + fn a_bezier_drag_preview_follows_the_pointer_back_into_the_window_and_to_the_release() { + use egui::{Event, Modifiers}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.ui.view.smart_guides = false; + app.ui.view.snap_to_point = false; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + let near = |p: Point, q: Pos2| p.distance(xf.to_doc(q)) < 1e-3; + let first_anchor = |app: &VectorcraftApp| { + let st = app.session.active().unwrap(); + st.doc.node(st.selection.objects[0]).unwrap().path_data().unwrap().subpaths[0].anchors[0] + }; + // The Pen pulls a handle out of a new anchor: the pointer leaves the window mid-drag, comes + // back further on and stops there, then moves once more in the frame it lets go. + app.select_tool("pen"); + let a = app.canvas_rect.unwrap().center() - vec2(100.0, 60.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(a)]); + frame(&mut app, &ctx, vec![button(a, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(40.0, 0.0))]); + assert!(near(first_anchor(&app).h_out, a + vec2(40.0, 0.0)), "{:?}", first_anchor(&app)); + frame(&mut app, &ctx, vec![Event::PointerGone]); + frame(&mut app, &ctx, vec![Event::PointerMoved(a + vec2(80.0, 0.0))]); + assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "back in the window: {:?}", first_anchor(&app)); + frame(&mut app, &ctx, vec![]); + assert!(near(first_anchor(&app).h_out, a + vec2(80.0, 0.0)), "still: {:?}", first_anchor(&app)); + let end = a + vec2(100.0, 20.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); + assert!(near(first_anchor(&app).h_out, end), "released: {:?}", first_anchor(&app)); + assert!(app.session.active().unwrap().interaction.is_none(), "committed"); + // Direct Selection moving that anchor: the same path through the engine's previews. + app.select_tool("directSelection"); + let anchor_at = xf.to_screen(first_anchor(&app).p); + frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at)]); + frame(&mut app, &ctx, vec![button(anchor_at, true)]); + frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(30.0, 30.0))]); + assert!(near(first_anchor(&app).p, anchor_at + vec2(30.0, 30.0)), "{:?}", first_anchor(&app)); + frame(&mut app, &ctx, vec![Event::PointerGone]); + frame(&mut app, &ctx, vec![Event::PointerMoved(anchor_at + vec2(60.0, 30.0))]); + assert!(near(first_anchor(&app).p, anchor_at + vec2(60.0, 30.0)), "back in the window: {:?}", first_anchor(&app)); + let end = anchor_at + vec2(60.0, 50.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(end), button(end, false)]); + assert!(near(first_anchor(&app).p, end), "released: {:?}", first_anchor(&app)); + // A click whose release frame brings a little jitter is still a click: the Pen places a + // corner anchor (no handle pulled out by the jitter) and the anchor stays where pressed. + app.session.execute("select.none", &json!({})).unwrap(); + app.select_tool("pen"); + let c = app.canvas_rect.unwrap().center() + vec2(120.0, 80.0); + frame(&mut app, &ctx, vec![Event::PointerMoved(c)]); + frame(&mut app, &ctx, vec![button(c, true), Event::PointerMoved(c + vec2(0.6, 0.2)), button(c + vec2(0.6, 0.2), false)]); + frame(&mut app, &ctx, vec![]); + // (egui hands the press the frame's last pointer position, so it lands within the jitter.) + let a = first_anchor(&app); + assert!(a.p.distance(xf.to_doc(c)) < 1.0 && a.h_out == a.p && a.h_in == a.p, "corner anchor where pressed: {a:?}"); + assert!(app.session.active().unwrap().interaction.is_none(), "committed"); + } + #[test] fn frame_moves_keeps_touch_samples_and_pressure_but_leaves_mouse_unchanged() { let ctx = egui::Context::default(); @@ -7290,16 +8700,28 @@ mod tests { let c = pos2(30.0, 40.0); let events = vec![egui::Event::PointerMoved(a), egui::Event::PointerMoved(b), egui::Event::PointerMoved(c)]; let mut mouse = vec![]; - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| mouse = frame_moves(ui)); + let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| mouse = frame_moves(ui, None)); out.textures_delta.clear(); assert!(mouse.is_empty()); + // A macOS pen: the feed kept a pressure for each move, the last for the last. + let feed = StylusFeed::default(); + for p in [0.9, 0.3, 0.0, 0.5] { + feed.set_move(crate::stylus::PenSample { pressure: p, eraser: false }); + } + let mut samples = vec![]; + let mut out = ctx.run_ui(egui::RawInput { events: events.clone(), ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed))); + out.textures_delta.clear(); + assert_eq!(samples, [(a, Some(0.3)), (b, None)], "paired from the end; a lift's 0 is no pressure"); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, Some(&feed))); + out.textures_delta.clear(); + assert!(samples.is_empty(), "the moves were taken: a mouse's moves again"); let events = [pen(egui::TouchPhase::Move, a, 0.2), pen(egui::TouchPhase::Move, b, 0.8), pen(egui::TouchPhase::Move, c, 0.6)].concat(); let mut samples = vec![]; - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui)); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None)); out.textures_delta.clear(); assert_eq!(samples, [(a, Some(0.2)), (b, Some(0.8))], "the last sample follows the normal pointer path"); let events = (0..200).flat_map(|n| pen(egui::TouchPhase::Move, pos2(n as f32, 0.0), 0.5)).collect(); - let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui)); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| samples = frame_moves(ui, None)); out.textures_delta.clear(); assert!(samples.len() <= 64, "a stalled frame stays bounded"); } @@ -7331,6 +8753,46 @@ mod tests { assert!(app.session.active().unwrap().doc.art_bounds().is_none(), "releasing cannot commit the cancelled press"); } + /// #2826: a second finger drops a move waiting over a panel, so its interaction doesn't + /// outlive the drag that began it. + #[test] + fn a_second_finger_drops_a_move_waiting_over_a_panel() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.touch_gestures = true; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap(); + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let before = app.session.active().unwrap().doc.clone(); + let start = xf.to_screen(Point::new(100.0, 100.0)); + // One finger presses on the art, moves it, and leaves the canvas for a panel. + frame(&mut app, &ctx, pen(egui::TouchPhase::Start, start, 0.5)); + frame(&mut app, &ctx, pen(egui::TouchPhase::Move, start + vec2(40.0, 0.0), 0.5)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos2(850.0, 100.0))]); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Art))); + assert!(app.session.active().unwrap().interaction.is_some()); + assert!(egui::DragAndDrop::payload::(&ctx).is_some()); + // A second finger lands: the move is dropped, not left live without its pointer. + let second = egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(2), + phase: egui::TouchPhase::Start, + pos: pos2(400.0, 300.0), + force: Some(0.5), + }; + frame(&mut app, &ctx, vec![second]); + assert!(matches!(ctx.data(|d| d.get_temp::(drag_id())), Some(Drag::Gesture))); + let st = app.session.active().unwrap(); + assert!(st.interaction.is_none(), "the move is dropped"); + assert_eq!(*st.doc, *before, "the art is back where it was"); + assert!(egui::DragAndDrop::payload::(&ctx).is_none(), "no panel drag is left"); + // Lifting the first finger commits nothing. + frame(&mut app, &ctx, pen(egui::TouchPhase::End, start + vec2(40.0, 0.0), 0.5)); + assert_eq!(*app.session.active().unwrap().doc, *before); + } + /// The events eframe's winit integration sends for a pen (Windows Ink) or a finger: a touch /// with its pressure, and the mouse it stands in for (#491). fn pen(phase: egui::TouchPhase, pos: Pos2, force: f32) -> Vec { @@ -7381,6 +8843,275 @@ mod tests { assert!(pressures.contains(&0.5) && pressures.contains(&0.8), "{pressures:?}"); } + /// #764: on Linux under X11 a pen reaches egui as a mouse, and the host reads its pressure + /// itself (`Services::stylus`): the Paintbrush and Liquify's Use Pressure Pen get that + /// pressure, and once a mouse moves (`None`) a stroke presses fully. + #[test] + fn the_hosts_pen_pressure_drives_the_tools_under_mouse_events() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let feed = StylusFeed::default(); + app.services.stylus = Some(feed.clone()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + // One frame per sample: the reader has set the pressure before the frame's mouse events. + let stroke = |app: &mut VectorcraftApp, pts: &[(f64, f64, Option)]| { + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let mut at = Pos2::ZERO; + for (i, &(x, y, pressure)) in pts.iter().enumerate() { + feed.set(pressure); + at = xf.to_screen(Point::new(x, y)); + let mut events = vec![egui::Event::PointerMoved(at)]; + if i == 0 { + events.push(button(at, true)); + } + frame(app, &ctx, events); + } + frame(app, &ctx, vec![button(at, false)]); + }; + let pressures = |p: &serde_json::Value| -> Vec { + // A point without a third value was pressed fully. + let pressure = |s: &serde_json::Value| s.get(2).and_then(serde_json::Value::as_f64).map_or(1.0, |f| (f * 100.0).round() / 100.0); + p["points"].as_array().unwrap().iter().map(pressure).collect() + }; + + app.select_tool("paintbrush"); + stroke(&mut app, &[(100.0, 100.0, Some(0.3)), (140.0, 120.0, Some(0.6)), (180.0, 150.0, Some(0.9))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "path.freehand"); + assert_eq!(pressures(&p), [0.3, 0.6, 0.9], "{p}"); + // The lift reaches the feed before the frame of the stroke's last move: that move keeps the + // pressure the pen last touched with, not the lift's 0. + stroke(&mut app, &[(100.0, 300.0, Some(0.3)), (140.0, 320.0, Some(0.5)), (180.0, 350.0, Some(0.0))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "path.freehand"); + assert_eq!(pressures(&p), [0.3, 0.5, 0.5], "{p}"); + + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 200, "height": 200})).unwrap(); + app.select_tool("bloat"); + app.session.set_tool_option("usePressure", &json!(true)); + stroke(&mut app, &[(500.0, 350.0, Some(0.2)), (500.0, 400.0, Some(0.5)), (500.0, 420.0, Some(0.8))]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + let pen = pressures(&p); + assert_eq!(pen.first(), Some(&0.2), "the press's own pressure: {pen:?}"); + assert!(pen.contains(&0.5) && pen.contains(&0.8), "{pen:?}"); + + // A mouse moved last: no pen pressure. + stroke(&mut app, &[(350.0, 350.0, None), (350.0, 400.0, None), (350.0, 420.0, None)]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + assert!(pressures(&p).iter().all(|f| *f == 1.0), "{p}"); + + // A tap whose touch and lift both reach the feed before the frame that handles its press + // presses with the tap's pressure, not the lift's 0. + for p in [0.0, 0.4, 0.7, 0.0] { + feed.set(Some(p)); + } + let at = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(Point::new(450.0, 400.0)); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), button(true)]); + frame(&mut app, &ctx, vec![button(false)]); + let (cmd, p) = app.session.journal.last().unwrap().clone(); + assert_eq!(cmd, "object.liquify"); + assert_eq!(pressures(&p).first(), Some(&0.7), "{p}"); + } + + /// An app with an 800 × 600 document whose host reads the pen itself (`Services::stylus`), + /// after its first frame, with the pointer in the window. + fn pen_app() -> (VectorcraftApp, StylusFeed, egui::Context) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let feed = StylusFeed::default(); + app.services.stylus = Some(feed.clone()); + app.session.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))]); + (app, feed, ctx) + } + + /// The pen turned to its eraser end, with `pressure`. + fn eraser_end(pressure: f32) -> Option { + Some(crate::stylus::PenSample { pressure, eraser: true }) + } + + /// #764: turning the pen to its eraser end selects the Eraser, and turning it back to the tip + /// selects the tool before it, as the reference app's help describes ("Erase parts of a path + /// using a stylus pen"). Each turn acts once. + #[test] + fn the_eraser_end_selects_the_eraser_and_the_tip_brings_back_the_tool() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the tip hovers"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the eraser end hovers"); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the tip brings back the tool before"); + assert_eq!(app.ui.group_tool.get(vectorcraft_tools::catalog::group_of("paintbrush").unwrap()).map(String::as_str), Some("paintbrush")); + + // A turn acts once: a tool that becomes active while the eraser end stays in stays, and + // the tip doesn't replace a tool other than the Eraser. + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.session.select_tool("pencil", app.view_info()).unwrap(); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "pencil"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "pencil"); + } + + /// #764: the eraser end erases what it crosses, also when it comes in and presses in one + /// frame; the tool before it does not paint a stroke. + #[test] + fn the_eraser_end_erases() { + let (mut app, feed, ctx) = pen_app(); + app.session.execute("shape.rectangle", &json!({"x": 300, "y": 200, "width": 100, "height": 100})).unwrap(); + app.select_tool("paintbrush"); + let steps = app.session.journal.len(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let [a, b, c] = [280.0, 350.0, 420.0].map(|x| xf.to_screen(Point::new(x, 250.0))); + feed.set_sample(eraser_end(0.6)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c)]); + frame(&mut app, &ctx, vec![button(c, false)]); + let cmds: Vec<&str> = app.session.journal.since(steps).iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(cmds, ["path.eraseRegion"], "one Eraser step, no Paintbrush stroke"); + assert_eq!(app.session.active().unwrap().doc.layers[0].children().unwrap().len(), 2, "the rectangle cut in two"); + } + + /// #764: a turn of the pen while a press is held takes effect after the release. + #[test] + fn a_flip_during_a_press_waits_for_the_release() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let [a, b, c] = [100.0, 150.0, 200.0].map(|x| xf.to_screen(Point::new(x, 100.0))); + feed.set(Some(0.5)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(a), button(a, true)]); + feed.set_sample(eraser_end(0.5)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(b)]); + assert_eq!(app.session.tool_id(), "paintbrush"); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(c), button(c, false)]); + assert_eq!(app.session.tool_id(), "paintbrush"); + assert_eq!(app.session.journal.last().unwrap().0, "path.freehand", "the stroke ends with the Paintbrush"); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the frame after the release"); + } + + /// #764: a tool chosen while the eraser end stands in (the toolbar and shortcuts call + /// `select_tool`; menus and MCP run `tool.select`) stays when the tip comes back. + #[test] + fn a_tool_chosen_while_the_eraser_end_stands_in_stays() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.select_tool("rectangle"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "rectangle"); + + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + app.run("tool.select", json!({"tool": "eraser"})).unwrap(); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "the Eraser chosen again stays too"); + } + + /// #764: a mouse moving while the eraser end stands in does not change the tool, and an Eraser + /// chosen before the eraser end comes in stays when the tip comes back. + #[test] + fn a_mouse_and_an_explicit_eraser_change_nothing() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(None); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "a mouse"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(None); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "a mouse between the turns"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush"); + + app.select_tool("eraser"); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser", "chosen before the eraser end came in"); + } + + /// #764: the host reads the pen over every window. A turn made while the pointer is outside + /// this window takes effect when the pointer comes in, and a turn there and back does not + /// change the tool. + #[test] + fn a_turn_over_another_window_waits_for_the_pointer() { + let (mut app, feed, ctx) = pen_app(); + app.select_tool("paintbrush"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + let inside = egui::Event::PointerMoved(Pos2::new(400.0, 300.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "paintbrush", "the pointer is in another window"); + frame(&mut app, &ctx, vec![inside.clone()]); + assert_eq!(app.session.tool_id(), "eraser", "the pointer came in"); + + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + frame(&mut app, &ctx, vec![inside.clone()]); + assert_eq!(app.session.tool_id(), "paintbrush"); + + frame(&mut app, &ctx, vec![egui::Event::PointerGone]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![inside]); + assert_eq!(app.session.tool_id(), "paintbrush", "turned there and back outside"); + } + + /// #764: the tip brings back the Perspective Grid tool as it was, with a grid hidden by Hide + /// Grid still hidden (choosing the tool afresh shows the grid). + #[test] + fn the_tip_brings_back_the_perspective_grid_tool_without_showing_the_grid() { + let (mut app, feed, ctx) = pen_app(); + let visible = + |app: &VectorcraftApp| vectorcraft_tools::distort::perspective::PerspectiveGrid::current(&app.session.active().unwrap().doc).visible; + app.select_tool("perspectiveGrid"); + assert!(visible(&app)); + app.session.execute("perspective.grid.show", &json!({"visible": false})).unwrap(); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + feed.set_sample(eraser_end(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "eraser"); + feed.set(Some(0.0)); + frame(&mut app, &ctx, vec![]); + assert_eq!(app.session.tool_id(), "perspectiveGrid"); + assert!(!visible(&app), "the grid stays hidden"); + } + /// #491: a pen tap whose press and lift come in one frame selects what it tapped, and the /// lift (the pointer gone with it) doesn't drag the selection to the canvas's centre. #[test] @@ -7916,6 +9647,203 @@ mod tests { } } + /// Distance Guides on a canvas at 100%: a 400 × 300 pt document with a 50 pt square at (50, 50), + /// selected, and with `other` another 100 pt to its right, the Selection tool and Smart Guides + /// on → the app, its context, the view, the screen point over the middle of where the other + /// square is (or would be) and the selected square's id. + fn distance_scene(other: bool) -> (VectorcraftApp, egui::Context, Xf, Pos2, serde_json::Value) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let a = app.run("shape.rectangle", json!({"x": 50, "y": 50, "width": 50, "height": 50})).unwrap()["id"].clone(); + if other { + app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap(); + } + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.ui.view.smart_guides = true; + app.select_tool("selection"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let view = app.view_mut().unwrap(); + view.zoom = 1.0; + view.rotation = 0.0; + view.center = Point::new(200.0, 150.0); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let over = xf.to_screen(Point::new(225.0, 75.0)); + (app, ctx, xf, over, a) + } + + /// One frame with `events` → the shapes drawn, flattened. The modifiers held stay from one + /// frame to the next until an `Event::ModifiersChanged`. + fn distance_frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { + distance_frame_covered(app, ctx, events, None) + } + + /// What opens over the canvas in [`distance_frame_covered`]. + #[derive(Clone, Copy)] + enum Cover { + /// A floating window (a floating panel, a menu, a popup) over this rectangle. + Panel(egui::Rect), + /// A modal dialog, with the backdrop that keeps the rest of the app from the pointer. + Dialog, + } + + /// [`distance_frame`] with `cover` drawn over the canvas. + fn distance_frame_covered(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec, cover: Option) -> Vec { + let raw = egui::RawInput { screen_rect: Some(WINDOW), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + show(app, ui); + match cover { + Some(Cover::Panel(r)) => { + egui::Area::new(egui::Id::new("test-floating-panel")).order(egui::Order::Foreground).fixed_pos(r.min).show(ui.ctx(), |ui| { + ui.allocate_space(r.size()); + }); + } + Some(Cover::Dialog) => { + crate::dialogs::modal::show(ui.ctx(), "Test", egui::Id::new("test-dialog"), 0.0, 8, |ui| ui.label("A dialog")); + } + None => {} + } + }); + out.textures_delta.clear(); + fn flat(s: Shape, out: &mut Vec) { + match s { + Shape::Vec(v) => v.into_iter().for_each(|s| flat(s, out)), + s => out.push(s), + } + } + let mut v = vec![]; + out.shapes.into_iter().for_each(|c| flat(c.shape, &mut v)); + v + } + + /// The texts drawn in `s`. + fn texts(s: &[Shape]) -> Vec { + s.iter().filter_map(|s| if let Shape::Text(t) = s { Some(t.galley.text().to_string()) } else { None }).collect() + } + + /// The label "100.00 pt" drawn in `s` and the pill of the smart guide colour it sits in, if any. + fn gap_label(s: &[Shape]) -> Option<(egui::Rect, Option)> { + let magenta = c32(vectorcraft_tools::guides::MAGENTA); + let text = + s.iter().find_map(|s| if let Shape::Text(t) = s { (t.galley.text() == "100.00 pt").then(|| t.visual_bounding_rect()) } else { None })?; + let pill = s + .iter() + .find_map(|s| if let Shape::Rect(r) = s { (r.fill == magenta && r.rect.contains(text.center())).then_some(r.rect) } else { None }); + Some((text, pill)) + } + + /// Distance Guides on screen: with a square selected and Alt held over another to its right, + /// the frame draws the measured line between their facing edges with the gap's length in a + /// pill of the smart guide colour on its middle; without Alt, or with Alt let go, nothing. + #[test] + fn alt_over_another_object_draws_the_distance_guide_label() { + use egui::{Event, Modifiers}; + let (mut app, ctx, xf, over, _) = distance_scene(true); + let mut run = |events| distance_frame(&mut app, &ctx, events); + run(vec![Event::PointerMoved(over)]); + assert_eq!(gap_label(&run(vec![])), None, "without Alt"); + run(vec![Event::ModifiersChanged(Modifiers::ALT)]); + let s = run(vec![]); + let (text, pill) = gap_label(&s).expect("the gap's length is drawn"); + let pill = pill.expect("in a pill of the smart guide colour"); + let mid = xf.to_screen(Point::new(150.0, 75.0)); + assert!(pill.center().distance(mid) < 1.0 && pill.contains_rect(text), "{pill:?} on {mid:?}"); + let (from, to) = (xf.to_screen(Point::new(100.0, 75.0)), xf.to_screen(Point::new(200.0, 75.0))); + let magenta = c32(vectorcraft_tools::guides::MAGENTA); + let measured = s.iter().any(|s| matches!(s, Shape::LineSegment { points, stroke } if *points == [from, to] && stroke.color == magenta)); + assert!(measured, "the measured line between the facing edges"); + run(vec![Event::ModifiersChanged(Modifiers::NONE)]); + assert_eq!(gap_label(&run(vec![])), None, "Alt let go"); + } + + /// The Distance Guides go when the pointer stops hovering for the tool with Alt still held: it + /// leaves the canvas for a panel or out of the window, or a press pans (Alt+Space) and takes it + /// over the panels; they come back when it hovers the other square again. + #[test] + fn distance_guides_go_when_the_hover_ends() { + use egui::{Event, Modifiers}; + let (mut app, ctx, xf, over, _) = distance_scene(true); + let outside = pos2(xf.rect.max.x + 10.0, xf.rect.center().y); + assert!(WINDOW.contains(outside) && !xf.rect.contains(outside), "a panel beside the canvas"); + let mut run = |events| distance_frame(&mut app, &ctx, events); + run(vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]); + assert!(gap_label(&run(vec![])).is_some(), "shown over the other square"); + run(vec![Event::PointerMoved(outside)]); + assert_eq!(gap_label(&run(vec![])), None, "gone over a panel, Alt still held"); + run(vec![Event::PointerMoved(over)]); + assert!(gap_label(&run(vec![])).is_some(), "back over the other square"); + run(vec![Event::PointerGone]); + assert_eq!(gap_label(&run(vec![])), None, "gone out of the window"); + // Back over the square, Space held with Alt pans: they go at the press, and stay gone while + // the pan takes the pointer out over a panel. + let space = |pressed| Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::ALT }; + let button = |pos, pressed| Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::ALT }; + run(vec![Event::PointerMoved(over)]); + run(vec![space(true)]); + assert!(gap_label(&run(vec![])).is_some(), "Space held: still shown"); + run(vec![button(over, true)]); + assert_eq!(gap_label(&run(vec![])), None, "gone at the pan's press"); + run(vec![Event::PointerMoved(over + vec2(20.0, 0.0))]); + run(vec![Event::PointerMoved(outside)]); + assert_eq!(gap_label(&run(vec![])), None, "still gone after panning out over a panel"); + run(vec![button(outside, false), space(false)]); + } + + /// With Alt held and the pointer still, the Distance Guides follow what lies under it: they go + /// under a floating panel or a modal dialog and come back when it closes; scrolled so that + /// empty artboard lies under the pointer, they measure to the artboard; when an edit puts an + /// object under the pointer, they measure to it; and the Selection tool switched to shows them + /// at once. + #[test] + fn distance_guides_follow_what_lies_under_a_still_pointer() { + use egui::{Event, Modifiers}; + let (mut app, ctx, _, over, _) = distance_scene(true); + let run = |app: &mut VectorcraftApp, events, cover| distance_frame_covered(app, &ctx, events, cover); + run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)], None); + assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "shown over the other square"); + // A floating panel, then a modal dialog, opens over the still pointer. + for cover in [Cover::Panel(egui::Rect::from_center_size(over, vec2(120.0, 120.0))), Cover::Dialog] { + run(&mut app, vec![], Some(cover)); + run(&mut app, vec![], Some(cover)); + assert_eq!(gap_label(&run(&mut app, vec![], Some(cover))), None, "gone under it"); + // A frame for egui to drop the closed window, one for the hover to come back. + run(&mut app, vec![], None); + run(&mut app, vec![], None); + assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "back once it closes"); + } + // Another tool and back to the Selection tool (its shortcut, the toolbar): a new Selection + // tool, hovered at once. + app.select_tool("rectangle"); + run(&mut app, vec![], None); + assert_eq!(gap_label(&run(&mut app, vec![], None)), None, "the Rectangle tool shows none"); + app.select_tool("selection"); + run(&mut app, vec![], None); + assert!(gap_label(&run(&mut app, vec![], None)).is_some(), "the Selection tool again"); + // The view scrolls 150 pt (the wheel, a gesture, the keys): empty artboard is under the + // pointer, and the selected square is 300 pt from the artboard's right edge. + app.view_mut().unwrap().center.x += 150.0; + run(&mut app, vec![], None); + let s = run(&mut app, vec![], None); + assert_eq!(gap_label(&s), None, "no longer over the other square"); + assert!(texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s)); + + // A square made under the still pointer, the first selected again (an edit, an undo). + let (mut app, ctx, _, over, a) = distance_scene(false); + let run = |app: &mut VectorcraftApp, events| distance_frame(app, &ctx, events); + run(&mut app, vec![Event::ModifiersChanged(Modifiers::ALT), Event::PointerMoved(over)]); + let s = run(&mut app, vec![]); + assert!(gap_label(&s).is_none() && texts(&s).iter().any(|t| t == "300.00 pt"), "to the artboard: {:?}", texts(&s)); + app.run("shape.rectangle", json!({"x": 200, "y": 50, "width": 50, "height": 50})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + run(&mut app, vec![]); + assert!(gap_label(&run(&mut app, vec![])).is_some(), "to the new square"); + // Another document (a new tab) under the still pointer: nothing selected there, none. + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + run(&mut app, vec![]); + assert_eq!(gap_label(&run(&mut app, vec![])), None, "another document"); + } + /// The 800 × 600 window [`frame`] draws the canvas in. const WINDOW: egui::Rect = egui::Rect { min: Pos2::ZERO, max: Pos2 { x: 800.0, y: 600.0 } }; @@ -8062,4 +9990,190 @@ mod tests { assert!((after - want).length() < 0.5, "bar {k}: {before:?} -> {after:?}, want {want:?}"); } } + #[test] + fn live_trace_selection_highlight_excludes_the_retained_source_rectangle() { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, ImageObject, NodeId, TraceView}; + let mut source = Node::new( + NodeId(2), + NodeKind::Image(Box::new(ImageObject { + key: "synthetic".into(), + width: 200, + height: 200, + xf: Affine::IDENTITY, + link: None, + placement: Default::default(), + pixels: None, + })), + ); + source.visible = false; + let art = + Node::path(NodeId(3), vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0)), Appearance::default()); + let mut group = Node::group(NodeId(1), vec![Arc::new(source), Arc::new(art)]); + let doc = vectorcraft_doc::Document::new(300.0, 300.0); + let symbols = doc.symbol_boxes(); + for view in TraceView::ALL { + group.extras_mut().trace = Some(Box::new(json!({"view":view.id()}))); + let mut visited = vec![]; + walk_drawn(&group, &mut |n| visited.push(n.id)); + assert_eq!(visited.contains(&NodeId(2)), view.shows_source()); + let want = if view.shows_source() { + vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0) + } else { + vectorcraft_geom::Rect::new(20.0, 30.0, 80.0, 90.0) + }; + assert_eq!(node_outline(&group, &symbols).bounding_box(), want); + } + group.extras_mut().trace = None; + assert_eq!(node_outline(&group, &symbols).bounding_box(), vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0)); + } + + /// Hovering over point type shows only its baseline (not the transform bounding box), while + /// selecting it shows both the bounding box with handles and the baseline. + #[test] + fn point_type_hover_shows_baseline_only_and_selection_shows_both_box_and_baseline() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); + app.ui.task_bar = false; + app.ui.view.rulers = false; + let made = app.run("text.create", json!({"x": 200, "y": 150, "text": "Lorem ipsum", "size": 36})).unwrap(); + let id = vectorcraft_doc::NodeId(made["id"].as_u64().unwrap()); + app.run("select.none", json!({})).unwrap(); + app.select_tool("selection"); + + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let color = c32(app.session.active().unwrap().doc.layer_color(id)); + let layer_mode = egui::epaint::ColorMode::Solid(color); + + // Hover over the text: hover highlight shows the baseline (1.5 px stroke), NOT a bounding box. + let over = xf.to_screen(Point::new(220.0, 140.0)); + frame(&mut app, &ctx, vec![egui::Event::PointerMoved(over)]); + let hover_shapes = shapes(&mut app, &ctx); + + // There should be a 1.5px path outline for the hover highlight. + let hover_outlines: Vec<_> = + hover_shapes.iter().filter(|s| matches!(s, Shape::Path(ps) if ps.stroke.width == 1.5 && ps.stroke.color == layer_mode)).collect(); + assert!(!hover_outlines.is_empty(), "hover highlight shows baseline"); + + let is_handle = |s: &Shape| matches!(s, Shape::Rect(r) if (r.rect.width() - 6.0).abs() < 1.0 && (r.rect.height() - 6.0).abs() < 1.0 && r.fill == Color32::WHITE); + + // There should be NO bounding box resize handles (size 6x6 white squares) while just hovering. + let handles: Vec<_> = hover_shapes.iter().filter(|s| is_handle(s)).collect(); + assert!(handles.is_empty(), "hovering does not show bounding box handles: {handles:?}"); + + // Now select the text (click): both the bounding box (with handles) and the baseline are shown. + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + frame(&mut app, &ctx, vec![]); + let sel_shapes = shapes(&mut app, &ctx); + + // Bounding box handles (8 handles around the text) should now be present. + let handles: Vec<_> = sel_shapes.iter().filter(|s| is_handle(s)).collect(); + assert_eq!(handles.len(), 8, "selected text shows 8 bounding box handles"); + + // Baseline (Shape::LineSegment at y=150) should also be present. + let y = xf.to_screen(Point::new(0.0, 150.0)).y; + let baselines: Vec<_> = sel_shapes + .iter() + .filter(|s| matches!(s, Shape::LineSegment { points, stroke } if stroke.color == color && (points[0].y - y).abs() < 0.5)) + .collect(); + assert!(!baselines.is_empty(), "selected text shows baseline alongside bounding box"); + } + + /// #2572: Show Text Threads links a thread's frames from the out port of one to the in port of + /// the next, where the tools draw those ports, each with the arrow of a threaded port; nothing + /// is drawn while no frame of the thread is selected. + #[test] + fn text_threads_run_from_each_out_port_to_the_next_in_port() { + use vectorcraft_tools::text::{Port, port_centre}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let text = "Words that fill more than the frame holds. ".repeat(10); + let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone(); + let b = app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap()["id"].clone(); + let frame = |app: &VectorcraftApp, id: &serde_json::Value| match &app + .session + .active() + .unwrap() + .doc + .node(vectorcraft_doc::NodeId(id.as_u64().unwrap())) + .unwrap() + .kind + { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + }; + let zoom = 2.0; + let (out, input) = (port_centre(&frame(&app, &a), Port::Out, 0.5).unwrap(), port_centre(&frame(&app, &b), Port::In, 0.5).unwrap()); + let o = thread_overlays(&app, zoom); + assert!(o.iter().any(|x| matches!(x, Overlay::Line { a, b, .. } if *a == out && *b == input)), "the thread line: {o:?}"); + let boxes: Vec = + o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect(); + assert_eq!(boxes.len(), 2); + assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}"); + // Two arrows: three strokes each, beside the thread line. + assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7); + app.run("select.none", json!({})).unwrap(); + assert!(thread_overlays(&app, zoom).is_empty()); + } + + /// With more than `PORTS_MAX` type objects selected the tools draw no ports, and Show Text + /// Threads draws only its lines, no port boxes. + #[test] + fn text_threads_of_a_huge_selection_have_lines_but_no_ports() { + use vectorcraft_tools::text::PORTS_MAX; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let text = "Words that fill more than the frame holds. ".repeat(10); + let a = app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()["id"].clone(); + app.run("text.thread.add", json!({"from": a, "at": [300, 200]})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 300, "text": "x", "area": {"width": 50, "height": 20}})).unwrap(); + while app.session.active().unwrap().doc.layers[0].children().map_or(0, |c| c.len()) <= PORTS_MAX { + app.run("select.all", json!({})).unwrap(); + app.run("edit.duplicate", json!({})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + let o = thread_overlays(&app, 1.0); + assert!(o.iter().any(|x| matches!(x, Overlay::Line { .. })), "the thread lines"); + assert!(!o.iter().any(|x| matches!(x, Overlay::Highlight { .. } | Overlay::Path { .. })), "no port boxes"); + } + + /// A frame of a thread in perspective has no ports: Show Text Threads draws no port box on it + /// and no line to or from it; the links between frames with ports keep theirs. + #[test] + fn text_threads_skip_the_links_of_a_frame_in_perspective() { + use vectorcraft_tools::text::{Port, port_centre}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 400})).unwrap(); + let text = "Words that fill more than the frame holds. ".repeat(20); + let id = |v: serde_json::Value| vectorcraft_doc::NodeId(v["id"].as_u64().unwrap()); + let a = id(app.run("text.create", json!({"x": 20, "y": 20, "text": text, "area": {"width": 150, "height": 60}})).unwrap()); + let b = id(app.run("text.thread.add", json!({"from": a.0, "at": [200, 20]})).unwrap()); + let c = id(app.run("text.thread.add", json!({"from": b.0, "at": [20, 200]})).unwrap()); + let d = id(app.run("text.thread.add", json!({"from": c.0, "at": [200, 200]})).unwrap()); + app.session + .edit("Perspective", |doc, _| { + if let Some(n) = doc.node_mut(b) { + n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment::new("left", 0.0))); + } + Ok(()) + }) + .unwrap(); + app.run("select.set", json!({"ids": [a.0, b.0, c.0, d.0]})).unwrap(); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.text_threads, [vec![a, b, c, d]], "b is still threaded"); + let frame = |id| match &st.doc.node(id).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + }; + let (out, input) = (port_centre(&frame(c), Port::Out, 1.0).unwrap(), port_centre(&frame(d), Port::In, 1.0).unwrap()); + let o = thread_overlays(&app, 1.0); + let boxes: Vec = + o.iter().filter_map(|x| if let Overlay::Highlight { quad, .. } = x { Some(quad[0].midpoint(quad[2])) } else { None }).collect(); + assert_eq!(boxes.len(), 2, "only c's out port and d's in port: {boxes:?}"); + assert!(boxes[0].distance(out) < 1e-9 && boxes[1].distance(input) < 1e-9, "{boxes:?}"); + // One thread line (c to d) and its two arrows of three strokes each. + assert_eq!(o.iter().filter(|x| matches!(x, Overlay::Line { .. })).count(), 7); + } } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index efd99a045..1f4550473 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -215,6 +215,35 @@ pub fn anchor_buttons(app: &mut VectorcraftApp, ui: &mut Ui, controls: AnchorCon /// The id of the Control bar's isolation button. const ISOLATION_BUTTON: &str = "cb-isolation"; +/// The room the Control bar needs for the Snapping menu next to the Artboard tool's controls; with +/// less, the menu is left out so those controls still fit the narrowest window. +const ARTBOARD_SNAPPING_WIDTH: f32 = 900.0; + +/// The Control bar's snapping quick access; every change uses its existing command. +fn snapping_menu(app: &mut VectorcraftApp, ui: &mut Ui) { + ui.menu_button(tl!("Snapping"), |ui| { + for (label, cmd, on) in [ + (tl!("Smart Guides"), "view.smartGuides", app.ui.view.smart_guides), + (tl!("Snap to Point"), "view.snapToPoint", app.ui.view.snap_to_point), + (tl!("Snap to Grid"), "view.snapToGrid", app.ui.view.snap_to_grid), + (tl!("Snap to Pixel"), "view.snapToPixel", app.ui.view.snap_to_pixel), + ] { + if widgets::check(ui, label, on, true) { + crate::menus::invoke(app, cmd, json!({})); + } + } + ui.separator(); + for (key, label, on) in [ + ("snapTangent", tl!("Snap to Tangent"), app.session.prefs.snap_tangent), + ("snapPerpendicular", tl!("Snap to Perpendicular"), app.session.prefs.snap_perpendicular), + ] { + if widgets::check(ui, label, on, true) { + crate::menus::invoke(app, "prefs.set", json!({"key": key, "value": !on})); + } + } + }); +} + /// The Control bar's isolation button. In isolation mode it is Exit Isolation Mode, except while a /// pattern is edited (the isolation bar's Done and Cancel end that). Otherwise, with one object /// selected that [`vectorcraft_tools::select::isolates`], it is Isolate Selected Object, which @@ -258,10 +287,15 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { for i in 0..5 { ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); } - let Some(st) = app.session.active() else { + if app.session.active().is_none() { ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); return; - }; + } + // The Artboard tool's controls fill the narrowest window, so they keep it to themselves there. + if app.session.tool_id() != "artboard" || ui.available_width() >= ARTBOARD_SNAPPING_WIDTH { + snapping_menu(app, ui); + } + let Some(st) = app.session.active() else { return }; let sel = st.selection.objects.clone(); let units = app.session.general_unit(); let first = sel.first().and_then(|id| st.doc.node(*id)).cloned(); @@ -448,6 +482,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { let mut x = strip.left(); let mut activate = None; let mut close = None; + let mut hovering_art = false; for (i, ((_, galley), w)) in tabs.iter().zip(&widths).enumerate().skip(first) { if x + w > strip.left() + room && Some(i) != active { break; @@ -468,6 +503,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { let xresp = ui.interact(xr.expand(3.0), ui.id().with(("tabx", i)), Sense::click()); icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text }); ui.painter().galley(egui::pos2(title_left, r.center().y - galley.size().y / 2.0), galley.clone(), t.text); + hovering_art |= crate::document_drag::hover_tab(app, &resp, i, xr.expand(3.0)); if xresp.clicked() { close = Some(i); } else if resp.clicked() { @@ -475,6 +511,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) { } x += w; } + crate::document_drag::finish_tabs(ui.ctx(), hovering_art); // The » button: every open document, the active one checked. if overflow { let more = egui::Rect::from_min_max(egui::pos2(strip.right() - MORE, strip.top()), egui::pos2(strip.right(), strip.bottom() - 1.0)); @@ -798,7 +835,8 @@ fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { for (id, width, text) in [ ("render", render_width, format!("{render} {:.1} ms", app.perf.render_ms)), ("ui", ui_width, format!("{ui_name} {:.1} ms", app.perf.frame_ms)), - ("fps", fps_width, format!("{:.0} fps", app.perf.fps)), + // No rate until the window has drawn continuously for a moment (`frame_rate`). + ("fps", fps_width, if app.perf.fps > 0.0 { format!("{:.0} fps", app.perf.fps) } else { "– fps".into() }), ] { let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(width, area.height())); status_field(ui, id, rect, text, proportional.clone(), t.text_disabled); @@ -816,7 +854,8 @@ fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { let inspecting = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| coord_rect.contains(p)) && ui.rect_contains_pointer(coord_rect); let point = app.hover_doc.or_else(|| inspecting.then(|| ui.data(|data| data.get_temp::(point_id))).flatten()); if let Some(p) = point { - for (id, x, value) in [("X", left, p.x), ("Y", left + axis_width, p.y)] { + let origin = app.ruler_origin(); + for (id, x, value) in [("X", left, p.x - origin.x), ("Y", left + axis_width, p.y - origin.y)] { let rect = egui::Rect::from_min_size(egui::pos2(x, area.top()), vec2(axis_width, area.height())); status_field(ui, id, rect, format!("{id}: {}", unit.readout(value)), mono.clone(), t.text_dim); } @@ -830,6 +869,7 @@ fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { .filter(|j| !j.label.is_empty() && !j.cancelled()) .map(|j| j.label.as_str()) .chain(app.opening.pending.iter().map(|p| p.label.as_str())) + .chain(app.placing.pending.iter().map(|p| p.label.as_str())) .collect(); // Reserve the spinner's space while idle too: starting a background job changes the text, // not its origin. Messages take priority over optional readouts in a narrow window. @@ -971,7 +1011,14 @@ fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { (" to sample a color", false), ], "gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)], - "artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)], + "artboard" => &[ + ("Click", true), + (" to select an artboard | ", false), + ("Drag", true), + (" on the canvas to create one (", false), + ("Shift", true), + (" selects several, or draws one inside an artboard)", false), + ], "width" => &[ ("Drag", true), (" a stroke to add a width point | ", false), @@ -1615,6 +1662,110 @@ mod tests { .collect() } + /// Read the painted coordinates while checking that drawing never changes the source + /// position, document, selection or either branch of its edit history. + fn assert_status_coordinates(app: &mut crate::VectorcraftApp, expected: Option<&str>) { + let hover = app.hover_doc; + let before = + app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = status_texts(app, &ctx, vec![]); + let coordinates: Vec<&str> = + texts.iter().filter_map(|(text, _)| (text.starts_with("X: ") || text.starts_with("Y: ")).then_some(text.as_str())).collect(); + let expected: Vec<&str> = expected.into_iter().flat_map(|text| text.split(" ")).collect(); + assert_eq!(coordinates, expected, "{texts:?}"); + assert_eq!(app.hover_doc, hover, "tools still receive the absolute document position"); + let after = + app.session.active().map(|st| (st.doc.clone(), st.selection.clone(), st.history.undo.len(), st.history.redo.len(), st.is_dirty())); + assert_eq!(after, before, "painting is read-only"); + } + + #[test] + fn status_coordinates_follow_global_ruler_origins_and_general_units() { + use vectorcraft_geom::Point; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("view.globalRulers", json!({"global": true})).unwrap(); + app.session.active_mut().unwrap().mark_saved(); + app.hover_doc = Some(Point::new(72.0, 144.0)); + assert_status_coordinates(&mut app, Some("X: 72.00 pt Y: 144.00 pt")); + app.run("document.setRulerOrigin", json!({"x": 36, "y": 72})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 36.00 pt Y: 72.00 pt")); + app.run("document.setRulerOrigin", json!({"x": -72, "y": 216})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 144.00 pt Y: -72.00 pt")); + app.run("document.setUnits", json!({"units": "Inches"})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 2.00 in Y: -1.00 in")); + app.run("document.setUnits", json!({"units": "Millimeters"})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 50.80 mm Y: -25.40 mm")); + app.run("document.resetRulerOrigin", json!({})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 25.40 mm Y: 50.80 mm")); + // Keep a nonempty Redo branch as well as Undo while painting. + app.run("edit.undo", json!({})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 50.80 mm Y: -25.40 mm")); + } + + #[test] + fn status_coordinates_follow_the_active_artboard_and_ruler_mode() { + use vectorcraft_geom::Point; + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("artboard.new", json!({"x": 700, "y": -250, "width": 200, "height": 150})).unwrap(); + app.hover_doc = Some(Point::new(760.0, -210.0)); + app.run("document.setRulerOrigin", json!({"artboard": 0, "x": 25, "y": 30})).unwrap(); + app.run("view.goToArtboard", json!({"index": 0})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 735.00 pt Y: -240.00 pt")); + app.run("view.goToArtboard", json!({"index": 1})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 60.00 pt Y: 40.00 pt")); + app.run("document.setRulerOrigin", json!({"artboard": 1, "x": 800, "y": -190})).unwrap(); + assert_status_coordinates(&mut app, Some("X: -40.00 pt Y: -20.00 pt")); + app.run("view.globalRulers", json!({"global": true})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 760.00 pt Y: -210.00 pt")); + app.run("document.setRulerOrigin", json!({"x": -20, "y": -10})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 780.00 pt Y: -200.00 pt")); + app.run("view.globalRulers", json!({"global": false})).unwrap(); + assert_status_coordinates(&mut app, Some("X: -40.00 pt Y: -20.00 pt")); + app.run("document.resetRulerOrigin", json!({"artboard": 1})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 60.00 pt Y: 40.00 pt")); + app.run("view.goToArtboard", json!({"index": 0})).unwrap(); + assert_status_coordinates(&mut app, Some("X: 735.00 pt Y: -240.00 pt")); + } + + #[test] + fn status_coordinates_handle_missing_pointer_or_document() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + assert_status_coordinates(&mut app, None); + app.hover_doc = Some(vectorcraft_geom::Point::new(42.0, -21.0)); + assert_status_coordinates(&mut app, Some("X: 42.00 pt Y: -21.00 pt")); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("document.setRulerOrigin", json!({"x": 10, "y": 20})).unwrap(); + app.hover_doc = None; + assert_status_coordinates(&mut app, None); + } + + #[test] + fn status_coordinates_keep_the_inspected_point_relative_after_an_origin_change() { + let (mut app, ctx) = status_readout_app(); + app.run("view.globalRulers", json!({"global": true})).unwrap(); + app.run("document.setRulerOrigin", json!({"x": 36, "y": 72})).unwrap(); + app.hover_doc = Some(vectorcraft_geom::Point::new(72.0, 144.0)); + let first = status_readout_frame(&mut app, &ctx, 1400.0); + let at = status_text(&first, "X:").bounds.center(); + app.hover_doc = None; + let inspecting = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerMoved(at)]); + assert_eq!(status_text(&inspecting, "X:").text, "X: 36.00 pt"); + assert_eq!(status_text(&inspecting, "Y:").text, "Y: 72.00 pt"); + app.run("document.setRulerOrigin", json!({"x": -72, "y": 216})).unwrap(); + let before = app.session.active().unwrap().doc.clone(); + let inspecting = status_readout_frame(&mut app, &ctx, 1400.0); + assert_eq!(status_text(&inspecting, "X:").text, "X: 144.00 pt"); + assert_eq!(status_text(&inspecting, "Y:").text, "Y: -72.00 pt"); + assert_eq!(app.hover_doc, None, "inspection never changes the absolute canvas hover state"); + assert_eq!(app.session.active().unwrap().doc, before, "painting is read-only"); + let gone = status_readout_frame_events(&mut app, &ctx, 1400.0, vec![egui::Event::PointerGone]); + assert!(!gone.iter().any(|field| field.text.starts_with("X:") || field.text.starts_with("Y:"))); + } + /// A click at `at` in the status bar; returns the texts of the frame after it. fn status_click(app: &mut crate::VectorcraftApp, ctx: &egui::Context, at: egui::Pos2) -> Vec<(String, egui::Rect)> { let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 20ff212a0..a99b6ee6e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -162,6 +162,9 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "background": app.background.jobs.iter().filter(|j| !j.label.is_empty()).map(|j| j.label.as_str()).collect::>(), // Documents still being read off the UI thread (File › Open, Open Recent): their paths. "opening": app.opening.pending.iter().map(|p| p.path.as_str()).collect::>(), + // Files still being read off the UI thread for Place (the Place dialog's OK, drops): their + // names, in the order they are placed. + "placing": app.placing.pending.iter().map(|p| p.name.as_str()).collect::>(), // The screen color sampler is shown, waiting for the person at the computer // (`ui.sampleScreenColor`). "screenSampler": app.screen_sampler.is_some(), diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 59dd138cd..178a05116 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -409,7 +409,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) { /// Every cursor with a glyph: its own, drawn here, or the one it borrows. #[cfg(test)] -const GLYPHS: [Cursor; 76] = [ +const GLYPHS: [Cursor; 78] = [ Cursor::Hand, Cursor::HandGrab, Cursor::ZoomIn, @@ -463,7 +463,9 @@ const GLYPHS: [Cursor; 76] = [ Cursor::Lasso, Cursor::MagicWand, Cursor::Pencil, + Cursor::PencilClose, Cursor::Paintbrush, + Cursor::PaintbrushClose, Cursor::BlobBrush, Cursor::Eraser, Cursor::Smooth, @@ -510,6 +512,12 @@ fn glyph(c: Cursor, p: Pos2) -> Option> { double_arrow(ink, p + vec2(15.0, 18.0), vec2(1.0, 0.0)); } Cursor::Crosshair => crosshair(ink, p), + // The crosshair the Pencil and the Paintbrush borrow, with the PenClose ring below right. + Cursor::PencilClose | Cursor::PaintbrushClose => { + crosshair(ink, p); + ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(3.0, HALO)); + ink.circle_stroke(p + vec2(8.0, 8.0), 3.0, Stroke::new(1.2, INK)); + } Cursor::Eyedropper => eyedropper(ink, p), Cursor::ResizeH => double_arrow(ink, p, vec2(1.0, 0.0)), Cursor::ResizeV => double_arrow(ink, p, vec2(0.0, 1.0)), @@ -848,6 +856,39 @@ mod tests { } } + /// The Pencil's and the Paintbrush's closing cursors are the crosshair those tools show, with a + /// ring below right. + #[test] + fn the_closing_cursors_are_the_crosshair_with_a_ring() { + for ppp in [1.0, 2.0] { + let cross = rasterize(Cursor::Crosshair, ppp).unwrap(); + for c in [Cursor::PencilClose, Cursor::PaintbrushClose] { + let img = rasterize(c, ppp).unwrap(); + assert_eq!(img.hotspot, cross.hotspot, "{c:?} at {ppp}"); + let [hx, hy] = img.hotspot.map(usize::from); + let at = |img: &CustomCursorImage, dx: f32, dy: f32| { + let (x, y) = ((hx as f32 + dx * ppp) as usize, (hy as f32 + dy * ppp) as usize); + if x < img.size[0] as usize && y < img.size[1] as usize { pixel(img, x, y) } else { [0; 4] } + }; + // Black pixels in the square 4 to 12 points right of and below the hotspot, clear of the arms. + let ring_ink = |img: &CustomCursorImage| { + let steps = (8.0 * ppp) as usize; + (0..steps * steps) + .filter(|i| { + let p = at(img, 4.0 + (i % steps) as f32 / ppp, 4.0 + (i / steps) as f32 / ppp); + p[3] > 200 && p[0] < 64 + }) + .count() + }; + assert!(ring_ink(&img) > 0, "{c:?} has its ring at {ppp}"); + assert_eq!(ring_ink(&cross), 0, "the crosshair has no ring at {ppp}"); + assert_eq!(at(&img, 8.0, 8.0)[3], 0, "{c:?}'s ring is hollow at {ppp}"); + // The arms are the crosshair's. + assert_eq!(at(&img, -6.0, 0.0), at(&cross, -6.0, 0.0), "{c:?} at {ppp}"); + } + } + } + #[test] fn each_bitmap_is_made_once_so_the_os_cursor_is_kept() { let mut images = Images::default(); diff --git a/crates/ui-egui/src/dialogs/action_set_options.rs b/crates/ui-egui/src/dialogs/action_set_options.rs new file mode 100644 index 000000000..1312f80d5 --- /dev/null +++ b/crates/ui-egui/src/dialogs/action_set_options.rs @@ -0,0 +1,63 @@ +//! Actions panel › Create New Set (and ≡ › New Set…) and ≡ › Set Options…: the name of a new action +//! set, or of the current one. +//! +//! Fields: `set` (the index of the set renamed; none for a new set) and `name`. OK adds the set and +//! makes it the current one, or renames the set. + +use serde_json::{Value, json}; + +use super::DialogSpec; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of New Set and Set Options. +pub const KIND: &str = "actionSetOptions"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading, body, confirm, ok_enabled: Some(|_, d| !d.str("name").trim().is_empty()), min_width: 300.0, ..DialogSpec::FORM }; + +fn heading(d: &Dialog) -> String { + if d.fields.contains_key("set") { tl!("Set Options").into() } else { tl!("New Set").into() } +} + +/// Open New Set, named "Set N" with the first number no set uses. +pub fn open_new(app: &mut VectorcraftApp) { + let taken = |n: usize| app.ui.action_sets.iter().any(|s| s.name == format!("Set {n}")); + let n = (1..).find(|&n| !taken(n)).unwrap_or(1); + app.ui.dialog = Some(Dialog::new(KIND, json!({"name": format!("Set {n}")}))); +} + +/// Open Set Options for the set at `set`, if there is one. +pub fn open(app: &mut VectorcraftApp, set: usize) { + if let Some(s) = app.ui.action_sets.get(set) { + app.ui.dialog = Some(Dialog::new(KIND, json!({"set": set, "name": s.name}))); + } +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + widgets::label_row(ui, "Name:", 50.0, |ui| { + super::form::text(ui, d, "name", 200.0); + }); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let name = d.str("name").trim().to_string(); + // OK is disabled without a name; `ui.dialog.confirm` keeps the dialog open the same way. + if name.is_empty() { + return Ok(Value::Null); + } + match d.fields.get("set").and_then(Value::as_u64) { + Some(set) => { + if let Some(s) = app.ui.action_sets.get_mut(set as usize) { + s.name = name; + } + } + None => { + app.ui.action_sets.push(crate::panels::actions::ActionSet { name, actions: vec![] }); + app.ui.action_set = Some(app.ui.action_sets.len() - 1); + } + } + app.ui.dialog = None; + Ok(Value::Null) +} diff --git a/crates/ui-egui/src/dialogs/artboard_options.rs b/crates/ui-egui/src/dialogs/artboard_options.rs index b99d7bafc..e599e174d 100644 --- a/crates/ui-egui/src/dialogs/artboard_options.rs +++ b/crates/ui-egui/src/dialogs/artboard_options.rs @@ -1,13 +1,25 @@ //! Artboard Options (double-click with the Artboard tool, double-click the tool, or the Artboards -//! panel's options icon or menu): the artboard's fields, sizes parsed with units, and its position -//! from a reference point. A new size also goes to the other selected artboards. +//! panel's options icon or menu): the artboard's fields, sizes parsed with units, its position +//! from a reference point, its background color (`color`: a color as the paint commands take +//! it, or null for none), Show Center Mark and Show Cross Hairs (`showCenterMark`, +//! `showCrossHairs`), the display marks the canvas draws on the artboard +//! (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/modify-display-settings-of-artboards.html), +//! and its video display options, Show Video Safe Areas and Video Ruler Pixel Aspect Ratio +//! (`showVideoSafeAreas`, `videoRulerPixelAspectRatio`) +//! (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). +//! Sizing choices also go to the other selected artboards; fitting changes only the active +//! artboard around stationary artwork. use serde_json::{Value, json}; +use vectorcraft_color::Color; use vectorcraft_doc::Unit; +use vectorcraft_engine::cmd::color_value; use vectorcraft_geom::{Point, Rect}; use super::{DialogSpec, form, run_and_close}; use crate::VectorcraftApp; +use crate::panels::artboards::{FILLS, fill_colors, fill_name}; +use crate::panels::properties::ArtboardSizeChoice; use crate::panels::transform::{artboard_rect, reference_index, reference_locator}; use crate::state::Dialog; @@ -25,11 +37,30 @@ fn placed(d: &Dialog) -> Rect { /// share, and X and Y are where its point lies on the rulers, counted from the ruler origin (#1212), /// as the Transform panel and the Control bar show an artboard's. A new Width or Height keeps the /// reference point where it was, as their sizes do. +/// Then Background Color: the Fill list (None, White, Black; Custom for another color) and a swatch +/// that picks a color of its own, the Show Center Mark, Show Cross Hairs and Show Video Safe Areas +/// checkboxes, and the Video Ruler Pixel Aspect Ratio field. fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (unit, origin, refi) = (app.session.general_unit(), app.ruler_origin(), reference_index(ui)); let (was, w0, h0) = (placed(d), d.f64("width", 0.0), d.f64("height", 0.0)); + let bounds = [fit_bounds(app, ui.ctx(), false), fit_bounds(app, ui.ctx(), true)]; + let (choice, fit) = crate::panels::properties::artboard_size_controls_with_fit(app, ui, w0, h0, bounds.map(|b| b.is_some())); + let picked = choice.is_some(); + if let Some(choice) = choice { + if let Some((w, h)) = choice.size_for(w0, h0) { + set_rect(d, artboard_rect(was, refi, None, None, Some(w), Some(h), false)); + } + remember_size_choice(d, choice); + d.fields.insert("__refx".into(), json!(refi % 3)); + d.fields.insert("__refy".into(), json!(refi / 3)); + d.fields.remove("__fit"); + } + ui.add_space(8.0); form::grid_then(ui, d, unit, |ui, d| { let (w, h) = (d.f64("width", 0.0), d.f64("height", 0.0)); + if !picked && (w != w0 || h != h0) { + d.fields.remove("__sizeChoice"); + } let n = artboard_rect(was, refi, None, None, Some(w), Some(h), false); // Only the side that changed moves, so an untouched coordinate stays exactly as it was. The // reference point's column or row is kept for the other selected artboards (`confirm`). @@ -41,9 +72,104 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } position_rows(ui, d, refi, unit, origin); }); + // Apply a Fit after the ordinary size/reference-point grid: that grid must not replace + // the chosen artwork's position with one derived from the previous artboard rectangle. + if let Some(selection) = fit + && let Some(bounds) = bounds[usize::from(selection)] + { + set_rect(d, bounds); + d.fields.insert("__fit".into(), json!(true)); + d.fields.remove("__sizeChoice"); + } + ui.add_space(8.0); + let color = d.fields.get("color").and_then(color_value); + let fills = fill_colors(app); + let mut set = None; + ui.horizontal(|ui| { + form::row_label(ui, tl!("Background Color:")); + if let Some(k) = crate::widgets::dropdown(ui, "ab-options-fill", fill_name(app, color), &FILLS, 110.0) { + set = fills.get(k).copied(); + } + let [r, g, b, _] = color.unwrap_or(Color::WHITE).to_rgba8(1.0); + let mut rgb = [r, g, b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + set = Some(Some(Color::rgb(f32::from(rgb[0]) / 255.0, f32::from(rgb[1]) / 255.0, f32::from(rgb[2]) / 255.0))); + } + }); + if let Some(c) = set { + d.fields.insert("color".into(), c.as_ref().map_or(Value::Null, crate::panels::color_json)); + } + ui.add_space(8.0); + form::check(ui, d, "showCenterMark", tl!("Show Center Mark")); + form::check(ui, d, "showCrossHairs", tl!("Show Cross Hairs")); + form::check(ui, d, "showVideoSafeAreas", tl!("Show Video Safe Areas")); + ui.horizontal(|ui| { + form::row_label(ui, tl!("Video Ruler Pixel Aspect Ratio:")); + let range = vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE; + let now = d.f64(PIXEL_ASPECT, 1.0); + if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", PIXEL_ASPECT), now, range, "", 4, 60.0) { + d.fields.insert(PIXEL_ASPECT.into(), json!(v)); + } + }); false } +/// The field of the Video Ruler Pixel Aspect Ratio, as `artboard.setProps` takes it. +pub(super) const PIXEL_ASPECT: &str = "videoRulerPixelAspectRatio"; + +/// Store document-coordinate draft bounds, retaining an editable minimum for zero-area art. +fn set_rect(d: &mut Dialog, r: Rect) { + for (key, value) in [("x", r.x0), ("y", r.y0), ("width", r.width().max(1.0)), ("height", r.height().max(1.0))] { + d.fields.insert(key.into(), json!(value)); + } +} + +/// Preserve preset/orientation intent separately from typed dimensions for the selected set. +fn remember_size_choice(d: &mut Dialog, choice: ArtboardSizeChoice) { + let value = match choice { + ArtboardSizeChoice::Preset(w, h) => json!({"preset": [w, h]}), + ArtboardSizeChoice::Orientation(landscape) => json!({"landscape": landscape}), + }; + d.fields.insert("__sizeChoice".into(), value); +} + +/// Validate stored sizing intent before deriving the other selected artboards' dimensions. +fn size_choice(d: &Dialog) -> Option { + let value = d.fields.get("__sizeChoice")?; + if let Some(landscape) = value.get("landscape").and_then(Value::as_bool) { + return Some(ArtboardSizeChoice::Orientation(landscape)); + } + let preset = value.get("preset")?.as_array()?; + let (w, h) = (preset.first()?.as_f64()?, preset.get(1)?.as_f64()?); + (w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0).then_some(ArtboardSizeChoice::Preset(w, h)) +} + +#[derive(Clone)] +struct ArtworkFitBounds { + key: (u64, u64), + bounds: Option, +} + +/// Artwork fitting includes all drawn artwork, even outside every artboard; selection uses +/// the shared selection cache. Draft/target geometry never restricts membership. Global artwork +/// bounds are cached only for the active document and its revision. +fn fit_bounds(app: &mut VectorcraftApp, ctx: &egui::Context, selection: bool) -> Option { + if selection { + return app.selection_bounds(); + } + let st = app.session.active()?; + let key = (st.uid, st.revision); + let id = egui::Id::new("artboard-options-artwork-fit-bounds"); + if let Some(cached) = ctx.data_mut(|data| data.get_temp::(id)) + && cached.key == key + { + return cached.bounds; + } + let bounds = vectorcraft_render::encode::art_bounds(&st.doc); + ctx.data_mut(|data| data.insert_temp(id, ArtworkFitBounds { key, bounds })); + bounds +} + /// The X and Y rows: where reference point `refi` lies, counted from the ruler origin `origin`, /// with the locator before X's label. A typed X or Y moves the artboard so that its reference point /// lands there. @@ -78,32 +204,43 @@ fn position_rows(ui: &mut egui::Ui, d: &mut Dialog, refi: usize, unit: Unit, ori /// The dialog kind of Artboard Options. pub const KIND: &str = "artboardOptions"; -/// Open Artboard Options for the active artboard: the Artboard tool's while it is in use, else the -/// window's. -pub fn open(app: &mut VectorcraftApp) -> Result { - let index = if app.session.tool_id() == "artboard" { +/// The active artboard's index: the Artboard tool's while it is in use, else the window's. +pub(crate) fn active_index(app: &VectorcraftApp) -> usize { + if app.session.tool_id() == "artboard" { app.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(0) } else { app.view().map_or(0, |v| v.artboard) - }; + } +} + +/// Open Artboard Options for the active artboard ([`active_index`]). +pub fn open(app: &mut VectorcraftApp) -> Result { + let index = active_index(app); let st = app.session.active().ok_or("no document")?; let a = st.doc.artboards.get(index).ok_or("no artboard")?; let r = a.rect; - let fields = json!({"index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height()}); + let color = a.color.as_ref().map(crate::panels::color_json); + let fields = json!({ + "index": index, "name": a.name, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "color": color, + "showCenterMark": a.show_center_mark, "showCrossHairs": a.show_cross_hairs, + "showVideoSafeAreas": a.show_video_safe_areas, PIXEL_ASPECT: a.video_ruler_pixel_aspect_ratio, + }); app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(json!({ "dialog": KIND })) } -/// OK sets the artboard's fields. A Width or Height that differs from the artboard's also goes to -/// the other selected artboards (when this one is among them), each resized about the reference +/// OK sets the artboard's fields. A preset or orientation choice applies separately to each +/// selected artboard; a typed Width or Height that differs also goes to the others (when this +/// one is among them), each resized about the reference /// point the form's last Width or Height change used (fields `__refx` and `__refy`, its column and -/// row; the top-left corner when the form set none), all in one undo step. +/// row; the top-left corner when the form set none), all in one undo step. Fitting applies only +/// to this artboard and never moves its artwork. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if d.bool("new") { return confirm_new(app, d); } let mut p = Value::Object(d.fields.clone()); - for k in ["x", "y", "width", "height"] { + for k in ["x", "y", "width", "height", PIXEL_ASPECT] { if d.fields.contains_key(k) { p[k] = json!(d.f64(k, 0.0)); } @@ -117,17 +254,28 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let (w, h) = (changed("width", was.map(|r| r.width())), changed("height", was.map(|r| r.height()))); let part = |k: &str| d.fields.get(k).and_then(Value::as_u64).map_or(0, |v| v.min(2) as usize); let refi = part("__refy") * 3 + part("__refx"); + let choice = size_choice(d); let mut entries = vec![]; - if w.is_some() || h.is_some() { + if !d.bool("__fit") && (choice.is_some() || w.is_some() || h.is_some()) { for i in crate::panels::artboards::resized_with(app, index).into_iter().filter(|i| *i != index) { if let Some(r) = app.session.active().and_then(|st| st.doc.artboards.get(i)).map(|a| a.rect) { + let (w, h) = if let Some(choice) = choice { + let Some((w, h)) = choice.size_for(r.width(), r.height()) else { continue }; + (Some(w), Some(h)) + } else { + (w, h) + }; let n = artboard_rect(r, refi, None, None, w, h, false); entries.push(json!({"index": i, "x": n.x0, "y": n.y0, "width": n.width(), "height": n.height()})); } } } entries.insert(0, p); - let common = json!({"moveArt": crate::panels::artboards::move_art(app)}); + // Fitting changes the artboard around existing art; it must neither move nor scale that art. + let common = json!({ + "scaleArt": !d.bool("__fit") && crate::panels::artboards::scale_art(app), + "moveArt": !d.bool("__fit") && crate::panels::artboards::move_art(app), + }); let p = crate::panels::artboards::set_props_params(common, entries).ok_or("no artboard")?; run_and_close(app, "artboard.setProps", p) } @@ -148,7 +296,8 @@ pub fn open_new(app: &mut VectorcraftApp) -> Result { } /// OK for a new artboard: `artboard.new` with the fields (an empty name gives the default one), -/// and the new artboard becomes the active one. +/// the background color picked, the display marks ticked and the Video Ruler Pixel Aspect Ratio +/// typed, and the new artboard becomes the active one. fn confirm_new(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut p = json!({}); for k in ["x", "y", "width", "height"] { @@ -158,6 +307,17 @@ fn confirm_new(app: &mut VectorcraftApp, d: &Dialog) -> Result { if !name.trim().is_empty() { p["name"] = json!(name); } + if let Some(c) = d.fields.get("color") { + p["color"] = c.clone(); + } + for k in ["showCenterMark", "showCrossHairs", "showVideoSafeAreas"] { + if d.bool(k) { + p[k] = json!(true); + } + } + if d.fields.contains_key(PIXEL_ASPECT) { + p[PIXEL_ASPECT] = json!(d.f64(PIXEL_ASPECT, 1.0)); + } let r = run_and_close(app, "artboard.new", p)?; if let Some(i) = r["index"].as_u64().and_then(|i| usize::try_from(i).ok()) { crate::panels::artboards::select(app, i); diff --git a/crates/ui-egui/src/dialogs/blend_options.rs b/crates/ui-egui/src/dialogs/blend_options.rs index 4e139ff53..9ab6e3249 100644 --- a/crates/ui-egui/src/dialogs/blend_options.rs +++ b/crates/ui-egui/src/dialogs/blend_options.rs @@ -76,8 +76,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { m => { let steps = d.f64("steps", 5.0); ui.add_enabled_ui(m == "steps", |ui| { - if let Some(n) = widgets::plain_field(ui, "blend-steps", steps, "", 0, 72.0) { - d.fields.insert("steps".into(), json!(n.round().clamp(1.0, 1000.0))); + if let Some(n) = widgets::range_field(ui, "blend-steps", steps, 1.0..=1000.0, "", 0, 72.0) { + d.fields.insert("steps".into(), json!(n)); } }); } diff --git a/crates/ui-egui/src/dialogs/brush_art_options.rs b/crates/ui-egui/src/dialogs/brush_art_options.rs index 43e747d8b..29a664679 100644 --- a/crates/ui-egui/src/dialogs/brush_art_options.rs +++ b/crates/ui-egui/src/dialogs/brush_art_options.rs @@ -1,10 +1,12 @@ //! Scatter, Art and Pattern Brush Options: the [`super::brush_options`] of the brushes made of //! art. Each has the brush's own values, Colorization (Method, and Key Color for Hue Shift, picked -//! from the art's colours) and a live preview of a stroke. +//! from the art's colors), a Tips button beside Method that opens Colorization Tips (what each +//! method does, beside a sample of the brush painted with it) and a live preview of a stroke. //! //! Fields (besides `__brush`, `__type` and `name`): `__def` (the brush definition, for the preview), //! `__colors` (the art's colours, most used first), `colorization` (`none` | `tints` | -//! `tintsAndShades` | `hueShift`), `keyColor` (a colour). +//! `tintsAndShades` | `hueShift`), `keyColor` (a color), `tips` (whether Colorization Tips is +//! open). //! Scatter: per value (`size`, `spacing`, `scatter` %, `rotation` °) `…Min`, `…Max` and `…Mode` //! (`fixed` | `random` | `pressure`), `rotationRelativeTo` (`page` | `path`). //! Art: `width` (%), `scaleMode` (`proportional` | `stretch` | `betweenGuides`), `guideStart` and @@ -14,14 +16,16 @@ //! `flipAcross`, `__tiles` (slot → preview key, for the tiles the brush has). use std::hash::{Hash, Hasher}; +use std::sync::Arc; use egui::{Color32, Sense, Stroke, pos2, vec2}; use serde_json::{Map, Value, json}; use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization, Direction, PatternBrush, PatternFit, Scatter, Variation}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::{Document, Node}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; use super::brush_options::{clamped, mode_key, stroke_preview}; +use crate::VectorcraftApp; use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets; @@ -45,6 +49,15 @@ const LABEL_W: f32 = 132.0; /// Width of the stroke preview. const PREVIEW_W: f32 = 420.0; +/// The color of the stroke the Colorization Tips samples are painted on. +pub(super) const TIP_STROKE: Color = Color::Rgb { r: 0.18, g: 0.44, b: 0.86 }; + +/// Size of a Colorization Tips sample. +const TIP_SAMPLE: egui::Vec2 = vec2(120.0, 44.0); + +/// Width of the text beside a Colorization Tips sample. +const TIP_TEXT_W: f32 = 250.0; + pub(super) fn heading(ty: &str) -> &'static str { match ty { "scatter" => tl!("Scatter Brush Options"), @@ -187,22 +200,27 @@ pub(super) fn params(d: &Dialog) -> Value { // ---------- body ---------- -pub(super) fn body(ui: &mut egui::Ui, d: &mut Dialog) { +pub(super) fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { match d.str("__type").as_str() { "scatter" => scatter_body(ui, d), "art" => art_body(ui, d), _ => pattern_body(ui, d), } ui.add_space(6.0); - colorization_body(ui, d); + colorization_body(app, ui, d); ui.add_space(10.0); // The brush as OK would make it, on a sample stroke. + let ty = d.str("__type"); + stroke_preview(ui, &ty, edited_def(d), vec2(PREVIEW_W, 64.0)); +} + +/// The brush definition as OK would make it: `__def` with the fields' params. +pub(super) fn edited_def(d: &Dialog) -> Value { let mut def = d.fields.get("__def").cloned().unwrap_or_default(); if let (Some(o), Value::Object(p)) = (def.as_object_mut(), params(d)) { o.extend(p); } - let ty = d.str("__type"); - stroke_preview(ui, &ty, def, vec2(PREVIEW_W, 64.0)); + def } /// A whole-number field (`suffix`: % or °) bound to `d.fields[key]`, kept within `range`. @@ -214,6 +232,13 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, range: (f64, f64), suffi }); } +/// A row labeled `label` with a slider and a whole-number field (%) bound to `d.fields[key]`, both +/// kept within `range`; screen readers call the slider `label`. +fn slider_row(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, range: (f64, f64)) { + let rail = Tokens::get(ui.ctx()).input_border; + widgets::label_row(ui, label, LABEL_W, |ui| super::form::slider_field_named(ui, d, key, label, range.0..=range.1, "%", &|_| rail)); +} + /// A row of radio buttons bound to the string field `key`: (value, label). fn radios(ui: &mut egui::Ui, d: &mut Dialog, key: &str, options: &[(&str, &str)]) { let cur = d.str(key); @@ -260,7 +285,7 @@ fn scatter_body(ui: &mut egui::Ui, d: &mut Dialog) { } fn art_body(ui: &mut egui::Ui, d: &mut Dialog) { - widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| number(ui, d, "width", ArtBrush::WIDTH_RANGE, "%", true)); + slider_row(ui, d, "width", tl!("Width:"), ArtBrush::WIDTH_RANGE); ui.add_space(4.0); widgets::section_header(ui, tl!("Brush Scale Options")); let scales = [ @@ -318,7 +343,7 @@ fn arrow_button(ui: &mut egui::Ui, dir: Direction, selected: bool, tip: &str) -> } fn pattern_body(ui: &mut egui::Ui, d: &mut Dialog) { - widgets::label_row(ui, tl!("Scale:"), LABEL_W, |ui| number(ui, d, "scale", PatternBrush::SCALE_RANGE, "%", true)); + slider_row(ui, d, "scale", tl!("Scale:"), PatternBrush::SCALE_RANGE); widgets::label_row(ui, tl!("Spacing:"), LABEL_W, |ui| number(ui, d, "spacing", PatternBrush::SPACING_RANGE, "%", true)); ui.add_space(6.0); tiles(ui, d); @@ -369,6 +394,74 @@ fn tiles(ui: &mut egui::Ui, d: &Dialog) { }); } +/// Colorization Tips: for each method, a sample of the brush as OK would make it, painted with +/// that method on a [`TIP_STROKE`] stroke (Hue Shift with the dialog's Key Color), beside the +/// method's name and what it does. Screen readers call the popover "Colorization Tips" and each +/// sample by its method's name. +fn tips(ui: &mut egui::Ui, d: &Dialog) { + let t = Tokens::get(ui.ctx()); + ui.ctx().accesskit_node_builder(ui.unique_id(), |node| { + node.set_role(egui::accesskit::Role::Dialog); + node.set_label(tl!("Colorization Tips")); + }); + ui.label(egui::RichText::new(tl!("Colorization Tips")).strong().color(t.text_strong)); + ui.label(egui::RichText::new(tl!("The samples show this brush on a blue stroke.")).color(t.text_dim)); + ui.add_space(4.0); + let names = [tl!("None"), tl!("Tints"), tl!("Tints and Shades"), tl!("Hue Shift")]; + let about = [ + tl!("The art keeps its own colors."), + tl!("Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness."), + tl!("Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it."), + tl!("The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness."), + ]; + let (def, ty, key) = (edited_def(d), d.str("__type"), key_color(d)); + for ((id, name), about) in Colorization::IDS.into_iter().zip(names).zip(about) { + let Some(method) = Colorization::parse(id, key) else { continue }; + ui.horizontal(|ui| { + tip_sample(ui, &def, &ty, method, name); + ui.vertical(|ui| { + ui.set_width(TIP_TEXT_W); + ui.label(egui::RichText::new(name).strong().color(t.text_strong)); + ui.label(egui::RichText::new(about).color(t.text)); + }); + }); + ui.add_space(4.0); + } +} + +/// A Colorization Tips sample: [`tip_doc`] on white, which screen readers call `name`. +fn tip_sample(ui: &mut egui::Ui, def: &Value, ty: &str, method: Colorization, name: &str) { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(TIP_SAMPLE, Sense::hover()); + ui.painter().rect_filled(r, 0.0, Color32::WHITE); + let key = format!("brush-tip:{}:{def}", json!(method)); + if let Some(tex) = widgets::doc_preview(ui, &key, r.size(), |w, h| tip_doc(def, ty, method, w, h)) { + ui.painter().image(tex.id(), r, egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Image, true, name)); +} + +/// A document of `w` × `h` showing brush definition `def` (of type `ty`) with colorization +/// `method`, drawn by [`vectorcraft_brush::stroke_pieces`] along a gentle S-curve stroked +/// [`TIP_STROKE`] 1 pt wide, and scaled to fit, centered. +pub(super) fn tip_doc(def: &Value, ty: &str, method: Colorization, w: f64, h: f64) -> Option { + let mut def = match def { + Value::Object(o) => o.clone(), + _ => Map::new(), + }; + def.insert("name".into(), json!("tip")); + def.insert("type".into(), json!(ty)); + def.insert("colorization".into(), serde_json::to_value(method).ok()?); + let brush: Brush = serde_json::from_value(Value::Object(def)).ok()?; + let ap = Appearance::basic(Paint::None, Paint::solid(TIP_STROKE), 1.0); + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((0.0, 20.0)); + bp.curve_to((35.0, 6.0), (65.0, 34.0), (100.0, 20.0)); + let pieces = vectorcraft_brush::stroke_pieces(&Document::new(w, h), &brush, &bp, ap.stroke()?); + tile_doc(&Node::group(NodeId(0), pieces.into_iter().map(Arc::new).collect()), w, h) +} + /// A document of `w` × `h` showing `art` scaled to fit, centred. fn tile_doc(art: &Node, w: f64, h: f64) -> Option { let b = art.visual_bounds().or_else(|| art.geometric_bounds())?; @@ -387,7 +480,15 @@ fn tile_doc(art: &Node, w: f64, h: f64) -> Option { Some(doc) } -fn colorization_body(ui: &mut egui::Ui, d: &mut Dialog) { +/// Key Color eyedropper: sample the next click on the art into this dialog's `keyColor`. +fn key_color_eyedropper(app: &mut VectorcraftApp) { + let back = app.session.tool_id(); + app.select_tool("eyedropper"); + app.session.set_tool_option("brushKeyColor", &json!(true)); + app.session.set_tool_option("stop", &json!(back)); +} + +fn colorization_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { widgets::section_header(ui, tl!("Colorization")); let methods = [tl!("None"), tl!("Tints"), tl!("Tints and Shades"), tl!("Hue Shift")]; let cur = d.str("colorization"); @@ -398,6 +499,14 @@ fn colorization_body(ui: &mut egui::Ui, d: &mut Dialog) { { d.fields.insert("colorization".into(), json!(id)); } + // Tips opens and closes Colorization Tips; Escape and a click outside it close it too. + ui.add_space(6.0); + let button = widgets::flat_button(ui, tl!("Tips"), 64.0); + let mut open = d.bool("tips") != button.clicked(); + egui::Popup::from_response(&button).open_bool(&mut open).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| tips(ui, d)); + if open != d.bool("tips") { + d.fields.insert("tips".into(), json!(open)); + } }); // Hue Shift's key colour: the art colour that becomes the stroke colour. let colors: Vec = d.fields.get("__colors").and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); @@ -413,6 +522,19 @@ fn colorization_body(ui: &mut egui::Ui, d: &mut Dialog) { d.fields.insert("keyColor".into(), serde_json::to_value(c).unwrap_or_default()); } } + if widgets::icon_button(ui, "pipette", tl!("Eyedropper: click the art to sample the key color"), false, 22.0).clicked() { + key_color_eyedropper(app); + } }); }); } + +/// Apply a sampled colour to the open Brush Options dialog (`brushKeyColor` tool request). +pub(super) fn apply_key_color_sample(app: &mut VectorcraftApp, color: &Value) { + let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == super::brush_options::KIND) else { return }; + if d.str("colorization") != "hueShift" { + return; + } + let Some(c) = vectorcraft_engine::cmd::color_value(color) else { return }; + d.fields.insert("keyColor".into(), serde_json::to_value(c).unwrap_or_default()); +} diff --git a/crates/ui-egui/src/dialogs/brush_options.rs b/crates/ui-egui/src/dialogs/brush_options.rs index 354bbb358..3d6dc863c 100644 --- a/crates/ui-egui/src/dialogs/brush_options.rs +++ b/crates/ui-egui/src/dialogs/brush_options.rs @@ -1,9 +1,10 @@ //! Brush Options (double-click a brush in the Brushes panel, or the panel menu's Brush Options…). //! Calligraphic Brush Options: Name, then Angle, Roundness and Size, each Fixed, Random or //! Pressure with its Variation, over live previews of the nib and of a stroke (light, heavy, light -//! pressure). Bristle Brush Options: Name, Shape and the bristle sliders. Scatter, Art and Pattern -//! Brush Options: see [`super::brush_art_options`]. OK runs `brush.options` (strokes painted with -//! the brush update); a name another brush has keeps the dialog open. +//! pressure). Bristle Brush Options: Name (typing stops at [`Bristle::MAX_NAME_CHARS`] +//! characters), Shape and the bristle sliders. Scatter, Art and Pattern Brush Options: see +//! [`super::brush_art_options`]. OK runs `brush.options` (strokes painted with the brush update); +//! a name another brush has, or a new bristle brush name that is too long, keeps the dialog open. //! //! Fields: `__brush` (the brush's name), `__type` (`calligraphic` | `bristle` | `scatter` | `art` | //! `pattern`), `name`. @@ -11,6 +12,10 @@ //! `angleMode`, `roundnessMode`, `sizeMode` (`fixed` | `random` | `pressure`) and //! `angleVariation`, `roundnessVariation`, `sizeVariation`. Bristle: `shape` (`roundPoint`…), //! `size` (pt), `length` (25..300 %), `density`, `thickness`, `opacity`, `stiffness` (1..100 %). +//! +//! New Brush opens the dialog for a brush that does not exist yet ([`open_new`]), with `__create` +//! and, for dropped art, `__ids`: OK runs `brush.new` instead, which gives a name another brush has +//! a number, and Cancel adds nothing. use egui::{Color32, Sense, Stroke, pos2, vec2}; use serde_json::{Map, Value, json}; @@ -25,7 +30,13 @@ use crate::{VectorcraftApp, widgets}; /// The dialog kind of Brush Options. pub const KIND: &str = "brushOptions"; -pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, min_width: 440.0, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm: ok, min_width: 440.0, ..DialogSpec::FORM }; + +/// The field of a Brush Options dialog that New Brush opened. +const CREATE: &str = "__create"; + +/// The field of such a dialog holding the ids of the art dropped on the Brushes panel. +const IDS: &str = "__ids"; /// Calligraphic values: (field, label, suffix, decimals), in [`Calligraphic::RANGES`] order. const CALLI: [(&str, &str, &str, usize); 3] = [("angle", "Angle:", "°", 0), ("roundness", "Roundness:", "%", 0), ("size", "Size:", " pt", 1)]; @@ -84,8 +95,15 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result Result, String> { + let mut f = Map::new(); f.insert("name".into(), json!(b.name)); f.insert("__type".into(), json!(b.kind.type_id())); match &b.kind { @@ -105,10 +123,9 @@ pub fn open(app: &mut VectorcraftApp, name: Option<&str>) -> Result brush_art_options::fields(&b, def, &mut f), + BrushKind::Scatter(_) | BrushKind::Art(_) | BrushKind::Pattern(_) => brush_art_options::fields(b, def, &mut f), } - app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f }); - Ok(Value::Null) + Ok(f) } /// Can Brush Options open without a name (the selected path's brush, else the current one, exists)? @@ -124,11 +141,72 @@ fn target(app: &VectorcraftApp) -> Option { selected.or_else(|| vectorcraft_brush::current(&st.doc)) } +/// Open the Brush Options of a new brush of type `ty` (`calligraphic` | `scatter` | `art` | +/// `bristle` | `pattern`), the second step of New Brush. The fields start from the type's default +/// values and the name "New Brush", numbered when another brush has it. A scatter or art +/// brush takes the art `brush.selectionArt` gives for `ids` (else for the selection) as its art, +/// and a pattern brush takes it as its side tile; the preview draws it. Without that art nothing +/// opens. +pub(super) fn open_new(app: &mut VectorcraftApp, ty: &str, ids: Option) -> Result { + let st = app.session.active().ok_or("no document open")?; + let title: String = ty.chars().take(1).flat_map(char::to_uppercase).chain(ty.chars().skip(1)).collect(); + let name = vectorcraft_brush::unique_name(&vectorcraft_brush::library(&st.doc), &format!("New {title} Brush")); + let mut def = json!({ "name": name, "type": ty }); + let art_key = match ty { + "scatter" | "art" => Some("art"), + "pattern" => Some("side"), + _ => None, + }; + if let Some(key) = art_key { + let mut r = app.run("brush.selectionArt", ids.clone().map_or_else(|| json!({}), |ids| json!({ "ids": ids })))?; + let art = r["art"].take(); + if art.is_null() { + return Err("nothing selected".into()); + } + def[key] = art; + } + let mut b: Brush = serde_json::from_value(def).map_err(|e| e.to_string())?; + b.sanitize(); + let def = serde_json::to_value(&b).map_err(|e| e.to_string())?; + let mut f = fields(&b, def)?; + f.insert(CREATE.into(), json!(true)); + if let Some(ids) = ids { + f.insert(IDS.into(), ids); + } + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields: f }); + Ok(Value::Null) +} + +/// OK of a new brush's Brush Options: `brush.new` with the type, the name, the values set and the +/// dropped art's ids. The dialog closes once the brush is made. +fn create(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut p = json!({ "type": d.str("__type"), "name": d.str("name").trim(), "params": params(d) }); + if let Some(ids) = d.fields.get(IDS) { + p["ids"] = ids.clone(); + } + let r = app.run("brush.new", p); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +/// OK: make the new brush when New Brush opened the dialog, else edit the brush. +fn ok(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.bool(CREATE) { create(app, d) } else { confirm(app, d) } +} + /// The value of field `key` within `range`. pub(super) fn clamped(d: &Dialog, key: &str, (lo, hi): (f64, f64)) -> f64 { d.f64(key, lo).clamp(lo, hi) } +/// Is `d` Scatter, Art or Pattern Brush Options with Colorization Tips open? Escape then closes +/// Tips, not the dialog. +pub(crate) fn tips_open(d: &Dialog) -> bool { + d.kind == KIND && d.bool("tips") && !matches!(d.str("__type").as_str(), "bristle" | "calligraphic") +} + /// The Calligraphic brush the fields describe. fn calligraphic(d: &Dialog) -> Calligraphic { let value = |i: usize| CALLI.get(i).zip(Calligraphic::RANGES.get(i)).map_or(0.0, |((key, ..), r)| clamped(d, key, *r)); @@ -156,14 +234,15 @@ fn params(d: &Dialog) -> Value { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { grid(ui, |ui| { label(ui, tl!("Name:")); - form::text(ui, d, "name", 240.0); + let max = if d.str("__type") == "bristle" { Bristle::MAX_NAME_CHARS } else { usize::MAX }; + form::text_edit_limited(ui, d, "name", 240.0, max); ui.end_row(); }); ui.add_space(10.0); match d.str("__type").as_str() { "bristle" => bristle_body(app, ui, d), "calligraphic" => calligraphic_body(ui, d), - _ => brush_art_options::body(ui, d), + _ => brush_art_options::body(app, ui, d), } false } @@ -215,11 +294,15 @@ fn bristle_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { } /// A stroke painted with the brush the fields describe (type `ty`, definition fields `def`). -pub(super) fn stroke_preview(ui: &mut egui::Ui, ty: &str, mut def: Value, size: egui::Vec2) { - def["name"] = json!("preview"); - def["type"] = json!(ty); +pub(super) fn stroke_preview(ui: &mut egui::Ui, ty: &str, def: Value, size: egui::Vec2) { + let mut def = match def { + Value::Object(o) => o, + _ => Map::new(), + }; + def.insert("name".into(), json!("preview")); + def.insert("type".into(), json!(ty)); let (r, _) = ui.allocate_exact_size(size, Sense::hover()); - crate::panels::brushes::chip(ui, r, &def); + crate::panels::brushes::chip(ui, r, &Value::Object(def)); ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, Tokens::get(ui.ctx()).input_border), egui::StrokeKind::Inside); } @@ -263,7 +346,8 @@ fn nib_preview(ui: &mut egui::Ui, c: &Calligraphic, size: egui::Vec2) { p.rect_stroke(r, 0.0, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); } -/// OK: edit the brush (and rename it). A name another brush has keeps the dialog open. +/// OK: edit the brush (and rename it). A name another brush has, or a new bristle brush name that +/// is too long, keeps the dialog open. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let name = d.str("name").trim().to_string(); let r = app.run("brush.options", json!({ "name": d.str("__brush"), "newName": name, "params": params(d) })); @@ -345,6 +429,36 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// Bristle Brush Options' Name field takes at most 31 characters as they are typed; the + /// Calligraphic Brush Options' Name field takes more. + #[test] + fn bristle_brush_options_name_field_takes_at_most_31_characters() { + let mut app = app(); + let typed = "é".repeat(40); + for (brush, kept) in [("Bristle Round", 31), ("6 pt. Flat", 40)] { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| super::super::show(app, ui.ctx())); + out.textures_delta.clear(); + }; + app.run("ui.brushOptions", json!({ "name": brush })).unwrap(); + // The Name field takes the focus with its text selected; typing replaces it. + frame(&mut app, vec![]); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text(typed.clone())]); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "é".repeat(kept), "{brush}"); + super::super::confirm(&mut app).unwrap(); + assert!(app.run("brush.get", json!({ "name": "é".repeat(kept) })).is_ok(), "{brush}"); + } + // A new name of more than 31 characters in the field (a longer saved name with some of its + // characters deleted) is refused, and the dialog stays open. + app.run("ui.brushOptions", json!({ "name": "é".repeat(31) })).unwrap(); + set(&mut app, json!({ "name": "é".repeat(35) })); + assert!(super::super::confirm(&mut app).is_err()); + assert!(app.ui.dialog.is_some()); + } + /// A path painted with brush `name`, and the height of the art its stroke draws. fn brushed(app: &mut VectorcraftApp, name: &str) -> (vectorcraft_doc::NodeId, f64) { let id = app.run("shape.line", json!({"x1": 20, "y1": 100, "x2": 280, "y2": 100})).unwrap()["id"].as_u64().unwrap(); @@ -407,6 +521,25 @@ mod tests { assert_eq!((d.str("sizeMode"), d.str("spacingMode"), d.str("colorization")), ("fixed".into(), "pressure".into(), "hueShift".into())); } + #[test] + fn brush_key_color_eyedropper_updates_the_open_dialog() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app(); + app.run("paint.setFill", json!({"color": "#336699"})).unwrap(); + app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap(); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + set(&mut app, json!({"colorization": "hueShift"})); + let back = app.session.tool_id(); + app.select_tool("eyedropper"); + app.session.set_tool_option("brushKeyColor", &json!(true)); + app.session.set_tool_option("stop", &json!(back)); + let view = app.view_info(); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Down, 80.0, 70.0), view); + let key = app.ui.dialog.as_ref().unwrap().fields.get("keyColor").cloned().unwrap(); + assert_eq!(vectorcraft_engine::cmd::color_value(&key).unwrap().to_hex(), "#336699"); + assert_eq!(app.session.tool_id(), back); + } + #[test] fn art_options_set_width_scale_direction_flips_and_colorization() { let mut app = app(); @@ -453,6 +586,161 @@ mod tests { assert_eq!(app.run("brush.get", json!({"name": "Arrow"})).unwrap()["scale"], json!({"mode": "stretch"})); } + /// A context with the app's fonts and theme, for [`frame`]. + fn ctx() -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + ctx + } + + /// Runs one 1200 × 900 frame with `events` through the app's key handling and the dialogs, and + /// returns the center of the text `label` if the frame shows it. + fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec, label: &str) -> Option { + use egui::{Pos2, Rect}; + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + crate::shortcuts::handle(app, ui.ctx()); + super::super::show(app, ui.ctx()); + }); + out.textures_delta.clear(); + out.shapes.iter().find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.text() == label => Some(egui::Rect::from_min_size(t.pos, t.galley.size()).center()), + _ => None, + }) + } + + /// Clicks the text `label` the open dialog shows. + fn click_text(ctx: &egui::Context, app: &mut VectorcraftApp, label: &str) { + use egui::{Event, PointerButton}; + frame(ctx, app, vec![], label); + let at = frame(ctx, app, vec![], label).unwrap_or_else(|| panic!("no {label:?} shown")); + let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(ctx, app, vec![Event::PointerMoved(at), button(true)], label); + frame(ctx, app, vec![button(false)], label); + frame(ctx, app, vec![], label); + } + + /// Presses Escape. + fn escape(ctx: &egui::Context, app: &mut VectorcraftApp) { + let key = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + frame(ctx, app, vec![key], ""); + frame(ctx, app, vec![], ""); + } + + /// Scatter, Art and Pattern Brush Options have a Tips button beside Method. A click on it opens + /// Colorization Tips, which names each method and what it does beside a sample of the brush, + /// and a second click closes it. Screen readers name the button, the popover and each sample. + #[test] + fn tips_names_each_colorization_method_beside_a_sample() { + use crate::widgets::accessibility_tests::{role_and_state, tree}; + use egui::accesskit::Role; + let mut app = app(); + for name in ["Confetti", "Arrow", "Chain"] { + app.run("ui.brushOptions", json!({ "name": name })).unwrap(); + let text = shown(&mut app); + assert!(text.lines().any(|l| l == "Tips") && !text.contains("Colorization Tips"), "{name}: {text}"); + } + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + let ctx = ctx(); + click_text(&ctx, &mut app, "Tips"); + assert!(app.ui.dialog.as_ref().unwrap().bool("tips"), "Tips opens Colorization Tips"); + let text = shown(&mut app); + for label in [ + "Colorization Tips", + "The samples show this brush on a blue stroke.", + "None", + "The art keeps its own colors.", + "Tints", + "Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness.", + "Tints and Shades", + "Hue Shift", + "The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness.", + "Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it.", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + let nodes = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert_eq!(role_and_state(&nodes, "Tips").0, Role::Button); + assert_eq!(role_and_state(&nodes, "Colorization Tips").0, Role::Dialog); + for method in ["None", "Tints", "Tints and Shades", "Hue Shift"] { + assert!(nodes.iter().any(|n| n.role() == Role::Image && n.label() == Some(method)), "a sample named {method}"); + } + click_text(&ctx, &mut app, "Tips"); + assert!(!app.ui.dialog.as_ref().unwrap().bool("tips"), "a second click closes it"); + assert!(!shown(&mut app).contains("Colorization Tips")); + // `ui.dialog.set {field: "tips", value: true}` opens it too. + set(&mut app, json!({"tips": true})); + assert!(shown(&mut app).contains("Colorization Tips")); + } + + /// Escape with Colorization Tips or the Method list open closes only that: Brush Options stays + /// open with its edits, and the next Escape cancels it. + #[test] + fn escape_closes_tips_or_the_method_list_and_leaves_the_dialog_open() { + let (mut app, ctx) = (app(), ctx()); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + set(&mut app, json!({"colorization": "tints"})); + click_text(&ctx, &mut app, "Tips"); + assert!(app.ui.dialog.as_ref().unwrap().bool("tips")); + escape(&ctx, &mut app); + let d = app.ui.dialog.as_ref().expect("Escape closes Tips, not Brush Options"); + assert!(!d.bool("tips") && d.str("colorization") == "tints"); + assert!(frame(&ctx, &mut app, vec![], "Colorization Tips").is_none()); + click_text(&ctx, &mut app, "Tints"); + assert!(egui::Popup::is_any_open(&ctx), "the Method list is open"); + escape(&ctx, &mut app); + assert!(app.ui.dialog.as_ref().is_some_and(|d| d.str("colorization") == "tints"), "Escape closes the list, not Brush Options"); + assert!(!egui::Popup::is_any_open(&ctx)); + escape(&ctx, &mut app); + assert!(app.ui.dialog.is_none(), "the next Escape cancels Brush Options"); + // Calligraphic Brush Options has no Tips: with `tips` set, Escape still cancels it. + app.run("ui.brushOptions", json!({"name": "6 pt. Flat"})).unwrap(); + set(&mut app, json!({"tips": true})); + escape(&ctx, &mut app); + assert!(app.ui.dialog.is_none(), "Escape cancels Calligraphic Brush Options"); + } + + /// Each Colorization Tips sample paints the brush with its own method on the blue stroke: + /// Confetti's black and gray art keeps its colors with None, its black becomes the stroke color + /// with Tints and with Hue Shift (black is its key color), and stays black with Tints and Shades. + #[test] + fn each_tips_sample_paints_the_brush_with_its_method() { + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + let key: vectorcraft_color::Color = serde_json::from_value(d.fields["keyColor"].clone()).unwrap(); + let colors = |id: &str| -> Vec { + let method = vectorcraft_brush::Colorization::parse(id, key).unwrap(); + let doc = brush_art_options::tip_doc(&brush_art_options::edited_def(d), "scatter", method, 120.0, 44.0).unwrap(); + let mut hex: Vec = vectorcraft_brush::art_colors(doc.layers.iter().map(|l| l.as_ref())).iter().map(|c| c.to_hex()).collect(); + hex.sort(); + hex + }; + let (black, blue) = ("#000000".to_string(), brush_art_options::TIP_STROKE.to_hex()); + let confetti = vectorcraft_brush::defaults().iter().find(|b| b.name == "Confetti").unwrap(); + let art: Vec = vectorcraft_brush::art_colors(confetti.art()).iter().map(|c| c.to_hex()).collect(); + let none = colors("none"); + assert!(none.len() == 2 && art.iter().all(|c| none.contains(c)), "None keeps the art's colors {art:?}: {none:?}"); + let (tints, shades, hue) = (colors("tints"), colors("tintsAndShades"), colors("hueShift")); + assert!(tints.contains(&blue) && !tints.contains(&black), "{tints:?}"); + assert!(shades.contains(&black) && !shades.contains(&blue), "{shades:?}"); + assert!(hue.contains(&blue) && !hue.contains(&black), "{hue:?}"); + assert!(tints != hue && tints != shades && none != shades, "four different samples"); + } + + /// `ui.dialog.set` writes any field, `__def` included. With a brush definition that is not + /// an object, Brush Options and Colorization Tips still draw. + #[test] + fn a_brush_definition_that_is_not_an_object_still_draws() { + let (mut app, ctx) = (app(), ctx()); + app.run("ui.brushOptions", json!({"name": "Confetti"})).unwrap(); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "__def", "value": 5})); + crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"field": "tips", "value": true})); + let text = shown(&mut app); + assert!(text.contains("Scatter Brush Options") && text.contains("Colorization Tips"), "{text}"); + } + #[test] fn pattern_options_show_the_tiles_and_set_scale_spacing_fit() { let mut app = app(); @@ -493,4 +781,21 @@ mod tests { ); assert!((drawn_height(&app, id) / before - 2.0).abs() < 1e-6, "tiles twice the size"); } + + /// Art Brush Options' Width and Pattern Brush Options' Scale each have a slider beside their + /// field, which screen readers name as the row, with the field's number and text over the + /// value's whole range. + #[test] + fn art_width_and_pattern_scale_have_sliders() { + use crate::widgets::accessibility_tests::{slider_value, tree}; + let mut app = app(); + app.run("ui.brushOptions", json!({"name": "Arrow"})).unwrap(); + set(&mut app, json!({"width": 250})); + let shown = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert_eq!(slider_value(&shown, "Width:"), (Some(250.0), Some(1.0), Some(1000.0), Some("250%".into()))); + app.ui.dialog = None; + app.run("ui.brushOptions", json!({"name": "Diamonds"})).unwrap(); + let shown = tree(|ui| super::super::show(&mut app, ui.ctx())); + assert_eq!(slider_value(&shown, "Scale:"), (Some(100.0), Some(1.0), Some(10000.0), Some("100%".into()))); + } } diff --git a/crates/ui-egui/src/dialogs/color_picker.rs b/crates/ui-egui/src/dialogs/color_picker.rs index 982355d92..d79b3d46d 100644 --- a/crates/ui-egui/src/dialogs/color_picker.rs +++ b/crates/ui-egui/src/dialogs/color_picker.rs @@ -4,10 +4,12 @@ //! button that samples a color anywhere on the screen ([`crate::screen_color`]); and a Color //! Swatches list. With art selected the colour shows on it live as it is picked; OK keeps it as one //! undo step through `paint.setFill` / `paint.setStroke`, Cancel puts the art back. +//! Opened for an artboard (Properties › Artboards › Fill), it shows the color on the artboard's +//! paper live, and OK keeps it as one undo step through `artboard.setProps {index, color}`. //! //! Fields agents set with `ui.dialog.set`: `hex` (`"00FF00"`) or `color` (any colour the paint //! commands take), `channel` (`hue`, `saturation`, `brightness`, `red`, `green`, `blue`), `webOnly` -//! and `swatches`. `stroke` and `original` come from the opener. +//! and `swatches`. `stroke`, `artboard`, `keep` and `original` come from the opener. use egui::{Rect, Sense, Ui, vec2}; use serde_json::{Value, json}; @@ -201,18 +203,37 @@ fn proxy_color(app: &VectorcraftApp, stroke: bool) -> Color { } } -/// `ui.colorPicker {stroke?, color?}`: open the picker for the fill (or stroke) proxy, starting -/// from `color` or the proxy's colour. +/// `ui.colorPicker {stroke?, artboard?, color?}`: open the picker for the fill (or stroke) proxy, +/// or for artboard `artboard`'s background color, starting from `color`, else the proxy's color +/// or the artboard's color (white when it has none). pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!app.session.fill_active); - let color = match p.get("color") { - Some(v) => color_value(v).ok_or_else(|| format!("bad color {v}"))?, - None => proxy_color(app, stroke), + let artboard = match p.get("artboard") { + None | Some(Value::Null) => None, + Some(v) => { + let index = v.as_u64().and_then(|i| usize::try_from(i).ok()); + let found = index.and_then(|i| Some((i, app.session.active()?.doc.artboards.get(i)?.color))); + let (i, color) = found.ok_or("no such artboard")?; + Some((i, color.unwrap_or(Color::WHITE))) + } + }; + let color = match (p.get("color"), artboard) { + (Some(v), _) => color_value(v).ok_or_else(|| format!("bad color {v}"))?, + (None, Some((_, c))) => c, + (None, None) => proxy_color(app, stroke), }; let mut d = Dialog::new( KIND, json!({"stroke": stroke, "original": color_json(&color), "channel": Channel::default().id(), "webOnly": false, "swatches": false}), ); + if let Some((i, _)) = artboard { + d.fields.insert("artboard".into(), json!(i)); + // OK with the artboard's color (or the white that stands in for none) unchanged keeps the + // artboard as it is. + if p.get("color").is_none() { + d.fields.insert("keep".into(), json!(true)); + } + } store(&mut d, &Picked::of(color)); d.fields.insert(crate::screen_color::PICKER_ID.into(), json!(crate::screen_color::picker_id())); app.ui.dialog = Some(d); @@ -382,15 +403,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> DialogAction { /// Show the picked colour on the selected art while the picker is open: each new colour replaces /// the last preview. Nothing previews until the colour first changes, nor without a selection /// (the colour then only becomes the default paint, on OK). +/// Opened for an artboard, the picker shows the color on that artboard's paper, with or without a +/// selection. fn preview(app: &mut VectorcraftApp, d: &mut Dialog) { let (cmd, params) = paint_command(app, d); let first = !app.session.in_interaction(); let unchanged = d.fields.get(PREVIEWED) == Some(¶ms) || first && d.fields.get("original").and_then(color_value) == Some(picked(d).color); - let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + let artboard = d.fields.contains_key("artboard"); + let selected = artboard || app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); if unchanged || !selected { return; } - let _ = app.session.begin_interaction(if d.bool("stroke") { "Stroke Color" } else { "Fill Color" }); + let _ = app.session.begin_interaction(if artboard { + "Artboard Options" + } else if d.bool("stroke") { + "Stroke Color" + } else { + "Fill Color" + }); if let Err(e) = app.session.preview(cmd, ¶ms) { app.status(e.to_string()); } @@ -398,13 +428,16 @@ fn preview(app: &mut VectorcraftApp, d: &mut Dialog) { } /// The command that applies the picked colour to the proxy, and its params (as CMYK in a CMYK -/// document). +/// document). Opened for an artboard, the command sets that artboard's background color. fn paint_command(app: &VectorcraftApp, d: &Dialog) -> (&'static str, Value) { let mut c = picked(d).color; if matches!(c, Color::Rgb { .. }) && app.session.active().is_some_and(|s| s.doc.color_mode == ColorMode::Cmyk) { let [cy, m, y, k] = c.to_cmyk_managed(cms::active().settings().intent); c = Color::cmyk(cy, m, y, k); } + if let Some(i) = d.fields.get("artboard").and_then(Value::as_u64) { + return ("artboard.setProps", json!({ "index": i, "color": color_json(&c) })); + } (if d.bool("stroke") { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": color_json(&c) })) } @@ -556,8 +589,14 @@ fn swatch_list(app: &VectorcraftApp, ui: &mut Ui, current: &Color, side: f32) -> chosen } -/// OK: apply the colour to the proxy, keeping the live preview as one undo step. +/// OK: apply the colour to the proxy, keeping the live preview as one undo step. For an artboard, +/// OK with the starting color unchanged and nothing previewed closes the picker and leaves the +/// artboard as it was: without a background color, or with its color in its own color model. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + if d.bool("keep") && !app.session.in_interaction() && d.fields.get("original").and_then(color_value) == Some(picked(d).color) { + app.ui.dialog = None; + return Ok(Value::Null); + } let (cmd, params) = paint_command(app, d); super::form::commit_preview(app, cmd, params) } @@ -700,6 +739,112 @@ mod tests { assert_eq!(undo(&app), before + 1); } + /// Opened for an artboard (Properties › Artboards › Fill), the picker starts from the artboard's + /// background color (white without one) and shows each color on its paper live, with nothing + /// selected. OK keeps the last as one undo step, the art's fill unchanged, and Cancel puts the + /// artboard back. + #[test] + fn an_artboard_shows_its_background_color_live() { + let mut app = app(); + app.run("select.set", json!({"ids": []})).unwrap(); + app.run("artboard.new", json!({})).unwrap(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let art = app.session.doc().unwrap().doc.layers.clone(); + let before = undo(&app); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "FFFFFF", "starts from white paper"); + frame(&mut app); + assert!(!app.session.in_interaction(), "nothing previews before the color changes"); + for hex in ["ff0000", "00ff00"] { + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!(hex)); + frame(&mut app); + assert_eq!(color(&app), Some(format!("#{hex}")), "live, before OK"); + } + super::super::confirm(&mut app).unwrap(); + assert_eq!(color(&app).as_deref(), Some("#00ff00")); + assert_eq!(undo(&app), before + 1, "one step"); + assert_eq!(app.session.doc().unwrap().doc.layers, art, "the art keeps its fill"); + // Cancel: back to the color it had, which the picker starts from. + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("hex"), "00FF00"); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("0000ff")); + frame(&mut app); + assert_eq!(color(&app).as_deref(), Some("#0000ff")); + super::super::cancel(&mut app); + assert_eq!(color(&app).as_deref(), Some("#00ff00")); + assert_eq!(undo(&app), before + 1); + assert!(app.run("ui.colorPicker", json!({"artboard": 9})).is_err()); + } + + /// OK on a picker opened for an artboard without a background color, with the color left at the + /// white it starts from, leaves the artboard without one and adds no undo step. A white the + /// opener passes as `color` is set. + #[test] + fn an_untouched_picker_leaves_an_artboard_without_a_color() { + let mut app = app(); + app.run("artboard.new", json!({})).unwrap(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes the picker"); + assert_eq!(color(&app), None, "no color"); + assert_eq!(undo(&app), before, "no undo step"); + app.run("ui.colorPicker", json!({"artboard": 1, "color": "#ffffff"})).unwrap(); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert_eq!(color(&app).as_deref(), Some("#ffffff")); + assert_eq!(undo(&app), before + 1); + } + + /// OK on an untouched picker opened for an artboard with an RGB background color in a CMYK + /// document keeps the RGB color and adds no undo step. A color picked in it is set as CMYK. + #[test] + fn an_untouched_picker_keeps_an_artboards_rgb_color_in_a_cmyk_document() { + let mut app = app(); + app.run("artboard.new", json!({})).unwrap(); + app.run("file.documentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color; + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let start = color(&app); + assert!(matches!(start, Some(Color::Rgb { .. })), "set as RGB"); + let before = undo(&app); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes the picker"); + assert_eq!(color(&app), start, "the RGB color is kept"); + assert_eq!(undo(&app), before, "no undo step"); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("hex".into(), json!("00ff00")); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(matches!(color(&app), Some(Color::Cmyk { .. })), "a picked color is set as CMYK"); + assert_eq!(undo(&app), before + 1); + } + + /// OK on an untouched picker opened for an artboard with a background color in an RGB + /// document keeps the color and adds no undo step. + #[test] + fn an_untouched_picker_adds_no_undo_step_for_an_artboards_color() { + let mut app = app(); + app.run("artboard.new", json!({})).unwrap(); + app.run("artboard.setProps", json!({"index": 1, "color": "#ff0000"})).unwrap(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[1].color.map(|c| c.to_hex()); + let undo = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len(); + let before = undo(&app); + app.run("ui.colorPicker", json!({"artboard": 1})).unwrap(); + frame(&mut app); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "OK closes the picker"); + assert_eq!(color(&app).as_deref(), Some("#ff0000")); + assert_eq!(undo(&app), before, "no undo step"); + } + #[test] fn open_set_hex_and_confirm_fills() { let mut app = app(); diff --git a/crates/ui-egui/src/dialogs/command.rs b/crates/ui-egui/src/dialogs/command.rs index 59309c5e9..e4237ca27 100644 --- a/crates/ui-egui/src/dialogs/command.rs +++ b/crates/ui-egui/src/dialogs/command.rs @@ -13,8 +13,9 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| tl!(&d.str("__label")).to_string(), body: |app, ui, d| { let command = d.str("__command"); - let lengths = lengths(&command); - form::param_fields(ui, d, &|k| lengths.contains(&k), &|k| choices(&command, k), &|_| 0, app.session.general_unit()); + 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()); false }, confirm, @@ -30,6 +31,17 @@ fn lengths(command: &str) -> &'static [&'static str] { "object.repeat.options" => &["radius", "hSpacing", "vSpacing"], "text.areaOptions" => &["width", "height", "gutter", "inset", "firstBaselineMin"], "type.pathOptions" => &["spacing"], + "object.pathfinder.trap" => &["thickness"], + _ => &[], + } +} + +/// The order the fields of the commands this dialog edits come in, where the order of their +/// names isn't it (the rest follow, by name). +fn order(command: &str) -> &'static [&'static str] { + match command { + // The public Trap options' order. + "object.pathfinder.trap" => &["thickness", "heightWidth", "tintReduction", "trapsWithProcessColor", "reverseTraps"], _ => &[], } } diff --git a/crates/ui-egui/src/dialogs/corners.rs b/crates/ui-egui/src/dialogs/corners.rs index 8f1342eb3..2d72072e6 100644 --- a/crates/ui-egui/src/dialogs/corners.rs +++ b/crates/ui-egui/src/dialogs/corners.rs @@ -49,12 +49,20 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { return Err("give corners of the path: anchors where its sides meet at an angle".into()); } let (radius, kind) = live.style(&corners); - let mut fields = json!({"id": id.0, "corners": corners}); + let relative = st.doc.node(id).and_then(|n| n.live_shape()).map(|l| l.radii_relative()).unwrap_or(false); + let mut fields = json!({"id": id.0, "corners": corners, "relativeRadii": relative}); if let Some(k) = kind { fields["kind"] = json!(k); } if let Some(r) = radius { - fields["radius"] = json!(r); + if relative { + let pct = corners.iter().find_map(|k| live.corner(*k).map(|_| live.radius(*k) * 100.0)); + if let Some(pct) = pct { + fields["radius"] = json!(pct); + } + } else { + fields["radius"] = json!(r); + } } app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) @@ -73,8 +81,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); ui.end_row(); + ui.horizontal(|ui| { + for (label, on) in [(tl!("Relative"), true), (tl!("Absolute"), false)] { + let cur = d.fields.get("relativeRadii").and_then(|v| v.as_bool()).unwrap_or(false); + if widgets::radio(ui, label, cur == on, true) { + d.fields.insert("relativeRadii".into(), json!(on)); + } + } + }); + ui.end_row(); label(ui, tl!("Radius:")); - form::length(ui, d, "radius", unit, 120.0); + let relative = d.fields.get("relativeRadii").and_then(|v| v.as_bool()).unwrap_or(false); + if relative { + if let Some(n) = widgets::range_field(ui, ("corners", "radius"), d.f64("radius", 0.0), 0.0..=100.0, "%", 1, 120.0) { + d.fields.insert("radius".into(), json!(n)); + } + } else { + form::length(ui, d, "radius", unit, 120.0); + } ui.end_row(); }); false @@ -94,8 +118,24 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(k) = d.fields.get("kind") { p["kind"] = k.clone(); } + if let Some(v) = d.fields.get("relativeRadii") { + p["relativeRadii"] = v.clone(); + } if d.fields.contains_key("radius") { - p["radius"] = json!(d.f64("radius", 0.0)); + let relative = d.fields.get("relativeRadii").and_then(|v| v.as_bool()).unwrap_or(false); + let mut r = d.f64("radius", 0.0); + if relative { + let id = NodeId(d.fields.get("id").and_then(|v| v.as_u64()).unwrap_or(0)); + let st = app.session.active().ok_or("no document")?; + if let Some(lc) = st.doc.node(id).and_then(LiveCorners::of) { + let corners: BTreeSet = serde_json::from_value(d.fields.get("corners").cloned().unwrap_or(json!([]))).unwrap_or_default(); + if let Some(k) = corners.iter().next() { + let max = lc.corner(*k).map(|c| c.max_radius()).unwrap_or(0.0); + r = (r / 100.0).clamp(0.0, 1.0) * max; + } + } + } + p["radius"] = json!(r); } run_and_close(app, "object.setLiveShape", p) } diff --git a/crates/ui-egui/src/dialogs/dimension.rs b/crates/ui-egui/src/dialogs/dimension.rs index e494928bb..30afcbf8f 100644 --- a/crates/ui-egui/src/dialogs/dimension.rs +++ b/crates/ui-egui/src/dialogs/dimension.rs @@ -73,8 +73,8 @@ fn number(ui: &mut Ui, fields: &mut serde_json::Map, key: &str, l let mut changed = false; widgets::label_row(ui, tl!(label), 96.0, |ui| { let value = fields.get(key).and_then(Value::as_f64).unwrap_or_default(); - if let Some(v) = widgets::plain_field(ui, ("dimension", key), value, suffix, 2, 90.0) { - fields.insert(key.into(), json!(v.clamp(range.0, range.1))); + if let Some(v) = widgets::range_field(ui, ("dimension", key), value, range.0..=range.1, suffix, 2, 90.0) { + fields.insert(key.into(), json!(v)); changed = true; } }); diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs index 3aff969f6..4182be5de 100644 --- a/crates/ui-egui/src/dialogs/document_setup.rs +++ b/crates/ui-egui/src/dialogs/document_setup.rs @@ -78,6 +78,7 @@ fn unit(d: &Dialog) -> Unit { Unit::named(&d.str("units")).unwrap_or_default() } +/// The General tab of Document Setup (units, bleed, artboard editing, options). fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let units: Vec<(&str, &str)> = Unit::ALL.iter().map(|u| (u.label(), u.label())).collect(); choice(ui, d, "units", tl!("Units:"), &units); @@ -88,7 +89,7 @@ fn general(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool let mut close = false; widgets::label_row(ui, "", LABEL, |ui| { // Applies the settings, then edits the artboards with the Artboard tool. - if widgets::flat_button(ui, tl!("Edit Artboards"), 120.0).clicked() && confirm(app, d).is_ok() { + if widgets::flat_button_fit(ui, tl!("Edit Artboards"), 120.0).clicked() && confirm(app, d).is_ok() { app.select_tool("artboard"); // The shared frame restores its working copy after drawing the body. Tell it to // close too, otherwise it reopens the dialog that confirm just closed. diff --git a/crates/ui-egui/src/dialogs/dxf_import.rs b/crates/ui-egui/src/dialogs/dxf_import.rs index 8e6bd9502..6bbf0d1d3 100644 --- a/crates/ui-egui/src/dialogs/dxf_import.rs +++ b/crates/ui-egui/src/dialogs/dxf_import.rs @@ -109,7 +109,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let r = if d.str("mode") == "place" { let mut p = with_file(d.fields.get("__place"), d, &file); p["dxf"] = dxf; - crate::place::run(app, &p).map(|_| ()) + crate::place::run_for_user(app, &p).map(|_| ()) } else { let path = d.fields.get("path").and_then(Value::as_str).map(str::to_string); io::open_document(app, &d.str("name"), &file.bytes, path, &json!({ "dxf": dxf })).map(|_| ()) diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 32b11744d..a4b84e075 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -156,7 +156,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let changed = match vectorcraft_plugins::effect::installed(&id) { Some(plugin) => form::schema_fields(app, ui, d, plugin.manifest()), None if vectorcraft_effects::is_adjustment(&id) => adjust_fields(ui, d), - None if id == GLASS => glass_fields(app, ui, d), + None if id == GLASS => glass_fields(app, ui, d, GLASS), + None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER), None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()), None if id == ZIG_ZAG => zig_zag_fields(ui, d, app.session.general_unit()), None if id == DROP_SHADOW => drop_shadow_fields(ui, d, app.session.general_unit()), @@ -200,6 +201,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { pub(crate) const FILM_GRAIN_FIELDS: [(&str, &str, f64); 3] = [("grain", "Grain", 20.0), ("highlightArea", "Highlight Area", 20.0), ("intensity", "Intensity", 10.0)]; +/// Paint Daubs › Brush Type labels ([`vectorcraft_effects::pixel::PAINT_DAUB_BRUSH_TYPES`]); extend +/// [`crate::menus::menu_strings`] with these like [`FILM_GRAIN_FIELDS`]. +pub(crate) const PAINT_DAUB_BRUSH_TYPE_LABELS: [&str; 6] = ["Simple", "Light Rough", "Dark Rough", "Wide Sharp", "Wide Blurry", "Sparkle"]; + fn film_grain_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { let before = d.fields.clone(); // Use one column wide enough for this language's longest label, keeping every rail aligned. @@ -219,20 +224,22 @@ fn film_grain_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// The Glass effect (Effect › Distort › Glass…). const GLASS: &str = "distort.glass"; +/// The Texturizer effect (Effect › Texture › Texturizer…). +const TEXTURIZER: &str = "texture.texturizer"; -/// Glass's dialog: its parameters, the Texture menu ending with Loaded Texture once a texture is +/// Glass and Texturizer: parameters, the Texture menu ending with Loaded Texture once a texture is /// loaded, and Load Texture… with the loaded texture's size. Returns whether the values differ from /// those last previewed. -fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { +fn glass_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) -> bool { use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture}; // The stored texture is shown as its size, not as a text field. let stored = d.fields.remove(TEXTURE_IMAGE); let size = stored.as_ref().and_then(Value::as_str).and_then(texture).map(|t| t.size()); - let menu = glass_textures(size.is_some()); - let doc = vectorcraft_effects::effect_info(GLASS).map(|e| e.params).unwrap_or_default(); + let menu = if id == TEXTURIZER { texturizer_textures(size.is_some()) } else { glass_textures(size.is_some()) }; + let doc = vectorcraft_effects::effect_info(id).map(|e| e.params).unwrap_or_default(); let unit = app.session.general_unit(); - let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(GLASS, k) }; - form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(GLASS, k, false), &pick, &|k| doc_rank(doc, k), unit); + let pick = |k: &str| if k == "texture" { Some(menu) } else { choices(id, k) }; + form::param_fields(ui, d, &|k| vectorcraft_effects::is_length(id, k, false), &pick, &|k| doc_rank(doc, k), unit); if let Some(v) = stored { d.fields.insert(TEXTURE_IMAGE.into(), v); } @@ -259,11 +266,17 @@ fn glass_textures(loaded: bool) -> form::Choices { if loaded { &GLASS_TEXTURES_LOADED } else { &GLASS_TEXTURES } } +/// Texturizer's Texture menu, ending with Loaded Texture once a texture is `loaded`. +fn texturizer_textures(loaded: bool) -> form::Choices { + use vectorcraft_effects::pixel::{TEXTURES, TEXTURES_LOADED}; + if loaded { &TEXTURES_LOADED } else { &TEXTURES } +} + /// Glass's Load Texture…: an image file the user picks, read into `textureImage` as Glass stores /// it and picked in the Texture menu. fn load_texture(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { use vectorcraft_engine::cmd::fileio; - let filters = fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, f.extensions)).collect(); + let filters = crate::FilePick::filters(fileio::FORMATS.iter().filter(|f| f.read && f.raster).map(|f| (f.label, f.extensions))); let path = crate::picks::open(app, &crate::FilePick { filters, ..Default::default() }).ok_or("cancelled")?; // The button is off where files can't be read. let Some(read) = app.services.read.as_ref() else { return Ok(()) }; @@ -283,12 +296,15 @@ fn choices(effect: &str, key: &str) -> Option { ("brushStrokes.sprayedStrokes", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS), ("distort.glass", "texture") => Some(&vectorcraft_effects::pixel::GLASS_TEXTURES), ("pixelate.mezzotint", "type") => Some(&vectorcraft_effects::pixel::MEZZOTINT_TYPES), + ("artistic.paintDaubs", "brushType") => Some(&vectorcraft_effects::pixel::PAINT_DAUB_BRUSH_TYPES), ("sketch.graphicPen", "strokeDirection") => Some(&vectorcraft_effects::pixel::STROKE_DIRECTIONS), ("sketch.halftonePattern", "patternType") => Some(&vectorcraft_effects::pixel::HALFTONE_TYPES), ("sketch.plaster", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS), ("texture.grain", "grainType") => Some(&vectorcraft_effects::pixel::GRAIN_TYPES), - ("texture.texturizer", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES), - ("sketch.basRelief" | "texture.texturizer", "lightDirection") => Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS), + ("texture.texturizer" | "artistic.underpainting" | "sketch.conteCrayon", "texture") => Some(&vectorcraft_effects::pixel::TEXTURES), + ("sketch.basRelief" | "texture.texturizer" | "artistic.underpainting" | "sketch.conteCrayon", "lightDirection") => { + Some(&vectorcraft_effects::pixel::LIGHT_DIRECTIONS) + } ("video.deinterlace", "eliminate") => Some(&[("Odd Fields", "odd"), ("Even Fields", "even")]), ("video.deinterlace", "create") => Some(&[("Duplication", "duplication"), ("Interpolation", "interpolation")]), ("stylize.innerGlow", "source") => Some(&[("Center", "center"), ("Edge", "edge")]), @@ -344,9 +360,10 @@ pub(crate) fn shown_choices(effect: &str, key: &str, params: &Map if let Some(own) = own_choices(effect, key, cmyk) { return Some(own.into_iter().map(|(label, value)| (tl!(label).to_string(), value.to_string())).collect()); } - let options = if effect == GLASS && key == "texture" { + let options = if (effect == GLASS || effect == TEXTURIZER) && key == "texture" { use vectorcraft_effects::pixel::{TEXTURE_IMAGE, texture}; - glass_textures(params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some()) + let loaded = params.get(TEXTURE_IMAGE).and_then(Value::as_str).and_then(texture).is_some(); + if effect == GLASS { glass_textures(loaded) } else { texturizer_textures(loaded) } } else { choices(effect, key)? }; @@ -460,8 +477,9 @@ fn values_changed(d: &mut Dialog) -> bool { const TRANSFORM: &str = "distort.transform"; /// The Transform effect's dialog: Transform Each's Scale and Move sliders and Rotate dial, then -/// Copies, the reflections, the reference point and Random. Returns whether the values differ from -/// those last previewed. +/// the options Transform Objects, Transform Patterns and Scale Strokes & Effects (#885), Copies, +/// the reflections, the reference point and Random. Returns whether the values differ from those +/// last previewed. fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) -> bool { ui.horizontal_top(|ui| { ui.vertical(|ui| transform_each::sections(ui, d, unit)); @@ -469,6 +487,9 @@ fn transform_fields(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Un ui.vertical(|ui| { widgets::subheader(ui, tl!("Options")); ui.add_space(4.0); + form::check(ui, d, "objects", tl!("Transform Objects")); + form::check(ui, d, "patterns", tl!("Transform Patterns")); + form::check(ui, d, "strokes", tl!("Scale Strokes & Effects")); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Copies:")); if let Some(n) = widgets::range_field(ui, "fx-copies", d.f64("copies", 0.0), 0.0..=1000.0, "", 0, 50.0) { @@ -704,6 +725,7 @@ mod tests { ("distort.roughen", "points"), ("stylize.innerGlow", "source"), (DROP_SHADOW, "shade"), + ("artistic.paintDaubs", "brushType"), ] { let doc = vectorcraft_effects::effect_info(effect).unwrap().params; let listed = doc.split(&format!("{key}: ")).nth(1).unwrap().split([',', '}', ' ']).next().unwrap().to_string(); @@ -772,4 +794,34 @@ mod tests { assert_eq!((¶ms["texture"], ¶ms["textureImage"]), (&json!("loaded"), &json!(stored))); let _ = std::fs::remove_dir_all(&dir); } + + /// Texturizer's Load Texture… matches Glass: pick, Loaded Texture in the menu, OK applies. + #[test] + fn texturizer_load_texture_reads_the_picked_image_into_the_effect() { + let dir = std::env::temp_dir().join(format!("vectorcraft-texturizer-dialog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("texture.png"); + image::RgbaImage::from_fn(6, 4, |x, y| image::Rgba([(x * 40) as u8, (y * 60) as u8, 90, 255])).save(&path).unwrap(); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let picked = path.to_string_lossy().into_owned(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": TEXTURIZER})).unwrap(); + let paint = |app: &mut VectorcraftApp| crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())); + let text = paint(&mut app); + assert!(text.lines().any(|l| l == "Load Texture…") && text.lines().any(|l| l == "Canvas"), "{text}"); + let mut d = app.ui.dialog.take().unwrap(); + load_texture(&mut app, &mut d).unwrap(); + let stored = d.str(vectorcraft_effects::pixel::TEXTURE_IMAGE); + assert_eq!((d.str("texture"), vectorcraft_effects::pixel::texture(&stored).map(|t| t.size())), ("loaded".to_string(), Some((6, 4)))); + app.ui.dialog = Some(d); + super::super::confirm(&mut app).unwrap(); + let st = app.session.doc().unwrap(); + let params = &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params; + assert_eq!((¶ms["texture"], ¶ms["textureImage"]), (&json!("loaded"), &json!(stored))); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/ui-egui/src/dialogs/effect_gallery.rs b/crates/ui-egui/src/dialogs/effect_gallery.rs new file mode 100644 index 000000000..92af1125f --- /dev/null +++ b/crates/ui-egui/src/dialogs/effect_gallery.rs @@ -0,0 +1,478 @@ +//! Effect → Effect Gallery: browse Photoshop-style raster filters by category, build a stack, and +//! apply it as separate appearance effects in one undo step. + +use egui; +use serde_json::{Map, Value, json}; +use vectorcraft_effects::{EffectInfo, PIXEL_EFFECTS}; + +use super::{DialogSpec, effect, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, widgets}; + +/// Dialog kind for the Effect Gallery. +pub const KIND: &str = "effectGallery"; + +/// Raster effect submenus in the gallery (the six Photoshop-style folders it holds). +const CATEGORIES: [(&str, &[&str]); 6] = [ + ("Artistic", &["Effect", "Artistic"]), + ("Brush Strokes", &["Effect", "Brush Strokes"]), + ("Distort", &["Effect", "Distort"]), + ("Sketch", &["Effect", "Sketch"]), + ("Stylize", &["Effect", "Stylize"]), + ("Texture", &["Effect", "Texture"]), +]; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Effect Gallery").into(), + body, + confirm, + preview: true, + min_width: 720.0, + max_width: Some(820.0), + ..DialogSpec::FORM +}; + +#[derive(Clone)] +struct StackEntry { + effect: String, + visible: bool, + params: Map, +} + +/// Open the gallery on the current selection. +pub fn open(app: &mut VectorcraftApp, _p: &Value) -> Result { + if app.session.active().is_none_or(|s| s.selection.is_empty()) { + return Err(tl!("Select artwork to apply effects").into()); + } + let selected = effects_in_category(0).first().map(|e| e.id).unwrap_or(""); + app.ui.dialog = Some(Dialog::new( + KIND, + json!({ "category": 0, "selected": selected, "stack": [], "stackSelected": 0, "stackFocus": false, "preview": true }), + )); + Ok(Value::Null) +} + +fn effects_in_category(cat: usize) -> Vec { + let path = CATEGORIES.get(cat).map(|(_, p)| *p).unwrap_or(CATEGORIES[0].1); + let mut out: Vec = + vectorcraft_effects::effect_catalog().into_iter().filter(|e| e.menu == path && PIXEL_EFFECTS.contains(&e.id)).collect(); + out.sort_by(|a, b| a.label.cmp(b.label)); + out +} + +fn default_params(id: &str) -> Map { + match vectorcraft_effects::effect_info(id).map(|e| e.defaults) { + Some(Value::Object(m)) => m, + _ => Map::new(), + } +} + +fn merged_params(id: &str, stored: &Map) -> Map { + match vectorcraft_effects::merged_params(id, &Value::Object(stored.clone())) { + Value::Object(m) => m, + _ => default_params(id), + } +} + +fn stack_of(d: &Dialog) -> Vec { + d.fields + .get("stack") + .and_then(Value::as_array) + .map(|rows| { + rows.iter() + .filter_map(|row| { + let effect = row.get("effect").and_then(Value::as_str)?; + let visible = row.get("visible").and_then(Value::as_bool).unwrap_or(true); + let params = row + .get("params") + .and_then(Value::as_object) + .cloned() + .map(|m| merged_params(effect, &m)) + .unwrap_or_else(|| default_params(effect)); + Some(StackEntry { effect: effect.to_string(), visible, params }) + }) + .collect() + }) + .unwrap_or_default() +} + +fn write_stack(d: &mut Dialog, stack: &[StackEntry]) { + let rows: Vec = stack.iter().map(|e| json!({ "effect": e.effect, "visible": e.visible, "params": e.params })).collect(); + d.fields.insert("stack".into(), Value::Array(rows)); +} + +fn category_ix(d: &Dialog) -> usize { + d.fields.get("category").and_then(Value::as_u64).unwrap_or(0) as usize +} + +fn selected_id(d: &Dialog) -> String { + d.str("selected") +} + +fn set_selected(d: &mut Dialog, id: &str) { + d.fields.insert("selected".into(), json!(id)); +} + +fn stack_focus(d: &Dialog) -> bool { + d.fields.get("stackFocus").and_then(Value::as_bool).unwrap_or(false) +} + +fn set_stack_focus(d: &mut Dialog, on: bool) { + d.fields.insert("stackFocus".into(), json!(on)); +} + +fn browse_params(d: &Dialog) -> Map { + let effect = d.fields.get("browseEffect").and_then(Value::as_str).unwrap_or(""); + let stored = d.fields.get("browseParams").and_then(Value::as_object).cloned().unwrap_or_default(); + if effect.is_empty() { stored } else { merged_params(effect, &stored) } +} + +fn set_browse_params(d: &mut Dialog, effect: &str, params: Map) { + d.fields.insert("browseEffect".into(), json!(effect)); + d.fields.insert("browseParams".into(), Value::Object(params)); +} + +/// The effect whose parameters the panel edits, and its values. +fn param_target(d: &Dialog, stack: &[StackEntry], stack_sel: usize, browse: &str) -> (String, Map) { + if stack_focus(d) { + let entry = stack.get(stack_sel); + if let Some(e) = entry { + return (e.effect.clone(), e.params.clone()); + } + } + let id = browse.to_string(); + let params = if d.fields.get("browseEffect").and_then(Value::as_str) == Some(browse) { browse_params(d) } else { default_params(browse) }; + (id, params) +} + +fn write_param_target(d: &mut Dialog, stack: &mut [StackEntry], stack_sel: usize, browse: &str, effect: &str, params: Map) { + if stack_focus(d) { + if let Some(e) = stack.get_mut(stack_sel) + && e.effect == effect + { + e.params = params; + } + return; + } + if browse == effect { + set_browse_params(d, effect, params); + } +} + +/// Reuse the live effect dialog's fields for `effect_id` and `params`. +fn effect_param_fields(app: &mut VectorcraftApp, ui: &mut egui::Ui, effect_id: &str, params: &mut Map) { + if effect_id.is_empty() { + return; + } + let label = + vectorcraft_effects::effect_info(effect_id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_else(|| effect_id.to_string()); + let mut fields = params.clone(); + fields.insert("__effect".into(), json!(effect_id)); + fields.insert("__label".into(), json!(label)); + fields.insert("preview".into(), json!(false)); + let mut temp = Dialog::new("effect", Value::Object(fields)); + (effect::SPEC.body)(app, ui, &mut temp); + if let Value::Object(m) = form::params(&temp) { + *params = m; + } + if let Value::Object(m) = vectorcraft_effects::upgrade_params(effect_id, &Value::Object(params.clone())) { + *params = m; + } +} + +fn stack_batch_commands(stack: &[StackEntry]) -> Vec { + let mut commands = Vec::with_capacity(stack.len() * 2); + for (i, entry) in stack.iter().enumerate() { + let params = Value::Object(entry.params.clone()); + commands.push(json!({ "command": "effect.apply", "params": { "effect": entry.effect, "params": params } })); + if !entry.visible { + commands.push(json!({ "command": "effect.setParams", "params": { "index": i, "visible": false } })); + } + } + commands +} + +fn stack_batch_params(stack: &[StackEntry]) -> Value { + json!({ "label": "Effect Gallery", "commands": stack_batch_commands(stack) }) +} + +/// Live canvas preview of the stack (`command.batch` would settle the dialog interaction each frame). +fn preview_stack_on_canvas(app: &mut VectorcraftApp, stack: &[StackEntry]) { + if app.session.in_interaction() { + let _ = app.session.cancel_interaction(); + } + let _ = app.session.begin_interaction("Effect Gallery"); + for (i, entry) in stack.iter().enumerate() { + let params = Value::Object(entry.params.clone()); + if app.run("effect.apply", json!({ "effect": entry.effect, "params": params })).is_err() { + let _ = app.session.cancel_interaction(); + return; + } + if !entry.visible && app.run("effect.setParams", json!({ "index": i, "visible": false })).is_err() { + let _ = app.session.cancel_interaction(); + return; + } + } +} + +fn stack_preview_row(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, stack: &[StackEntry]) { + const LAST: &str = super::form::PREVIEWED; + ui.add_space(8.0); + let mut on = d.bool("preview"); + if widgets::check(ui, tl!("Preview"), on, true) { + on = !on; + d.fields.insert("preview".into(), json!(on)); + } + if !on { + if d.fields.remove(LAST).is_some() { + let _ = app.session.cancel_interaction(); + } + return; + } + if stack.is_empty() { + if d.fields.remove(LAST).is_some() { + let _ = app.session.cancel_interaction(); + } + return; + } + let batch = stack_batch_params(stack); + if d.fields.get(LAST) != Some(&batch) || !app.session.in_interaction() { + preview_stack_on_canvas(app, stack); + d.fields.insert(LAST.into(), batch); + } +} + +fn effect_label(id: &str) -> String { + vectorcraft_effects::effect_info(id).map(|e| tl!(e.label).trim_end_matches('…').to_string()).unwrap_or_else(|| id.to_string()) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let cat = category_ix(d); + let effects = effects_in_category(cat); + let mut sel = selected_id(d); + if !effects.iter().any(|e| e.id == sel) { + sel = effects.first().map(|e| e.id.to_string()).unwrap_or_default(); + set_selected(d, &sel); + } + + let mut stack = stack_of(d); + let mut stack_sel = d.fields.get("stackSelected").and_then(Value::as_u64).unwrap_or(0) as usize; + if stack_sel >= stack.len() && !stack.is_empty() { + stack_sel = stack.len() - 1; + } + let (param_effect, mut param_values) = param_target(d, &stack, stack_sel, &sel); + if !param_effect.is_empty() { + widgets::subheader(ui, tl!("Settings")); + egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| { + effect_param_fields(app, ui, ¶m_effect, &mut param_values); + }); + write_param_target(d, &mut stack, stack_sel, &sel, ¶m_effect, param_values); + } + + ui.horizontal_top(|ui| { + ui.vertical(|ui| { + widgets::subheader(ui, tl!("Categories")); + ui.set_width(120.0); + for (i, (name, _)) in CATEGORIES.iter().enumerate() { + let count = effects_in_category(i).len(); + if count == 0 { + continue; + } + let label = tl!(name); + if ui.add(egui::Button::selectable(cat == i, label)).clicked() { + d.fields.insert("category".into(), json!(i)); + let first = effects_in_category(i).first().map(|e| e.id).unwrap_or(""); + set_selected(d, first); + set_stack_focus(d, false); + } + } + }); + ui.add_space(8.0); + ui.vertical(|ui| { + widgets::subheader(ui, tl!("Filters")); + ui.set_width(220.0); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for e in &effects { + let picked = e.id == sel; + let name = tl!(e.label).trim_end_matches('…').to_string(); + let resp = ui.add(egui::Button::selectable(picked, name)); + if resp.clicked() { + set_selected(d, e.id); + set_stack_focus(d, false); + } + if resp.double_clicked() { + set_selected(d, e.id); + set_stack_focus(d, false); + add_selected(d, e.id); + stack = stack_of(d); + } + } + }); + if widgets::flat_button(ui, tl!("Add to Stack"), 120.0).clicked() && !sel.is_empty() { + add_selected(d, &sel); + stack = stack_of(d); + } + }); + }); + + ui.add_space(10.0); + widgets::subheader(ui, tl!("Applied Filters")); + let mut stack_sel = d.fields.get("stackSelected").and_then(Value::as_u64).unwrap_or(0) as usize; + if stack_sel >= stack.len() && !stack.is_empty() { + stack_sel = stack.len() - 1; + } + enum StackOp { + Toggle(usize), + Up(usize), + Down(usize), + Remove(usize), + Select(usize), + } + let mut op = None; + let stack_len = stack.len(); + egui::ScrollArea::vertical().max_height(120.0).show(ui, |ui| { + if stack.is_empty() { + ui.label(egui::RichText::new(tl!("Add filters from the list above")).color(t.text_dim)); + return; + } + for (i, entry) in stack.iter().enumerate().take(stack_len) { + ui.horizontal(|ui| { + let eye = if entry.visible { "eye" } else { "eye-off" }; + if widgets::icon_button(ui, eye, tl!("Click to toggle visibility"), false, 22.0).clicked() { + op = Some(StackOp::Toggle(i)); + } + let name = effect_label(&entry.effect); + if ui.add(egui::Button::selectable(stack_sel == i, name)).clicked() { + op = Some(StackOp::Select(i)); + } + if widgets::icon_button_enabled(ui, "dc-arrow-up", tl!("Move Up"), false, i > 0, 22.0).clicked() { + op = Some(StackOp::Up(i)); + } + if widgets::icon_button_enabled(ui, "dc-arrow-down", tl!("Move Down"), false, i + 1 < stack_len, 22.0).clicked() { + op = Some(StackOp::Down(i)); + } + if widgets::flat_button(ui, tl!("Remove"), 64.0).clicked() { + op = Some(StackOp::Remove(i)); + } + }); + } + }); + match op { + Some(StackOp::Toggle(i)) if stack.get(i).is_some() => stack[i].visible = !stack[i].visible, + Some(StackOp::Select(i)) => { + stack_sel = i; + set_stack_focus(d, true); + } + Some(StackOp::Up(i)) if i > 0 => { + stack.swap(i, i - 1); + stack_sel = i - 1; + } + Some(StackOp::Down(i)) if i + 1 < stack.len() => { + stack.swap(i, i + 1); + stack_sel = i + 1; + } + Some(StackOp::Remove(i)) if i < stack.len() => { + stack.remove(i); + if stack_sel >= stack.len() && !stack.is_empty() { + stack_sel = stack.len() - 1; + } + } + _ => {} + } + write_stack(d, &stack); + d.fields.insert("stackSelected".into(), json!(stack_sel)); + stack_preview_row(app, ui, d, &stack); + false +} + +fn add_selected(d: &mut Dialog, id: &str) { + let params = + if !stack_focus(d) && d.fields.get("browseEffect").and_then(Value::as_str) == Some(id) { browse_params(d) } else { default_params(id) }; + let mut stack = stack_of(d); + stack.push(StackEntry { effect: id.to_string(), visible: true, params }); + write_stack(d, &stack); + d.fields.insert("stackSelected".into(), json!(stack.len().saturating_sub(1))); + set_stack_focus(d, true); +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let stack = stack_of(d); + if stack.is_empty() { + if app.session.in_interaction() { + let _ = app.session.cancel_interaction(); + } + app.ui.dialog = None; + return Ok(Value::Null); + } + let batch = stack_batch_params(&stack); + if d.bool("preview") && app.session.in_interaction() { + return super::form::commit_preview(app, "command.batch", batch); + } + let r = app.run("command.batch", batch); + if r.is_ok() { + app.ui.dialog = None; + } + r +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use vectorcraft_engine::Session; + + use crate::VectorcraftApp; + + fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx, Default::default()); + let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| crate::dialogs::show(app, ui.ctx())); + out.textures_delta.clear(); + } + + #[test] + fn stack_preview_is_one_interaction() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("ui.effectGalleryDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("stack".into(), json!([{ "effect": "artistic.cutout", "visible": true, "params": { "levels": 3 } }])); + frame(&mut app); + assert!(app.session.in_interaction(), "the stack previews on the canvas"); + crate::dialogs::cancel(&mut app); + assert!(!app.session.in_interaction()); + assert!(app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.is_empty()); + } + + #[test] + fn stack_ok_is_one_undo_step() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap()["id"].as_u64().unwrap(); + app.run("ui.effectGalleryDialog", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::KIND)); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert( + "stack".into(), + json!([ + { "effect": "artistic.cutout", "visible": true, "params": { "levels": 3 } }, + { "effect": "stylize.glowingEdges", "visible": false, "params": { "edgeWidth": 2 } } + ]), + ); + frame(&mut app); + let steps = app.session.active().unwrap().history.undo.len(); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let n = st.doc.node(vectorcraft_doc::NodeId(id)).unwrap(); + assert_eq!(n.appearance.effects.len(), 2); + assert_eq!(n.appearance.effects[0].id, "artistic.cutout"); + assert_eq!(n.appearance.effects[0].params.get("levels").and_then(|v| v.as_i64()), Some(3)); + assert_eq!(n.appearance.effects[1].id, "stylize.glowingEdges"); + assert!(!n.appearance.effects[1].visible); + assert_eq!(st.history.undo.len(), steps + 1); + } +} diff --git a/crates/ui-egui/src/dialogs/envelope.rs b/crates/ui-egui/src/dialogs/envelope.rs index 0cdb2b241..f537595eb 100644 --- a/crates/ui-egui/src/dialogs/envelope.rs +++ b/crates/ui-egui/src/dialogs/envelope.rs @@ -135,8 +135,8 @@ fn percent(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { /// A whole number field row clamped to 1..50. fn count(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { widgets::label_row(ui, label, LABEL_W, |ui| { - if let Some(n) = widgets::plain_field(ui, ("env-count", key), d.f64(key, 4.0), "", 0, 60.0) { - set(d, key, json!(n.round().clamp(1.0, 50.0))); + if let Some(n) = widgets::range_field(ui, ("env-count", key), d.f64(key, 4.0), 1.0..=50.0, "", 0, 60.0) { + set(d, key, json!(n)); } }); } diff --git a/crates/ui-egui/src/dialogs/export_as.rs b/crates/ui-egui/src/dialogs/export_as.rs index 8e55a31bd..f19e056ff 100644 --- a/crates/ui-egui/src/dialogs/export_as.rs +++ b/crates/ui-egui/src/dialogs/export_as.rs @@ -140,8 +140,25 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into()); } app.ui.dialog = None; - let path = io::target_path(app, None, f.extensions[0])?; + let path = io::export_path(app, None, f)?; let range = if all { "all".to_string() } else { range }; + let r = options_or_write(app, f, plugin, path, use_artboards, all, range); + // The options follow the save panel: the Enter that closed it isn't theirs (as in Save As). + super::opened_by_panel(app); + r +} + +/// After the save panel picked `path`: the options dialog of format `f` (or of its exporter +/// plug-in), else the file written. +fn options_or_write( + app: &mut VectorcraftApp, + f: &'static Format, + plugin: Option>, + path: String, + use_artboards: bool, + all: bool, + range: String, +) -> Result { match f.id { "svg" | "svgz" => { svg_options::open(app, svg_options::Mode::Export, Some(&path)); diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index e8fc11484..932eb3166 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -11,6 +11,8 @@ //! `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. //! //! Fields (`ui.dialog.set`): `tab` (artboards|assets|layers|selection), `select` (all|range|full), `range`, //! `boards` ([bool] per artboard), `assets` ([asset id] checked on the Assets tab), `includeBleed`, diff --git a/crates/ui-egui/src/dialogs/flatten.rs b/crates/ui-egui/src/dialogs/flatten.rs index c387555b2..fbef5dbee 100644 --- a/crates/ui-egui/src/dialogs/flatten.rs +++ b/crates/ui-egui/src/dialogs/flatten.rs @@ -192,7 +192,7 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions dim(ui, tl!("Rasters")); let rail = (ui.available_width() - FIELD - 56.0).clamp(80.0, 200.0); let shown = widgets::SliderValue::field(o.balance, 0.0..=100.0, "", 0); - if let (Some(x), _) = widgets::color_slider_valued( + let (x, _, keyed) = widgets::color_slider_stepped( ui, (id, "balance-slider"), tl!("Raster/Vector Balance:"), @@ -200,12 +200,13 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions (o.balance / 100.0) as f32, rail, &|_| t.input_border, - ) { - o.balance = (x as f64 * 100.0).round(); + ); + if let Some(b) = x.map(|x| (x as f64 * 100.0).round()).or(keyed) { + o.balance = b; } dim(ui, tl!("Vectors")); - if let Some(v) = widgets::plain_field(ui, (id, "balance"), o.balance, "", 0, FIELD - 12.0) { - o.balance = v.round().clamp(0.0, 100.0); + if let Some(v) = widgets::range_field(ui, (id, "balance"), o.balance, 0.0..=100.0, "", 0, FIELD - 12.0) { + o.balance = v; } }); ui.add_space(4.0); @@ -214,8 +215,8 @@ pub(crate) fn options_editor(ui: &mut egui::Ui, id: &str, o: &mut FlattenOptions widgets::dim_label(ui, label); ui.horizontal(|ui| { let v = if key == 0 { &mut o.line_art_ppi } else { &mut o.gradient_ppi }; - if let Some(n) = widgets::plain_field(ui, (id, key), *v, "", 0, FIELD) { - *v = n.round().clamp(1.0, 2400.0); + if let Some(n) = widgets::range_field(ui, (id, key), *v, 1.0..=2400.0, "", 0, FIELD) { + *v = n; } dim(ui, "ppi"); }); diff --git a/crates/ui-egui/src/dialogs/flattener_presets.rs b/crates/ui-egui/src/dialogs/flattener_presets.rs index 4fbac19a5..5a546b532 100644 --- a/crates/ui-egui/src/dialogs/flattener_presets.rs +++ b/crates/ui-egui/src/dialogs/flattener_presets.rs @@ -189,7 +189,7 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> f(); return Ok(()); } - let pick = crate::FilePick { filters: vec![("Flattener presets", PRESET_EXTS)], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Flattener presets", PRESET_EXTS)], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("flattener.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index a61fbdbb0..b715ff98a 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -61,9 +61,10 @@ fn lengths(kind: &str) -> &'static [&'static str] { "move" => &["dx", "dy", "distance"], "rectangle" | "ellipse" | "rectangularGrid" | "polarGrid" | "artboardOptions" => &["width", "height"], "roundedRectangle" => &["width", "height", "radius"], - "polygon" => &["radius"], + "polygon" | "spiral" => &["radius"], "star" => &["radius1", "radius2"], "lineSegment" => &["length"], + "arc" => &["lengthX", "lengthY"], "offsetPath" => &["offset"], _ => &[], } @@ -73,12 +74,22 @@ fn lengths(kind: &str) -> &'static [&'static str] { /// number: fields that ↑/↓ step by one (Shift: ten) and that do math, as the distances do. fn suffix(kind: &str, key: &str) -> Option<&'static str> { match (kind, key) { - ("rotate" | "shear" | "reflect" | "move", "angle") => Some("°"), - ("scale", "sx" | "sy") => Some("%"), + ("rotate" | "shear" | "reflect" | "move" | "lineSegment", "angle") => Some("°"), + ("scale", "sx" | "sy") | ("spiral", "decay") => Some("%"), _ => None, } } +/// The fields of the form dialogs ([`grid`]) that the dialog's own body draws, by dialog kind. +fn own_rows(kind: &str) -> &'static [&'static str] { + match kind { + "artboardOptions" => &["color", super::artboard_options::PIXEL_ASPECT], + // The grids' Reference Point is their 9-point locator. + "rectangularGrid" | "polarGrid" => &["reference"], + _ => &[], + } +} + /// The fields of the form dialogs ([`grid`]) that come first, in this order (the others follow by /// name). fn order(kind: &str) -> &'static [&'static str] { @@ -87,7 +98,9 @@ fn order(kind: &str) -> &'static [&'static str] { "offsetPath" => &["offset", "joins", "miterLimit"], "rectangularGrid" => &["width", "height", "rows", "rowSkew", "columns", "columnSkew"], "polarGrid" => &["width", "height", "concentric", "concentricSkew", "radial", "radialSkew"], + "spiral" => &["radius", "decay", "segments"], "artboardOptions" => &["name", "width", "height"], + "lineSegment" => &["length", "angle"], _ => &[], } } @@ -126,6 +139,13 @@ pub(super) fn text(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> /// [`text`] returning the field's response (focus, Enter). pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32) -> egui::Response { + text_edit_limited(ui, d, key, width, usize::MAX) +} + +/// [`text_edit`] that takes at most `max_chars` characters: typing and pasting stop at the limit, +/// and a longer value already in the field is kept, with nothing added to it until it has fewer +/// than `max_chars` characters. +pub(super) fn text_edit_limited(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, max_chars: usize) -> egui::Response { let t = Tokens::get(ui.ctx()); let mut s = d.str(key); let id = ui.id().with(("dlg-text", key)); @@ -137,7 +157,7 @@ pub(super) fn text_edit(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32 .inner_margin(egui::Margin::symmetric(6, 3)) .show(ui, |ui| { crate::theme::strong_selected_text(ui); - ui.add(egui::TextEdit::singleline(&mut s).id(id).frame(egui::Frame::NONE).desired_width(width)) + ui.add(egui::TextEdit::singleline(&mut s).id(id).frame(egui::Frame::NONE).desired_width(width).char_limit(max_chars)) }); if r.inner.changed() { d.fields.insert(key.into(), Value::String(s)); @@ -199,9 +219,9 @@ fn dropdown(ui: &mut egui::Ui, d: &mut Dialog, key: &str, width: f32, options: & } } -/// The generic dialog body: a field per non-boolean value (positions, indices and UI-only keys -/// hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as dropdowns and the -/// [`skew_ends`] fields as sliders. +/// The generic dialog body: a field per non-boolean value (positions, indices, UI-only keys and the +/// kind's [`own_rows`] hidden) in the kind's [`order`], the distances in `unit`, the [`choices`] as +/// dropdowns and the [`skew_ends`] fields as sliders. pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { grid_then(ui, d, unit, |_, _| {}); } @@ -210,11 +230,12 @@ pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { /// with the generic ones. pub(super) fn grid_then(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit, more: impl FnOnce(&mut egui::Ui, &mut Dialog)) { let rail = Tokens::get(ui.ctx()).input_border; - let (lengths, first) = (lengths(&d.kind), order(&d.kind)); + let (lengths, first, own) = (lengths(&d.kind), order(&d.kind), own_rows(&d.kind)); let mut keys: Vec = d .fields .iter() .filter(|(k, v)| !matches!(k.as_str(), "x" | "y" | "origin" | "index") && !k.starts_with("__") && !v.is_boolean()) + .filter(|(k, _)| !own.contains(&k.as_str())) .map(|(k, _)| k.clone()) .collect(); // Stable: the fields `order` doesn't name keep their order after the ones it does. @@ -570,6 +591,8 @@ pub(crate) fn humanize(k: &str) -> String { "Align To Path" => "Align to Path:".into(), "Overlap Variation" | "Curviness Variation" | "Spacing Variation" => "Variation:".into(), "Create" => "Create New Fields by:".into(), + "Height Width" => "Height/Width:".into(), + "Traps With Process Color" => "Traps with Process Color:".into(), _ => format!("{s}:"), } } @@ -633,14 +656,16 @@ pub(super) fn slider_field_named( let v = d.f64(key, 0.0).clamp(min, max); let shown = crate::widgets::SliderValue::field(v, range.clone(), suffix, 0); slider_rail_valued(ui, d, key, name, range, Some(shown), track); - if let Some(n) = crate::widgets::plain_field(ui, ("dlg-field", key), v, suffix, 0, 52.0).map(|x| x.round().clamp(min, max)).filter(|n| *n != v) { + if let Some(n) = crate::widgets::range_field(ui, ("dlg-field", key), v, min..=max, suffix, 0, 52.0).filter(|n| *n != v) { d.fields.insert(key.into(), json!(n)); } } /// The rail of [`slider_field_named`] alone: dragging it sets the number `d.fields[key]` to a -/// whole number in `range` (a value past the range shows at its end until the rail moves); screen -/// readers call it `name` (already translated), with the number `d.fields[key]` as its value. +/// whole number in `range`, and its keys step the number while it has the keyboard focus +/// ([`crate::widgets::color_slider_stepped`]); a value past the range shows at its end until the +/// rail moves. Screen readers call it `name` (already translated), with the number +/// `d.fields[key]` as its value. pub(super) fn slider_rail_named( ui: &mut egui::Ui, d: &mut Dialog, @@ -654,7 +679,9 @@ pub(super) fn slider_rail_named( } /// [`slider_rail_named`] that screen readers get `shown` from: the number and text of the field -/// the row draws beside it (no value for `None`, as for a blank field). +/// the row draws beside it (no value for `None`, as for a blank field). Its keys step `shown`'s +/// number, which sets `d.fields[key]` to the number at the same place on `range` (`shown`'s range +/// and `range` are the same span in two units, such as millimeters and points). pub(super) fn slider_rail_valued( ui: &mut egui::Ui, d: &mut Dialog, @@ -665,10 +692,14 @@ pub(super) fn slider_rail_valued( track: &dyn Fn(f32) -> egui::Color32, ) { let (min, max) = (*range.start(), *range.end()); - let v = d.f64(key, 0.0).clamp(min, max); - let (dragged, _) = - crate::widgets::color_slider_valued(ui, ("dlg-slider", key), name, shown, ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track); - if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).filter(|n| *n != v) { + let held = d.f64(key, 0.0); + let v = held.clamp(min, max); + let units = shown.as_ref().map(|s| s.range.clone()); + let (dragged, _, keyed) = + crate::widgets::color_slider_stepped(ui, ("dlg-slider", key), name, shown, ((v - min) / (max - min)) as f32, SLIDER_WIDTH, track); + let keyed = + keyed.zip(units).map(|(k, u)| if u == range { k } else { (min + (k - u.start()) / (u.end() - u.start()) * (max - min)).clamp(min, max) }); + if let Some(n) = dragged.map(|x| (min + x as f64 * (max - min)).round()).or(keyed).filter(|n| *n != held) { d.fields.insert(key.into(), json!(n)); } } @@ -867,3 +898,38 @@ mod typed_number_tests { assert!(typed_number(1e20).is_f64()); } } + +#[cfg(test)] +mod selected_text_tests { + use super::*; + use crate::widgets::look_tests::{frame, glyph_colours, themed}; + + // Regression test for #1465: selected text in a dialog's text field was in the accent, on a + // selection tinted with the same accent. It is in the strong text colour. + #[test] + fn selected_text_in_a_dialog_text_field_is_in_the_strong_text_colour() { + for brightness in crate::theme::Brightness::ALL { + let ctx = themed(brightness); + let t = Tokens::get(&ctx); + let mut d = Dialog::new("test", json!({"name": "Artboard 1"})); + let id = std::cell::Cell::new(egui::Id::NULL); + let mut add = |ui: &mut egui::Ui| { + id.set(ui.id().with(("dlg-text", "name"))); + text_edit(ui, &mut d, "name", 120.0); + }; + frame(&ctx, 0.0, vec![], &mut add); + // Focus the field and select all of its text. + ctx.memory_mut(|m| m.request_focus(id.get())); + frame(&ctx, 0.1, vec![], &mut add); + let mut state = egui::TextEdit::load_state(&ctx, id.get()).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(10)))); + state.store(&ctx, id.get()); + let shapes = frame(&ctx, 0.2, vec![], &mut add); + let colours = glyph_colours(&shapes); + assert!(!colours.is_empty(), "{brightness:?}: the text is drawn"); + let mut used = colours.clone(); + used.dedup(); + assert_eq!(used, [t.text_strong], "{brightness:?}: selected text in {used:?}"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/freehand.rs b/crates/ui-egui/src/dialogs/freehand.rs index a39139f67..50e4d2c4c 100644 --- a/crates/ui-egui/src/dialogs/freehand.rs +++ b/crates/ui-egui/src/dialogs/freehand.rs @@ -56,8 +56,8 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, (key, label): (&str, &str), suffix: let label_w = label_w(d); let v = d.f64(key, 0.0); widgets::label_row(ui, label, label_w, |ui| { - if let Some(x) = widgets::plain_field(ui, ("freehand-field", key), v, suffix, decimals, 72.0) { - d.fields.insert(key.into(), json!(x.clamp(range.0, range.1))); + if let Some(x) = widgets::range_field(ui, ("freehand-field", key), v, range.0..=range.1, suffix, decimals, 72.0) { + d.fields.insert(key.into(), json!(x)); } }); } @@ -72,13 +72,11 @@ fn fidelity(ui: &mut egui::Ui, d: &mut Dialog) { widgets::label_row(ui, tl!("Fidelity:"), label_w, |ui| { let at = ((v.clamp(min, max) - min) / (max - min)) as f32; let shown = widgets::SliderValue::field(v, min..=max, "pt", 1); - if let (Some(x), _) = - widgets::color_slider_valued(ui, "freehand-fidelity", tl!("Fidelity:"), Some(shown), at, form::SLIDER_WIDTH, &|_| t.input_border) - { - new = Some(((min + x as f64 * (max - min)) * 10.0).round() / 10.0); - } - if let Some(x) = widgets::plain_field(ui, "freehand-fidelity-field", v, "pt", 1, 64.0) { - new = Some(x.clamp(0.05, 100.0)); + let (x, _, keyed) = + widgets::color_slider_stepped(ui, "freehand-fidelity", tl!("Fidelity:"), Some(shown), at, form::SLIDER_WIDTH, &|_| t.input_border); + new = x.map(|x| ((min + x as f64 * (max - min)) * 10.0).round() / 10.0).or(keyed); + if let Some(x) = widgets::range_field(ui, "freehand-fidelity-field", v, 0.05..=100.0, "pt", 1, 64.0) { + new = Some(x); } }); if let Some(n) = new.filter(|n| *n != v) { diff --git a/crates/ui-egui/src/dialogs/graph_data.rs b/crates/ui-egui/src/dialogs/graph_data.rs index 33227897c..92b7e0139 100644 --- a/crates/ui-egui/src/dialogs/graph_data.rs +++ b/crates/ui-egui/src/dialogs/graph_data.rs @@ -327,7 +327,7 @@ fn revert(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { /// Import Data: a tab-separated text file's rows into the sheet. fn import(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> { - let pick = crate::FilePick { filters: vec![("Text", &["txt", "tsv", "csv"])], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Text", &["txt", "tsv", "csv"])], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; // The button is off where files can't be read. let Some(read) = app.services.read.as_ref() else { return Ok(()) }; diff --git a/crates/ui-egui/src/dialogs/graph_design.rs b/crates/ui-egui/src/dialogs/graph_design.rs index deafd4060..75659920e 100644 --- a/crates/ui-egui/src/dialogs/graph_design.rs +++ b/crates/ui-egui/src/dialogs/graph_design.rs @@ -119,7 +119,7 @@ fn preview(ui: &egui::Ui, app: &VectorcraftApp, name: &str, r: egui::Rect) { let px = (r.width() * ui.ctx().pixels_per_point()).round() as u32; let tex = CACHE.with(|c| c.borrow().get(&key).cloned()).or_else(|| { let img = RENDERER.with(|rr| rr.borrow_mut().render_node_thumbnail(&st.doc, &art, px.max(16), Some([255, 255, 255, 255])))?; - let color = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let color = crate::canvas::color_image(&img); let tex = ui.ctx().load_texture(format!("graph-design-{key}"), color, egui::TextureOptions::LINEAR); CACHE.with(|c| { let mut c = c.borrow_mut(); diff --git a/crates/ui-egui/src/dialogs/hyphenation.rs b/crates/ui-egui/src/dialogs/hyphenation.rs index 47e1c5863..d982f37c0 100644 --- a/crates/ui-egui/src/dialogs/hyphenation.rs +++ b/crates/ui-egui/src/dialogs/hyphenation.rs @@ -1,8 +1,9 @@ //! Paragraph panel menu › Hyphenation… (or `ui.hyphenation`): the Hyphenation checkbox and the //! limits automatic hyphenation keeps to in the paragraphs the Paragraph panel acts on: Words //! Longer Than, After First, Before Last, Hyphen Limit, Hyphenation Zone and Hyphenate Capitalized -//! Words. The dialog opens on the values the Paragraph panel shows. OK runs `text.setFormat` with -//! the values that changed, as one undo step, and paragraphs that differ keep the values left alone. +//! Words. The dialog opens on the values the Paragraph panel shows (blank fields and dashed +//! checkboxes where paragraphs in scope disagree). OK runs `text.setFormat` with the values that +//! changed, as one undo step, and paragraphs that differ keep the values left alone. //! //! Fields: `hyphenate`, `wordsLongerThan` (letters, 1..25), `afterFirst` and `beforeLast` //! (letters, 1..15), `hyphenLimit` (hyphens, 0..25, 0: no limit), `hyphenationZone` (pt) and @@ -26,26 +27,41 @@ pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| tl!("Hyphenation") /// Open Hyphenation on the paragraph attributes the Paragraph panel shows. pub fn open(app: &mut VectorcraftApp) -> Result { - let (_, para) = crate::panels::character::text_style(app).ok_or("select some type first")?; - let h = ¶.hyphenation; - let values = json!({ - "hyphenate": para.hyphenate, - "wordsLongerThan": h.words_longer_than, - "afterFirst": h.after_first, - "beforeLast": h.before_last, - "hyphenLimit": h.hyphen_limit, - "hyphenationZone": h.zone, - "hyphenateCapitalized": h.capitalized, - }); - let mut fields = values.clone(); - fields[INITIAL] = values; + crate::panels::character::text_style(app).ok_or("select some type first")?; + let mut values = Map::new(); + if let Some(v) = crate::panels::paragraph::shared_hyphenate(app) { + values.insert("hyphenate".into(), json!(v)); + } + if let Some(v) = crate::panels::paragraph::shared_hyphenation_field(app, |h| h.words_longer_than) { + values.insert("wordsLongerThan".into(), json!(v)); + } + if let Some(v) = crate::panels::paragraph::shared_hyphenation_field(app, |h| h.after_first) { + values.insert("afterFirst".into(), json!(v)); + } + if let Some(v) = crate::panels::paragraph::shared_hyphenation_field(app, |h| h.before_last) { + values.insert("beforeLast".into(), json!(v)); + } + if let Some(v) = crate::panels::paragraph::shared_hyphenation_field(app, |h| h.hyphen_limit) { + values.insert("hyphenLimit".into(), json!(v)); + } + if let Some(v) = crate::panels::paragraph::shared_hyphenation_zone(app) { + values.insert("hyphenationZone".into(), json!(v)); + } + if let Some(v) = crate::panels::paragraph::shared_hyphenation_field(app, |h| h.capitalized) { + values.insert("hyphenateCapitalized".into(), json!(v)); + } + let mut fields = Value::Object(values.clone()); + fields[INITIAL] = Value::Object(values); app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let unit = app.session.general_unit(); - form::check(ui, d, "hyphenate", tl!("Hyphenation")); + let hyphenate = d.fields.get("hyphenate").and_then(|v| v.as_bool()); + if widgets::check3(ui, tl!("Hyphenation"), hyphenate, true) { + d.fields.insert("hyphenate".into(), json!(hyphenate != Some(true))); + } ui.add_space(8.0); // The whole-number fields: (field, label, unit, range). let counts = [ @@ -58,7 +74,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { for (key, text, suffix, (lo, hi)) in counts { label(ui, text); ui.horizontal(|ui| { - if let Some(v) = widgets::range_field(ui, ("hyphenation", key), d.f64(key, lo), lo..=hi, "", 0, 60.0) { + let value = d.fields.get(key).and_then(|v| v.as_f64()); + if let Some(v) = widgets::range_field(ui, ("hyphenation", key), value, lo..=hi, "", 0, 60.0) { d.fields.insert(key.into(), json!(v)); } ui.label(suffix); @@ -70,7 +87,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); }); ui.add_space(8.0); - form::check(ui, d, "hyphenateCapitalized", tl!("Hyphenate Capitalized Words")); + let capitalized = d.fields.get("hyphenateCapitalized").and_then(|v| v.as_bool()); + if widgets::check3(ui, tl!("Hyphenate Capitalized Words"), capitalized, true) { + d.fields.insert("hyphenateCapitalized".into(), json!(capitalized != Some(true))); + } false } @@ -163,8 +183,6 @@ mod tests { app.run("ui.hyphenation", json!({})).unwrap(); let d = app.ui.dialog.as_mut().unwrap(); d.fields.insert("beforeLast".into(), json!(4)); - // Edited and set back to the value it opened with, as a field stores it. - d.fields.insert("afterFirst".into(), json!(3.0)); super::super::confirm(&mut app).unwrap(); let (first, second) = paras(&app, id); assert_eq!((first.hyphenation.after_first, first.hyphenation.before_last), (3, 4)); @@ -179,4 +197,22 @@ mod tests { assert!(app.run("ui.hyphenation", json!({})).is_err()); assert!(app.ui.dialog.is_none()); } + + /// When paragraphs in scope disagree, only shared hyphenation values are filled in. + #[test] + fn hyphenation_dialog_mixed_paragraph_fields_show_blank() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "afterFirst": 3})).unwrap(); + app.run("text.setFormat", json!({"ids": [id], "afterFirst": 2, "start": 5, "end": 5})).unwrap(); + app.run("ui.hyphenation", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert!(!d.fields.contains_key("afterFirst")); + assert_eq!(d.f64("beforeLast", 0.0), 3.0); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("afterFirst".into(), json!(4)); + super::super::confirm(&mut app).unwrap(); + let (first, second) = paras(&app, id); + assert_eq!((first.hyphenation.after_first, second.hyphenation.after_first), (4, 4)); + assert_eq!((first.hyphenation.before_last, second.hyphenation.before_last), (3, 3)); + } } diff --git a/crates/ui-egui/src/dialogs/import_pdf.rs b/crates/ui-egui/src/dialogs/import_pdf.rs index c485a3d89..b9488d607 100644 --- a/crates/ui-egui/src/dialogs/import_pdf.rs +++ b/crates/ui-egui/src/dialogs/import_pdf.rs @@ -202,7 +202,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(pw) = password(&d) { p["password"] = json!(pw); } - crate::place::run(app, &p).map(|_| ()) + crate::place::run_for_user(app, &p).map(|_| ()) } else { let mut p = json!({"cropTo": crop(&d).id(), "password": password(&d)}); let range = d.str("range"); @@ -249,8 +249,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { nav(ui, "chevrons-left", tl!("First Page"), 1, current > 1), nav(ui, "chevron-left", tl!("Previous Page"), current.saturating_sub(1).max(1), current > 1), ]; - if let Some(v) = widgets::plain_field(ui, "pdf-page", current as f64, "", 0, 40.0) { - current = (v.max(1.0) as usize).min(count.max(1)); + if let Some(v) = widgets::range_field(ui, "pdf-page", current as f64, 1.0..=count.max(1) as f64, "", 0, 40.0) { + current = v as usize; } ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {count}"), &[("count", &count.to_string())])).color(t.text_dim)); let go2 = [ diff --git a/crates/ui-egui/src/dialogs/javascript_files.rs b/crates/ui-egui/src/dialogs/javascript_files.rs index 6f89feead..6fb734e1d 100644 --- a/crates/ui-egui/src/dialogs/javascript_files.rs +++ b/crates/ui-egui/src/dialogs/javascript_files.rs @@ -43,7 +43,7 @@ const JS_EXTS: &[&str] = &["js"]; /// The open dialog for a JavaScript file. fn pick() -> FilePick { - FilePick { filters: vec![("JavaScript", JS_EXTS)], ..Default::default() } + FilePick { filters: vec![FilePick::filter("JavaScript", JS_EXTS)], ..Default::default() } } /// Choose…: the file picked in the open dialog into File or URL, as a `file:///` URL diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index 758523ce2..054c08159 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -1,9 +1,10 @@ //! Paragraph panel menu › Justification… (or `ui.justification`): Word Spacing, Letter Spacing and //! Glyph Scaling (each Minimum, Desired and Maximum, in percent), Auto Leading and Single Word //! Justification of the paragraphs the Paragraph panel acts on. The dialog opens on the values the -//! Paragraph panel shows and previews them on the canvas. OK runs `text.setFormat` with the values -//! that changed, as one undo step; paragraphs that differ keep the values left alone. OK is -//! disabled while a row's Minimum exceeds its Desired value or Desired its Maximum. +//! Paragraph panel shows (blank fields where paragraphs in scope differ) and previews them on the +//! canvas. OK runs `text.setFormat` with the values that changed, as one undo step; paragraphs +//! that differ keep the values left alone. OK is disabled while a row's Minimum exceeds its Desired +//! value or Desired its Maximum. //! //! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`, //! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading` @@ -65,22 +66,32 @@ fn single_word_name(s: SingleWordJustify) -> &'static str { /// Open Justification on the paragraph attributes the Paragraph panel shows. pub fn open(app: &mut VectorcraftApp) -> Result { - let (_, para) = crate::panels::character::text_style(app).ok_or("select some type first")?; + crate::panels::character::text_style(app).ok_or("select some type first")?; // While typing, the dialog acts on the paragraphs of the text being edited. let mut targets = json!({}); if let Some((id, a, b)) = crate::panels::character::text_editing(app) { crate::panels::character::end_typing(app); targets = json!({"ids": [id.0], "start": a, "end": b}); } - let j = ¶.justification; let mut values = Map::new(); - for ((_, prefix, _), v) in ROWS.iter().zip([j.word_spacing, j.letter_spacing, j.glyph_scaling]) { - for ((suffix, _), x) in COLUMNS.iter().zip(v) { - values.insert(format!("{prefix}{suffix}"), json!(x)); + for (_, prefix, _) in ROWS { + for (i, (suffix, _)) in COLUMNS.iter().enumerate() { + let key = format!("{prefix}{suffix}"); + if let Some(v) = crate::panels::paragraph::shared_justification_f64(app, |j| match prefix { + "word" => j.word_spacing[i], + "letter" => j.letter_spacing[i], + _ => j.glyph_scaling[i], + }) { + values.insert(key, json!(v)); + } } } - values.insert("autoLeading".into(), json!(j.auto_leading)); - values.insert("singleWord".into(), json!(single_word_name(j.single_word))); + if let Some(v) = crate::panels::paragraph::shared_justification_f64(app, |j| j.auto_leading) { + values.insert("autoLeading".into(), json!(v)); + } + if let Some(s) = crate::panels::paragraph::shared_single_word_justify(app) { + values.insert("singleWord".into(), json!(single_word_name(s))); + } let mut fields = Value::Object(values.clone()); fields[INITIAL] = Value::Object(values); fields[TARGETS] = targets; @@ -92,7 +103,11 @@ pub fn open(app: &mut VectorcraftApp) -> Result { /// Why OK can't apply the dialog: a row whose values are out of order. fn problem(d: &Dialog) -> Option<&'static str> { let out_of_order = ROWS.iter().any(|(_, prefix, _)| { - let [min, desired, max] = COLUMNS.map(|(suffix, _)| d.f64(&format!("{prefix}{suffix}"), 0.0)); + let keys = COLUMNS.map(|(suffix, _)| format!("{prefix}{suffix}")); + if !keys.iter().all(|k| d.fields.contains_key(k)) { + return false; + } + let [min, desired, max] = keys.map(|k| d.f64(&k, 0.0)); !(min <= desired && desired <= max) }); out_of_order.then(|| tl!("Minimum must not exceed Desired, nor Desired Maximum.")) @@ -110,7 +125,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { label(ui, text); for (suffix, _) in COLUMNS { let key = format!("{prefix}{suffix}"); - if let Some(v) = widgets::range_field(ui, ("justification", key.as_str()), d.f64(&key, 0.0), *lo..=*hi, "%", 0, 70.0) { + let value = d.fields.get(&key).and_then(|v| v.as_f64()); + if let Some(v) = widgets::range_field(ui, ("justification", key.as_str()), value, *lo..=*hi, "%", 0, 70.0) { d.fields.insert(key, json!(v)); } } @@ -121,14 +137,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { egui::Grid::new("justification-more").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { label(ui, tl!("Auto Leading:")); let (lo, hi) = JustificationOptions::AUTO_LEADING; - if let Some(v) = widgets::range_field(ui, ("justification", "autoLeading"), d.f64("autoLeading", 120.0), lo..=hi, "%", 0, 64.0) { + let leading = d.fields.get("autoLeading").and_then(|v| v.as_f64()); + if let Some(v) = widgets::range_field(ui, ("justification", "autoLeading"), leading, lo..=hi, "%", 0, 64.0) { d.fields.insert("autoLeading".into(), json!(v)); } ui.end_row(); label(ui, tl!("Single Word Justification:")); let choices = single_word_choices(); - let current = d.str("singleWord"); - let shown = choices.iter().find(|(v, _)| *v == current).map_or(choices[0].1, |(_, l)| *l); + let current = d.fields.get("singleWord").and_then(|v| v.as_str()); + let shown = current.and_then(|current| choices.iter().find(|(v, _)| *v == current).map(|(_, l)| *l)).unwrap_or(""); let labels = choices.map(|(_, l)| l); if let Some(i) = widgets::dropdown(ui, ("justification", "singleWord"), shown, &labels, 140.0) && let Some((v, _)) = choices.get(i) @@ -157,6 +174,9 @@ fn params(d: &Dialog) -> Map { let mut changed = Map::new(); for (suffix, name) in COLUMNS { let key = format!("{prefix}{suffix}"); + if !d.fields.contains_key(&key) { + continue; + } let v = d.f64(&key, 0.0); if was(&key) != Some(v) { changed.insert(name.into(), json!(v)); @@ -166,12 +186,15 @@ fn params(d: &Dialog) -> Map { p.insert(param.into(), Value::Object(changed)); } } - let leading = d.f64("autoLeading", 120.0); - if was("autoLeading") != Some(leading) { - p.insert("autoLeading".into(), json!(leading)); + if d.fields.contains_key("autoLeading") { + let leading = d.f64("autoLeading", 120.0); + if was("autoLeading") != Some(leading) { + p.insert("autoLeading".into(), json!(leading)); + } } - let single = d.str("singleWord"); - if initial.and_then(|m| m.get("singleWord")).and_then(Value::as_str) != Some(single.as_str()) { + if let Some(single) = d.fields.get("singleWord").and_then(|v| v.as_str()) + && initial.and_then(|m| m.get("singleWord")).and_then(Value::as_str) != Some(single) + { p.insert("singleWordJustification".into(), json!(single)); } p @@ -308,4 +331,21 @@ mod tests { assert!(app.ui.dialog.is_none()); assert_eq!(paras(&app, id).0.justification.auto_leading, 120.0); } + + /// When paragraphs in scope disagree, only shared justification values are filled in. + #[test] + fn justification_dialog_mixed_paragraph_fields_show_blank() { + let (mut app, id) = app(); + app.run("text.setFormat", json!({"ids": [id], "wordSpacing": [70, 100, 200], "start": 5, "end": 5})).unwrap(); + app.run("ui.justification", json!({})).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert!(!d.fields.contains_key("wordMin")); + assert!(!d.fields.contains_key("wordMax")); + assert_eq!(d.f64("wordDesired", 0.0), 100.0); + set(&mut app, "wordMin", json!(75.0)); + super::super::confirm(&mut app).unwrap(); + let (first, second) = paras(&app, id); + assert_eq!(first.justification.word_spacing, [75.0, 100.0, 133.0]); + assert_eq!(second.justification.word_spacing, [75.0, 100.0, 200.0]); + } } diff --git a/crates/ui-egui/src/dialogs/liquify.rs b/crates/ui-egui/src/dialogs/liquify.rs index cc2f841bb..40aa677f1 100644 --- a/crates/ui-egui/src/dialogs/liquify.rs +++ b/crates/ui-egui/src/dialogs/liquify.rs @@ -87,15 +87,15 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { }); } widgets::label_row(ui, tl!("Angle:"), LABEL_W, |ui| { - if let Some(v) = widgets::plain_field(ui, "lq-angle", d.f64("angle", 0.0), "°", 1, 110.0) { - d.fields.insert("angle".into(), json!(v.clamp(-360.0, 360.0))); + if let Some(v) = widgets::range_field(ui, "lq-angle", d.f64("angle", 0.0), -360.0..=360.0, "°", 1, 110.0) { + d.fields.insert("angle".into(), json!(v)); } }); let pressure = d.bool("usePressure"); widgets::label_row(ui, tl!("Intensity:"), LABEL_W, |ui| { ui.add_enabled_ui(!pressure, |ui| { - if let Some(v) = widgets::plain_field(ui, "lq-intensity", d.f64("intensity", 50.0), "%", 0, 110.0) { - d.fields.insert("intensity".into(), json!(v.clamp(1.0, 100.0))); + if let Some(v) = widgets::range_field(ui, "lq-intensity", d.f64("intensity", 50.0), 1.0..=100.0, "%", 0, 110.0) { + d.fields.insert("intensity".into(), json!(v)); } }); }); diff --git a/crates/ui-egui/src/dialogs/live_paint_gaps.rs b/crates/ui-egui/src/dialogs/live_paint_gaps.rs index 3d99287f7..98c7abfba 100644 --- a/crates/ui-egui/src/dialogs/live_paint_gaps.rs +++ b/crates/ui-egui/src/dialogs/live_paint_gaps.rs @@ -2,7 +2,8 @@ //! or Custom with its size), Gap Preview Color (a layer color or a custom color) and Close Gaps //! With Paths for the selected Live Paint groups, opened on the first group's options. With Preview //! on, the options apply to the groups live and the gaps they find draw on the canvas in the preview -//! color; OK keeps them as one undo step (`livePaint.gapOptions`). +//! color; OK keeps them as one undo step (`livePaint.gapOptions`). View › Show Live Paint Gaps +//! draws the gaps of the selected groups the same way ([`selected_gaps`]). //! //! Fields: `detect`, `size` (`small` | `medium` | `large` | `custom`), `custom` (pt), `color` //! (`#rrggbb`, or a preset's name such as "Red"), `closeGaps`, `preview`, `__ids` (the groups) and @@ -139,14 +140,32 @@ pub(crate) fn overlays(app: &VectorcraftApp) -> Vec { .filter_map(|gap| { let v: Vec = gap.as_array()?.iter().filter_map(Value::as_f64).collect(); let [x0, y0, x1, y1] = v.as_slice() else { return None }; - let mut path = BezPath::new(); - path.move_to(Point::new(*x0, *y0)); - path.line_to(Point::new(*x1, *y1)); - Some(Overlay::Path { path, color: [r, g, b], width: 2.0, dashed: false }) + Some(gap_line(Point::new(*x0, *y0), Point::new(*x1, *y1), [r, g, b])) }) .collect() } +/// View › Show Live Paint Gaps: the gaps of the selected Live Paint groups, as lines in each +/// group's Gap Preview Color, drawn as the preview draws them. +pub(crate) fn selected_gaps(app: &VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let groups = st.selection.objects.iter().filter_map(|id| st.doc.node(*id)).filter(|n| vectorcraft_tools::builder::is_live_paint(n)); + groups + .flat_map(|n| { + let color = n.gap_options().color; + vectorcraft_engine::cmd::live_paint_gaps(n).into_iter().map(move |[a, b]| gap_line(a, b, color)) + }) + .collect() +} + +/// A gap from `a` to `b` as a line in `color`, 2 screen points wide. +fn gap_line(a: Point, b: Point, color: [u8; 3]) -> Overlay { + let mut path = BezPath::new(); + path.move_to(a); + path.line_to(b); + Overlay::Path { path, color, width: 2.0, dashed: false } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/dialogs/live_paint_selection.rs b/crates/ui-egui/src/dialogs/live_paint_selection.rs index a7e4c4149..8ba8cf49b 100644 --- a/crates/ui-egui/src/dialogs/live_paint_selection.rs +++ b/crates/ui-egui/src/dialogs/live_paint_selection.rs @@ -67,8 +67,8 @@ fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (min, max) = LP_SELECT_WIDTH_RANGE; let v = d.f64("width", min); widgets::label_row(ui, tl!("Width:"), LABEL_W, |ui| { - if let Some(x) = widgets::plain_field(ui, "lp-select-width", v, "px", 0, 72.0) { - d.fields.insert("width".into(), json!(x.round().clamp(min, max))); + if let Some(x) = widgets::range_field(ui, "lp-select-width", v, min..=max, "px", 0, 72.0) { + d.fields.insert("width".into(), json!(x)); } }); false diff --git a/crates/ui-egui/src/dialogs/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs index 9f7f2ad89..45130b92a 100644 --- a/crates/ui-egui/src/dialogs/missing_fonts.rs +++ b/crates/ui-egui/src/dialogs/missing_fonts.rs @@ -736,27 +736,6 @@ mod tests { assert_eq!((d.str("state"), &d.fields["chosen"]), ("done".to_string(), &json!([]))); } - /// The status lines are translated with the reasons in them, and so are the reasons a search - /// failed and a file wasn't copied, which the code holds as constants. - #[test] - fn the_status_lines_read_in_the_interface_language() { - let fr = crate::i18n::Lang::from_code("fr").unwrap(); - let shown = crate::i18n::message(fr, "2 font file(s) are now in /Fonts; 1 couldn't be added: a.otf: larger than 256 MB"); - assert!(shown.contains("se trouvent maintenant dans /Fonts") && shown.contains("a.otf") && shown.contains("plus de 256 Mo"), "{shown}"); - for code in ["de", "es", "fr", "hu", "it", "ka", "pl", "ru", "sv"] { - let lang = crate::i18n::Lang::from_code(code).unwrap(); - for s in [ - "every search thread stopped on an internal error (please report this bug)", - "not a file", - "not a font file", - "the files come to more than 1 GB: add fewer at a time", - "every numbered name for it is taken in the Fonts folder", - ] { - assert_ne!(crate::i18n::message(lang, s), s, "{code}"); - } - } - } - #[test] fn the_web_says_how_many_fonts_are_missing() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/dialogs/missing_links.rs b/crates/ui-egui/src/dialogs/missing_links.rs index 27a9c6791..5ea9d77b8 100644 --- a/crates/ui-egui/src/dialogs/missing_links.rs +++ b/crates/ui-egui/src/dialogs/missing_links.rs @@ -172,7 +172,8 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let path = match d.str("path") { p if !p.is_empty() => p, _ => { - let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::place_filters().collect(), ..Default::default() }; + let pick = + crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; crate::picks::open(app, &pick).ok_or("cancelled")? } }; diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 8ca1a2b84..827855d28 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -8,6 +8,7 @@ mod about; pub mod action_batch; +pub mod action_set_options; pub mod artboard_options; pub mod blend_options; mod brush_art_options; @@ -24,6 +25,7 @@ pub mod dxf_import; pub mod dxf_options; pub mod edit_selection; mod effect; +pub mod effect_gallery; pub mod envelope; pub mod eps_options; pub mod expand; @@ -55,6 +57,7 @@ pub mod live_paint_selection; pub mod missing_fonts; pub mod missing_links; pub(crate) mod modal; +pub mod new_brush; pub mod new_color_group; mod new_document; pub mod new_swatch; @@ -86,8 +89,10 @@ pub(crate) mod save_options; mod save_pdf; pub mod save_style_library; pub mod save_swatch_library; +pub mod save_symbol_library; pub mod script_check; -mod shapes; +pub mod shape_builder_options; +pub(crate) mod shapes; pub mod shortcut_import; pub(crate) mod simplify_slider; pub mod slices; @@ -113,7 +118,7 @@ use serde_json::{Value, json}; pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; -pub(crate) use effect::{FILM_GRAIN_FIELDS, SHADES, shown_choices}; +pub(crate) use effect::{FILM_GRAIN_FIELDS, PAINT_DAUB_BRUSH_TYPE_LABELS, SHADES, shown_choices}; pub use export_as::open as open_export_as; pub use export_for_screens::open as open_export_for_screens; pub(crate) use export_for_screens::{ @@ -122,7 +127,7 @@ pub(crate) use export_for_screens::{ }; pub(crate) use form::{humanize, number_list_text, read_number_list, unit_suffix}; pub(crate) use new_document::preset_name; -pub use new_document::{open as open_new_document, preset_card}; +pub use new_document::{create_without_dialog as new_document_without_dialog, open as open_new_document, preset_card}; pub use png_options::open as open_raster_options; pub use save_pdf::{open as open_save_pdf, open_preset as open_pdf_preset}; pub use tools::open_tool_dialog; @@ -243,7 +248,7 @@ macro_rules! registry { registry! { DimensionOptions: [dimension::KIND] => dimension::SPEC, NewDocument: [new_document::KIND] => new_document::SPEC, - Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "rectangularGrid", "polarGrid"] => shapes::SPEC, + Shape: ["rectangle", "roundedRectangle", "ellipse", "polygon", "star", "lineSegment", "arc", "spiral", "rectangularGrid", "polarGrid"] => shapes::SPEC, Transform: ["move", "rotate", "scale", "reflect", "shear"] => transform::SPEC, PathOp: ["average", "offsetPath", "simplify", "splitIntoGrid"] => path_ops::SPEC, DocumentSetup: ["documentSetup"] => document_setup::SPEC, @@ -281,6 +286,7 @@ registry! { FlattenTransparency: [flatten::KIND] => flatten::SPEC, FlattenerPresets: [flattener_presets::KIND] => flattener_presets::SPEC, SaveStyleLibrary: [save_style_library::KIND] => save_style_library::SPEC, + SaveSymbolLibrary: [save_symbol_library::KIND] => save_symbol_library::SPEC, Expand: [expand::KIND] => expand::SPEC, SpotColors: [spot_colors::KIND] => spot_colors::SPEC, TransformEach: [transform_each::KIND] => transform_each::SPEC, @@ -299,6 +305,7 @@ registry! { SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC, FileInfo: [file_info::KIND] => file_info::SPEC, RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC, + EffectGallery: [effect_gallery::KIND] => effect_gallery::SPEC, MissingLinks: [missing_links::KIND] => missing_links::SPEC, MissingFonts: [missing_fonts::KIND] => missing_fonts::SPEC, FindLinks: [find_links::KIND] => find_links::SPEC, @@ -312,6 +319,7 @@ registry! { PlacementOptions: [placement_options::KIND] => placement_options::SPEC, Package: [package::KIND] => package::SPEC, ActionBatch: [action_batch::KIND] => action_batch::SPEC, + ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC, SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC, DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC, EpsOptions: [eps_options::KIND] => eps_options::SPEC, @@ -332,6 +340,7 @@ registry! { Envelope: [envelope::WARP, envelope::MESH, envelope::OPTIONS] => envelope::SPEC, LiquifyOptions: [liquify::KIND] => liquify::SPEC, FreehandOptions: [freehand::KIND] => freehand::SPEC, + ShapeBuilderOptions: [shape_builder_options::KIND] => shape_builder_options::SPEC, FlareOptions: [flare_options::KIND] => flare_options::SPEC, SymbolOptions: [symbol_options::KIND] => symbol_options::SPEC, SymbolismOptions: [symbolism_options::KIND] => symbolism_options::SPEC, @@ -341,6 +350,7 @@ registry! { LivePaintGaps: [live_paint_gaps::KIND] => live_paint_gaps::SPEC, LivePaintSelectionOptions: [live_paint_selection::KIND] => live_paint_selection::SPEC, BrushOptions: [brush_options::KIND] => brush_options::SPEC, + NewBrush: [new_brush::KIND] => new_brush::SPEC, EditSelection: [edit_selection::KIND] => edit_selection::SPEC, PerspectivePlane: [perspective_plane::KIND] => perspective_plane::SPEC, LayerOptions: [layer_options::KIND] => layer_options::SPEC, @@ -422,6 +432,14 @@ pub(crate) fn settle(app: &mut VectorcraftApp) { /// until Enter has been let go. pub(crate) const AFTER_PANEL: &str = "__afterPanel"; +/// Marks the open dialog, if any, as one a save panel opened ([`AFTER_PANEL`]): the Enter that +/// closed the panel, still held, doesn't confirm it. +pub(crate) fn opened_by_panel(app: &mut VectorcraftApp) { + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert(AFTER_PANEL.into(), json!(true)); + } +} + /// Draws the active dialog and handles shared initial focus, keyboard shortcuts and actions. /// Inline actions and footer buttons use the same confirmation and preview rollback paths. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/dialogs/modal.rs b/crates/ui-egui/src/dialogs/modal.rs index 4ac6d6cd5..2addf6129 100644 --- a/crates/ui-egui/src/dialogs/modal.rs +++ b/crates/ui-egui/src/dialogs/modal.rs @@ -70,10 +70,6 @@ fn handle(id: Id) -> Id { id.with("modal-drag") } -/// The least height of [`scrolled_rows`] in a window too short for the rest of its contents: about -/// three rows. -const MIN_ROWS_H: f32 = 100.0; - /// Under a modal window's id: how tall its [`scrolled_rows`] were drawn this frame, and how tall /// the rest of it was when last drawn. const ROWS_KEY: &str = "modal-rows"; @@ -81,12 +77,12 @@ const REST_KEY: &str = "modal-rest"; /// Rows of the modal window `ui` is in (one list of them per window) that scroll if the window /// would be taller than the screen. Their height is the screen's less the rest of the window as -/// last drawn (a frame late, as only the drawn window knows its height), and at least -/// [`MIN_ROWS_H`]. Rows that fit are as tall as they are. +/// last drawn (a frame late, as only the drawn window knows its height). +/// Rows that fit are as tall as they are. pub(crate) fn scrolled_rows(ui: &mut egui::Ui, salt: impl egui::AsIdSalt, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let id = ui.layer_id().id; let rest = ui.ctx().data(|m| m.get_temp::(id.with(REST_KEY))).unwrap_or(0.0); - let room = (ui.ctx().content_rect().height() - rest - 2.0 * super::EDGE_GAP).max(MIN_ROWS_H); + let room = (ui.ctx().content_rect().height() - rest - 2.0 * super::EDGE_GAP).max(0.0); // As tall as the room at once: by itself a scroll area grows a frame at a time with the window. let rows = ui.scope(|ui| egui::ScrollArea::vertical().id_salt(salt).max_height(room).min_scrolled_height(room).show(ui, add).inner); ui.ctx().data_mut(|m| m.insert_temp(id.with(ROWS_KEY), rows.response.rect.height())); diff --git a/crates/ui-egui/src/dialogs/new_brush.rs b/crates/ui-egui/src/dialogs/new_brush.rs new file mode 100644 index 000000000..3746a5874 --- /dev/null +++ b/crates/ui-egui/src/dialogs/new_brush.rs @@ -0,0 +1,188 @@ +//! New Brush (the Brushes panel's New Brush button, its menu's New Brush…, and art dropped on the +//! panel): the type of brush to make. Scatter, Art and Pattern are off when no art is selected or +//! dropped. OK opens that type's Brush Options for the new brush +//! ([`super::brush_options::open_new`]), whose OK makes it; Cancel makes nothing. +//! +//! Fields: `type` (`calligraphic` | `scatter` | `art` | `bristle` | `pattern`), `__art` (art is +//! selected or was dropped) and `__ids` (the ids of the dropped art; without it, the selection's). + +use serde_json::{Value, json}; + +use super::{DialogSpec, brush_options}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of New Brush. +pub const KIND: &str = "newBrush"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("New Brush").into(), body, confirm, ok_enabled: Some(ok_enabled), min_width: 300.0, ..DialogSpec::FORM }; + +/// The brush types, in the Brushes panel's order. +const TYPES: [&str; 5] = ["calligraphic", "scatter", "art", "bristle", "pattern"]; + +/// Open New Brush for the art of `ids` (art dropped on the Brushes panel), else for the selection. +/// It starts on Art when there is art, and it starts on Calligraphic otherwise. +pub fn open(app: &mut VectorcraftApp, ids: Option) -> Result { + let st = app.session.active().ok_or("no document open")?; + let art = match &ids { + Some(ids) => ids.as_array().is_some_and(|a| !a.is_empty()), + None => !st.selection.is_empty(), + }; + let mut f = json!({ "type": if art { "art" } else { "calligraphic" }, "__art": art }); + if let Some(ids) = ids { + f["__ids"] = ids; + } + app.ui.dialog = Some(Dialog::new(KIND, f)); + Ok(Value::Null) +} + +/// Does a brush of type `ty` need art? +fn needs_art(ty: &str) -> bool { + matches!(ty, "scatter" | "art" | "pattern") +} + +/// The type the fields choose: `type`, or Calligraphic when it names none. +fn chosen(d: &Dialog) -> &'static str { + let ty = d.str("type"); + TYPES.into_iter().find(|t| *t == ty).unwrap_or("calligraphic") +} + +fn ok_enabled(_: &VectorcraftApp, d: &Dialog) -> bool { + d.bool("__art") || !needs_art(chosen(d)) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let labels = [tl!("Calligraphic Brush"), tl!("Scatter Brush"), tl!("Art Brush"), tl!("Bristle Brush"), tl!("Pattern Brush")]; + let (art, cur) = (d.bool("__art"), chosen(d)); + for (ty, label) in TYPES.into_iter().zip(labels) { + if widgets::radio(ui, label, cur == ty, art || !needs_art(ty)) { + d.fields.insert("type".into(), json!(ty)); + } + } + if !art { + ui.add_space(6.0); + widgets::dim_label(ui, tl!("Select art to make a scatter, art or pattern brush.")); + } + false +} + +/// OK: the chosen type's Brush Options for the new brush. A type that needs art, without art, +/// keeps the dialog open. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let ty = chosen(d); + if needs_art(ty) && !d.bool("__art") { + return Err("nothing selected".into()); + } + brush_options::open_new(app, ty, d.fields.get("__ids").cloned()) +} + +#[cfg(test)] +mod tests { + use vectorcraft_engine::Session; + + use super::*; + use crate::widgets::accessibility_tests::{named, tree}; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app + } + + fn set(app: &mut VectorcraftApp, fields: Value) { + let d = app.ui.dialog.as_mut().unwrap(); + for (k, v) in fields.as_object().unwrap() { + d.fields.insert(k.clone(), v.clone()); + } + } + + fn library(app: &mut VectorcraftApp) -> Vec<(String, String)> { + crate::panels::brushes::brushes(app).0 + } + + fn undo_steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() + } + + /// Which of the five types screen readers find on, in the dialog's order. + fn types_on(app: &mut VectorcraftApp) -> Vec { + let t = tree(|ui| super::super::show(app, ui.ctx())); + ["Calligraphic Brush", "Scatter Brush", "Art Brush", "Bristle Brush", "Pattern Brush"].map(|l| !named(&t, l).is_disabled()).to_vec() + } + + /// With nothing selected, Scatter, Art and Pattern are off, New Brush starts on Calligraphic, + /// and OK on one of them keeps the dialog open. With art selected, every type is on and New + /// Brush starts on Art. + #[test] + fn scatter_art_and_pattern_brushes_need_art() { + let mut app = app(); + app.run("ui.newBrush", json!({})).unwrap(); + assert_eq!(types_on(&mut app), [true, false, false, true, false]); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("type"), "calligraphic"); + set(&mut app, json!({"type": "art"})); + assert_eq!(super::super::confirm(&mut app), Err("nothing selected".into())); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND)); + app.ui.dialog = None; + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + app.run("ui.newBrush", json!({})).unwrap(); + assert_eq!(types_on(&mut app), [true; 5]); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("type"), "art"); + } + + /// OK opens the chosen type's Brush Options for a new brush, named New Brush; their OK + /// makes the brush with the name and values set, from the selected art, in one undo step. + #[test] + fn ok_opens_the_types_brush_options_and_their_ok_makes_the_brush() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let (before, steps) = (library(&mut app), undo_steps(&app)); + app.run("ui.newBrush", json!({})).unwrap(); + set(&mut app, json!({"type": "scatter"})); + super::super::confirm(&mut app).unwrap(); + let d = app.ui.dialog.as_ref().expect("Scatter Brush Options"); + assert_eq!((d.kind.as_str(), d.str("__type"), d.str("name")), (super::brush_options::KIND, "scatter".into(), "New Scatter Brush".into())); + assert!(d.fields["__def"]["art"].is_object(), "the selected art, for the preview"); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| super::super::show(app, ui.ctx())); + assert!(text.contains("Scatter Brush Options") && text.contains("Spacing:"), "{text}"); + assert_eq!(library(&mut app), before, "no brush until OK"); + // The name is trimmed, and a name another brush has gets a number, as `brush.new` gives it. + set(&mut app, json!({"name": " Dots ", "sizeMin": 40, "spacingMin": 150})); + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(library(&mut app)[before.len()..], [("Dots 2".to_string(), "scatter".to_string())]); + let def = app.run("brush.get", json!({"name": "Dots 2"})).unwrap(); + assert_eq!((&def["size"][0], &def["spacing"][0]), (&json!(40.0), &json!(150.0))); + assert!(def["art"].is_object(), "{def}"); + assert_eq!(undo_steps(&app), steps + 1); + // A Calligraphic brush, with no art. + app.run("ui.newBrush", json!({})).unwrap(); + set(&mut app, json!({"type": "calligraphic"})); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("name"), "New Calligraphic Brush"); + set(&mut app, json!({"size": 9})); + super::super::confirm(&mut app).unwrap(); + let def = app.run("brush.get", json!({"name": "New Calligraphic Brush"})).unwrap(); + assert_eq!((&def["type"], &def["size"]), (&json!("calligraphic"), &json!(9.0))); + } + + /// Cancel in New Brush, or in the Brush Options it opens, adds no brush and no undo step. + #[test] + fn cancel_adds_no_brush() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap(); + let before = (library(&mut app), undo_steps(&app)); + app.run("ui.newBrush", json!({})).unwrap(); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none()); + for ty in TYPES { + app.run("ui.newBrush", json!({})).unwrap(); + set(&mut app, json!({"type": ty})); + super::super::confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(super::brush_options::KIND), "{ty}"); + super::super::cancel(&mut app); + assert!(app.ui.dialog.is_none(), "{ty}"); + assert_eq!((library(&mut app), undo_steps(&app)), before, "{ty}"); + } + } +} diff --git a/crates/ui-egui/src/dialogs/new_document.rs b/crates/ui-egui/src/dialogs/new_document.rs index 11f5d3c6d..2a41fcb9e 100644 --- a/crates/ui-egui/src/dialogs/new_document.rs +++ b/crates/ui-egui/src/dialogs/new_document.rs @@ -117,11 +117,7 @@ fn sync(app: &VectorcraftApp, d: &mut Dialog) { /// Create the document; the dialog stays open when that fails (bad size, …). fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); - // Recent entries such as "Custom" aren't presets file.new knows. - if newdoc::find(&app.session, &d.str("preset")).is_none() { - p.remove("preset"); - } + let p = params(app, d); let r = app.run("file.new", Value::Object(p)); if r.is_ok() { app.ui.dialog = None; @@ -129,6 +125,27 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { r } +/// New Without Dialog (`file.newWithoutDialog`, Cmd+Alt+N): create the document [`open`] would +/// offer, as its Create would, without showing the dialog. A dialog already open stays open. +pub fn create_without_dialog(app: &mut VectorcraftApp) -> Result { + let shown = app.ui.dialog.take(); + open(app); + let offered = app.ui.dialog.take(); + app.ui.dialog = shown; + let p = offered.map(|d| params(app, &d)).unwrap_or_default(); + app.run("file.new", Value::Object(p)) +} + +/// The `file.new` params of the fields. +fn params(app: &VectorcraftApp, d: &Dialog) -> Map { + let mut p: Map = PARAMS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + // Recent entries such as "Custom" aren't presets file.new knows. + if newdoc::find(&app.session, &d.str("preset")).is_none() { + p.remove("preset"); + } + p +} + /// The units the size fields show. fn unit(d: &Dialog) -> Unit { Unit::named(&d.str("units")).unwrap_or_default() diff --git a/crates/ui-egui/src/dialogs/new_swatch.rs b/crates/ui-egui/src/dialogs/new_swatch.rs index a55631dec..5b6c5b117 100644 --- a/crates/ui-egui/src/dialogs/new_swatch.rs +++ b/crates/ui-egui/src/dialogs/new_swatch.rs @@ -34,7 +34,7 @@ pub fn open(app: &mut VectorcraftApp, spot: bool, global: bool, group: Option<&s json!({"color": color, "mode": mode_id(Mode::of(color)), "spot": spot, "global": spot || global, "group": group.unwrap_or_default()}) } Paint::None => return Err("pick a colour, gradient or pattern to save as a swatch".into()), - other => json!({"__paint": crate::panels::paint_params(other)}), + other => json!({"__paint": crate::panels::swatch_paint_params(other)}), }; fields["name"] = json!(name); fields["__auto"] = json!(name); diff --git a/crates/ui-egui/src/dialogs/package.rs b/crates/ui-egui/src/dialogs/package.rs index 020c53096..839f76383 100644 --- a/crates/ui-egui/src/dialogs/package.rs +++ b/crates/ui-egui/src/dialogs/package.rs @@ -5,12 +5,18 @@ //! //! Fields: `folder` (the location), `name` (the package folder), `copyLinks`, `linksFolder`, //! `relink`, `copyFonts`, `report`. OK runs `file.package` with them, then offers to show the -//! package folder (`file.showPackage`). +//! package folder (`file.showPackage`). On the desktop with Background Export on, the package is +//! collected and written on the background thread ([`crate::background`]) after the dialog closes. + +use std::cell::Cell; +use std::rc::Rc; use serde_json::{Value, json}; +use vectorcraft_engine::EngineError; use super::confirm::ask; use super::{DialogSpec, form}; +use crate::background::{self, Writer}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, io, widgets}; @@ -110,44 +116,100 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { return Ok(r); } } - let r = app.run("file.package", params)?; - app.ui.dialog = None; + // Background Export (Preferences › File Handling): the linked files and fonts are collected + // and the package is written from the document as it is now, while editing goes on. The jobs + // started before it (a save started above, an earlier package to the same folder) are written + // first: the worker runs its jobs in order, and a package made at once waits for them. + let enabled = app.session.prefs.background_export; + if !enabled { + background::wait_all(app); + } + let package = app.session.prepare_package(¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; + let label = format!("Packaging {}", d.str("name")); + let later = Rc::new(Cell::new(false)); + let in_background = later.clone(); + let work = move |write: Writer| { + // A writer error that already names the file (an automation root's refusal) is kept as it is. + let mut write = + |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(if e.contains(path) { e } else { format!("{path}: {e}") })); + package.write(&mut write).map_err(|e| e.to_string()) + }; + let r = background::run(app, enabled, label, None, work, move |app, r| packaged(app, r, web, in_background.get()))?; + if r["background"] == true { + later.set(true); + app.ui.dialog = None; + } + Ok(r) +} + +/// Report the package `r` (`file.package`'s reply): the status bar, the web's download, and the +/// offer to show the package folder. `later`: it was written in the background, after the dialog +/// closed, and a dialog open now is another one, which stays (the status bar names the folder). +fn packaged(app: &mut VectorcraftApp, r: Result, web: bool, later: bool) -> Result { + let r = r.inspect_err(|e| app.ui.status = e.clone())?; + if !later { + app.ui.dialog = None; + } let n = r["files"].as_array().map_or(0, Vec::len); let missing = r["missingLinks"].as_array().map_or(0, Vec::len); // The fonts left out: their license doesn't allow it, or they aren't on this computer. let fonts = r["skippedFonts"].as_array().into_iter().flatten().filter_map(|f| f["font"].as_str()).collect::>().join(", "); - let mut note = if missing > 0 { format!(" ({missing} linked file(s) not found)") } else { String::new() }; - if !fonts.is_empty() { - note.push_str(&format!(" (fonts not copied: {fonts})")); - } + let shown_note = package_status_note(missing, &fonts); + let n_s = n.to_string(); if web { let (name, data) = (r["name"].as_str().unwrap_or("package.zip"), r["dataBase64"].as_str().unwrap_or_default()); let bytes = vectorcraft_format::base64_decode(data).ok_or("the package came back damaged")?; io::write_named(app, None, name, &bytes)?; - app.status(format!("Packaged {n} file(s){note}")); + let status = if shown_note.is_empty() { + crate::i18n::fmt(tl!("Packaged {n} file(s)"), &[("n", &n_s)]) + } else { + crate::i18n::fmt(tl!("Packaged {n} file(s) {note}"), &[("n", &n_s), ("note", &shown_note)]) + }; + app.status(status); return Ok(r); } let folder = r["folder"].as_str().unwrap_or_default().to_string(); - app.status(format!("Packaged {n} file(s) in {folder}{note}")); - let mut shown_note = if missing > 0 { - format!(" ({})", crate::i18n::fmt(tl!("{count} linked file(s) not found"), &[("count", &missing.to_string())])) + let status = if shown_note.is_empty() { + crate::i18n::fmt(tl!("Packaged {n} file(s) in {folder}"), &[("n", &n_s), ("folder", &folder)]) } else { - String::new() + crate::i18n::fmt(tl!("Packaged {n} file(s) in {folder} {note}"), &[("n", &n_s), ("folder", &folder), ("note", &shown_note)]) }; - if !fonts.is_empty() { - shown_note.push_str(&format!(" ({})", crate::i18n::fmt(tl!("fonts not copied: {fonts}"), &[("fonts", &fonts)]))); + app.status(status); + let detail = if shown_note.is_empty() { + crate::i18n::fmt(tl!("{count} file(s) in {folder}. Show the package folder?"), &[("count", &n_s), ("folder", &folder)]) + } else { + crate::i18n::fmt( + tl!("{count} file(s) in {folder} {note}. Show the package folder?"), + &[("count", &n_s), ("folder", &folder), ("note", &shown_note)], + ) + }; + if app.ui.dialog.is_none() { + ask(app, tl!("Package created"), &detail, "file.showPackage", json!({ "folder": folder })); } - let detail = crate::i18n::fmt( - tl!("{count} file(s) in {folder}{note}. Show the package folder?"), - &[("count", &n.to_string()), ("folder", &folder), ("note", &shown_note)], - ); - ask(app, tl!("Package created"), &detail, "file.showPackage", json!({ "folder": folder })); Ok(r) } +/// Parenthetical notes after the folder path in a package status (`(2 linked file(s) not found)` …), +/// joined with spaces and without a leading space (#3494). +fn package_status_note(missing: usize, fonts: &str) -> String { + let mut parts = Vec::new(); + if missing > 0 { + parts.push(format!("({})", crate::i18n::fmt(tl!("{count} linked file(s) not found"), &[("count", &missing.to_string())]))); + } + if !fonts.is_empty() { + parts.push(format!("({})", crate::i18n::fmt(tl!("fonts not copied: {fonts}"), &[("fonts", fonts)]))); + } + parts.join(" ") +} + /// `file.showPackage {folder}`: show a package folder in the file manager. pub fn show_package(app: &mut VectorcraftApp, p: &Value) -> Result { + app.refuse_automation("file.showPackage")?; let folder = p.get("folder").and_then(Value::as_str).ok_or("missing `folder`")?.to_string(); + // A package is a folder: an executable, shortcut or app bundle would run, not be shown. + if !std::path::Path::new(&folder).is_dir() || folder.to_ascii_lowercase().ends_with(".app") { + return Err(format!("{folder} is not a package folder")); + } io::open_in_app(app, &folder)?; Ok(json!({ "folder": folder })) } diff --git a/crates/ui-egui/src/dialogs/path_ops.rs b/crates/ui-egui/src/dialogs/path_ops.rs index c46096a00..d627405f1 100644 --- a/crates/ui-egui/src/dialogs/path_ops.rs +++ b/crates/ui-egui/src/dialogs/path_ops.rs @@ -72,8 +72,8 @@ fn split_grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { egui::Grid::new("dlg").num_columns(4).spacing([10.0, 8.0]).show(ui, |ui| { for (number, label, ..) in axes { form::row_label(ui, label); - if let Some(v) = crate::widgets::plain_field(ui, ("dlg-num", number), d.f64(number, 2.0), "", 0, WIDTH) { - d.fields.insert(number.into(), json!(v.round().clamp(1.0, 500.0))); + if let Some(v) = crate::widgets::range_field(ui, ("dlg-num", number), d.f64(number, 2.0), 1.0..=500.0, "", 0, WIDTH) { + d.fields.insert(number.into(), json!(v)); } } ui.end_row(); @@ -193,7 +193,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } // The fields as the dialog held them, so they show as typed when it opens again. if d.kind == "offsetPath" { - let kept = ["offset", "joins", "miterLimit"].into_iter().filter_map(|k| Some((k.to_string(), d.fields.get(k)?.clone()))); + // Preview among them: the checkbox keeps its last state across opens (#3195), as + // Simplify's retained fields keep theirs. + let kept = ["offset", "joins", "miterLimit", "preview"].into_iter().filter_map(|k| Some((k.to_string(), d.fields.get(k)?.clone()))); app.ui.offset_path = Value::Object(kept.collect()); } Ok(r) diff --git a/crates/ui-egui/src/dialogs/pdf_presets.rs b/crates/ui-egui/src/dialogs/pdf_presets.rs index c8c442ac3..10f7e0e45 100644 --- a/crates/ui-egui/src/dialogs/pdf_presets.rs +++ b/crates/ui-egui/src/dialogs/pdf_presets.rs @@ -177,7 +177,10 @@ fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current: &str) -> f(); return Ok(()); } - let pick = crate::FilePick { filters: vec![("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], ..Default::default() }; + let pick = crate::FilePick { + filters: vec![crate::FilePick::filter("PDF presets", vectorcraft_engine::cmd::pdfcmds::PRESET_EXTS)], + ..Default::default() + }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("pdf.preset.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/perspective_grid.rs b/crates/ui-egui/src/dialogs/perspective_grid.rs index caf0603dd..6ca855f76 100644 --- a/crates/ui-egui/src/dialogs/perspective_grid.rs +++ b/crates/ui-egui/src/dialogs/perspective_grid.rs @@ -333,11 +333,11 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let v = d.f64("opacity", 50.0).clamp(0.0, 100.0); let track = |x: f32| egui::Color32::from_gray((60.0 + x * 160.0) as u8); let shown = widgets::SliderValue::field(v, 0.0..=100.0, "%", 0); - let mut new = widgets::color_slider_valued(ui, "persp-opacity", tl!("Opacity:"), Some(shown), (v / 100.0) as f32, form::SLIDER_WIDTH, &track) - .0 - .map(|x| (f64::from(x) * 100.0).round()); - if let Some(x) = widgets::plain_field(ui, "persp-opacity-field", v, "%", 0, 52.0) { - new = Some(x.round().clamp(0.0, 100.0)); + let (x, _, keyed) = + widgets::color_slider_stepped(ui, "persp-opacity", tl!("Opacity:"), Some(shown), (v / 100.0) as f32, form::SLIDER_WIDTH, &track); + let mut new = x.map(|x| (f64::from(x) * 100.0).round()).or(keyed); + if let Some(x) = widgets::range_field(ui, "persp-opacity-field", v, 0.0..=100.0, "%", 0, 52.0) { + new = Some(x); } if let Some(n) = new.filter(|n| *n != v) { d.fields.insert("opacity".into(), json!(n)); diff --git a/crates/ui-egui/src/dialogs/perspective_presets.rs b/crates/ui-egui/src/dialogs/perspective_presets.rs index a182e66f8..7432bd3b2 100644 --- a/crates/ui-egui/src/dialogs/perspective_presets.rs +++ b/crates/ui-egui/src/dialogs/perspective_presets.rs @@ -195,7 +195,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current f(); return Ok(()); } - let pick = crate::FilePick { filters: vec![("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Perspective grid presets", PRESET_EXTS)], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("perspective.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/place.rs b/crates/ui-egui/src/dialogs/place.rs index 90e234b6a..e53c0f4a4 100644 --- a/crates/ui-egui/src/dialogs/place.rs +++ b/crates/ui-egui/src/dialogs/place.rs @@ -5,7 +5,8 @@ //! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file //! and one selected object: Replace applies), `__documents` and `__others` (VectorCraft documents //! from files are picked, other files are: what Link's tooltip explains), `__info` (a line per -//! file). +//! file; on the desktop the lines are read off the UI thread and are empty until they are read, +//! [`crate::place::fill_lines`]). use serde_json::{Value, json}; @@ -34,7 +35,10 @@ const OPTIONS: [(&str, &str, &str); 3] = [ ("replace", "Replace", "Swap the selected object for the file, keeping its place and transform"), ]; -fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + if crate::place::fill_lines(app, d) { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } let t = Tokens::get(ui.ctx()); let files: Vec = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default(); let info = d.fields.get("__info").cloned().unwrap_or_default(); diff --git a/crates/ui-egui/src/dialogs/plugin.rs b/crates/ui-egui/src/dialogs/plugin.rs index 152e8f025..eef4787ee 100644 --- a/crates/ui-egui/src/dialogs/plugin.rs +++ b/crates/ui-egui/src/dialogs/plugin.rs @@ -5,13 +5,15 @@ //! effect dialog (`effect.dialog`). A filter with parameters opens dialog `plugin`: one field per //! declared parameter (fields named after them; `__plugin` is the plug-in id) with a live //! Preview, and OK keeps the run as one undo step (`plugin.run`). A filter without parameters runs -//! at once. Exporters are formats of File › Export › Export As; one with parameters opens dialog -//! `pluginExport` once the file is picked, and its Export button writes the file. +//! at once. A heavy filter (its manifest's `budget`) has no Preview, since a run may take minutes: +//! it runs in the background ([`crate::plugin_runs`]). Exporters are formats of File › Export › +//! Export As; one with parameters opens dialog `pluginExport` once the file is picked, and its +//! Export button writes the file. use std::sync::Arc; use serde_json::{Map, Value, json}; -use vectorcraft_plugins::{Kind, Manifest, Plugin, registry}; +use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, registry}; use super::{DialogSpec, form}; use crate::VectorcraftApp; @@ -80,10 +82,12 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { } let given = p.get("params").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); if m.params.is_empty() || given.is_some() { - return app.run(CMD, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)})); + return run(app, &plugin, json!({"id": id, "params": given.cloned().unwrap_or(Value::Null)})); } let mut fields = fields_for(m); - fields.insert("preview".into(), json!(true)); + if m.budget == Budget::Light { + fields.insert("preview".into(), json!(true)); + } app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); Ok(Value::Null) } @@ -113,16 +117,48 @@ fn params(d: &Dialog) -> Value { json!({"id": d.str("__plugin"), "params": form::params(d)}) } +/// Runs filter `plugin` with `plugin.run`'s params `p`: at once, or in the background when it is +/// heavy. +fn run(app: &mut VectorcraftApp, plugin: &Plugin, p: Value) -> Result { + match plugin.manifest().budget { + Budget::Light => app.run(CMD, p), + Budget::Heavy => crate::plugin_runs::start(app, p), + } +} + +/// The plug-in's description and fields, then the live Preview; a heavy filter has a note instead +/// that it runs in the background, since a run may take minutes. fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let Some(plugin) = described(ui, d) else { return true }; form::schema_fields(app, ui, d, plugin.manifest()); - let (label, p) = (d.str("__label"), params(d)); - form::preview(app, ui, d, &label, CMD, p); + match plugin.manifest().budget { + Budget::Light => { + let (label, p) = (d.str("__label"), params(d)); + form::preview(app, ui, d, &label, CMD, p); + } + // No Preview: each run may take minutes. + Budget::Heavy => { + ui.add_space(8.0); + let note = tl!("It may take minutes, so it runs in the background, where you can cancel it."); + ui.label(egui::RichText::new(note).color(crate::theme::Tokens::get(ui.ctx()).text_dim)); + } + } false } +/// OK: a filter keeps its previewed run as one undo step; a heavy one, which has no preview, starts +/// in the background ([`run`]) and the dialog closes. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { - form::commit_preview(app, CMD, params(d)) + match registry::get(&d.str("__plugin")) { + Some(plugin) if plugin.manifest().budget == Budget::Heavy => { + let r = run(app, &plugin, params(d)); + if r.is_ok() { + app.ui.dialog = None; + } + r + } + _ => form::commit_preview(app, CMD, params(d)), + } } /// The dialog kind of an exporter's parameters, after Export As picked the file. diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 93d3284e6..d21692ac3 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -143,9 +143,9 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { d.fields.insert("__otherPpi".into(), json!(i == 3)); } if preset.is_none() - && let Some(v) = widgets::plain_field(ui, "ro-ppi-other", ppi, " ppi", 0, 80.0) + && let Some(v) = widgets::range_field(ui, "ro-ppi-other", ppi, 1.0..=4608.0, " ppi", 0, 80.0) { - d.fields.insert("ppi".into(), json!(v.round().clamp(1.0, 4608.0))); + d.fields.insert("ppi".into(), json!(v)); } }); ui.end_row(); diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index ea4f58d39..eb163d8c1 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -558,8 +558,8 @@ fn art_texture(app: &mut VectorcraftApp, ctx: &egui::Context, layers: PrintLayer fn whole(ui: &mut egui::Ui, d: &mut Dialog, path: &str, range: std::ops::RangeInclusive, enabled: bool) { let v = get(d, path).as_f64().unwrap_or(*range.start()); ui.add_enabled_ui(enabled, |ui| { - if let Some(x) = widgets::plain_field(ui, path, v, "", 0, 56.0) { - set(d, path, json!(x.round().clamp(*range.start(), *range.end()) as u64)); + if let Some(x) = widgets::range_field(ui, path, v, range, "", 0, 56.0) { + set(d, path, json!(x as u64)); } }); } diff --git a/crates/ui-egui/src/dialogs/print_presets.rs b/crates/ui-egui/src/dialogs/print_presets.rs index 747269d11..a8a9f4244 100644 --- a/crates/ui-egui/src/dialogs/print_presets.rs +++ b/crates/ui-egui/src/dialogs/print_presets.rs @@ -157,7 +157,7 @@ pub(super) fn run(app: &mut VectorcraftApp, d: &mut Dialog, act: Action, current f(); return Ok(()); } - let pick = crate::FilePick { filters: vec![("Print presets", PRESET_EXTS)], ..Default::default() }; + let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Print presets", PRESET_EXTS)], ..Default::default() }; let path = crate::picks::open(app, &pick).ok_or("cancelled")?; let r = app.run("print.presets.import", json!({ "path": path }))?; if let Some(first) = r["imported"].get(0).and_then(Value::as_str) { diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 576007ccf..8a66b410e 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -229,7 +229,9 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { let mut rs = rows(d); let top = ui.cursor().top(); let changed = if d.str("tab") == "edit" { edit_tab(ui, d, &mut rs) } else { assign_tab(app, ui, d, &mut rs) }; - ui.add_space((TAB_H - (ui.cursor().top() - top)).max(0.0)); + if d.str("tab") != "edit" { + ui.add_space((TAB_H - (ui.cursor().top() - top)).max(0.0)); + } if changed { snap(app, d, &mut rs); d.fields.insert("rows".into(), Value::Array(rs)); @@ -452,7 +454,8 @@ fn assign_tab(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut // Import a library (.ase, .acb, .gpl, .vcswatches or a document's swatches) and limit to it. if app.services.pick_open.is_some() && widgets::icon_button(ui, "folder-open", tl!("Other Library…"), false, 24.0).clicked() { let r = crate::picks::in_dialog(app, d, |app, d| { - let pick = crate::FilePick { filters: vectorcraft_engine::cmd::fileio::open_filters().collect(), ..Default::default() }; + let pick = + crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::open_filters()), ..Default::default() }; let Some(path) = crate::picks::open(app, &pick) else { return Ok(()) }; let r = app.run("swatch.library.load", json!({ "path": path }))?; d.fields.insert("limitTo".into(), r["library"].clone()); @@ -542,6 +545,7 @@ const DRAG: &str = "__drag"; /// The Edit tab. Returns true when the rows changed. fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool { + let top = ui.cursor().top(); let t = Tokens::get(ui.ctx()); let mut changed = false; let linked = d.bool("linked"); @@ -594,17 +598,22 @@ fn edit_tab(ui: &mut Ui, d: &mut Dialog, rows: &mut [Value]) -> bool { } ui.add_space(6.0); // The new colours: click one to make it the base colour. - ui.horizontal_wrapped(|ui| { - for &i in &active { - let (r, resp) = ui.allocate_exact_size(vec2(28.0, 20.0), Sense::click()); - chip(ui, r, to_color(&rows[i]).unwrap_or_default(), false); - if Some(i) == base { - ui.painter().rect_stroke(r.expand(1.5), 2.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Outside); + let min_height = (TAB_H - (ui.cursor().top() - top)).max(0.0); + super::modal::scrolled_rows(ui, "recolor-edit-colors", |ui| { + // Keep the usual tab height when it fits; the empty space scrolls with the chips. + ui.set_min_height(min_height); + ui.horizontal_wrapped(|ui| { + for &i in &active { + let (r, resp) = ui.allocate_exact_size(vec2(28.0, 20.0), Sense::click()); + chip(ui, r, to_color(&rows[i]).unwrap_or_default(), false); + if Some(i) == base { + ui.painter().rect_stroke(r.expand(1.5), 2.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Outside); + } + if resp.on_hover_text(rows[i]["to"].as_str().unwrap_or_default()).clicked() { + d.fields.insert("__sel".into(), json!([i])); + } } - if resp.on_hover_text(rows[i]["to"].as_str().unwrap_or_default()).clicked() { - d.fields.insert("__sel".into(), json!([i])); - } - } + }); }); changed } @@ -632,6 +641,16 @@ mod tests { ] } + #[test] + fn recolor_dialog_keeps_the_default_model_without_an_extra_dropdown() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app.run("ui.recolorDialog", json!({"colors": ["#ff8000"]})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(!d.fields.contains_key("model")); + assert!(apply_params(d).get("model").is_none()); + } + #[test] fn merge_and_separate_rows() { let mut r = rows(); diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index bd14c5e21..ca281d95d 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -293,6 +293,7 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { } }); ui.add_enabled_ui(d.bool("shade"), |ui| { + lighting_presets(ui, d); light_list(ui, d); let mut selected = selected_light(d); let before = light_record(&selected); @@ -310,6 +311,26 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { }); } +fn lighting_presets(ui: &mut egui::Ui, d: &mut Dialog) { + let names = [tl!("Standard"), tl!("Diffuse"), tl!("Top Left"), tl!("Right")]; + let current = vectorcraft_effects::matching_lighting_preset(&super::form::params(d)); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + label(ui, tl!("Presets")); + let shown = current.and_then(|i| names.get(i).copied()).unwrap_or(tl!("Custom")); + if let Some(preset) = widgets::dropdown(ui, "three-d-lighting-presets", shown, &names, VALUE_W + rail_width(ui) + 6.0) + .and_then(|i| vectorcraft_effects::LIGHTING_PRESETS.get(i)) + && let Some(patch) = preset.params().as_object() + { + d.fields.extend(patch.clone()); + d.fields.insert("__light".into(), json!(0)); + ui.memory_mut(|m| m.stop_text_input()); + super::revolve_gizmo::clear(ui.ctx()); + } + }); + ui.add_space(4.0); +} + const LIGHT_KEYS: [(&str, &str); 6] = [ ("color", "lightColor"), ("intensity", "lightIntensity"), @@ -499,7 +520,9 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, if response.changed() { next = Some(value); } - if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) { + if let Some(n) = + scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32, &(from(range.0)..=from(range.1))) + { next = Some(n); } // Lengths' rails cover a useful working range; larger values can still be typed. @@ -515,7 +538,7 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, let places = ((f64::from(ui.input(|i| i.aim_radius())) / speed).log10().ceil().clamp(0.0, 15.0) as usize).min(decimals); let number = ui.style().number_formatter.format(value, places..=decimals); let shown = widgets::SliderValue::text(number.parse().unwrap_or(value), rail_min..=rail_max, format!("{number}{suffix}")); - let slider = widgets::color_slider_valued( + let slider = widgets::color_slider_stepped( ui, ("revolve-slider", key), name, @@ -524,8 +547,8 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, rail, &|_| t.input_border, ); - if let (Some(n), _) = slider { - next = Some(rail_min + f64::from(n) * (rail_max - rail_min)); + if let Some(n) = slider.0.map(|n| rail_min + f64::from(n) * (rail_max - rail_min)).or(slider.2) { + next = Some(n); } if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { next = Some(from(default)); diff --git a/crates/ui-egui/src/dialogs/save_for_web.rs b/crates/ui-egui/src/dialogs/save_for_web.rs index 73cf74d89..50863b93f 100644 --- a/crates/ui-egui/src/dialogs/save_for_web.rs +++ b/crates/ui-egui/src/dialogs/save_for_web.rs @@ -68,11 +68,12 @@ const MATTE_LABELS: [&str; 4] = ["None", "White", "Black", "Other"]; /// JPEG quality steps: (label, quality). const QUALITIES: [(&str, u8); 5] = [("Low", 10), ("Medium", 30), ("High", 60), ("Very High", 80), ("Maximum", 100)]; -/// Open the dialog on the remembered settings. +/// Open on the document's active artboard, keeping the other remembered settings. pub fn open(app: &mut VectorcraftApp) -> Result { let n = app.session.active().ok_or("no document")?.doc.artboards.len(); let mut s = app.session.last_web_settings(); - // A remembered artboard this document doesn't have: its first. + s.artboard = Some(crate::panels::artboards::selected(app, n)); + // A document with no artboards has no valid index. if s.artboard.is_some_and(|a| a >= n) { s.artboard = None; } @@ -200,30 +201,79 @@ pub(crate) fn browser_preview(app: &mut VectorcraftApp, p: &Value) -> Result Result { - preview_folder(&std::env::temp_dir().join("VectorCraft Save for Web"), PREVIEW_KEEP) + preview_folder_in(&std::env::temp_dir(), PREVIEW_KEEP) } -/// A new folder in `root`, named after the process id and a count of the process's previews. A -/// name that is already taken, by an earlier process with the same id, is skipped for the next -/// count. Entries of `root` last changed `keep` or longer ago are removed first. +/// The name of a folder that a preview made in the temporary folder, beside a `VectorCraft Save +/// for Web` it couldn't use, before the process id and the count. +#[cfg(not(target_arch = "wasm32"))] +const BESIDE: &str = "VectorCraft Save for Web "; + +/// A new folder for a browser preview in the temporary folder `tmp`: inside `VectorCraft Save for +/// Web` ([`preview_folder`]), or, when that folder can't be used, directly in `tmp`, named +/// [`BESIDE`] followed by the process id and a count. The log says why the shared folder wasn't +/// used. Each preview first removes the folders of that name in `tmp` last changed `keep` or longer +/// ago ([`remove_old`]). +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn preview_folder_in(tmp: &std::path::Path, keep: std::time::Duration) -> Result { + remove_old(tmp, BESIDE, keep); + preview_folder(&tmp.join("VectorCraft Save for Web"), keep).or_else(|e| { + log::warn!("vectorcraft: Preview in Browser can't use {e}, so the preview goes to a new folder of its own"); + new_folder(tmp, BESIDE) + }) +} + +/// A new folder in `root` ([`new_folder`]). `root` is made when it is missing. An existing `root` +/// is refused when it is a link or not a folder, or, on unix, when it belongs to another user or +/// users other than its owner can write to it. Before the new folder is made, the preview folders +/// in `root` last changed `keep` or longer ago are removed ([`remove_old`]). #[cfg(not(target_arch = "wasm32"))] pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) -> Result { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - for e in std::fs::read_dir(root).into_iter().flatten().flatten() { - let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep); - if old { + match private_folder().recursive(true).create(root) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(format!("{}: {e}", root.display())), + } + let m = std::fs::symlink_metadata(root).map_err(|e| format!("{}: {e}", root.display()))?; + if let Some(why) = refused(&m) { + return Err(format!("{}: {}", root.display(), why)); + } + remove_old(root, "", keep); + new_folder(root, "") +} + +/// Removes the preview folders in `dir` last changed `keep` or longer ago: folders (not links) +/// named `prefix` followed by `-`, on unix only those the current user owns. Other +/// entries are left alone. +#[cfg(not(target_arch = "wasm32"))] +fn remove_old(dir: &std::path::Path, prefix: &str, keep: std::time::Duration) { + for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { + if !is_preview_name(&e.file_name(), prefix) { + continue; + } + // `DirEntry::metadata` doesn't follow a link, so a link is never a folder here. + let Ok(m) = e.metadata() else { continue }; + let old = m.modified().ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age >= keep); + if old && m.is_dir() && mine(&m) { // Best effort: one that can't be removed now is tried again by the next preview. - let _ = if e.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(e.path()) } else { std::fs::remove_file(e.path()) }; + let _ = std::fs::remove_dir_all(e.path()); } } - std::fs::create_dir_all(root).map_err(|e| format!("{}: {e}", root.display()))?; +} + +/// A new folder in `parent` named `prefix` followed by the process id, `-` and a count of the +/// process's previews; on unix only its owner can open it. A name that is already taken, by an +/// earlier process with the same id or by anyone else, is skipped for the next count. +#[cfg(not(target_arch = "wasm32"))] +fn new_folder(parent: &std::path::Path, prefix: &str) -> Result { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); loop { - let dir = root.join(format!("{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); - match std::fs::create_dir(&dir) { + let dir = parent.join(format!("{prefix}{}-{}", std::process::id(), NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))); + match private_folder().create(&dir) { Ok(()) => return Ok(dir.to_string_lossy().replace('\\', "/")), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} Err(e) => return Err(format!("{}: {e}", dir.display())), @@ -231,6 +281,69 @@ pub(super) fn preview_folder(root: &std::path::Path, keep: std::time::Duration) } } +/// A builder for folders that only their owner can read, write or open (mode 0o700). +#[cfg(unix)] +fn private_folder() -> std::fs::DirBuilder { + let mut b = std::fs::DirBuilder::new(); + std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700); + b +} + +/// A builder for folders: a plain one outside unix. +#[cfg(all(not(unix), not(target_arch = "wasm32")))] +fn private_folder() -> std::fs::DirBuilder { + std::fs::DirBuilder::new() +} + +/// Why a preview root whose metadata (of the root itself, not what a link points to) is `m` can't +/// be used, or `None` when it can. +#[cfg(not(target_arch = "wasm32"))] +fn refused(m: &std::fs::Metadata) -> Option<&'static str> { + if m.file_type().is_symlink() { + Some("it is a link") + } else if !m.is_dir() { + Some("it is not a folder") + } else if !mine(m) { + Some("it belongs to another user") + } else if others_can_write(m) { + Some("other users can write to it") + } else { + None + } +} + +/// Whether the current user owns the file with metadata `m`. +#[cfg(unix)] +fn mine(m: &std::fs::Metadata) -> bool { + std::os::unix::fs::MetadataExt::uid(m) == rustix::process::geteuid().as_raw() +} + +/// Whether the current user owns the file with metadata `m`: always true outside unix. +#[cfg(all(not(unix), not(target_arch = "wasm32")))] +fn mine(_: &std::fs::Metadata) -> bool { + true +} + +/// Whether users other than the owner of the file with metadata `m` can write to it. +#[cfg(unix)] +fn others_can_write(m: &std::fs::Metadata) -> bool { + std::os::unix::fs::MetadataExt::mode(m) & 0o022 != 0 +} + +/// Whether users other than the owner of the file with metadata `m` can write to it: always false +/// outside unix. +#[cfg(all(not(unix), not(target_arch = "wasm32")))] +fn others_can_write(_: &std::fs::Metadata) -> bool { + false +} + +/// Whether `name` is a preview folder's name: `prefix`, then digits, `-` and digits. +#[cfg(not(target_arch = "wasm32"))] +fn is_preview_name(name: &std::ffi::OsStr, prefix: &str) -> bool { + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + name.to_str().and_then(|n| n.strip_prefix(prefix)).and_then(|n| n.split_once('-')).is_some_and(|(a, b)| digits(a) && digits(b)) +} + #[cfg(target_arch = "wasm32")] fn temp_folder() -> Result { Err("no file system here".into()) @@ -855,12 +968,12 @@ fn image_size(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, s: Option d.fields.insert(key.into(), v); }; widgets::label_row(ui, tl!("W:"), LABEL_W, |ui| { - if let Some(v) = widgets::plain_field(ui, "sfw-w", f64::from(w), " px", 0, 80.0) { - set(d, "width", json!(v.round().max(1.0))); + if let Some(v) = widgets::range_field(ui, "sfw-w", f64::from(w), 1.0..=f64::INFINITY, " px", 0, 80.0) { + set(d, "width", json!(v)); } widgets::field_label(ui, tl!("H:")); - if let Some(v) = widgets::plain_field(ui, "sfw-h", f64::from(h), " px", 0, 80.0) { - set(d, "height", json!(v.round().max(1.0))); + if let Some(v) = widgets::range_field(ui, "sfw-h", f64::from(h), 1.0..=f64::INFINITY, " px", 0, 80.0) { + set(d, "height", json!(v)); } }); let percent = region.map_or(100.0, |(_, scale)| (scale * 100.0 * 100.0).round() / 100.0); diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index ec9b1c43a..9118252b3 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -543,7 +543,7 @@ mod tests { assert_eq!((r["pending"].as_str(), r["path"].as_str()), (Some("svgOptions"), Some("art.svg"))); // The save panel offers every save format, the document's own first. let pick = &picks.borrow()[0]; - let labels: Vec<&str> = pick.filters.iter().map(|f| f.0).collect(); + let labels: Vec<&str> = pick.filters.iter().map(|f| f.0.as_str()).collect(); assert_eq!(labels, ["VectorCraft", "VectorCraft Template", "PDF", "SVG", "SVG Compressed", "PDF-compatible .ai"]); assert_eq!(pick.name, "Untitled-1.vectorcraft"); let d = dialog(&app); @@ -698,8 +698,11 @@ mod tests { let pick = &picks.borrow()[1]; assert_eq!(pick.name, "Untitled-1 template.vctemplate"); assert_eq!(pick.folder.as_deref().map(std::path::Path::new), Some(folder.as_path())); - assert_eq!(pick.filters, [("VectorCraft Template", &["vctemplate"][..]), ("PDF-compatible .ait template", &["ait"][..])]); - assert_eq!(written.borrow()[1].0, "copy.vectorcraft.vctemplate", "the picked name, with the template's extension"); + assert_eq!( + pick.filters, + crate::FilePick::filters([("VectorCraft Template", &["vctemplate"][..]), ("PDF-compatible .ait template", &["ait"][..])]) + ); + assert_eq!(written.borrow()[1].0, "copy.vctemplate", "the picked name, its extension replaced with the template's"); } #[test] @@ -737,13 +740,11 @@ mod tests { assert!(!names("ait").contains(&"aiVersion"), "{:?}", names("ait")); } - /// A name typed in the Save as Template panel with a Save As format's extension gets the - /// template's extension added, as Save As adds its own to a name it doesn't write. + /// A name typed in the Save as Template panel with a Save As format's extension has it + /// replaced with the template's, as Save As replaces the extension of a format it doesn't write. #[test] - fn save_as_template_adds_its_extension_to_a_save_formats_name() { - for (picked, saved) in - [("card.pdf", "card.pdf.vctemplate"), ("card.vectorcraft", "card.vectorcraft.vctemplate"), ("card.svg", "card.svg.vctemplate")] - { + fn save_as_template_replaces_a_save_formats_extension() { + for (picked, saved) in [("card.pdf", "card.vctemplate"), ("card.vectorcraft", "card.vctemplate"), ("card.svg", "card.vctemplate")] { let (mut app, written, _) = desktop(picked); app.session.prefs.templates_folder = vectorcraft_testkit::temp_dir("save-template-ext").to_string_lossy().into_owned(); app.run("file.saveAsTemplate", json!({})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 5b2734a23..175943697 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -95,8 +95,20 @@ pub fn open(app: &mut VectorcraftApp, params: &Value) -> Result { /// dialog fields. A standard the writer can't produce yet still shows (Save PDF then says so). fn settings_fields(app: &VectorcraftApp, params: &Value) -> Result, String> { let settings = pdf::resolve("ui.savePdfDialog", params, &app.session.prefs.pdf_presets).map_err(|e| e.to_string())?; - match serde_json::to_value(settings).map_err(|e| e.to_string())? { - Value::Object(fields) => Ok(fields), + match serde_json::to_value(&settings).map_err(|e| e.to_string())? { + Value::Object(mut fields) => { + // Serialization omits passwords from presets and summaries; the live dialog needs + // the validated passwords so confirming it still encrypts the PDF. + let security = fields.get_mut("security").and_then(Value::as_object_mut).ok_or("PDF security settings are not an object")?; + for (key, password) in + [("openPassword", settings.security.open_password), ("permissionsPassword", settings.security.permissions_password)] + { + if !password.is_empty() { + security.insert(key.into(), json!(password)); + } + } + Ok(fields) + } _ => Err("PDF settings are not an object".into()), } } @@ -599,7 +611,12 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog) { }); // PDF/X-1a converts to CMYK even without a conversion. let converting = get(d, "output.conversion") != ColorConversion::None.id() || standard.cmyk_only(); - row(ui, tl!("Destination:"), |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), 1, converting)); + // The document's own profile (blank), the CMYK profile in effect, then every profile. + let destinations: Vec<&str> = std::iter::once(DOCUMENT_PROFILE) + .chain(std::iter::once(vectorcraft_pdf::DOCUMENT_CMYK)) + .chain(profiles.iter().map(|p| p.name.as_str())) + .collect(); + row(ui, tl!("Destination:"), |ui| profile_pick(ui, d, "output.destination", &destinations, 2, converting)); row(ui, tl!("Profile inclusion:"), |ui| { // A standard decides whether colours are tagged. ui.add_enabled_ui(standard == Standard::None, |ui| pick::(ui, d, "output.profiles", 300.0, |_| true)); @@ -1015,6 +1032,46 @@ mod tests { assert_eq!(SECTIONS[section_of("standard")], "General"); } + #[test] + fn confirming_preserves_passwords_supplied_when_opening_the_dialog() { + for (open, owner) in [("open", ""), ("", "owner"), ("open", "owner")] { + let (mut app, written, _) = app(); + app.run( + "ui.savePdfDialog", + json!({"path": "/tmp/locked.pdf", "security": {"openPassword": open, "permissionsPassword": owner, "printing": "none"}}), + ) + .unwrap(); + // Drawing the Security and Summary sections must retain the live passwords. + for section in ["Security", "Summary"] { + set_field(&mut app, "__section", json!(section)); + frame(&mut app); + } + let p = params(app.ui.dialog.as_ref().unwrap()); + let summary = summary_of(&mut app, &egui::Context::default(), &p).unwrap(); + assert!(!summary.to_string().contains("Password"), "summaries omit passwords"); + let mut d = app.ui.dialog.clone().unwrap(); + set(&mut d, SAVE_AS, json!("Protected")); + save_preset(&mut app, &mut d).unwrap(); + let saved = &app.session.prefs.pdf_presets[0].settings.security; + assert!(saved.open_password.is_empty() && saved.permissions_password.is_empty(), "presets forget passwords"); + + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let written = written.borrow(); + let bytes = &written[0].1; + assert!(String::from_utf8_lossy(bytes).contains("/Encrypt")); + if open.is_empty() { + assert_eq!(vectorcraft_pdf::info(bytes, None).unwrap().pages.len(), 3); + } else { + assert_eq!(vectorcraft_pdf::info(bytes, None).unwrap_err(), vectorcraft_pdf::PdfError::NeedsPassword); + } + assert_eq!(vectorcraft_pdf::info(bytes, Some("wrong")).unwrap_err(), vectorcraft_pdf::PdfError::WrongPassword); + for password in [open, owner].into_iter().filter(|p| !p.is_empty()) { + assert_eq!(vectorcraft_pdf::info(bytes, Some(password)).unwrap().pages.len(), 3); + } + } + } + #[test] fn the_security_section_takes_passwords_that_presets_drop() { let (mut app, _, _) = app(); diff --git a/crates/ui-egui/src/dialogs/save_swatch_library.rs b/crates/ui-egui/src/dialogs/save_swatch_library.rs index f2c08c51c..a3227c659 100644 --- a/crates/ui-egui/src/dialogs/save_swatch_library.rs +++ b/crates/ui-egui/src/dialogs/save_swatch_library.rs @@ -7,7 +7,7 @@ //! (there is a user library folder). //! //! The name and Save To rows and the saving are shared with Save Graphic Style Library -//! ([`super::save_style_library`]). +//! ([`super::save_style_library`]) and Save Symbol Library ([`super::save_symbol_library`]). use serde_json::{Value, json}; use vectorcraft_color::palette_io::PaletteFormat; diff --git a/crates/ui-egui/src/dialogs/save_symbol_library.rs b/crates/ui-egui/src/dialogs/save_symbol_library.rs new file mode 100644 index 000000000..f0bb73cfd --- /dev/null +++ b/crates/ui-egui/src/dialogs/save_symbol_library.rs @@ -0,0 +1,40 @@ +//! Save Symbol Library: a name and where it goes: the user library folder (listed under Window → +//! Symbol Libraries → User Defined) or a `.vectorcraft` file picked in a save dialog (a download +//! on the web). OK runs `symbol.saveLibrary`. +//! +//! Fields: `name`, `user` (save to the user library folder), `selectedOnly` and `names` (the +//! symbol selected in the Symbols panel), `__user` (there is a user library folder). + +use serde_json::Value; +use vectorcraft_engine::cmd::symbollib::SYMBOLS_EXT; + +use super::DialogSpec; +use super::save_swatch_library::{destination, fields, name_row, params, save}; +use super::swatch_options::grid; +use crate::VectorcraftApp; +use crate::state::Dialog; + +/// The dialog kind of Save Symbol Library. +pub const KIND: &str = "saveSymbolLibrary"; + +pub(super) const SPEC: DialogSpec = + DialogSpec { heading: |_| tl!("Save Symbol Library").into(), body, confirm, min_width: 360.0, ..DialogSpec::FORM }; + +/// Open Save Symbol Library for the document's symbols (`names`: the one selected in the panel). +pub fn open(app: &mut VectorcraftApp, names: Vec) -> Result { + let fields = fields(app, app.session.symbol_libraries.user_dir().is_some(), names)?; + app.ui.dialog = Some(Dialog::new(KIND, fields)); + Ok(Value::Null) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + grid(ui, |ui| { + name_row(ui, d); + destination(ui, d, tl!("Selected Symbols Only")); + }); + false +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + save(app, d, "symbol.saveLibrary", SYMBOLS_EXT, params(d)) +} diff --git a/crates/ui-egui/src/dialogs/script_check.rs b/crates/ui-egui/src/dialogs/script_check.rs index eb1eff38e..257227cc6 100644 --- a/crates/ui-egui/src/dialogs/script_check.rs +++ b/crates/ui-egui/src/dialogs/script_check.rs @@ -40,7 +40,7 @@ pub(crate) fn command(app: &mut VectorcraftApp, p: &Value) -> Result String { + let label = vectorcraft_tools::tool_info("shapeBuilder").map_or("", |t| t.label); + crate::i18n::fmt(tl!("{tool} Options"), &[("tool", tl!(label))]) +} + +/// Open the Shape Builder's options with their current values. +pub fn open(app: &mut VectorcraftApp) -> Value { + let o = app.session.tool_options_of("shapeBuilder"); + let fields: Map = KEYS.iter().filter_map(|key| Some((key.to_string(), o.get(*key)?.clone()))).collect(); + app.ui.dialog = Some(Dialog { kind: KIND.into(), fields }); + json!({ "dialog": KIND }) +} + +fn body(_app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + form::check(ui, d, "gapDetection", tl!("Gap Detection")); + ui.add_enabled_ui(d.bool("gapDetection"), |ui| { + widgets::label_row(ui, tl!("Gap Length:"), LABEL_W, |ui| { + let lengths = vectorcraft_tools::builder::GAP_LENGTHS; + let len = d.f64("gapLength", lengths[0]); + let current = lengths.iter().position(|n| *n == len).unwrap_or(3); + let labels = [tl!("Small"), tl!("Medium"), tl!("Large"), tl!("Custom")]; + if let Some(i) = widgets::dropdown(ui, "shape-builder-gap", labels[current], &labels, 120.0) { + d.fields.insert("gapLength".into(), json!(lengths.get(i).copied().unwrap_or(9.0))); + } + ui.add_enabled_ui(current == 3, |ui| { + if let Some(n) = + widgets::range_field(ui, "shape-builder-gap-length", len, 0.0..=vectorcraft_tools::builder::SHAPE_BUILDER_MAX_GAP, "pt", 1, 64.0) + { + d.fields.insert("gapLength".into(), json!(n)); + } + }); + }); + }); + ui.add_space(8.0); + form::check(ui, d, "openFilledAsClosed", tl!("Consider Open Filled Paths as Closed")); + form::check(ui, d, "clickStrokeSplitsPath", tl!("In Merge Mode, Clicking Stroke Splits the Path")); + ui.add_space(8.0); + let sources = [("artwork", tl!("Artwork")), ("swatches", tl!("Color Swatches"))]; + form::choice(ui, d, "pickColorFrom", tl!("Pick Color From:"), (LABEL_W, 160.0), &sources); + // Cursor Swatch Preview can be changed with Color Swatches only. + let preview = d.bool("cursorSwatchPreview"); + if widgets::check(ui, tl!("Cursor Swatch Preview"), preview, d.str("pickColorFrom") == "swatches") { + d.fields.insert("cursorSwatchPreview".into(), json!(!preview)); + } + ui.add_space(8.0); + let selection = d.str("selection"); + widgets::label_row(ui, tl!("Selection:"), LABEL_W, |ui| { + for (value, label) in [("freeform", tl!("Freeform")), ("straightLine", tl!("Straight Line"))] { + if widgets::radio(ui, label, selection == value, true) { + d.fields.insert("selection".into(), json!(value)); + } + } + }); + widgets::label_row(ui, tl!("Highlight:"), LABEL_W, |ui| { + form::check(ui, d, "highlightFill", tl!("Fill")); + form::check(ui, d, "highlightStroke", tl!("Stroke When Editable")); + }); + // The stroke highlight takes a layer color, or any other color from the color button. + let color = resolve(&d.str("highlightColor")).unwrap_or(Color::BLACK); + let [r, g, b, _] = color.to_rgba8(1.0); + let preset = LAYER_COLORS.iter().position(|(_, c)| *c == [r, g, b]); + let names: Vec<&str> = LAYER_COLORS.iter().map(|(n, _)| *n).chain(["Custom"]).collect(); + ui.add_enabled_ui(d.bool("highlightStroke"), |ui| { + widgets::label_row(ui, tl!("Color:"), LABEL_W, |ui| { + if let Some((_, [r, g, b])) = widgets::dropdown(ui, "shape-builder-color", names[preset.unwrap_or(LAYER_COLORS.len())], &names, 130.0) + .and_then(|i| LAYER_COLORS.get(i)) + { + d.fields.insert("highlightColor".into(), json!(Color::rgb8(*r, *g, *b).to_hex())); + } + let mut rgb = [r, g, b]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert("highlightColor".into(), json!(Color::rgb8(rgb[0], rgb[1], rgb[2]).to_hex())); + } + }); + }); + false +} + +/// OK: set the Shape Builder's options. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let values: Map = KEYS.iter().filter_map(|k| Some((k.to_string(), d.fields.get(*k)?.clone()))).collect(); + run_and_close(app, "tool.setOption", json!({ "tool": "shapeBuilder", "values": values })) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app + } + + /// 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())) + } + + #[test] + fn gap_options_share_the_existing_dialog_and_custom_is_capped() { + let mut a = app(); + a.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap(); + let text = painted(&mut a); + assert!(text.contains("Gap Detection") && text.contains("Gap Length:"), "{text}"); + let d = a.ui.dialog.as_mut().unwrap(); + d.fields.insert("gapDetection".into(), json!(true)); + d.fields.insert("gapLength".into(), json!(1000)); + super::super::confirm(&mut a).unwrap(); + let opts = a.session.tool_options_of("shapeBuilder"); + assert_eq!(opts["gapLength"], 72.0); + assert_eq!(opts["gapDetection"], true); + assert_eq!(opts["openFilledAsClosed"], true); + } + + #[test] + fn double_clicking_the_tool_opens_its_options_and_ok_keeps_them() { + let mut app = app(); + let r = app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap(); + assert_eq!(r["dialog"], json!(KIND)); + let text = painted(&mut app); + for label in [ + "Shape Builder Tool Options", + "Consider Open Filled Paths as Closed", + "Pick Color From:", + "Artwork", + "Cursor Swatch Preview", + "Selection:", + "Freeform", + "Straight Line", + "Highlight:", + "Fill", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + // The defaults: VectorCraft's Shape Builder as it works without the dialog. + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!( + (d.bool("openFilledAsClosed"), d.str("pickColorFrom"), d.bool("cursorSwatchPreview"), d.str("selection"), d.bool("highlightFill")), + (true, "artwork".into(), false, "freeform".into(), true) + ); + for (k, v) in [ + ("openFilledAsClosed", json!(false)), + ("pickColorFrom", json!("swatches")), + ("cursorSwatchPreview", json!(true)), + ("selection", json!("straightLine")), + ("highlightFill", json!(false)), + ] { + d.fields.insert(k.into(), v); + } + super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + // They last while another tool is chosen, and the dialog opens on them again. + app.select_tool("shapeBuilder"); + app.select_tool("selection"); + let o = app.session.tool_options_of("shapeBuilder"); + assert_eq!( + o, + json!({ + "openFilledAsClosed": false, + "pickColorFrom": "swatches", + "cursorSwatchPreview": true, + "selection": "straightLine", + "highlightFill": false, + "gapDetection": false, + "gapLength": 3.0, + "clickStrokeSplitsPath": false, + "highlightStroke": true, + "highlightColor": "#ff4f4f" + }) + ); + app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap(); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("selection"), d.bool("highlightFill")), ("straightLine".into(), false)); + } + + #[test] + fn the_stroke_rows_turn_on_stroke_splitting_and_its_highlight_color() { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); + app.run("tool.options", json!({"tool": "shapeBuilder"})).unwrap(); + let text = painted(&mut app); + for label in ["In Merge Mode, Clicking Stroke Splits the Path", "Stroke When Editable", "Color:", "Red"] { + assert!(text.contains(label), "{label} in {text}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.bool("clickStrokeSplitsPath"), d.bool("highlightStroke"), d.str("highlightColor")), (false, true, "#ff4f4f".into())); + d.fields.insert("clickStrokeSplitsPath".into(), json!(true)); + d.fields.insert("highlightColor".into(), json!("Green")); + super::super::confirm(&mut app).unwrap(); + let o = app.session.tool_options_of("shapeBuilder"); + assert_eq!((&o["clickStrokeSplitsPath"], &o["highlightStroke"], &o["highlightColor"]), (&json!(true), &json!(true), &json!("#4fff4f"))); + // A click on the rectangle's top side highlights it in that color and splits the rectangle + // into the side and the rest. + app.run("select.all", json!({})).unwrap(); + app.select_tool("shapeBuilder"); + let view = app.view_info(); + let ev = |kind| vectorcraft_tools::PointerEvent::new(kind, 200.0, 100.5); + crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Move), view); + let ov = app.session.overlays(view); + assert!(ov.iter().any(|o| matches!(o, vectorcraft_tools::Overlay::Path { color: [0x4f, 0xff, 0x4f], .. })), "{ov:?}"); + crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Down), view); + crate::canvas::dispatch(&mut app, &ev(vectorcraft_tools::PointerKind::Up), view); + let doc = &app.session.doc().unwrap().doc; + let mut bounds: Vec<_> = + doc.layers[0].children().unwrap().iter().filter_map(|n| doc.bounds_of(&[n.id], false)).map(|b| [b.x0, b.y0, b.x1, b.y1]).collect(); + bounds.sort_by(|a, b| a[3].total_cmp(&b[3])); + assert_eq!(bounds, vec![[100.0, 100.0, 300.0, 100.0], [100.0, 100.0, 300.0, 200.0]]); + } + + #[test] + fn a_straight_line_drag_merges_only_the_regions_the_line_crosses() { + // A wide rectangle and a tall one over the middle of its top edge: three regions, the wide + // one's own, the overlap and the tall one's top. + let mut app = app(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": 180, "y": 50, "width": 40, "height": 100})).unwrap(); + // A drag that goes up into the tall rectangle's top and back down; the bounds of each + // object afterwards. + let drag = |app: &mut VectorcraftApp| { + app.run("select.all", json!({})).unwrap(); + app.select_tool("shapeBuilder"); + let view = app.view_info(); + let ev = |kind, (x, y): (f64, f64)| vectorcraft_tools::PointerEvent::new(kind, x, y); + let pts = [(120.0, 180.0), (200.0, 75.0), (280.0, 180.0)]; + crate::canvas::dispatch(app, &ev(vectorcraft_tools::PointerKind::Down, pts[0]), view); + for p in &pts[1..] { + crate::canvas::dispatch(app, &ev(vectorcraft_tools::PointerKind::Drag, *p), view); + } + crate::canvas::dispatch(app, &ev(vectorcraft_tools::PointerKind::Up, pts[2]), view); + let doc = &app.session.doc().unwrap().doc; + let ids: Vec<_> = doc.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + ids.iter().filter_map(|id| doc.bounds_of(&[*id], false)).map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::round)).collect::>() + }; + // Freeform: the drag merges all three regions into one shape. + assert_eq!(drag(&mut app), vec![[100.0, 50.0, 300.0, 200.0]]); + app.run("edit.undo", json!({})).unwrap(); + // Straight Line: the line from the first point to the last stays in the wide rectangle's + // own region, so only that region is merged: the shape made from it leaves the tall + // rectangle's top out, and the overlap stays an object of its own. + app.run("tool.setOption", json!({"tool": "shapeBuilder", "values": {"selection": "straightLine"}})).unwrap(); + let after = drag(&mut app); + assert!(!after.iter().any(|b| b[1] == 50.0 && b[0] == 100.0), "{after:?}"); + assert!(after.contains(&[180.0, 100.0, 220.0, 150.0]), "{after:?}"); + } +} diff --git a/crates/ui-egui/src/dialogs/shapes.rs b/crates/ui-egui/src/dialogs/shapes.rs index 55181043b..f7965965a 100644 --- a/crates/ui-egui/src/dialogs/shapes.rs +++ b/crates/ui-egui/src/dialogs/shapes.rs @@ -1,7 +1,11 @@ //! Shape size dialogs (a click with a shape tool): Rectangle, Rounded Rectangle, Ellipse, Polygon, -//! Star, Line Segment Tool Options and the two grids (size, divider counts and skews, switches). +//! Star, Line Segment, Arc and Spiral tool options, and the two grids (size, divider counts and skews, switches). +//! The Line Segment, Arc and grid dialogs are also their tools' Tool Options (a double-click on the +//! tool, [`open_options`]): OK then sets the options the tool's next drag and click use, and draws +//! nothing. use serde_json::{Value, json}; +use vectorcraft_doc::Unit; use vectorcraft_geom::{Point, Rect}; use super::{DialogSpec, form, run_and_close}; @@ -10,21 +14,97 @@ use crate::panels::transform::artboard_rect; use crate::state::Dialog; use crate::widgets; -pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(&d.kind).into(), body, confirm, ..DialogSpec::FORM }; +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |d| title(d).into(), body, confirm, ..DialogSpec::FORM }; -fn title(kind: &str) -> &'static str { - match kind { +/// The field that marks a dialog opened as its tool's Tool Options rather than by a click. +const TOOL_OPTIONS: &str = "__toolOptions"; + +/// The tools whose Tool Options are this dialog. +pub(crate) const OPTION_TOOLS: [&str; 4] = ["rectangularGrid", "polarGrid", "arc", "lineSegment"]; + +fn title(d: &Dialog) -> &'static str { + let options = d.bool(TOOL_OPTIONS); + match d.kind.as_str() { "rectangle" => tl!("Rectangle"), "roundedRectangle" => tl!("Rounded Rectangle"), "ellipse" => tl!("Ellipse"), "polygon" => tl!("Polygon"), "star" => tl!("Star"), + "spiral" => tl!("Spiral"), + "rectangularGrid" if options => tl!("Rectangular Grid Tool Options"), "rectangularGrid" => tl!("Rectangular Grid"), + "polarGrid" if options => tl!("Polar Grid Tool Options"), "polarGrid" => tl!("Polar Grid"), + "arc" => tl!("Arc Segment Tool Options"), _ => tl!("Line Segment Tool Options"), } } +/// Open `tool`'s Tool Options (one of [`OPTION_TOOLS`]), as a double-click on the tool does +/// (, +/// ): the click dialog's +/// fields at the tool's options, with no click to draw at. +pub(crate) fn open_options(app: &mut VectorcraftApp, tool: &str) -> Result { + if !OPTION_TOOLS.contains(&tool) { + return Err(format!("the {tool} tool has no options")); + } + super::open_tool_dialog(app, tool, json!({})); + let d = app.ui.dialog.as_mut().filter(|d| d.kind == tool).ok_or_else(|| format!("the {tool} tool has no options"))?; + for key in ["x", "y", "__anchorX", "__anchorY"] { + d.fields.remove(key); + } + d.fields.insert(TOOL_OPTIONS.into(), json!(true)); + Ok(json!({ "dialog": tool })) +} + +/// What OK sets as the options of `d`'s tool ([`OPTION_TOOLS`]), from its fields: a grid's are its +/// switches as they are, its typed fields as numbers, its Default Size and Reference Point. +fn tool_options(d: &Dialog) -> Value { + match d.kind.as_str() { + "lineSegment" => { + let (length, angle) = line_length_angle(d); + json!({"length": length, "angle": angle, "fillLine": d.bool("fillLine")}) + } + "arc" => arc_options(d), + kind => { + let (mut o, (counts, skews)) = (form::params(d), grid_keys(kind)); + if let Some(m) = o.as_object_mut() { + m.remove("x"); + m.remove("y"); + } + for (count, skew) in counts.into_iter().zip(skews) { + o[count] = json!((d.f64(count, 5.0) as u64).min(999)); + o[skew] = json!(d.f64(skew, 0.0)); + } + o["width"] = json!(d.f64("width", 100.0)); + o["height"] = json!(d.f64("height", 100.0)); + o["reference"] = json!(grid_reference(d)); + o + } + } +} + +/// The Line Segment dialog's length and angle (degrees), a negative length turned into the same +/// line drawn the other way: -80 pt at 0° is 80 pt at 180°. The line a click draws and the options +/// OK keeps both use it, so the next line goes the way this one went. +fn line_length_angle(d: &Dialog) -> (f64, f64) { + let (length, angle) = (d.f64("length", 100.0), d.f64("angle", 0.0)); + if length < 0.0 { (-length, (angle + 180.0).rem_euclid(360.0)) } else { (length, angle) } +} + +/// The Arc tool's options from its dialog's fields. +fn arc_options(d: &Dialog) -> Value { + json!({ + "closed": d.str("arcType").eq_ignore_ascii_case("closed"), + "lengthX": d.f64("lengthX", 100.0), + "lengthY": d.f64("lengthY", 100.0), + "reference": arc_reference(d), + "baseAlong": d.str("baseAlong"), + "slope": d.f64("slope", 50.0), + "fillArc": d.bool("fillArc"), + }) +} + /// The grids' divider counts and skews: (rows / concentric, columns / radial). fn grid_keys(kind: &str) -> ([&'static str; 2], [&'static str; 2]) { if kind == "polarGrid" { (["concentric", "radial"], ["concentricSkew", "radialSkew"]) } else { (["rows", "columns"], ["rowSkew", "columnSkew"]) } @@ -48,6 +128,21 @@ fn set_grid_placed(d: &mut Dialog, r: Rect) { d.fields.insert("height".into(), json!(r.height())); } +/// Arc segment dialog: keep reference `refi` on the canvas click (`__anchorX`/`__anchorY`). +pub(crate) fn arc_pin_to_anchor(d: &mut Dialog) { + let anchor = Point::new(d.f64("__anchorX", d.f64("x", 0.0)), d.f64("__anchorY", d.f64("y", 0.0))); + let refi = d.fields.get("reference").and_then(|v| v.as_u64()).map(|n| (n as usize).min(8)).unwrap_or(0); + let w = d.f64("lengthX", 100.0).abs().max(1e-9); + let h = d.f64("lengthY", 100.0).abs().max(1e-9); + let rp = vectorcraft_geom::reference_point(Rect::new(0.0, 0.0, w, h), refi); + d.fields.insert("x".into(), json!(anchor.x - rp.x)); + d.fields.insert("y".into(), json!(anchor.y - rp.y)); +} + +fn arc_reference(d: &Dialog) -> usize { + d.fields.get("reference").and_then(|v| v.as_u64()).map(|n| (n as usize).min(8)).unwrap_or(0) +} + /// Top-left `x`/`y` so reference point `refi` sits on the canvas click (`__anchorX`/`__anchorY`). pub(crate) fn grid_pin_to_anchor(d: &mut Dialog) { let anchor = Point::new(d.f64("__anchorX", d.f64("x", 0.0)), d.f64("__anchorY", d.f64("y", 0.0))); @@ -78,12 +173,24 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { set_grid_placed(d, artboard_rect(was, refi, None, None, Some(w), Some(h), false)); } }); + } else if d.kind == "arc" { + arc_form(ui, d, unit); } else { form::grid(ui, d, unit); } + if d.kind == "spiral" { + ui.add_space(8.0); + form::check(ui, d, "clockwise", tl!("Clockwise")); + return false; + } let own = match d.kind.as_str() { "rectangularGrid" => ("frame", tl!("Use Outside Rectangle As Frame")), "polarGrid" => ("compound", tl!("Create Compound Path From Ellipses")), + "lineSegment" => { + ui.add_space(8.0); + form::check(ui, d, "fillLine", tl!("Fill Line")); + return false; + } _ => return false, }; ui.add_space(8.0); @@ -93,15 +200,99 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { false } -fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { +fn arc_form(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { + ui.horizontal(|ui| { + let refi = arc_reference(d); + if let Some(i) = widgets::reference_point(ui, refi) { + d.fields.insert("reference".into(), json!(i)); + arc_pin_to_anchor(d); + } + }); + ui.add_space(4.0); + let rail = crate::theme::Tokens::get(ui.ctx()).input_border; + egui::Grid::new("arc-dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + let lx0 = d.f64("lengthX", 100.0); + form::length_field(ui, d, "lengthX", tl!("Length X-Axis:"), unit); + if d.f64("lengthX", 100.0) != lx0 { + arc_pin_to_anchor(d); + } + let ly0 = d.f64("lengthY", 100.0); + form::length_field(ui, d, "lengthY", tl!("Length Y-Axis:"), unit); + if d.f64("lengthY", 100.0) != ly0 { + arc_pin_to_anchor(d); + } + form::choice(ui, d, "arcType", tl!("Type:"), (form::FIELD_W, form::FIELD_W - 12.0), &[("open", tl!("Open")), ("closed", tl!("Closed"))]); + form::choice( + ui, + d, + "baseAlong", + tl!("Base Along:"), + (form::FIELD_W, form::FIELD_W - 12.0), + &[ + ("x", crate::i18n::tr_ctx(crate::i18n::current(), "axis", "X Axis")), + ("y", crate::i18n::tr_ctx(crate::i18n::current(), "axis", "Y Axis")), + ], + ); + form::row_label(ui, tl!("Slope:")); + ui.vertical(|ui| { + form::slider_field_named(ui, d, "slope", tl!("Slope:"), -100.0..=100.0, "", &|_| rail); + form::slider_ends(ui, form::SLIDER_LABEL, (tl!("Concave"), tl!("Convex"))); + }); + ui.end_row(); + }); + ui.add_space(8.0); + form::check(ui, d, "fillArc", tl!("Fill Arc")); +} + +/// The top-left corner of a rectangle or ellipse: the click itself, or with Alt held (draw from centre) the click minus half the size. +fn box_origin(d: &Dialog) -> (f64, f64) { let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); + if d.fields.get("alt").and_then(Value::as_bool).unwrap_or(false) { + (x - d.f64("width", 100.0) / 2.0, y - d.f64("height", 100.0) / 2.0) + } else { + (x, y) + } +} + +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + // Tool Options: the options the tool's next drag and click use; nothing is drawn. + if d.bool(TOOL_OPTIONS) { + let r = app.run("tool.setOption", json!({"tool": d.kind, "values": tool_options(d)})); + app.ui.dialog = None; + return r; + } + let count = match d.kind.as_str() { + "polygon" => Some(("sides", 6.0, 3.0)), + "star" => Some(("points", 5.0, 2.0)), + _ => None, + }; + let options = count + .map(|(key, default, min)| { + let mut values = json!({}); + values[key] = json!(d.f64(key, default).clamp(min, 1000.0) as u64); + values + }) + .or_else(|| OPTION_TOOLS.contains(&d.kind.as_str()).then(|| tool_options(d))); + let (x, y) = (d.f64("x", 0.0), d.f64("y", 0.0)); + let (bx, by) = box_origin(d); let (id, params) = match d.kind.as_str() { - "ellipse" => ("shape.ellipse", json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})), + "ellipse" => ("shape.ellipse", json!({"x": bx, "y": by, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0)})), "polygon" => ("shape.polygon", json!({"cx": x, "cy": y, "radius": d.f64("radius", 50.0), "sides": d.f64("sides", 6.0) as u64})), "star" => ( "shape.star", json!({"cx": x, "cy": y, "radius1": d.f64("radius1", 50.0), "radius2": d.f64("radius2", 25.0), "points": d.f64("points", 5.0) as u64}), ), + "spiral" => ( + "shape.spiral", + json!({ + "cx": x, + "cy": y, + "radius": d.f64("radius", 50.0).max(0.01), + "decay": d.f64("decay", 80.0).clamp(5.0, 150.0), + "segments": (d.f64("segments", 10.0) as u64).clamp(2, 1000), + "clockwise": d.bool("clockwise"), + }), + ), kind @ ("rectangularGrid" | "polarGrid") => { // The switches as they are; the typed fields as numbers. let (mut p, (counts, skews)) = (form::params(d), grid_keys(kind)); @@ -116,22 +307,120 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { (if kind == "polarGrid" { "shape.polarGrid" } else { "shape.rectangularGrid" }, p) } "lineSegment" => { - let (l, a) = (d.f64("length", 100.0), d.f64("angle", 0.0).to_radians()); - ("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()})) + let (l, a) = line_length_angle(d); + let a = a.to_radians(); + let mut p = json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}); + if d.bool("fillLine") { + p["fill"] = json!(true); + } + ("shape.line", p) + } + "arc" => { + let anchor = Point::new(d.f64("__anchorX", x), d.f64("__anchorY", y)); + ( + "shape.arc", + json!({ + "x": anchor.x, + "y": anchor.y, + "lengthX": d.f64("lengthX", 100.0), + "lengthY": d.f64("lengthY", 100.0), + "reference": arc_reference(d), + "baseAlong": d.str("baseAlong"), + "slope": d.f64("slope", 50.0), + "closed": d.str("arcType").eq_ignore_ascii_case("closed"), + "fillArc": d.bool("fillArc"), + }), + ) } _ => ( "shape.rectangle", - json!({"x": x, "y": y, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}), + json!({"x": bx, "y": by, "width": d.f64("width", 100.0), "height": d.f64("height", 100.0), "radius": d.f64("radius", 0.0)}), ), }; - // On the active plane while the perspective grid shows, sized in plane units from the click. - let options = matches!(d.kind.as_str(), "rectangularGrid" | "polarGrid").then(|| params.clone()); + // On the active plane while the perspective grid shows, sized in plane units from the click; + // else along General › Constrain Angle, turned about the point the dialog places at the click + // (a grid's reference point, the other shapes' top-left corner or centre). + let options = options.or_else(|| (d.kind == "spiral").then(|| params.clone())); let result = match vectorcraft_engine::perspective_click(&app.session, id, ¶ms, vectorcraft_geom::Point::new(x, y)) { Some((id, params)) => run_and_close(app, &id, params), - None => run_and_close(app, id, params), + None => { + let pivot = if matches!(d.kind.as_str(), "rectangularGrid" | "polarGrid") { + vectorcraft_geom::reference_point(grid_placed(d), grid_reference(d)) + } else { + vectorcraft_geom::Point::new(x, y) + }; + let params = vectorcraft_engine::constrain_click(&app.session, id, params, pivot); + run_and_close(app, id, params) + } }?; if let Some(options) = options { app.run("tool.setOption", json!({"tool": d.kind, "values": options}))?; } Ok(result) } + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + #[test] + fn spiral_click_dialog_uses_and_updates_tool_options() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("tool.select", json!({"tool": "spiral"})).unwrap(); + app.run("tool.setOption", json!({"tool": "spiral", "values": {"decay": 72.0, "segments": 14, "clockwise": false}})).unwrap(); + + super::super::open_tool_dialog(&mut app, "spiral", json!({"x": 40.0, "y": 60.0})); + let d = app.ui.dialog.as_ref().expect("Spiral dialog"); + assert_eq!(d.kind, "spiral"); + assert_eq!((d.f64("x", 0.0), d.f64("y", 0.0), d.f64("radius", 0.0)), (40.0, 60.0, 50.0)); + assert_eq!((d.f64("decay", 0.0), d.f64("segments", 0.0), d.bool("clockwise")), (72.0, 14.0, false)); + + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("radius".into(), json!(35.0)); + d.fields.insert("decay".into(), json!(65.0)); + d.fields.insert("segments".into(), json!(12)); + d.fields.insert("clockwise".into(), json!(true)); + super::super::confirm(&mut app).unwrap(); + + assert!(app.ui.dialog.is_none()); + assert_eq!(app.session.doc().unwrap().doc.layers[0].children().unwrap().len(), 1); + assert_eq!(app.session.tool_options_of("spiral"), json!({"decay": 65.0, "segments": 12, "clockwise": true})); + } + + #[test] + fn alt_click_centres_the_typed_size_on_the_click() { + let fields = |alt: bool| Dialog::new("rectangle", json!({"x": 100.0, "y": 50.0, "alt": alt, "width": "40 pt", "height": "20 pt"})); + assert_eq!(box_origin(&fields(false)), (100.0, 50.0)); + assert_eq!(box_origin(&fields(true)), (80.0, 40.0)); + } + + #[test] + fn a_negative_line_length_draws_and_keeps_the_same_direction() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.run("tool.select", json!({"tool": "lineSegment"})).unwrap(); + let line_bounds = |app: &VectorcraftApp, i: usize| { + let doc = &app.session.doc().unwrap().doc; + let n = &doc.layers[0].children().unwrap()[i]; + n.path_data().and_then(|p| p.bounds()).expect("line bounds") + }; + + super::super::open_tool_dialog(&mut app, "lineSegment", json!({"x": 100.0, "y": 60.0})); + let d = app.ui.dialog.as_mut().expect("Line Segment dialog"); + d.fields.insert("length".into(), json!(-80.0)); + d.fields.insert("angle".into(), json!(0.0)); + super::super::confirm(&mut app).unwrap(); + let first = line_bounds(&app, 0); + assert!((first.x0 - 20.0).abs() < 1e-6 && (first.x1 - 100.0).abs() < 1e-6, "{first:?}"); + let kept = app.session.tool_options_of("lineSegment"); + assert_eq!((kept["length"].as_f64(), kept["angle"].as_f64()), (Some(80.0), Some(180.0))); + + // The next click opens at what was kept and draws the same way. + super::super::open_tool_dialog(&mut app, "lineSegment", json!({"x": 100.0, "y": 120.0})); + super::super::confirm(&mut app).unwrap(); + let second = line_bounds(&app, 1); + assert!((second.x0 - 20.0).abs() < 1e-6 && (second.x1 - 100.0).abs() < 1e-6, "{second:?}"); + } +} diff --git a/crates/ui-egui/src/dialogs/shortcut_import.rs b/crates/ui-egui/src/dialogs/shortcut_import.rs index 218cc218f..6330bd7ed 100644 --- a/crates/ui-egui/src/dialogs/shortcut_import.rs +++ b/crates/ui-egui/src/dialogs/shortcut_import.rs @@ -462,7 +462,8 @@ pub fn command(app: &mut VectorcraftApp, p: &Value) -> Result { Some(path) => path, None if app.services.open_async.is_some() => return crate::io::open_dialog(app).map(|_| Value::Null), None => { - let pick = FilePick { filters: vec![("Shortcut sets", vectorcraft_engine::cmd::shortcutset::EXTS)], ..Default::default() }; + let pick = + FilePick { filters: vec![FilePick::filter("Shortcut sets", vectorcraft_engine::cmd::shortcutset::EXTS)], ..Default::default() }; crate::picks::open(app, &pick).ok_or("cancelled")? } }; diff --git a/crates/ui-egui/src/dialogs/simplify_slider.rs b/crates/ui-egui/src/dialogs/simplify_slider.rs index a1c8871de..9387b8dbd 100644 --- a/crates/ui-egui/src/dialogs/simplify_slider.rs +++ b/crates/ui-egui/src/dialogs/simplify_slider.rs @@ -2,14 +2,17 @@ //! the fewest anchor points (left) to the closest fit to the original (right), and More Options, //! which opens the Simplify dialog at the slider's tolerance. While the strip is open, the canvas //! previews `object.path.simplify {level}`; Enter or a click outside the strip keeps the result as -//! one undo step, and Escape puts the paths back. The slider opens at level 50, a tolerance of -//! 1 pt (`vectorcraft_pathops::simplify_level_tolerance` maps levels to tolerances). +//! one undo step, and Escape puts the paths back. The slider opens at Auto-Simplify's level for +//! the selection (`vectorcraft_engine::auto_simplify_level`), so the canvas first shows the +//! auto-simplified paths, as the Simplify help page describes +//! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/manually-simplify-paths.html). +//! `vectorcraft_pathops::simplify_level_tolerance` maps levels to tolerances. //! //! Fields: `level` (0..=100, the command's parameter). use egui::{Color32, CornerRadius, Order, Pos2, Sense, Stroke, vec2}; use serde_json::{Value, json}; -use vectorcraft_engine::{SIMPLIFY_LEVEL, simplify_level_tolerance}; +use vectorcraft_engine::{SIMPLIFY_LEVEL, auto_simplify_level, simplify_level_tolerance}; use vectorcraft_geom::Rect; use super::{DialogResult, DialogSpec, form, modal}; @@ -26,9 +29,10 @@ pub(super) const SPEC: DialogSpec = DialogSpec { window: Some(show), confirm, pr const CMD: &str = "object.path.simplify"; const LABEL: &str = "Simplify"; -/// The fields the slider opens with. -pub(crate) fn fields() -> Value { - json!({ "level": SIMPLIFY_LEVEL }) +/// The fields the slider opens with: Auto-Simplify's level for the selection, or +/// [`SIMPLIFY_LEVEL`] without a document. +pub(crate) fn fields(app: &VectorcraftApp) -> Value { + json!({ "level": auto_simplify_level(&app.session).unwrap_or(SIMPLIFY_LEVEL) }) } /// Whether the Simplify slider is open. diff --git a/crates/ui-egui/src/dialogs/slices.rs b/crates/ui-egui/src/dialogs/slices.rs index 723f5be62..7efcd2905 100644 --- a/crates/ui-egui/src/dialogs/slices.rs +++ b/crates/ui-egui/src/dialogs/slices.rs @@ -144,8 +144,8 @@ fn divide_section(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, keys: if widgets::radio_button(ui, labels[1], !by_size, enabled) { d.fields.insert(mode.into(), json!("count")); } - if let Some(n) = widgets::plain_field(ui, ("divide", count), d.f64(count, 2.0), "", 0, 50.0) { - d.fields.insert(count.into(), json!(n.round().max(1.0) as u64)); + if let Some(n) = widgets::range_field(ui, ("divide", count), d.f64(count, 2.0), 1.0..=f64::INFINITY, "", 0, 50.0) { + d.fields.insert(count.into(), json!(n as u64)); } widgets::dim_label(ui, labels[1]); }); diff --git a/crates/ui-egui/src/dialogs/swatch_options.rs b/crates/ui-egui/src/dialogs/swatch_options.rs index 4b74171be..fdb2843fb 100644 --- a/crates/ui-egui/src/dialogs/swatch_options.rs +++ b/crates/ui-egui/src/dialogs/swatch_options.rs @@ -220,7 +220,8 @@ fn sliders(ui: &mut egui::Ui, d: &mut Dialog) -> bool { c2[i] = mode.value_at(i, nv); new = Some((from_components(mode, &c2), c2)); } - if let Some(fv) = widgets::plain_field(ui, ("swatch-field", i), v as f64, mode.suffix(i), 0, 52.0) { + let bounds = f64::from(mode.min(i))..=f64::from(mode.max(i)); + if let Some(fv) = widgets::range_field(ui, ("swatch-field", i), v as f64, bounds, mode.suffix(i), 0, 52.0) { let mut c2 = comps.clone(); c2[i] = (fv as f32).clamp(mode.min(i), mode.max(i)); new = Some((from_components(mode, &c2), c2)); diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index b4167c129..f6b09c311 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -116,8 +116,8 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { for (key, text) in SLICE_KEYS.into_iter().zip([tl!("Left:"), tl!("Top:"), tl!("Right:"), tl!("Bottom:")]) { label(ui, text); let v = d.f64(key, 100.0 / 3.0); - if let Some(n) = widgets::plain_field(ui, ("dlg-field", key), v, "%", 0, 64.0) { - d.fields.insert(key.into(), json!(n.clamp(0.0, 100.0))); + if let Some(n) = widgets::range_field(ui, ("dlg-field", key), v, 0.0..=100.0, "%", 0, 64.0) { + d.fields.insert(key.into(), json!(n)); } ui.end_row(); } diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index be41199ba..83534e998 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -41,6 +41,7 @@ fn kinds_map_to_their_dialogs() { ("newDocument", DialogKind::NewDocument), ("roundedRectangle", DialogKind::Shape), ("lineSegment", DialogKind::Shape), + ("arc", DialogKind::Shape), ("rectangularGrid", DialogKind::Shape), ("polarGrid", DialogKind::Shape), ("shear", DialogKind::Transform), @@ -88,6 +89,7 @@ fn every_dialog_draws() { "polygon", "star", "lineSegment", + "arc", "rectangularGrid", "polarGrid", "rotate", @@ -215,6 +217,8 @@ fn raster_filter_dialogs_preview_and_apply() { ("pixelate.pointillize", "cellSize", "12"), ("sketch.basRelief", "lightDirection", "left"), ("sketch.charcoal", "thickness", "5"), + ("sketch.conteCrayon", "texture", "brick"), + ("sketch.conteCrayon", "foregroundLevel", "16"), ("sketch.halftonePattern", "patternType", "line"), ("sketch.photocopy", "darkness", "40"), ("sketch.stamp", "lightDarkBalance", "10"), @@ -527,6 +531,8 @@ struct PluginIntField { } impl PluginIntField { + /// The dialog for a `seed` in `min..=max` showing `start`, drawn once to find the number, + /// where drags start (`pos`). fn new(min: i64, max: i64, start: i64) -> Self { let ctx = egui::Context::default(); theme::install_fonts(&ctx); @@ -540,6 +546,7 @@ impl PluginIntField { author: String::new(), params: vec![("seed".into(), vectorcraft_plugins::ParamSpec::Int { min, max, default: 0 }.into())], extensions: Vec::new(), + budget: Default::default(), }; let mut f = PluginIntField { ctx, d: Dialog::new(plugin::KIND, json!({ "seed": start })), m, pos: egui::Pos2::ZERO }; fn number_rect(shape: &egui::Shape, text: &str) -> Option { @@ -923,11 +930,17 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga "Reflect Y", "Reference Point", "Random", + "Transform Objects", + "Transform Patterns", + "Scale Strokes & Effects", "Preview", ] { assert!(text.lines().any(|l| l == s), "{s} in {text}"); } assert!(app.session.in_interaction(), "the preview runs"); + // The options start at the catalog defaults (#885). + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.bool("objects"), d.bool("patterns"), d.bool("strokes")), (true, false, false)); let effect = |app: &VectorcraftApp| app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].clone(); // The reference point and Random update the preview. let d = app.ui.dialog.as_mut().unwrap(); @@ -939,6 +952,10 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga app.ui.dialog.as_mut().unwrap().fields.insert("random".into(), json!(true)); frame(&mut app, Default::default()); assert_eq!(effect(&app).params["random"], json!(true)); + // So does Scale Strokes & Effects. + app.ui.dialog.as_mut().unwrap().fields.insert("strokes".into(), json!(true)); + frame(&mut app, Default::default()); + assert_eq!(effect(&app).params["strokes"], json!(true)); confirm(&mut app).unwrap(); assert!(!app.session.in_interaction()); assert_eq!(app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.len(), 1); @@ -946,6 +963,7 @@ fn the_transform_effect_dialog_has_its_controls_previews_them_and_shows_them_aga app.run("effect.dialog", json!({"effect": "distort.transform", "index": 0})).unwrap(); let d = app.ui.dialog.as_ref().unwrap(); assert_eq!((d.f64("reference", 4.0), d.f64("copies", 0.0), d.f64("moveH", 0.0), d.bool("random")), (8.0, 3.0, 30.0, true)); + assert_eq!((d.bool("objects"), d.bool("strokes")), (true, true)); let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); assert!(text.lines().any(|l| l == "Reference Point"), "{text}"); } @@ -1040,6 +1058,156 @@ fn artboard_options_position_follows_the_tool_option_with_another_tool() { } } +/// Artboard Options shows the artboard's background color on its Background Color: row (None, +/// White, Black, or Custom for another color) and not as a text field. OK keeps the color while it +/// applies another field, and a null `color` clears it. +#[test] +fn artboard_options_shows_keeps_and_clears_the_background_color() { + let mut app = app(); + let color = |app: &VectorcraftApp| app.session.doc().unwrap().doc.artboards[0].color.map(|c| c.to_hex()); + let lines = |app: &mut VectorcraftApp| { + let text = crate::tests_labels::painted_text(app, |app, ui| show(app, ui.ctx())); + text.lines().map(str::trim).map(str::to_string).collect::>() + }; + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let shown = lines(&mut app); + assert!(shown.iter().any(|l| l == "Background Color:") && shown.iter().any(|l| l == "None"), "{shown:?}"); + app.ui.dialog = None; + app.run("artboard.setProps", json!({"index": 0, "color": "#336699"})).unwrap(); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let shown = lines(&mut app); + assert!(shown.iter().any(|l| l == "Custom") && !shown.iter().any(|l| l == "Color:" || l.starts_with('[')), "{shown:?}"); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Card")); + super::confirm(&mut app).unwrap(); + assert_eq!((app.session.doc().unwrap().doc.artboards[0].name.as_str(), color(&app).as_deref()), ("Card", Some("#336699"))); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!(null)); + super::confirm(&mut app).unwrap(); + assert_eq!(color(&app), None); +} + +/// Artboard Options for a new artboard (`ui.newArtboard`) gives the new artboard the background +/// color picked on its Background Color: row, and none when nothing was picked. +#[test] +fn new_artboard_options_give_the_new_artboard_its_background_color() { + let mut app = app(); + for (picked, want) in [(Some(json!("#336699")), Some("#336699")), (None, None)] { + app.run("ui.newArtboard", json!({})).unwrap(); + if let Some(c) = picked { + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), c); + } + super::confirm(&mut app).unwrap(); + let ab = app.session.doc().unwrap().doc.artboards.last().cloned().unwrap(); + assert_eq!(ab.color.map(|c| c.to_hex()).as_deref(), want); + } +} + +/// Artboard Options shows Show Center Mark and Show Cross Hairs, ticked as the artboard has them +/// whether it opens from the Artboards panel or from a double-click with the Artboard tool. OK +/// sets them in one undo step, and a new artboard's Artboard Options gives it the ones ticked. +#[test] +fn artboard_options_show_center_mark_and_cross_hairs() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app(); + let marks = |app: &VectorcraftApp, i: usize| { + let a = &app.session.doc().unwrap().doc.artboards[i]; + (a.show_center_mark, a.show_cross_hairs) + }; + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.contains("Show Center Mark") && text.contains("Show Cross Hairs"), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("showCenterMark") && !d.bool("showCrossHairs")); + d.fields.insert("showCenterMark".into(), json!(true)); + let undo = app.session.doc().unwrap().history.undo.len(); + super::confirm(&mut app).unwrap(); + assert_eq!(marks(&app, 0), (true, false)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Opened again, it shows them; unticking one clears it. + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.bool("showCenterMark")); + d.fields.insert("showCenterMark".into(), json!(false)); + d.fields.insert("showCrossHairs".into(), json!(true)); + super::confirm(&mut app).unwrap(); + assert_eq!(marks(&app, 0), (false, true)); + // A double-click with the Artboard tool on the artboard's empty space. + app.select_tool("artboard"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 100.0, 100.0), view); + } + let d = app.ui.dialog.clone().expect("Artboard Options opened"); + assert_eq!((d.kind.as_str(), d.bool("showCenterMark"), d.bool("showCrossHairs")), ("artboardOptions", false, true)); + cancel(&mut app); + // A new artboard takes the ones ticked, and none when none is. + for (ticked, want) in [(Some("showCenterMark"), (true, false)), (None, (false, false))] { + app.run("ui.newArtboard", json!({})).unwrap(); + if let Some(k) = ticked { + app.ui.dialog.as_mut().unwrap().fields.insert(k.into(), json!(true)); + } + super::confirm(&mut app).unwrap(); + let last = app.session.doc().unwrap().doc.artboards.len() - 1; + assert_eq!(marks(&app, last), want); + } +} + +/// Artboard Options shows Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (one row of +/// it), set as the artboard has them whether it opens from the Artboards panel or from a +/// double-click with the Artboard tool. OK sets them in one undo step (a typed ratio is read as a +/// number), an out-of-range ratio keeps the dialog open and changes nothing, and a new artboard's +/// Artboard Options gives it the ones set. +#[test] +fn artboard_options_show_video_display_options() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = app(); + let video = |app: &VectorcraftApp, i: usize| { + let a = &app.session.doc().unwrap().doc.artboards[i]; + (a.show_video_safe_areas, a.video_ruler_pixel_aspect_ratio) + }; + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.contains("Show Video Safe Areas"), "{text}"); + assert_eq!(text.matches("Video Ruler Pixel Aspect Ratio").count(), 1, "one row: {text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.bool("showVideoSafeAreas"), d.f64("videoRulerPixelAspectRatio", 0.0)), (false, 1.0)); + d.fields.insert("showVideoSafeAreas".into(), json!(true)); + d.fields.insert("videoRulerPixelAspectRatio".into(), json!("0.9")); + let undo = app.session.doc().unwrap().history.undo.len(); + super::confirm(&mut app).unwrap(); + assert_eq!(video(&app, 0), (true, 0.9)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + // Opened again, it shows them; a ratio out of range is refused. + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!((d.bool("showVideoSafeAreas"), d.f64("videoRulerPixelAspectRatio", 0.0)), (true, 0.9)); + d.fields.insert("videoRulerPixelAspectRatio".into(), json!(20)); + assert!(super::confirm(&mut app).is_err()); + assert_eq!(video(&app, 0), (true, 0.9), "nothing changed"); + cancel(&mut app); + // A double-click with the Artboard tool on the artboard's empty space. + app.select_tool("artboard"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 100.0, 100.0), view); + } + let d = app.ui.dialog.clone().expect("Artboard Options opened"); + assert_eq!((d.bool("showVideoSafeAreas"), d.f64("videoRulerPixelAspectRatio", 0.0)), (true, 0.9)); + cancel(&mut app); + // A new artboard takes the ones set, and the defaults otherwise. + for (set, want) in [(true, (true, 2.0)), (false, (false, 1.0))] { + app.run("ui.newArtboard", json!({})).unwrap(); + if set { + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("showVideoSafeAreas".into(), json!(true)); + d.fields.insert("videoRulerPixelAspectRatio".into(), json!(2)); + } + super::confirm(&mut app).unwrap(); + let last = app.session.doc().unwrap().doc.artboards.len() - 1; + assert_eq!(video(&app, last), want); + } +} + /// #1482: with Artboards 1 and 3 selected with the Artboard tool, a new Width in Artboard Options /// goes to both, each keeping its top-left corner and its height, in one undo step. The new name /// goes only to the artboard the dialog shows, and Artboard 2 keeps its size. A name alone resizes @@ -1317,6 +1485,24 @@ fn dialog_sliders_report_the_numbers_their_fields_show() { assert_eq!(slider_value(&shown, "Raster/Vector Balance:"), v(30.0, (0.0, 100.0), "30")); } +/// Dragging Pattern Brush Options' Scale slider past its right end sets Scale to 10000%, and +/// dragging Art Brush Options' Width slider past its left end sets Width to 1%; OK gives each +/// brush its new value. +#[test] +fn brush_options_sliders_set_pattern_scale_and_art_width() { + use egui::accesskit::Role::Slider; + let mut app = app(); + for (brush, label, key, by, want) in [("Diamonds", "Scale:", "scale", 400.0, 10000.0), ("Arrow", "Width:", "width", -400.0, 1.0)] { + app.run("ui.brushOptions", json!({ "name": brush })).unwrap(); + let mut f = Frames::new(); + let at = f.control(&mut app, label, Slider); + f.drag(&mut app, at, egui::vec2(by, 0.0)); + assert_eq!(app.ui.dialog.as_ref().expect("Brush Options is open").f64(key, 0.0), want, "{brush}"); + confirm(&mut app).unwrap(); + assert_eq!(app.run("brush.get", json!({ "name": brush })).unwrap()[key], json!(want), "{brush}"); + } +} + /// Frames of the dialog layer on one context (so a drag and the keyboard focus last from frame to /// frame), with the screen-reader tree turned on. struct Frames { @@ -1450,6 +1636,170 @@ fn free_distort_reset_puts_the_corners_back() { assert_eq!((effects.len(), effects[0].params["corners"].clone()), (1, rest)); } +/// The slider that screen readers name `label` in `out`'s tree: its node id, number and text. +fn slider_node(out: &egui::FullOutput, label: &str) -> (egui::accesskit::NodeId, Option, Option) { + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + let (id, node) = nodes + .iter() + .find(|(_, n)| n.role() == egui::accesskit::Role::Slider && n.label() == Some(label)) + .unwrap_or_else(|| panic!("no slider named {label:?}")); + (*id, node.numeric_value(), node.value().map(str::to_string)) +} + +/// A form slider that a click gave the keyboard focus (Vector Halftone's Frequency, 1 to 150 lpi) +/// steps the number of the field beside it with the arrow keys: one with → and ↑, back with ← and +/// ↓, ten with Shift, and ten with Page Up and Page Down. Home and End take it to the ends of its +/// range, a step stops there, and screen readers read each new number. +#[test] +fn a_focused_dialog_slider_steps_with_the_arrow_keys() { + use egui::Key::{ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Home, PageDown, PageUp}; + use egui::Modifiers as M; + let mut app = app(); + open(&mut app, halftone::KIND, halftone::fields()); + let mut f = Frames::new(); + let at = f.control(&mut app, "Frequency", egui::accesskit::Role::Slider); + f.press(&mut app, at, true); + f.press(&mut app, at, false); + let frequency = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Vector Halftone is open").f64("frequency", 0.0); + let start = frequency(&app); + assert!((2.0..=139.0).contains(&start), "the click set {start}"); + let mut seen = vec![]; + for (key, mods, want) in [ + (ArrowRight, M::NONE, start + 1.0), + (ArrowUp, M::NONE, start + 2.0), + (ArrowLeft, M::NONE, start + 1.0), + (ArrowDown, M::NONE, start), + (ArrowRight, M::SHIFT, start + 10.0), + (ArrowLeft, M::SHIFT, start), + (Home, M::NONE, 1.0), + (ArrowLeft, M::NONE, 1.0), + (PageUp, M::NONE, 11.0), + (End, M::NONE, 150.0), + (ArrowUp, M::SHIFT, 150.0), + (PageDown, M::NONE, 140.0), + (PageUp, M::NONE, 150.0), + ] { + f.key(&mut app, key, mods); + seen.push((key, mods.shift, frequency(&app))); + assert_eq!(frequency(&app), want, "{seen:?}"); + } + f.key(&mut app, ArrowLeft, M::NONE); + let out = f.run(&mut app, vec![]); + assert_eq!((frequency(&app), slider_node(&out, "Frequency").1), (149.0, Some(149.0))); + assert_eq!(slider_node(&out, "Frequency").2.as_deref(), Some("149 lpi")); +} + +/// Tab leaves a focused dialog slider for its field and Shift+Tab comes back to it, whose arrow +/// keys then step the number again: the Perspective Grid's Opacity. OK gives the grid the new +/// opacity. +#[test] +fn tab_and_shift_tab_reach_a_dialog_slider_for_the_arrow_keys() { + use egui::Key::{ArrowRight, Tab}; + use egui::Modifiers as M; + let mut app = app(); + app.run("ui.perspectiveGridDialog", json!({})).unwrap(); + let opacity = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Define Grid is open").f64("opacity", 0.0); + let mut f = Frames::new(); + let at = f.control(&mut app, "Opacity:", egui::accesskit::Role::Slider); + f.press(&mut app, at, true); + f.press(&mut app, at, false); + let start = opacity(&app); + f.key(&mut app, Tab, M::NONE); + f.key(&mut app, ArrowRight, M::NONE); + assert_eq!(opacity(&app), start, "the field has the focus, and → moves its caret"); + f.key(&mut app, Tab, M::SHIFT); + f.key(&mut app, ArrowRight, M::NONE); + f.key(&mut app, ArrowRight, M::SHIFT); + assert_eq!(opacity(&app), start + 11.0); + confirm(&mut app).unwrap(); + assert_eq!(app.run("perspective.grid.get", json!({})).unwrap()["define"]["opacity"].as_f64(), Some(start + 11.0)); +} + +/// Screen readers can step a dialog slider: the Pencil's Fidelity, a field with tenths, goes to +/// the next whole number on a decrement or an increment, as the slider's ← and → do. +#[test] +fn screen_readers_step_a_dialog_slider() { + use egui::accesskit::{Action, ActionRequest, TreeId}; + let mut app = app(); + app.run("tool.options", json!({"tool": "pencil"})).unwrap(); + app.ui.dialog.as_mut().expect("Pencil Tool Options is open").fields.insert("fidelity".into(), json!(2.5)); + let fidelity = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("Pencil Tool Options is open").f64("fidelity", 0.0); + let mut f = Frames::new(); + f.run(&mut app, vec![]); + let out = f.run(&mut app, vec![]); + let (node, ..) = slider_node(&out, "Fidelity:"); + let request = |action| egui::Event::AccessKitActionRequest(ActionRequest { action, target_node: node, target_tree: TreeId::ROOT, data: None }); + f.run(&mut app, vec![request(Action::Decrement)]); + assert_eq!(fidelity(&app), 2.0, "from 2.5"); + f.run(&mut app, vec![request(Action::Increment)]); + f.run(&mut app, vec![request(Action::Increment)]); + assert_eq!(fidelity(&app), 4.0); +} + +/// Transform Each's sliders step the numbers of their fields: a Scale past the rail's 200% steps +/// down from 200% and End sets 200%, → at the end leaves it as it is, and a Move slider steps one +/// millimeter in a millimeter document. +#[test] +fn transform_each_sliders_step_the_numbers_their_fields_show() { + use egui::Key::{ArrowLeft, ArrowRight, End, Tab}; + use egui::Modifiers as M; + let mut app = app(); + app.run("document.setUnits", json!({"units": "millimeters"})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + crate::menus::invoke(&mut app, "object.transformEach", json!({})); + let set = + |app: &mut VectorcraftApp, k: &str, v: serde_json::Value| app.ui.dialog.as_mut().expect("Transform Each is open").fields.insert(k.into(), v); + let get = |app: &VectorcraftApp, k: &str| app.ui.dialog.as_ref().expect("Transform Each is open").f64(k, f64::NAN); + let mut f = Frames::new(); + let at = f.control(&mut app, "Horizontal:", egui::accesskit::Role::Slider); + f.press(&mut app, at, true); + f.press(&mut app, at, false); + set(&mut app, "scaleH", json!(300)); + f.key(&mut app, ArrowRight, M::NONE); + assert_eq!(get(&app, "scaleH"), 300.0, "→ at the rail's end"); + f.key(&mut app, End, M::NONE); + assert_eq!(get(&app, "scaleH"), 200.0); + set(&mut app, "scaleH", json!(300)); + f.key(&mut app, ArrowLeft, M::NONE); + assert_eq!(get(&app, "scaleH"), 199.0); + // Tab goes through the Scale fields and sliders to the Move slider. + set(&mut app, "moveH", json!(0)); + for _ in 0..4 { + f.key(&mut app, Tab, M::NONE); + } + f.key(&mut app, ArrowRight, M::NONE); + assert!((get(&app, "moveH") - 72.0 / 25.4).abs() < 1e-9, "1 mm: {} pt", get(&app, "moveH")); +} + +/// The sliders the flattener options and the 3D dialogs draw take the arrow keys as well: Flatten +/// Transparency's Raster/Vector Balance and 3D Revolve's angle each step by ten with Shift, and +/// down to the next whole number with ↓. +#[test] +fn the_flattener_and_3d_sliders_step_with_the_arrow_keys() { + use egui::Modifiers as M; + let mut app = app(); + app.run("path.create", json!({"d": "M150 40 L180 60 L180 140 L150 160"})).unwrap(); + for (open, label, key) in [("flatten", "Raster/Vector Balance:", "balance"), ("revolve", "Revolve angle", "angle")] { + if open == "flatten" { + app.run("ui.flattenTransparencyDialog", json!({})).unwrap(); + } else { + app.run("effect.dialog", json!({"effect": vectorcraft_effects::REVOLVE, "item": null})).unwrap(); + } + let value = |app: &VectorcraftApp| app.ui.dialog.as_ref().expect("the dialog is open").f64(key, -1.0); + let mut f = Frames::new(); + let at = f.control(&mut app, label, egui::accesskit::Role::Slider); + f.press(&mut app, at, true); + f.press(&mut app, at, false); + let start = value(&app); + f.key(&mut app, egui::Key::ArrowRight, M::SHIFT); + assert_eq!(value(&app), start + 10.0, "{label}"); + // A plain step goes to the next whole number below. + f.key(&mut app, egui::Key::ArrowDown, M::NONE); + assert_eq!(value(&app), (start + 10.0).ceil() - 1.0, "{label} from {start}"); + app.ui.dialog = None; + } +} + #[test] fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() { use vectorcraft_doc::NodeId; @@ -1485,6 +1835,25 @@ fn polar_grid_shared_canvas_click_defaults_units_counts_and_history() { assert_eq!(app.session.doc().unwrap().doc, made); } +/// Part of gap G11: an Arc tool click opens its options at the pointer; OK draws an arc there. +#[test] +fn arc_click_opens_options_and_ok_draws() { + use vectorcraft_doc::NodeKind; + let mut app = app(); + open_tool_dialog(&mut app, "arc", json!({"x": 100.0, "y": 50.0})); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.kind, "arc"); + assert!(d.fields.contains_key("lengthX") && d.fields.contains_key("lengthY")); + super::confirm(&mut app).unwrap(); + let n = app.session.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap(); + assert!(matches!(n.kind, NodeKind::Path { .. })); + let pd = n.path_data().unwrap(); + let a0 = pd.subpaths[0].anchors[0].p; + let a1 = pd.subpaths[0].anchors[1].p; + assert!((a0.x - 100.0).abs() < 1e-6 && (a0.y - 50.0).abs() < 1e-6); + assert!((a1.x - 200.0).abs() < 1e-6 && (a1.y - 150.0).abs() < 1e-6); +} + /// #1377: Polar Grid's dialog has a skew slider under each divider count, Create Compound Path /// From Ellipses and Fill Grid, opened with the tool's stored values; OK draws with them and /// stores them. @@ -1529,7 +1898,10 @@ fn polar_grid_dialog_edits_skews_compound_and_fill() { assert!((widths[0] - 100.0).abs() < 1e-6 && (widths[1] - 93.75).abs() < 1e-6, "outward: {widths:?}"); let tips: Vec = children[3..].iter().map(|n| n.path_data().unwrap().subpaths[0].anchors[1].p.x).collect(); assert!(tips.iter().all(|x| *x > 60.0), "right of the top divider: {tips:?}"); - let saved = json!({"concentric": 3, "radial": 4, "concentricSkew": 100.0, "radialSkew": 500.0, "compound": true, "fill": true}); + let saved = json!({ + "concentric": 3, "radial": 4, "concentricSkew": 100.0, "radialSkew": 500.0, "compound": true, "fill": true, + "width": 100.0, "height": 100.0, "reference": 0, + }); assert_eq!(app.session.tool_options_of("polarGrid"), saved); } @@ -1566,3 +1938,451 @@ fn polar_grid_shared_cancel_reopen_zero_counts_and_failed_creation() { assert_eq!(app.session.tool_options_of("polarGrid"), options); assert_eq!(app.session.doc().unwrap().doc, before); } + +/// Open the real Preset menu before choosing a Fit; these actions are no longer body buttons. +fn artboard_fit_preset_at(frames: &mut Frames, app: &mut VectorcraftApp, label: &str) -> egui::Pos2 { + let texts = frames.texts(app); + assert!(texts.iter().all(|(t, _)| !matches!(t.as_str(), "Fit to Artwork Bounds" | "Fit to Selected Art")), "Fit lives only in Preset"); + let row = texts.iter().find(|(t, _)| t == "Preset:").unwrap().1; + let combo = frames.ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, widgets)| widgets.iter()) + .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) + .map(|w| w.rect) + .min_by(|a, b| a.left().total_cmp(&b.left())) + .unwrap() + .center() + }); + frames.press(app, combo, true); + frames.press(app, combo, false); + frames.texts(app).into_iter().find(|(text, _)| text.trim() == label).unwrap().1.center() +} + +#[test] +fn artboard_options_fit_drafts_cancel_and_apply_without_moving_art() { + for selected in [false, true] { + let mut app = app(); + app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + app.select_tool("artboard"); + app.session.set_tool_option("scaleArt", &json!(true)); + app.session.set_tool_option("moveArt", &json!(true)); + let before = app.session.doc().unwrap().doc.clone(); + for apply in [false, true] { + artboard_options::open(&mut app).unwrap(); + let mut frames = Frames::new(); + let label = if selected { "Fit to Selected Art" } else { "Fit to Artwork Bounds" }; + let at = artboard_fit_preset_at(&mut frames, &mut app, label); + frames.press(&mut app, at, true); + frames.press(&mut app, at, false); + assert_eq!(app.session.doc().unwrap().doc, before, "draft only"); + if !apply { + cancel(&mut app); + continue; + } + confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.layers, before.layers, "art stays in place"); + assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 110.0, 100.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + } + } +} + +/// Size edits honor the live tool switch; drafts/cancel and one-step history remain intact. +#[test] +fn artboard_options_size_changes_honor_scale_artwork() { + for scale in [false, true] { + for (choice, width, height) in + [(json!(null), 400.0, 200.0), (json!({"preset":[612.0,792.0]}), 792.0, 612.0), (json!({"landscape":false}), 100.0, 200.0)] + { + let mut app = app(); + app.run("file.new", json!({"width":200,"height":100})).unwrap(); + app.run("shape.rectangle", json!({"x":20,"y":10,"width":40,"height":20})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + app.select_tool("artboard"); + app.session.set_tool_option("scaleArt", &json!(scale)); + let before = app.session.doc().unwrap().doc.clone(); + for apply in [false, true] { + artboard_options::open(&mut app).unwrap(); + let draft = app.ui.dialog.as_mut().unwrap(); + draft.fields.insert("width".into(), json!(width)); + draft.fields.insert("height".into(), json!(height)); + if !choice.is_null() { + draft.fields.insert("__sizeChoice".into(), choice.clone()); + } + assert_eq!(app.session.doc().unwrap().doc, before, "size change remains a draft"); + if !apply { + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + continue; + } + let steps = app.session.doc().unwrap().history.undo.len(); + confirm(&mut app).unwrap(); + let after = app.session.doc().unwrap().doc.clone(); + let bounds = vectorcraft_render::encode::art_bounds(&after).unwrap(); + let (sx, sy) = if scale { (width / 200.0, height / 100.0) } else { (1.0, 1.0) }; + for (actual, expected) in [(bounds.x0, 20.0 * sx), (bounds.y0, 10.0 * sy), (bounds.x1, 60.0 * sx), (bounds.y1, 30.0 * sy)] { + assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}, scale={scale}, choice={choice}"); + } + assert_eq!((after.artboards[0].rect.width(), after.artboards[0].rect.height()), (width, height)); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + app.run("edit.undo", json!({})).unwrap(); + } + } + } +} + +#[test] +fn artboard_options_shared_preset_drafts_and_resizes_selected_boards_in_their_orientations() { + let mut app = app(); + app.run("file.new", json!({"width":200,"height":100,"artboards":3})).unwrap(); + app.run("artboard.setProps", json!({"index":2,"width":150,"height":300})).unwrap(); + app.select_tool("artboard"); + crate::panels::artboards::select(&mut app, 0); + app.session.set_tool_option("selected", &json!([0, 2])); + let before = app.session.doc().unwrap().doc.clone(); + for apply in [false, true] { + artboard_options::open(&mut app).unwrap(); + let mut frames = Frames::new(); + let at = |texts: &[(String, egui::Rect)], label: &str| { + texts.iter().find(|(t, _)| t.trim() == label).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {label:?} in {texts:?}")) + }; + let custom = at(&frames.texts(&mut app), "Custom"); + frames.press(&mut app, custom, true); + frames.press(&mut app, custom, false); + let print = at(&frames.texts(&mut app), "Print"); + frames.run(&mut app, vec![egui::Event::PointerMoved(print)]); + let letter = at(&frames.texts(&mut app), "Letter"); + frames.press(&mut app, letter, true); + frames.press(&mut app, letter, false); + assert_eq!(app.session.doc().unwrap().doc, before, "preset is a draft until OK"); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("width", 0.0), d.f64("height", 0.0)), (792.0, 612.0)); + if !apply { + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + continue; + } + let steps = app.session.doc().unwrap().history.undo.len(); + confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!((doc.artboards[0].rect.width(), doc.artboards[0].rect.height()), (792.0, 612.0)); + assert_eq!((doc.artboards[2].rect.width(), doc.artboards[2].rect.height()), (612.0, 792.0)); + assert_eq!(doc.artboards[1], before.artboards[1], "unselected board stays unchanged"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + } +} + +#[test] +fn artboard_options_orientation_applies_to_other_selected_boards_when_active_is_already_portrait() { + let mut app = app(); + app.run("file.new", json!({"width":100,"height":200,"artboards":3})).unwrap(); + app.run("artboard.setProps", json!({"index":2,"width":300,"height":150})).unwrap(); + app.select_tool("artboard"); + crate::panels::artboards::select(&mut app, 0); + app.session.set_tool_option("selected", &json!([0, 2])); + let before = app.session.doc().unwrap().doc.clone(); + artboard_options::open(&mut app).unwrap(); + let mut frames = Frames::new(); + let texts = frames.texts(&mut app); + let row = texts.iter().find(|(t, _)| t == "Preset:").unwrap().1; + let mut buttons: Vec = frames.ctx.viewport(|vp| { + vp.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.sense.senses_click() && w.rect.y_range().contains(row.center().y) && w.rect.left() > row.right()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.left().total_cmp(&b.left())); + let portrait = buttons[buttons.len() - 2].center(); + frames.press(&mut app, portrait, true); + frames.press(&mut app, portrait, false); + assert_eq!(app.session.doc().unwrap().doc, before, "orientation remains a draft"); + confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!(doc.artboards[0], before.artboards[0], "active board was already portrait"); + assert_eq!((doc.artboards[2].rect.width(), doc.artboards[2].rect.height()), (150.0, 300.0)); + assert_eq!(doc.artboards[1], before.artboards[1]); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); +} + +#[test] +fn artboard_options_fit_changes_only_active_board_even_with_an_artboard_multiselection() { + let mut app = app(); + app.run("file.new", json!({"width":200,"height":200,"artboards":3})).unwrap(); + app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + app.select_tool("artboard"); + crate::panels::artboards::select(&mut app, 0); + app.session.set_tool_option("selected", &json!([0, 2])); + let before = app.session.doc().unwrap().doc.clone(); + artboard_options::open(&mut app).unwrap(); + let mut frames = Frames::new(); + let fit = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds"); + frames.press(&mut app, fit, true); + frames.press(&mut app, fit, false); + assert_eq!(app.session.doc().unwrap().doc, before, "fit remains a draft"); + confirm(&mut app).unwrap(); + let doc = &app.session.doc().unwrap().doc; + assert_eq!(doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 110.0, 100.0)); + assert_eq!(&doc.artboards[1..], &before.artboards[1..]); + assert_eq!(doc.layers, before.layers, "fitting never moves artwork"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); +} + +#[test] +fn artboard_options_fit_artwork_ignores_draft_membership_and_invalidates_on_edits() { + let mut app = app(); + app.run("file.new", json!({"width":400,"height":320})).unwrap(); + app.run("shape.rectangle", json!({"x":30,"y":40,"width":80,"height":60})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + let boards = app.session.doc().unwrap().doc.artboards.clone(); + artboard_options::open(&mut app).unwrap(); + let dialog = app.ui.dialog.as_mut().unwrap(); + dialog.fields.insert("width".into(), json!(1)); + dialog.fields.insert("height".into(), json!(1)); + let mut frames = Frames::new(); + let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds"); + frames.press(&mut app, at, true); + frames.press(&mut app, at, false); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)), + (30.0, 40.0, 80.0, 60.0), + "Fit queries all artwork, not the shrunken draft" + ); + assert_eq!(app.session.doc().unwrap().doc.artboards, boards); + // A remote command can edit the document while the modal remains open. + app.run("shape.rectangle", json!({"x":600,"y":120,"width":40,"height":30})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + let layers = app.session.doc().unwrap().doc.layers.clone(); + let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds"); + frames.press(&mut app, at, true); + frames.press(&mut app, at, false); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)), + (30.0, 40.0, 610.0, 110.0), + "A document revision must invalidate cached fit bounds" + ); + confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.layers, layers); + assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 640.0, 150.0)); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.artboards, boards); + assert_eq!(app.session.doc().unwrap().doc.layers, layers); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 640.0, 150.0)); + assert_eq!(app.session.doc().unwrap().doc.layers, layers); +} + +#[test] +fn artboard_options_fit_artwork_includes_all_boards_and_is_independent_of_target() { + let mut app = app(); + app.run("file.new", json!({"width":400,"height":320,"artboards":2})).unwrap(); + let boards = app.session.doc().unwrap().doc.artboards.clone(); + for (index, width, height) in [(0, 80.0, 60.0), (1, 40.0, 30.0)] { + let rect = boards[index].rect; + app.run("shape.rectangle", json!({"x":rect.x0+30.0,"y":rect.y0+40.0,"width":width,"height":height})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + } + let layers = app.session.doc().unwrap().doc.layers.clone(); + let expected = + vectorcraft_geom::Rect::new(boards[0].rect.x0 + 30.0, boards[0].rect.y0 + 40.0, boards[0].rect.x0 + 110.0, boards[0].rect.y0 + 100.0).union( + vectorcraft_geom::Rect::new(boards[1].rect.x0 + 30.0, boards[1].rect.y0 + 40.0, boards[1].rect.x0 + 70.0, boards[1].rect.y0 + 70.0), + ); + app.select_tool("artboard"); + let mut frames = Frames::new(); + for index in [0, 1, 0] { + crate::panels::artboards::select(&mut app, index); + artboard_options::open(&mut app).unwrap(); + let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds"); + frames.press(&mut app, at, true); + frames.press(&mut app, at, false); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)), + (expected.x0, expected.y0, expected.width(), expected.height()) + ); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc.layers, layers); + assert_eq!(app.session.doc().unwrap().doc.artboards, boards); + } +} + +#[test] +fn artboard_options_fit_artwork_new_board_includes_art_outside_its_draft() { + let mut app = app(); + app.run("file.new", json!({"width":400,"height":320})).unwrap(); + app.run("shape.rectangle", json!({"x":450,"y":40,"width":80,"height":60})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + app.run("shape.rectangle", json!({"x":30,"y":170,"width":20,"height":20})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + let boards = app.session.doc().unwrap().doc.artboards.clone(); + let layers = app.session.doc().unwrap().doc.layers.clone(); + app.run("ui.newArtboard", json!({})).unwrap(); + assert!(app.ui.dialog.as_ref().unwrap().bool("new")); + let mut frames = Frames::new(); + let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds"); + frames.press(&mut app, at, true); + frames.press(&mut app, at, false); + let dialog = app.ui.dialog.as_ref().unwrap(); + assert_eq!((dialog.f64("x", 0.0), dialog.f64("y", 0.0), dialog.f64("width", 0.0), dialog.f64("height", 0.0)), (30.0, 40.0, 500.0, 150.0)); + confirm(&mut app).unwrap(); + let after = app.session.doc().unwrap().doc.artboards.clone(); + assert_eq!(after.len(), 2); + assert_eq!(after[0], boards[0]); + assert_eq!(after[1].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 530.0, 190.0)); + assert_eq!(app.session.doc().unwrap().doc.layers, layers); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.artboards, boards); + assert_eq!(app.session.doc().unwrap().doc.layers, layers); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.artboards, after); + assert_eq!(app.session.doc().unwrap().doc.layers, layers); +} + +/// Fit and background color share one draft and one undo step for existing and new boards. +#[test] +fn artboard_options_fit_and_background_color_coexist_through_cancel_undo_and_redo() { + for new in [false, true] { + for apply in [false, true] { + let mut app = app(); + app.run("file.new", json!({"width":400,"height":320})).unwrap(); + app.run("artboard.setProps", json!({"index":0,"color":"#336699"})).unwrap(); + let x = if new { 450.0 } else { 30.0 }; + app.run("shape.rectangle", json!({"x":x,"y":40,"width":80,"height":60})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let steps = app.session.doc().unwrap().history.undo.len(); + if new { + app.run("ui.newArtboard", json!({})).unwrap(); + } else { + artboard_options::open(&mut app).unwrap(); + } + let labels = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(labels.contains("Background Color:") && labels.contains("Preset:") && !labels.contains("Fit to Artwork Bounds"), "{labels}"); + app.ui.dialog.as_mut().unwrap().fields.insert("color".into(), json!("#c05030")); + let mut frames = Frames::new(); + let fit = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds"); + frames.press(&mut app, fit, true); + frames.press(&mut app, fit, false); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["color"], json!("#c05030")); + assert_eq!(app.session.doc().unwrap().doc, before, "both changes remain a draft"); + if !apply { + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps); + continue; + } + confirm(&mut app).unwrap(); + let after = app.session.doc().unwrap().doc.clone(); + let board = &after.artboards[usize::from(new)]; + assert_eq!(board.rect, vectorcraft_geom::Rect::new(x, 40.0, x + 80.0, 100.0)); + assert_eq!(board.color.map(|c| c.to_hex()).as_deref(), Some("#c05030")); + assert_eq!(after.layers, before.layers, "fitting never moves the art"); + if new { + assert_eq!(after.artboards[0], before.artboards[0]); + } + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); + } + } +} + +/// Moving only the board, with no resize, must not translate its art even when Move Artwork is on. +#[test] +fn artboard_options_position_only_fit_keeps_art_with_move_art_enabled() { + let mut app = app(); + app.run("file.new", json!({"width":80,"height":60})).unwrap(); + app.run("shape.rectangle", json!({"x":10,"y":10,"width":20,"height":20})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + app.run("shape.rectangle", json!({"x":70,"y":50,"width":20,"height":20})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + app.run("tool.setOption", json!({"tool":"artboard", "values":{"moveArt":true}})).unwrap(); + assert!(crate::panels::artboards::move_art(&app)); + let before = app.session.doc().unwrap().doc.clone(); + artboard_options::open(&mut app).unwrap(); + let mut frames = Frames::new(); + let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Artwork Bounds"); + frames.press(&mut app, at, true); + frames.press(&mut app, at, false); + confirm(&mut app).unwrap(); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(after.artboards[0].rect, vectorcraft_geom::Rect::new(10.0, 10.0, 90.0, 70.0)); + assert_eq!(after.layers, before.layers, "position-only fitting never moves art"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); +} + +#[test] +fn artboard_options_fit_preset_disabled_without_art_or_selection() { + for has_art in [false, true] { + for label in ["Fit to Artwork Bounds", "Fit to Selected Art"] { + if has_art && label == "Fit to Artwork Bounds" { + continue; + } + let mut app = app(); + if has_art { + app.run("shape.rectangle", json!({"x":600,"y":40,"width":80,"height":60})).unwrap(); + app.run("select.none", json!({})).unwrap(); + } + let before = app.session.doc().unwrap().doc.clone(); + artboard_options::open(&mut app).unwrap(); + let mut frames = Frames::new(); + let at = artboard_fit_preset_at(&mut frames, &mut app, label); + frames.press(&mut app, at, true); + frames.press(&mut app, at, false); + let draft = app.ui.dialog.as_ref().unwrap(); + assert_ne!(draft.fields.get("__fit"), Some(&json!(true)), "disabled {label} must not become a fit draft"); + assert_eq!(app.session.doc().unwrap().doc, before); + cancel(&mut app); + assert_eq!(app.session.doc().unwrap().doc, before); + } + } +} + +#[test] +fn artboard_options_selected_fit_spans_other_boards_and_off_board_art() { + let mut app = app(); + app.run("file.new", json!({"width":200,"height":200,"artboards":3})).unwrap(); + for (x, y, w, h) in [(30, 40, 80, 60), (1000, 500, 50, 40)] { + app.run("shape.rectangle", json!({"x":x,"y":y,"width":w,"height":h})).unwrap(); + app.run("paint.setStroke", json!({"none":true})).unwrap(); + } + app.run("select.all", json!({})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + artboard_options::open(&mut app).unwrap(); + let mut frames = Frames::new(); + let at = artboard_fit_preset_at(&mut frames, &mut app, "Fit to Selected Art"); + frames.press(&mut app, at, true); + frames.press(&mut app, at, false); + assert_eq!(app.session.doc().unwrap().doc, before, "selection fit is a draft"); + confirm(&mut app).unwrap(); + let after = app.session.doc().unwrap().doc.clone(); + assert_eq!(after.artboards[0].rect, vectorcraft_geom::Rect::new(30.0, 40.0, 1050.0, 540.0)); + assert_eq!(after.artboards[1..], before.artboards[1..]); + assert_eq!(after.layers, before.layers); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, after); +} diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs index 90780ad32..80227e776 100644 --- a/crates/ui-egui/src/dialogs/tests_export.rs +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -16,10 +16,15 @@ pub(super) type Written = Rc)>>>; /// An app with `artboards` 60×40 pt artboards whose save dialog answers `/out/` and /// whose writer records what it is given. pub(super) fn app(artboards: usize) -> (VectorcraftApp, Written) { + app_saving(artboards, |p| format!("/out/{}", p.name)) +} + +/// [`app`], with a save dialog that answers `pick`. +pub(super) fn app_saving(artboards: usize, pick: fn(&crate::FilePick) -> String) -> (VectorcraftApp, Written) { let written = Written::default(); let w = written.clone(); let services = Services { - pick_save: Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name)))), + pick_save: Some(Box::new(move |p: &crate::FilePick| Some(pick(p)))), write: Some(Box::new(move |p: &str, b: &[u8]| { w.borrow_mut().push((p.to_string(), b.to_vec())); Ok(()) @@ -266,3 +271,42 @@ fn export_as_writes_only_the_chosen_artboard_in_every_format() { } } } + +/// The Enter that closed the save panel, still held as the format's options open, doesn't write +/// the file; Enter pressed again after it is let go does. So for every options dialog Export As +/// opens after its panel, an exporter plug-in's included (as Save As's options). +#[test] +fn the_enter_that_closed_the_save_panel_does_not_confirm_the_options() { + let params = r#"{"speed":{"type":"number","min":1,"max":100,"default":40}}"#; + let module = wat::parse_str(vectorcraft_plugins::wat::returning( + &vectorcraft_plugins::wat::exporter("test.ui.held", r#"["tuh"]"#, params), + r#"{"dataBase64":"SGk="}"#, + )) + .unwrap(); + let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + }; + // Each format whose options follow the panel (PDF's with Use Artboards: Save PDF). + let formats = [("png", false), ("svg", false), ("pdf", true), ("txt", false), ("dxf", false), ("eps", false), ("plugin.test.ui.held", false)]; + for (format, artboards) in formats { + let (mut app, written) = app(1); + app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&module)})).unwrap(); + app.run("file.exportAs", json!({"format": format})).unwrap(); + set(&mut app, "useArtboards", json!(artboards)); + confirm(&mut app).unwrap(); + let options = kind(&app).map(str::to_string); + assert!(options.as_deref().is_some_and(|k| k != "exportAs"), "{format}: its options follow the panel, not {options:?}"); + frame(&mut app, vec![enter(true)]); + assert_eq!(kind(&app).map(str::to_string), options, "{format}: the Enter still held isn't the options'"); + assert!(written.borrow().is_empty(), "{format}: nothing written"); + frame(&mut app, vec![enter(false)]); + frame(&mut app, vec![enter(true)]); + assert!(app.ui.dialog.is_none(), "{format}: Enter pressed again confirms"); + assert!(!written.borrow().is_empty(), "{format}: and writes"); + } + vectorcraft_plugins::registry::remove("test.ui.held"); +} diff --git a/crates/ui-egui/src/dialogs/tests_modal.rs b/crates/ui-egui/src/dialogs/tests_modal.rs index 883194916..dd5d01b80 100644 --- a/crates/ui-egui/src/dialogs/tests_modal.rs +++ b/crates/ui-egui/src/dialogs/tests_modal.rs @@ -236,21 +236,6 @@ fn form_dialog_axes_are_dropdowns() { } } -/// The axis choices are translated in the "axis" context: in Japanese the plain Horizontal and -/// Vertical are the type orientations (horizontal and vertical writing). -#[test] -fn axis_choices_read_as_axes_in_every_complete_language() { - use crate::i18n::{LANGUAGES, Lang, tr, tr_ctx}; - let ja = Lang::from_code("ja").unwrap(); - assert_eq!(tr_ctx(ja, "axis", "Horizontal"), "水平方向"); - assert_ne!(tr_ctx(ja, "axis", "Horizontal"), tr(ja, "Horizontal")); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - for s in ["Horizontal", "Vertical", "Both"] { - assert!(l.source.lines().any(|line| line.starts_with(&format!("axis\t{s}\t"))), "{}: no axis row for {s}", l.code); - } - } -} - /// Offset Path previews on the canvas; Cancel rolls the preview back. #[test] fn offset_path_previews() { @@ -284,6 +269,21 @@ fn offset_path_remembers_its_last_values() { assert_eq!((d.f64("offset", 0.0), d.str("joins"), d.f64("miterLimit", 0.0)), (-4.5, "round".to_string(), 4.0)); } +/// Offset Path's Preview checkbox keeps its last state across opens, like the other fields (#3195). +#[test] +fn offset_path_remembers_its_last_preview_state() { + let mut app = app_with_selection(); + app.run("select.all", json!({})).unwrap(); + crate::menus::invoke(&mut app, "object.path.offsetPath", json!({})); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("preview"), "a first open is unchecked"); + d.fields.insert("preview".into(), json!(true)); + confirm(&mut app).unwrap(); + crate::menus::invoke(&mut app, "object.path.offsetPath", json!({})); + let d = app.ui.dialog.as_ref().unwrap(); + assert!(d.bool("preview"), "the state it was confirmed with"); +} + fn app_with_dense_path() -> VectorcraftApp { let mut app = VectorcraftApp::new(Session::new(), Default::default()); app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); @@ -323,9 +323,9 @@ fn slider_strip(ctx: &egui::Context) -> (Rect, Pos2, Pos2, Pos2) { (r, Pos2::new(r.left() + 8.0 + 14.0, y), Pos2::new(r.left() + 8.0 + 28.0 + 6.0 + 90.0, y), Pos2::new(r.right() - 8.0 - 14.0, y)) } -/// Object › Path › Simplify opens a slider under the selection that previews the 1 pt result. -/// Dragging it to the right previews more anchor points and to the left fewer, and Enter keeps the -/// result as one undo step. +/// Object › Path › Simplify opens a slider under the selection that previews the auto-simplified +/// result (the path is 285 pt wide: level 55, 0.7125 pt). Dragging it to the right previews more +/// anchor points and to the left fewer, and Enter keeps the result as one undo step. #[test] fn simplify_opens_a_slider_whose_drag_previews_fewer_or_more_points() { let (ctx, mut app) = (ctx(), app_with_dense_path()); @@ -333,8 +333,8 @@ fn simplify_opens_a_slider_whose_drag_previews_fewer_or_more_points() { crate::menus::invoke(&mut app, "object.path.simplify", json!({})); assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(simplify_slider::KIND)); settle(&ctx, &mut app); - assert_eq!(simplify_preview(&app), json!({"level": 50.0})); - assert_eq!(anchor_count(&app), 2, "the 1 pt result"); + assert_eq!(simplify_preview(&app), json!({"level": 55.0})); + assert_eq!(anchor_count(&app), 2, "the auto-simplified result"); let (_, _, rail, _) = slider_strip(&ctx); drag(&ctx, &mut app, rail, vec2(100.0, 0.0)); assert_eq!(simplify_preview(&app), json!({"level": 100.0})); @@ -354,6 +354,40 @@ fn simplify_opens_a_slider_whose_drag_previews_fewer_or_more_points() { assert_eq!(anchor_count(&app), 2); } +/// A wave of 20 anchors `width` pt wide and `width / 5` pt high, selected. +fn app_with_wave(width: f64) -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 2000, "height": 2000})).unwrap(); + let anchors: Vec<_> = (0..20) + .map(|i| { + let t = i as f64 / 19.0; + json!({"x": 30.0 + width * t, "y": 500.0 + width / 5.0 * (3.0 * std::f64::consts::PI * t).sin()}) + }) + .collect(); + app.run("path.create", json!({"anchors": anchors})).unwrap(); + app +} + +/// Object › Path › Simplify opens the slider at Auto-Simplify's level for the selection's size, +/// not at a fixed level, and the canvas previews the paths as Auto-Simplify leaves them. +#[test] +fn simplify_slider_opens_at_the_auto_simplify_level_of_the_selection() { + let ctx = ctx(); + // 1/400 of the longer side: 2.85 pt (level 35), 0.7125 pt (55), 0.07125 pt (89). + for (width, level) in [(1140.0, 35.0), (285.0, 55.0), (28.5, 89.0)] { + let mut app = app_with_wave(width); + crate::menus::invoke(&mut app, "object.path.simplify", json!({})); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("level", -1.0), level, "{width} pt"); + settle(&ctx, &mut app); + assert_eq!(simplify_preview(&app), json!({ "level": level }), "{width} pt"); + let previewed = app.session.doc().unwrap().doc.clone(); + cancel(&mut app); + let auto = app.run("object.path.simplify", json!({"auto": true})).unwrap(); + assert_eq!(auto["level"], json!(level), "{width} pt"); + assert_eq!(app.session.doc().unwrap().doc, previewed, "{width} pt: the preview is Auto-Simplify's result"); + } +} + /// Escape, taken by the app's key handling before the dialog layer draws, closes the Simplify /// slider and puts the paths back as they were, with no undo step. #[test] @@ -405,15 +439,17 @@ fn simplify_slider_stays_where_it_opened_while_the_preview_changes_the_bounds() assert_eq!(rect_of(&ctx, egui::Id::new(("dialog", simplify_slider::KIND))), opened); } -/// Auto-Simplify moves the slider to the level that keeps the path within 1/400 of its size, and a -/// click outside the strip keeps the result as one undo step. +/// Auto-Simplify moves the slider back to the level that keeps the path within 1/400 of its size, +/// and a click outside the strip keeps the result as one undo step. #[test] fn simplify_slider_auto_simplify_and_a_click_outside_keep_the_result() { let (ctx, mut app) = (ctx(), app_with_dense_path()); let before = app.session.doc().unwrap().doc.clone(); let undo_len = app.session.doc().unwrap().history.undo.len(); crate::menus::invoke(&mut app, "object.path.simplify", json!({})); + app.ui.dialog.as_mut().unwrap().fields.insert("level".into(), json!(100)); settle(&ctx, &mut app); + assert_eq!(simplify_preview(&app), json!({"level": 100.0})); let (_, auto, ..) = slider_strip(&ctx); click(&ctx, &mut app, auto); // The path is 285 pt wide: 0.7125 pt at most, level 55. @@ -428,6 +464,54 @@ fn simplify_slider_auto_simplify_and_a_click_outside_keep_the_result() { assert_ne!(app.session.doc().unwrap().doc, before); } +/// With anchors 10 to 30 of a 41-anchor wave direct-selected, the Simplify slider previews and +/// Enter keeps a refit of only the segments between them, as one undo step: the anchors before and +/// after the run stay exactly as they were. The Simplify dialog (More Options) does the same. +#[test] +fn simplify_slider_and_dialog_simplify_only_the_direct_selected_part_of_a_path() { + let ctx = ctx(); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let anchors: Vec<_> = (0..=40).map(|i| json!({"x": 20 + i * 8, "y": 150.0 + 60.0 * (f64::from(i) / 6.0).sin()})).collect(); + let id = app.run("path.create", json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap(); + let path = |app: &VectorcraftApp| { + app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).and_then(|n| n.path_data()).expect("a path").subpaths[0].anchors.clone() + }; + let original = path(&app); + let run: Vec<_> = (10..=30).map(|i| json!([0, i])).collect(); + app.run("select.anchors", json!({"id": id, "anchors": run})).unwrap(); + let undo_len = app.session.doc().unwrap().history.undo.len(); + let kept = |app: &VectorcraftApp, how: &str| { + let now = path(app); + assert!(now.len() < 41, "{how}: the run loses anchors"); + assert_eq!(now[..10], original[..10], "{how}: the anchors before the run"); + assert_eq!(now[10].p, original[10].p, "{how}: the run's start anchor"); + assert_eq!(now[now.len() - 10..], original[31..], "{how}: the anchors after the run"); + assert_eq!(now[now.len() - 11].p, original[30].p, "{how}: the run's end anchor"); + }; + crate::menus::invoke(&mut app, "object.path.simplify", json!({})); + app.ui.dialog.as_mut().unwrap().fields.insert("level".into(), json!(20)); + settle(&ctx, &mut app); + assert_eq!(simplify_preview(&app), json!({"level": 20.0})); + kept(&app, "the slider's preview"); + frame( + &ctx, + &mut app, + vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], + ); + assert!(app.ui.dialog.is_none() && !app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1); + kept(&app, "the slider"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(path(&app), original); + open_simplify_dialog(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("tolerance".into(), json!(4)); + settle(&ctx, &mut app); + confirm(&mut app).unwrap(); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo_len + 1); + kept(&app, "the dialog"); +} + /// More Options opens the Simplify dialog at the slider's tolerance. With "Open this dialog with /// these settings next time" ticked, OK makes the menu item open the dialog on its settings; with /// it unticked, the slider again. @@ -614,7 +698,7 @@ fn simplify_dialog_show_original_path_outlines_the_paths_before_the_preview() { #[test] fn printer_mark_labels_are_distinct_from_language_names() { let cs = crate::i18n::Lang::from_code("cs").unwrap(); - assert_eq!(save_pdf::choice_label(cs, "marks.kind", "Japanese"), "Japonské"); - assert_eq!(save_pdf::choice_label(cs, "language", "Japanese"), "Japonština"); + assert_eq!(save_pdf::choice_label(cs, "marks.kind", "Japanese"), crate::i18n::tr_ctx(cs, "mark-style", "Japanese")); + assert_eq!(save_pdf::choice_label(cs, "language", "Japanese"), crate::i18n::tr(cs, "Japanese")); assert_eq!(save_pdf::choice_label(crate::i18n::Lang::EN, "marks.kind", "Japanese"), "Japanese"); } diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs index ecd26301b..cd34213af 100644 --- a/crates/ui-egui/src/dialogs/tests_package.rs +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -2,11 +2,15 @@ use std::cell::RefCell; use std::rc::Rc; +use std::sync::Arc; use serde_json::json; use vectorcraft_engine::Session; +use vectorcraft_engine::file_access::{Access, AutomationRoots}; use super::*; +use crate::background; +use crate::tests_background::{Files, Gate, written}; /// A fresh folder for one test (removed when dropped). struct Folder(std::path::PathBuf); @@ -89,6 +93,153 @@ fn package_writes_the_folder_then_offers_to_show_it() { assert_eq!(opened.borrow().first().map(std::path::PathBuf::from), Some(root)); } +/// [`saved`] with a thread-safe writer, the background thread's, that records the files it is +/// given once `gate` opens. +fn saved_with_worker(dir: &Folder, gate: &Gate) -> (VectorcraftApp, Files) { + let mut app = saved(dir); + let (files, g) = (Files::default(), gate.clone()); + let recorded = files.clone(); + app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| { + g.wait(); + recorded.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + (app, files) +} + +/// Open the Package dialog for a package in `out`. +fn package_dialog(app: &mut VectorcraftApp, out: &str) { + app.run("ui.packageDialog", json!({})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(out)); +} + +#[test] +fn package_is_written_in_the_background_as_it_is_at_once() { + let dir = Folder::new("background"); + let gate = Gate::default(); + let (mut app, files) = saved_with_worker(&dir, &gate); + let out = dir.path("out"); + let root = std::path::Path::new(&out).join("card Folder"); + // With Background Export off, the package is written at once through the UI thread's writer. + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": false}})).unwrap(); + package_dialog(&mut app, &out); + let at_once = confirm(&mut app).unwrap(); + let want: Vec<(String, Vec)> = at_once["files"] + .as_array() + .unwrap() + .iter() + .map(|f| root.join(f.as_str().unwrap())) + .map(|p| (p.to_string_lossy().into_owned(), std::fs::read(&p).unwrap())) + .collect(); + assert_eq!(want.len(), 3, "the document, its linked file and the report: {at_once}"); + let (status, detail) = (app.ui.status.clone(), app.ui.dialog.as_ref().unwrap().str("detail")); + std::fs::remove_dir_all(&root).unwrap(); + app.ui.dialog = None; + // With Background Export on, the dialog closes at once, and the same files are written later. + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": true}})).unwrap(); + package_dialog(&mut app, &out); + let r = confirm(&mut app).unwrap(); + assert_eq!(r["background"], true, "{r}"); + assert!(app.ui.dialog.is_none(), "closed"); + assert_eq!(app.ui.status, "Packaging card Folder…"); + assert!(written(&files).is_empty(), "nothing written yet"); + gate.open(); + background::wait_all(&mut app); + assert_eq!(*files.lock().unwrap(), want); + assert_eq!(app.ui.status, status); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__command"), d.str("detail")), ("file.showPackage".to_string(), detail)); +} + +#[test] +fn edits_after_ok_do_not_reach_the_package() { + let dir = Folder::new("snapshot"); + let gate = Gate::default(); + let (mut app, files) = saved_with_worker(&dir, &gate); + let nodes = app.session.active().unwrap().doc.node_count(); + package_dialog(&mut app, &dir.path("out")); + assert_eq!(confirm(&mut app).unwrap()["background"], true); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + app.run("select.all", json!({})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + // A dialog opened meanwhile stays open; the status bar reports the package. + app.run("ui.packageDialog", json!({})).unwrap(); + gate.open(); + background::wait_all(&mut app); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("package")); + assert!(app.ui.status.starts_with("Packaged 3 file(s) in "), "{}", app.ui.status); + let files = files.lock().unwrap(); + let (_, bytes) = files.iter().find(|(p, _)| p.ends_with("card.vectorcraft")).unwrap(); + assert_eq!(vectorcraft_format::load(bytes).unwrap().node_count(), nodes, "the document as it was at OK"); + assert!(files.iter().any(|(p, _)| p.ends_with("pic.png")), "its linked file"); +} + +#[test] +fn a_dirty_documents_save_is_written_before_its_package() { + let dir = Folder::new("dirty"); + let gate = Gate::default(); + let (mut app, files) = saved_with_worker(&dir, &gate); + let (doc, out) = (dir.path("card.vectorcraft"), dir.path("out")); + // With Background Export on, the save and the package both run in the background, and the + // worker writes the save first. + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + package_dialog(&mut app, &out); + // A thread opens the gate after 10 s at the latest, so a confirm that waits for the save ends. + let opener = gate.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(10)); + opener.open(); + }); + assert_eq!(confirm(&mut app).unwrap()["background"], true); + assert_eq!(app.background.jobs.len(), 2, "the save and the package"); + gate.open(); + background::wait_all(&mut app); + let paths = written(&files); + assert_eq!(paths.first(), Some(&doc), "{paths:?}"); + assert_eq!(paths.len(), 4, "{paths:?}"); + assert!(!app.session.active().unwrap().is_dirty()); + // With Background Export off, the package is made at once and waits for the background save. + let gate = Gate::default(); + let (mut app, files) = saved_with_worker(&dir, &gate); + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": false}})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + package_dialog(&mut app, &out); + let opener = gate.clone(); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(50)); + opener.open(); + }); + confirm(&mut app).unwrap(); + release.join().unwrap(); + assert_eq!(written(&files), [doc]); + assert!(app.background.jobs.is_empty() && !app.session.active().unwrap().is_dirty()); + assert!(app.ui.status.starts_with("Packaged 3 file(s) in "), "the package's status came last: {}", app.ui.status); +} + +#[test] +fn a_package_made_at_once_waits_for_one_in_the_background() { + let dir = Folder::new("twice"); + let gate = Gate::default(); + let (mut app, files) = saved_with_worker(&dir, &gate); + let out = dir.path("out"); + package_dialog(&mut app, &out); + assert_eq!(confirm(&mut app).unwrap()["background"], true); + // With Background Export turned off, the same package made again is written after the one in + // the background. + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": false}})).unwrap(); + package_dialog(&mut app, &out); + let opener = gate.clone(); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + opener.open(); + }); + confirm(&mut app).unwrap(); + assert!(app.background.jobs.is_empty(), "the package in the background was written first"); + release.join().unwrap(); + assert_eq!(written(&files).len(), 3); + assert!(std::path::Path::new(&out).join("card Folder/card.vectorcraft").is_file()); +} + #[test] fn the_web_downloads_a_zip() { let dir = Folder::new("web"); @@ -103,6 +254,7 @@ fn the_web_downloads_a_zip() { assert!(!frame(&mut app).contains("Location:"), "no folder to choose"); confirm(&mut app).unwrap(); assert!(app.ui.dialog.is_none()); + assert!(app.ui.status.contains("Packaged") && app.ui.status.contains("file(s)"), "{}", app.ui.status); let got = got.borrow(); assert_eq!(got.len(), 1); assert_eq!(got[0].0, "card Folder.zip"); @@ -133,6 +285,47 @@ fn fonts_left_out_of_the_package_are_named() { assert!(app.ui.status.contains(gone), "{}", app.ui.status); } +#[test] +fn package_status_without_notes_has_no_extra_spaces() { + let dir = Folder::new("spaces"); + let mut app = saved(&dir); + app.run("ui.packageDialog", json!({})).unwrap(); + let out = dir.path("out"); + std::fs::create_dir_all(&out).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("folder".into(), json!(out)); + confirm(&mut app).unwrap(); + assert!(!app.ui.status.ends_with(' '), "{}", app.ui.status); + let detail = app.ui.dialog.as_ref().unwrap().str("detail"); + assert!(!detail.contains(" ."), "{detail}"); +} + +/// A file the writer refuses is named once in the status bar: a refusal from the automation-root +/// check already names the path, and an error without it gets the path in front. +#[test] +fn a_refused_file_is_named_once() { + let dir = Folder::new("refused"); + let mut app = saved(&dir); + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": false}})).unwrap(); + let (out, elsewhere, other) = (dir.path("out"), dir.path("elsewhere"), dir.path("other")); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + let doc = std::path::Path::new(&out).join("card Folder").join("card.vectorcraft").to_string_lossy().into_owned(); + let (elsewhere, other) = (std::path::Path::new(&elsewhere), std::path::Path::new(&other)); + for write_roots in [vec![elsewhere], vec![elsewhere, other]] { + let roots = AutomationRoots::new(&[], &write_roots).unwrap().unwrap(); + let refused = roots.check(&doc, Access::Write).unwrap_err(); + assert!(refused.ends_with(&doc), "{refused}"); + app.services.write = Some(Box::new(move |p: &str, _: &[u8]| roots.check(p, Access::Write))); + package_dialog(&mut app, &out); + assert_eq!(confirm(&mut app), Err(refused.clone())); + assert_eq!(app.ui.status, refused); + } + app.services.write = Some(Box::new(|_: &str, _: &[u8]| Err("Access is denied. (os error 5)".into()))); + package_dialog(&mut app, &out); + assert!(confirm(&mut app).is_err()); + assert_eq!(app.ui.status, format!("{doc}: Access is denied. (os error 5)")); +} + #[test] fn package_without_a_location_says_so() { let dir = Folder::new("empty"); diff --git a/crates/ui-egui/src/dialogs/tests_plugin_export.rs b/crates/ui-egui/src/dialogs/tests_plugin_export.rs index 85156326f..0005a3034 100644 --- a/crates/ui-egui/src/dialogs/tests_plugin_export.rs +++ b/crates/ui-egui/src/dialogs/tests_plugin_export.rs @@ -1,11 +1,12 @@ //! Exporter plug-ins in File › Export › Export As: listed after the built-in formats, written //! whole (Use Artboards is not theirs to apply), and their parameters asked for once the file is -//! picked. Each test installs exporters of its own: plug-ins are process-wide. +//! picked. Each test installs exporters of its own and removes them: plug-ins are process-wide, and +//! a format left installed would show in other tests' Export As. use serde_json::{Value, json}; use vectorcraft_plugins::wat::{self as tpl, Echo}; -use super::tests_export::{app, frame, kind, names, set}; +use super::tests_export::{app, app_saving, frame, kind, names, set}; use super::*; fn install(app: &mut VectorcraftApp, src: &str) { @@ -13,6 +14,8 @@ fn install(app: &mut VectorcraftApp, src: &str) { app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap(); } +/// Export As lists an exporter after the built-in formats and writes its file of the whole +/// document (Use Artboards greyed out), with the exporter's warnings in the status bar. #[test] fn export_as_lists_exporters_and_writes_their_file_whole() { let (mut app, written) = app(2); @@ -37,8 +40,10 @@ fn export_as_lists_exporters_and_writes_their_file_whole() { assert_eq!(names(&written), ["/out/Untitled-1.tuw"]); assert_eq!(written.borrow()[0].1, b"Hi"); assert!(app.ui.status.contains("a note"), "{}", app.ui.status); + app.run("plugin.remove", json!({"id": "test.ui.whole"})).unwrap(); } +/// An exporter with parameters asks for them once the file is picked, and writes with them. #[test] fn an_exporter_with_parameters_asks_for_them_once_the_file_is_picked() { let (mut app, written) = app(1); @@ -59,6 +64,7 @@ fn an_exporter_with_parameters_asks_for_them_once_the_file_is_picked() { assert_eq!(names(&written), ["/out/Untitled-1.tup"]); let sent: Value = serde_json::from_slice(&written.borrow()[0].1).unwrap(); assert_eq!(sent, json!({"density": 7.0, "_context": {"mode": "export", "extension": "tup", "name": "Untitled-1"}})); + app.run("plugin.remove", json!({"id": "test.ui.params"})).unwrap(); } /// `CRAFT_UI_PLUGIN_EXPORT_SHOT=` saves the pictures of the dialogs to that folder: Export As @@ -113,4 +119,18 @@ fn a_declining_exporter_writes_nothing() { assert!(written.borrow().is_empty()); let said = r.err().unwrap_or_default() + &app.ui.status; assert!(said.contains("Nothing to plot."), "{said}"); + app.run("plugin.remove", json!({"id": "test.ui.declines"})).unwrap(); +} + +/// Export options that aren't an object are none: the exporter is still told the file it writes, +/// by its name and extension (here the second it lists), not the document's name and its first. +#[test] +fn an_exporter_is_told_its_file_whatever_the_options_are() { + let (mut app, written) = app_saving(1, |_| "/out/plotter-job.tnb".into()); + install(&mut app, &tpl::echo_exporter(&tpl::exporter("test.ui.nulloptions", r#"["tna","tnb"]"#, "{}"), Echo::Params)); + crate::io::export(&mut app, Some("plugin.test.ui.nulloptions"), None, &Value::Null).unwrap(); + assert_eq!(names(&written), ["/out/plotter-job.tnb"]); + let sent: Value = serde_json::from_slice(&written.borrow()[0].1).unwrap(); + assert_eq!(sent, json!({"_context": {"mode": "export", "extension": "tnb", "name": "plotter-job"}})); + app.run("plugin.remove", json!({"id": "test.ui.nulloptions"})).unwrap(); } diff --git a/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs b/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs index 8c6f6331e..ef5dca5ed 100644 --- a/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs +++ b/crates/ui-egui/src/dialogs/tests_rectangular_grid.rs @@ -222,7 +222,7 @@ fn rectangular_grid_creates_exact_dividers_as_one_undo_redo_step() { assert_eq!(app.session.doc().unwrap().doc, before.doc); assert_eq!(app.session.doc().unwrap().selection.objects, before.selected); assert_eq!(app.session.doc().unwrap().history.undo.len(), before.undo); - let saved = json!({"rows": 2, "columns": 3, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false}); + let saved = json!({"rows": 2, "columns": 3, "rowSkew": 0.0, "columnSkew": 0.0, "frame": true, "fill": false, "width": 120.0, "height": 60.0, "reference": 0}); assert_eq!(app.session.tool_options_of(KIND), saved, "undo does not undo the saved tool settings"); app.run("edit.redo", json!({})).unwrap(); assert_eq!(app.session.doc().unwrap().doc, created); @@ -380,7 +380,7 @@ fn rectangular_grid_dialog_edits_skews_frame_and_fill() { } let x = paths[7].subpaths[0].anchors[0].p.x; assert!(x > 10.0 && x < 30.0, "toward the left: {x}"); - let saved = json!({"rows": 3, "columns": 1, "rowSkew": 100.0, "columnSkew": -250.0, "frame": false, "fill": true}); + let saved = json!({"rows": 3, "columns": 1, "rowSkew": 100.0, "columnSkew": -250.0, "frame": false, "fill": true, "width": 100.0, "height": 100.0, "reference": 0}); assert_eq!(app.session.tool_options_of(KIND), saved); // A drag draws with the same options. pointer(&mut app, PointerKind::Down, Point::new(300.0, 300.0)); diff --git a/crates/ui-egui/src/dialogs/tests_revolve.rs b/crates/ui-egui/src/dialogs/tests_revolve.rs index 374b1b8d1..2396fdf42 100644 --- a/crates/ui-egui/src/dialogs/tests_revolve.rs +++ b/crates/ui-egui/src/dialogs/tests_revolve.rs @@ -438,3 +438,63 @@ fn light_list_stops_at_ten_and_cancel_restores_the_source() { dialogs::cancel(&mut h.app); assert_eq!(h.app.session.doc().unwrap().doc, before); } + +#[test] +fn lighting_presets_preview_custom_edits_reset_and_commit_as_one_undo_step() { + for effect in [REVOLVE, EXTRUDE, INFLATE, vectorcraft_effects::ROTATE] { + let mut h = Harness::with("M100 100L200 100L200 200L100 200Z", effect, vec2(1280.0, 900.0)); + let before = h.app.session.doc().unwrap().doc.clone(); + h.settle(); + h.click(h.rect("Lighting").center()); + let view = h.app.ui.dialog.as_ref().unwrap().fields["rotationX"].clone(); + h.click(h.rect("Standard").center()); + h.click(h.rect("Right").center()); + assert_eq!(h.value("lightRotation"), 0.0); + assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields["rotationX"], view); + h.drag(h.rect("Height").center(), vec2(20.0, 0.0)); + assert!(!h.rect("Custom").is_negative()); + h.click(h.rect("Custom").center()); + h.click(h.rect("Diffuse").center()); + assert_eq!(h.value("lightSoftness"), 100.0); + assert_eq!(h.value("ambient"), 35.0); + h.click(h.rect("Add Light").center()); + assert!(!h.rect("Custom").is_negative()); + h.click(h.rect("Custom").center()); + h.click(h.rect("Top Left").center()); + let d = h.app.ui.dialog.as_ref().unwrap(); + assert!(d.fields["lights"].is_null()); + assert_eq!(d.fields["__light"], 0); + assert_eq!(d.f64("lightRotation", 0.0), 135.0); + h.click(h.response("revolve-reset-all").center()); + assert!(!h.rect("Standard").is_negative()); + h.click(h.rect("Standard").center()); + h.click(h.rect("Right").center()); + dialogs::confirm(&mut h.app).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc().unwrap().doc, before); + } +} + +#[test] +fn lighting_presets_cancel_restores_an_existing_custom_light_rig() { + let mut h = Harness::new(vec2(1280.0, 900.0)); + h.settle(); + dialogs::confirm(&mut h.app).unwrap(); + h.app + .run( + "effect.setParams", + json!({"index":0,"item":null,"params":{ + "ambient":19,"lights":[{"color":"#ff8040"},{"color":"#4080ff"}] + }}), + ) + .unwrap(); + let before = h.app.session.doc().unwrap().doc.clone(); + h.app.run("effect.dialog", json!({"effect":REVOLVE,"index":0,"item":null})).unwrap(); + h.settle(); + h.click(h.rect("Lighting").center()); + h.click(h.rect("Custom").center()); + h.click(h.rect("Right").center()); + assert_ne!(h.app.session.doc().unwrap().doc, before); + dialogs::cancel(&mut h.app); + assert_eq!(h.app.session.doc().unwrap().doc, before); +} diff --git a/crates/ui-egui/src/dialogs/tests_save_for_web.rs b/crates/ui-egui/src/dialogs/tests_save_for_web.rs index d9c2d1160..e5c834c10 100644 --- a/crates/ui-egui/src/dialogs/tests_save_for_web.rs +++ b/crates/ui-egui/src/dialogs/tests_save_for_web.rs @@ -37,6 +37,194 @@ fn app(dir: &std::path::Path, web: bool) -> (VectorcraftApp, Log) { (app, log) } +/// Two distinct export regions: 60×40 red and 32×24 blue, with no antialiased edges at the sample. +fn two_artboards(dir: &std::path::Path) -> VectorcraftApp { + let (mut app, _) = app(dir, false); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 60, "height": 40})).unwrap(); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + app.run("artboard.new", json!({"x": 100, "y": 0, "width": 32, "height": 24})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 0, "width": 32, "height": 24})).unwrap(); + app.run("paint.setFill", json!({"color": "#0000ff"})).unwrap(); + app +} + +#[test] +fn opens_on_the_artboard_tools_active_artboard_and_exports_it() { + let mut outputs = Vec::new(); + for remembered in [false, true] { + let dir = vectorcraft_testkit::temp_dir(if remembered { "sfw-active-remembered" } else { "sfw-active-default" }); + let mut app = two_artboards(&dir); + if remembered { + app.run("webExport.settings", json!({"artboard": 0, "quality": 70})).unwrap(); + } + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + app.run("tool.setOption", json!({"key": "active", "value": 1})).unwrap(); + assert_eq!(app.view().unwrap().artboard, 1); + app.run("file.saveForWeb", json!({})).unwrap(); + let initial = app.ui.dialog.as_ref().unwrap().fields["artboard"].clone(); + set(&mut app, "format", json!("png24")); + frame(&mut app); + let result = confirm(&mut app).unwrap(); + let image = image::open(result["path"].as_str().unwrap()).unwrap().to_rgba8(); + outputs.push((remembered, initial, image.dimensions(), image.get_pixel(8, 8).0)); + } + assert_eq!(outputs, [(false, json!(1), (32, 24), [0, 0, 255, 255]), (true, json!(1), (32, 24), [0, 0, 255, 255]),]); +} + +#[test] +fn opens_on_the_active_artboard_with_the_selection_tool_and_exports_it() { + for (active, size, color) in [(1, (32, 24), [0, 0, 255, 255]), (0, (60, 40), [255, 0, 0, 255])] { + let dir = vectorcraft_testkit::temp_dir(&format!("sfw-selection-active-{active}")); + let mut app = two_artboards(&dir); + app.run("webExport.settings", json!({"artboard": 1 - active, "format": "png24", "quality": 73})).unwrap(); + let saved = app.session.last_web_settings().to_json(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + app.run("view.goToArtboard", json!({"index": active})).unwrap(); + assert_eq!(app.session.tool_id(), "selection"); + assert_eq!(app.view().unwrap().artboard, active); + app.run("file.saveForWeb", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["artboard"], json!(active)); + assert_eq!(app.session.last_web_settings().to_json(), saved, "opening does not overwrite the remembered settings"); + frame(&mut app); + let result = confirm(&mut app).unwrap(); + let image = image::open(result["path"].as_str().unwrap()).unwrap().to_rgba8(); + assert_eq!(image.dimensions(), size); + assert_eq!(image.get_pixel(8, 8).0, color); + } +} + +#[test] +fn explicit_artboard_changes_update_the_preview_and_saved_pixels() { + let dir = vectorcraft_testkit::temp_dir("sfw-explicit-artboard"); + let mut app = two_artboards(&dir); + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + app.run("tool.setOption", json!({"key": "active", "value": 1})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("file.saveForWeb", json!({})).unwrap(); + set(&mut app, "format", json!("png24")); + for (index, size, color) in [(0, [60, 40], [255, 0, 0, 255]), (1, [32, 24], [0, 0, 255, 255])] { + set(&mut app, "artboard", json!(index)); + let mut out = ctx.run_ui(Default::default(), |ui| show(&mut app, ui.ctx())); + let previews: Vec<_> = out + .textures_delta + .set + .values() + .flatten() + .filter(|delta| delta.image.size() == size) + .map(|delta| { + let egui::ImageData::Color(image) = &delta.image; + image.pixels.iter().flat_map(|p| p.to_srgba_unmultiplied()).collect::>() + }) + .collect(); + assert_eq!(previews.len(), 2, "original and optimized, artboard {index}"); + assert!(previews.iter().all(|p| p.as_chunks::<4>().0.iter().all(|pixel| *pixel == color))); + out.textures_delta.clear(); + // Save through the same dialog while keeping the egui context (and its cache) alive. + let result = confirm(&mut app).unwrap(); + let image = image::open(result["path"].as_str().unwrap()).unwrap().to_rgba8(); + assert_eq!(image.dimensions(), (size[0] as u32, size[1] as u32)); + assert!(previews.iter().all(|p| *p == image.as_raw().as_slice())); + if index == 0 { + app.run("file.saveForWeb", json!({})).unwrap(); + } + } +} + +#[test] +fn choosing_the_second_artboard_in_the_dropdown_exports_its_pixels() { + use egui_kittest::kittest::{By, Queryable}; + let dir = vectorcraft_testkit::temp_dir("sfw-dropdown-artboard"); + let mut app = two_artboards(&dir); + app.run("file.saveForWeb", json!({})).unwrap(); + set(&mut app, "format", json!("png24")); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 1200.0)).build_ui_state( + move |ui, app: &mut VectorcraftApp| { + if !std::mem::replace(&mut fonts, true) { + crate::theme::install_fonts(ui.ctx()); + return; + } + show(app, ui.ctx()); + }, + app, + ); + h.run_steps(4); + h.get(By::new().role(egui::accesskit::Role::ComboBox).value("Artboard 1")).click(); + h.run(); + h.get_by_label("Artboard 2").click(); + h.run(); + assert_eq!(h.state().ui.dialog.as_ref().unwrap().fields["artboard"], json!(1)); + let result = confirm(h.state_mut()).unwrap(); + let image = image::open(result["path"].as_str().unwrap()).unwrap().to_rgba8(); + assert_eq!(image.dimensions(), (32, 24)); + assert_eq!(image.get_pixel(8, 8).0, [0, 0, 255, 255]); +} + +#[test] +fn opening_uses_the_active_artboard_in_a_multiselection_and_preserves_other_settings() { + let dir = vectorcraft_testkit::temp_dir("sfw-settings-artboard"); + let mut app = two_artboards(&dir); + app.run("webExport.settings", json!({"preset": "JPEG, quality 50", "artboard": 0, "quality": 73, "width": 96, "metadata": "all"})).unwrap(); + let saved = app.session.last_web_settings().to_json(); + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + app.run("tool.setOption", json!({"key": "selected", "value": [1, 0]})).unwrap(); + assert_eq!(app.view().unwrap().artboard, 1); + assert_eq!(crate::panels::artboards::picked(&app, 2), [0, 1]); + app.run("file.saveForWeb", json!({})).unwrap(); + let fields = &app.ui.dialog.as_ref().unwrap().fields; + let mut expected = saved.clone(); + expected["artboard"] = json!(1); + for (key, value) in expected.as_object().unwrap() { + assert_eq!(&fields[key], value, "{key}"); + } + assert_eq!(app.session.last_web_settings().to_json(), saved, "opening does not overwrite the remembered settings"); + app.ui.dialog = None; + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + assert_eq!(app.view().unwrap().artboard, 1); + app.run("file.saveForWeb", json!({})).unwrap(); + for (key, value) in expected.as_object().unwrap() { + assert_eq!(&app.ui.dialog.as_ref().unwrap().fields[key], value, "Selection tool keeps {key}"); + } + assert_eq!(app.session.last_web_settings().to_json(), saved, "opening still does not overwrite the remembered settings"); +} + +#[test] +fn a_remembered_artboard_missing_from_another_document_falls_back_to_its_first() { + let dir = vectorcraft_testkit::temp_dir("sfw-other-document"); + let mut app = two_artboards(&dir); + app.run("webExport.settings", json!({"artboard": 1, "format": "png24"})).unwrap(); + app.run("file.new", json!({"width": 20, "height": 12})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 20, "height": 12})).unwrap(); + app.run("paint.setFill", json!({"color": "#00ff00"})).unwrap(); + app.run("file.saveForWeb", json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["artboard"], json!(0)); + frame(&mut app); + let result = confirm(&mut app).unwrap(); + let image = image::open(result["path"].as_str().unwrap()).unwrap().to_rgba8(); + assert_eq!(image.dimensions(), (20, 12)); + assert_eq!(image.get_pixel(8, 8).0, [0, 255, 0, 255]); +} + +#[test] +fn explicit_command_artboards_override_the_ui_active_artboard() { + let dir = vectorcraft_testkit::temp_dir("sfw-api-artboard"); + let mut app = two_artboards(&dir); + app.run("tool.select", json!({"tool": "artboard"})).unwrap(); + for (active, explicit, size, color) in [(0, 1, (32, 24), [0, 0, 255, 255]), (1, 0, (60, 40), [255, 0, 0, 255])] { + app.run("tool.setOption", json!({"key": "active", "value": active})).unwrap(); + assert_eq!(app.view().unwrap().artboard, active); + for command in ["file.saveForWeb", "document.exportForWeb"] { + let path = dir.join("explicit.png").to_string_lossy().replace('\\', "/"); + app.run(command, json!({"format": "png24", "artboard": explicit, "path": path})).unwrap(); + let image = image::open(&path).unwrap().to_rgba8(); + assert_eq!(image.dimensions(), size, "{command}: UI active {active}, explicit {explicit}"); + assert_eq!(image.get_pixel(8, 8).0, color); + assert!(app.ui.dialog.is_none()); + } + } +} + #[test] fn every_view_and_format_draws() { let dir = vectorcraft_testkit::temp_dir("sfw-views"); @@ -162,7 +350,7 @@ fn a_later_browser_preview_leaves_an_earlier_one_alone() { } } -/// Making a preview's folder removes the folders and files in its root that are `keep` old or +/// Making a preview's folder removes the preview folders in its root that are `keep` old or /// older, and keeps younger ones. #[test] fn a_browser_preview_removes_old_previews() { @@ -175,11 +363,190 @@ fn a_browser_preview_removes_old_previews() { // The folder and the page are younger than an hour, so both stay. let kept = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); assert!(std::path::Path::new(&earlier).exists() && root.join("Untitled-1.html").exists()); - // With a `keep` of zero every entry is old enough: all three go, and only the new folder is left. + // With a `keep` of zero every entry is old enough: both preview folders go, and the page, which + // no preview made, stays beside the new folder. let next = save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap(); - assert!(!std::path::Path::new(&earlier).exists() && !std::path::Path::new(&kept).exists() && !root.join("Untitled-1.html").exists()); + assert!(!std::path::Path::new(&earlier).exists() && !std::path::Path::new(&kept).exists() && root.join("Untitled-1.html").exists()); assert!(std::path::Path::new(&next).is_dir()); - assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 2); +} + +/// Old entries of the root that aren't preview folders stay: a file, folders whose names aren't +/// `-`, and a file with a preview folder's name. +#[test] +fn a_browser_preview_removes_only_preview_folders() { + let root = vectorcraft_testkit::temp_dir("sfw-browser-filter").join("previews"); + let _ = std::fs::remove_dir_all(&root); + let earlier = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); + let folders = ["notes", "12-", "-3", "12-3a", "a12-3", "12_3"]; + for name in folders { + std::fs::create_dir(root.join(name)).unwrap(); + } + let files = ["Untitled-1.html", "12-3"]; + for name in files { + std::fs::write(root.join(name), b"kept").unwrap(); + } + let next = save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap(); + assert!(!std::path::Path::new(&earlier).exists() && std::path::Path::new(&next).is_dir()); + for name in folders { + assert!(root.join(name).is_dir(), "{name}"); + } + for name in files { + assert_eq!(std::fs::read(root.join(name)).unwrap(), b"kept", "{name}"); + } + assert_eq!(std::fs::read_dir(&root).unwrap().count(), folders.len() + files.len() + 1); +} + +/// When `VectorCraft Save for Web` in the temporary folder is a file, the preview goes to a new +/// folder of its own in the temporary folder, and the file stays as it was. +#[test] +fn a_browser_preview_goes_beside_a_root_that_is_not_a_folder() { + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-file-root"); + let root = tmp.join("VectorCraft Save for Web"); + std::fs::write(&root, b"a file").unwrap(); + let keep = std::time::Duration::ZERO; + let e = save_for_web::preview_folder(&root, keep).unwrap_err(); + assert!(e.ends_with(": it is not a folder"), "{e}"); + let dir = save_for_web::preview_folder_in(&tmp, keep).unwrap(); + let dir = std::path::Path::new(&dir); + assert!(dir.is_dir() && dir.parent() == Some(tmp.as_path()), "{}", dir.display()); + assert!(dir.file_name().unwrap().to_string_lossy().starts_with("VectorCraft Save for Web "), "{}", dir.display()); + assert_eq!(std::fs::read(&root).unwrap(), b"a file"); +} + +/// The folders that previews made beside a root they couldn't use are removed once they are +/// `keep` old, both while the root still can't be used and once it can be used again; younger +/// ones and other entries stay. +#[test] +fn a_browser_preview_removes_old_folders_made_beside_the_root() { + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-beside-old"); + let root = tmp.join("VectorCraft Save for Web"); + let _ = std::fs::remove_dir_all(&root); + std::fs::write(&root, b"a file").unwrap(); + let hour = std::time::Duration::from_secs(3600); + let first = save_for_web::preview_folder_in(&tmp, hour).unwrap(); + let second = save_for_web::preview_folder_in(&tmp, hour).unwrap(); + assert!(std::path::Path::new(&first).is_dir() && std::path::Path::new(&second).is_dir(), "{first} {second}"); + let others = ["VectorCraft Save for Web 7-7", "VectorCraft Save for Web 7-7.html", "VectorCraft Save for Web notes"]; + std::fs::write(tmp.join(others[0]), b"kept").unwrap(); + std::fs::write(tmp.join(others[1]), b"kept").unwrap(); + std::fs::create_dir_all(tmp.join(others[2])).unwrap(); + let third = save_for_web::preview_folder_in(&tmp, std::time::Duration::ZERO).unwrap(); + assert_eq!(std::path::Path::new(&third).parent(), Some(tmp.as_path()), "{third}"); + assert!(!std::path::Path::new(&first).exists() && !std::path::Path::new(&second).exists(), "{first} {second}"); + std::fs::remove_file(&root).unwrap(); + let dir = save_for_web::preview_folder_in(&tmp, std::time::Duration::ZERO).unwrap(); + assert_eq!(std::path::Path::new(&dir).parent(), Some(root.as_path()), "{dir}"); + assert!(!std::path::Path::new(&third).exists(), "{third}"); + for name in others { + assert!(tmp.join(name).exists(), "{name}"); + } +} + +/// The unix mode bits of `path`, not following a link. +#[cfg(unix)] +fn mode(path: impl AsRef) -> u32 { + std::os::unix::fs::MetadataExt::mode(&std::fs::symlink_metadata(path).unwrap()) & 0o777 +} + +/// A root and a preview folder that a preview makes are open to their owner only (0o700). +#[cfg(unix)] +#[test] +fn a_browser_preview_makes_private_folders() { + use std::os::unix::fs::PermissionsExt; + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-private"); + let root = tmp.join("previews"); + let _ = std::fs::remove_dir_all(&root); + let dir = save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); + assert_eq!((mode(&root), mode(&dir)), (0o700, 0o700)); + // Beside a root it can't use, too. + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap(); + let shared = tmp.join("VectorCraft Save for Web"); + let _ = std::fs::remove_dir_all(&shared); + std::fs::rename(&root, &shared).unwrap(); + let own = save_for_web::preview_folder_in(&tmp, std::time::Duration::from_secs(3600)).unwrap(); + assert_eq!(std::path::Path::new(&own).parent(), Some(tmp.as_path()), "{own}"); + assert_eq!(mode(&own), 0o700); +} + +/// A root that is a link is refused: nothing in the folder it points to is removed or added, and +/// the preview goes to a new folder of its own in the temporary folder. +#[cfg(unix)] +#[test] +fn a_browser_preview_refuses_a_linked_root() { + use std::os::unix::fs::symlink; + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-linked-root"); + let elsewhere = tmp.join("elsewhere"); + std::fs::create_dir_all(elsewhere.join("5-1")).unwrap(); + let root = tmp.join("VectorCraft Save for Web"); + let _ = std::fs::remove_file(&root); + symlink(&elsewhere, &root).unwrap(); + let keep = std::time::Duration::ZERO; + let e = save_for_web::preview_folder(&root, keep).unwrap_err(); + assert!(e.ends_with(": it is a link"), "{e}"); + let dir = save_for_web::preview_folder_in(&tmp, keep).unwrap(); + assert_eq!(std::path::Path::new(&dir).parent(), Some(tmp.as_path())); + let left: Vec = std::fs::read_dir(&elsewhere).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(left, ["5-1"]); +} + +/// A root that users other than its owner can write to is refused, and the old preview folder in +/// it stays. +#[cfg(unix)] +#[test] +fn a_browser_preview_refuses_a_root_others_can_write_to() { + use std::os::unix::fs::PermissionsExt; + let root = vectorcraft_testkit::temp_dir("sfw-browser-shared-root").join("previews"); + let keep = std::time::Duration::ZERO; + for bits in [0o777, 0o770, 0o703] { + let _ = std::fs::remove_dir_all(&root); + let earlier = save_for_web::preview_folder(&root, keep).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(bits)).unwrap(); + let e = save_for_web::preview_folder(&root, keep).unwrap_err(); + assert!(e.ends_with(": other users can write to it"), "{bits:o}: {e}"); + assert!(std::path::Path::new(&earlier).is_dir(), "{bits:o}"); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1, "{bits:o}"); + } +} + +/// An old link named like a preview folder stays, and so does the folder it points to, with what +/// it holds. +#[cfg(unix)] +#[test] +fn a_browser_preview_leaves_a_link_named_like_a_preview_folder() { + use std::os::unix::fs::symlink; + let tmp = vectorcraft_testkit::temp_dir("sfw-browser-link-entry"); + let root = tmp.join("previews"); + let _ = std::fs::remove_dir_all(&root); + let outside = tmp.join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("page.html"), b"kept").unwrap(); + save_for_web::preview_folder(&root, std::time::Duration::from_secs(3600)).unwrap(); + symlink(&outside, root.join("9-9")).unwrap(); + save_for_web::preview_folder(&root, std::time::Duration::ZERO).unwrap(); + assert!(std::fs::symlink_metadata(root.join("9-9")).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read(outside.join("page.html")).unwrap(), b"kept"); +} + +/// A root and a preview folder that another user owns are refused and left alone. Only root can +/// give a folder to another user, so the test skips itself for other users. +#[cfg(unix)] +#[test] +fn a_browser_preview_leaves_what_another_user_owns() { + let root = vectorcraft_testkit::temp_dir("sfw-browser-other-user").join("previews"); + let _ = std::fs::remove_dir_all(&root); + let keep = std::time::Duration::ZERO; + let theirs = save_for_web::preview_folder(&root, keep).unwrap(); + let other = std::os::unix::fs::MetadataExt::uid(&std::fs::metadata(&root).unwrap()) + 1; + if std::os::unix::fs::chown(&theirs, Some(other), None).is_err() { + eprintln!("skipped: only root can give a folder to another user"); + return; + } + save_for_web::preview_folder(&root, keep).unwrap(); + assert!(std::path::Path::new(&theirs).is_dir()); + std::os::unix::fs::chown(&root, Some(other), None).unwrap(); + let e = save_for_web::preview_folder(&root, keep).unwrap_err(); + assert!(e.ends_with(": it belongs to another user"), "{e}"); } /// A folder that an earlier process with the same id left, younger than `keep`, is not reused: diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 9fb40676f..74132e4db 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -1,9 +1,10 @@ //! Export for Screens, drawn headlessly: the fields it opens on (defaults, then the document's last //! settings, and the artboards the Artboard tool has selected), the artboard choice, Format -//! Settings, desktop folders and web downloads. +//! Settings, desktop folders, the export on the background thread and web downloads. use std::cell::RefCell; use std::rc::Rc; +use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_engine::Session; @@ -13,6 +14,8 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::tests_export::{frame, kind, set}; use super::*; use crate::Services; +use crate::background; +use crate::tests_background::{Files, Gate, written}; type Log = Rc)>>>; @@ -214,6 +217,202 @@ fn exports_into_the_folder_and_shows_it() { let _ = std::fs::remove_dir_all(dir); } +/// [`app`] on the desktop with Background Export's thread-safe writer, which records the files it +/// is given once `gate` opens. +fn app_with_worker(artboards: usize, gate: &Gate) -> (VectorcraftApp, Log, Files) { + let (mut app, log) = app(artboards, false); + let (files, g) = (Files::default(), gate.clone()); + let recorded = files.clone(); + app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| { + g.wait(); + recorded.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + (app, log, files) +} + +fn background_export(app: &mut VectorcraftApp, on: bool) { + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": on}})).unwrap(); +} + +/// The files reply `r` names (its `files`, else the ZIP at its `path`) as they are on disk. +fn on_disk(r: &Value) -> Vec<(String, Vec)> { + let paths: Vec<&str> = match r["path"].as_str() { + Some(zip) => vec![zip], + None => r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(), + }; + paths.into_iter().map(|p| (p.to_string(), std::fs::read(p).unwrap())).collect() +} + +/// With Background Export on, OK closes the dialog with nothing written, and the background thread +/// writes the files OK writes at once with it off: the same names (`-2` included), sub-folders and +/// bytes, and the same status; Open Location shows the first file once it is written. The same +/// holds for one ZIP in the folder. +#[test] +fn the_files_are_written_in_the_background_as_at_once() { + let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "png", "scale": "2x", "suffix": "@2x"}, {"format": "svg", "scale": "1x", "suffix": ""}]); + let dir = temp_folder("background"); + let folder = dir.to_string_lossy().replace('\\', "/"); + for zip in [false, true] { + let gate = Gate::default(); + let (mut app, log, files) = app_with_worker(2, &gate); + app.run("artboard.setProps", json!({"index": 0, "name": "Icon"})).unwrap(); + app.run("artboard.setProps", json!({"index": 1, "name": "icon"})).unwrap(); + let export = |app: &mut VectorcraftApp| { + if zip { + let p = json!({"folder": folder, "zip": true, "folders": "rows", "name": "", "formats": rows, "openLocation": true}); + return app.run("file.exportForScreens", p); + } + open(app); + set(app, "folder", json!(folder)); + set(app, "folders", json!("rows")); + set(app, "name", json!("")); + set(app, "formats", rows.clone()); + confirm(app) + }; + background_export(&mut app, false); + let r = export(&mut app).unwrap(); + let want = on_disk(&r); + if !zip { + assert_eq!(want.len(), 6, "{r}"); + assert_eq!(want[3].0, format!("{folder}/1x/icon-2.png")); + } + let (status, shown) = (app.ui.status.clone(), log.borrow().clone()); + assert_eq!(shown.len(), 1); + std::fs::remove_dir_all(&dir).unwrap(); + log.borrow_mut().clear(); + background_export(&mut app, true); + let r = export(&mut app).unwrap(); + assert_eq!(r["background"], true, "{r}"); + assert!(app.ui.dialog.is_none(), "closed"); + assert_eq!(app.ui.status, format!("Exporting {}…", dir.file_name().unwrap().to_string_lossy())); + assert!(written(&files).is_empty() && log.borrow().is_empty(), "nothing written yet"); + gate.open(); + background::wait_all(&mut app); + assert_eq!(*files.lock().unwrap(), want, "zip: {zip}"); + assert_eq!((app.ui.status.clone(), log.borrow().clone()), (status, shown)); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[test] +fn the_asset_export_panel_writes_in_the_background_as_at_once() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + app.run("select.all", json!({})).unwrap(); + app.run("assets.add", json!({"multiple": false})).unwrap(); + let dir = temp_folder("assets-background"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let p = json!({"folder": folder, "formats": [{"format": "png", "scale": "2x", "suffix": "@2x"}, {"format": "svg"}]}); + background_export(&mut app, false); + let want = on_disk(&crate::io::export_files(&mut app, "assets.export", p.clone()).unwrap()); + assert_eq!(want.len(), 2); + let status = app.ui.status.clone(); + std::fs::remove_dir_all(&dir).unwrap(); + background_export(&mut app, true); + assert_eq!(crate::io::export_files(&mut app, "assets.export", p).unwrap()["background"], true); + assert!(written(&files).is_empty(), "nothing written yet"); + gate.open(); + background::wait_all(&mut app); + assert_eq!((files.lock().unwrap().clone(), app.ui.status.clone()), (want, status)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn edits_after_ok_do_not_reach_the_files() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + let dir = temp_folder("snapshot"); + let p = json!({"folder": dir.to_string_lossy().replace('\\', "/"), "formats": [{"format": "svg"}]}); + background_export(&mut app, false); + let want = on_disk(&app.run("file.exportForScreens", p.clone()).unwrap()); + std::fs::remove_dir_all(&dir).unwrap(); + background_export(&mut app, true); + assert_eq!(app.run("file.exportForScreens", p).unwrap()["background"], true); + app.run("select.all", json!({})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + gate.open(); + background::wait_all(&mut app); + assert_eq!(*files.lock().unwrap(), want, "the document as it was at OK"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Cancel stops the export before its next file; the file being written when it came stays. +#[test] +fn cancel_stops_the_export_before_its_next_file() { + let (gate, writing) = (Gate::default(), Gate::default()); + let (mut app, _) = app(3, false); + let files = Files::default(); + let (f, g, w) = (files.clone(), gate.clone(), writing.clone()); + app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| { + w.open(); + g.wait(); + f.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let dir = temp_folder("cancel"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let r = app.run("file.exportForScreens", json!({"folder": folder, "formats": [{"format": "svg"}]})).unwrap(); + assert_eq!(r["background"], true, "{r}"); + writing.wait(); + assert_eq!(app.run("file.cancelExport", json!({})).unwrap()["cancelled"], 1); + gate.open(); + background::wait_all(&mut app); + assert_eq!(written(&files), [format!("{folder}/Artboard-1.svg")]); + assert_eq!(app.ui.status, crate::io::EXPORT_CANCELLED); + let _ = std::fs::remove_dir_all(&dir); +} + +/// With Background Export turned off, an export into the same folder is written after the one in +/// the background. +#[test] +fn an_export_made_at_once_waits_for_one_in_the_background() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + let dir = temp_folder("twice"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let p = json!({"folder": folder, "formats": [{"format": "svg"}]}); + assert_eq!(app.run("file.exportForScreens", p.clone()).unwrap()["background"], true); + background_export(&mut app, false); + let opener = gate.clone(); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + opener.open(); + }); + let r = app.run("file.exportForScreens", p).unwrap(); + assert!(app.background.jobs.is_empty(), "the export in the background was written first"); + release.join().unwrap(); + let file = format!("{folder}/Artboard-1.svg"); + assert_eq!((written(&files), r["files"].clone()), (vec![file.clone()], json!([file]))); + assert_eq!(app.ui.status, format!("Exported 1 file(s) to {folder}")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Without a folder (a script or an agent on the desktop) the files come back in the reply at once, +/// with Background Export on too. +#[test] +fn an_export_without_a_folder_replies_with_its_files() { + let (mut app, _, files) = app_with_worker(1, &Gate::default()); + let r = app.run("file.exportForScreens", json!({"formats": [{"format": "svg"}]})).unwrap(); + assert!(r.get("background").is_none(), "{r}"); + assert_eq!(r["files"][0]["name"], "Artboard-1.svg"); + assert!(app.background.jobs.is_empty() && written(&files).is_empty()); +} + +/// The command's refusal keeps the dialog open and starts nothing, as at once. +#[test] +fn a_refused_export_keeps_the_dialog_open_with_background_export_on() { + let (mut app, _, files) = app_with_worker(2, &Gate::default()); + open(&mut app); + set(&mut app, "folder", json!(temp_folder("refused").to_string_lossy().replace('\\', "/"))); + set(&mut app, "formats", json!([{"format": "dxf", "scale": "1x", "suffix": ""}])); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("Export for Screens writes png"), "{e}"); + assert_eq!((kind(&app), app.ui.status.as_str()), (Some(export_for_screens::KIND), e.as_str())); + assert!(app.background.jobs.is_empty() && written(&files).is_empty()); +} + #[test] fn the_web_downloads_one_zip_or_the_file() { let (mut app, log) = app(2, true); diff --git a/crates/ui-egui/src/dialogs/text_import.rs b/crates/ui-egui/src/dialogs/text_import.rs index 2ac09e349..295a5d1f9 100644 --- a/crates/ui-egui/src/dialogs/text_import.rs +++ b/crates/ui-egui/src/dialogs/text_import.rs @@ -92,7 +92,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({})); params["text"] = options(d); app.ui.dialog = None; - crate::place::run(app, ¶ms) + crate::place::run_for_user(app, ¶ms) } #[cfg(test)] diff --git a/crates/ui-egui/src/dialogs/tools.rs b/crates/ui-egui/src/dialogs/tools.rs index 0b1cd94d0..3a8cc570d 100644 --- a/crates/ui-egui/src/dialogs/tools.rs +++ b/crates/ui-egui/src/dialogs/tools.rs @@ -11,22 +11,73 @@ use crate::state::Dialog; pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { let x = p.get("x").and_then(Value::as_f64).unwrap_or(0.0); let y = p.get("y").and_then(Value::as_f64).unwrap_or(0.0); + let alt = p.get("alt").and_then(Value::as_bool).unwrap_or(false); let d = match kind { - "rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt"})), - "roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})), - "polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})), - "star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})), - "lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})), - // The grids: the tool's stored options (divider counts and skews, switches) at the default size. + "rectangle" | "ellipse" => Dialog::new(kind, json!({"x": x, "y": y, "alt": alt, "width": "100 pt", "height": "100 pt"})), + "roundedRectangle" => Dialog::new(kind, json!({"x": x, "y": y, "alt": alt, "width": "100 pt", "height": "100 pt", "radius": "12 pt"})), + "polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": app.session.tool_options_of(kind)["sides"]})), + "star" => { + Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": app.session.tool_options_of(kind)["points"]})) + } + // The Line Segment Tool Options: Length, Angle and Fill Line as the tool keeps them. + "lineSegment" => { + let o = app.session.tool_options_of(kind); + let fill = o.get("fillLine").and_then(Value::as_bool).unwrap_or(false); + Dialog::new( + kind, + json!({"x": x, "y": y, "length": pt(o.get("length")), "angle": o.get("angle").cloned().unwrap_or(json!(0)), "fillLine": fill}), + ) + } + "spiral" => { + let options = app.session.tool_options_of(kind); + Dialog::new( + kind, + json!({ + "x": x, + "y": y, + "radius": "50 pt", + "decay": options.get("decay").cloned().unwrap_or(json!(80.0)), + "segments": options.get("segments").cloned().unwrap_or(json!(10)), + "clockwise": options.get("clockwise").cloned().unwrap_or(json!(true)), + }), + ) + } + "arc" => { + let opts = app.session.tool_options_of(kind); + let closed = opts.get("closed").and_then(Value::as_bool).unwrap_or(false); + let mut d = Dialog::new( + kind, + json!({ + "__anchorX": x, + "__anchorY": y, + "x": x, + "y": y, + "lengthX": opts.get("lengthX").cloned().unwrap_or(json!("100 pt")), + "lengthY": opts.get("lengthY").cloned().unwrap_or(json!("100 pt")), + "reference": opts.get("reference").cloned().unwrap_or(json!(0)), + "arcType": if closed { "closed" } else { "open" }, + "baseAlong": opts.get("baseAlong").cloned().unwrap_or(json!("x")), + "slope": opts.get("slope").cloned().unwrap_or(json!(50)), + "fillArc": opts.get("fillArc").and_then(Value::as_bool).unwrap_or(false), + }), + ); + super::shapes::arc_pin_to_anchor(&mut d); + d + } + // The grids: the tool's stored options (Default Size, Reference Point, divider counts and + // skews, switches). "rectangularGrid" | "polarGrid" => { let mut obj = match app.session.tool_options_of(kind) { Value::Object(o) => o, _ => serde_json::Map::new(), }; - for (key, value) in [("__anchorX", json!(x)), ("__anchorY", json!(y)), ("width", json!("100 pt")), ("height", json!("100 pt"))] { - obj.insert(key.into(), value); - } - obj.insert("reference".into(), json!(0)); + obj.insert("__anchorX".into(), json!(x)); + obj.insert("__anchorY".into(), json!(y)); + // The Default Size in points, as typed lengths. + let (w, h) = (pt(obj.get("width")), pt(obj.get("height"))); + obj.insert("width".into(), w); + obj.insert("height".into(), h); + obj.entry("reference").or_insert(json!(0)); let mut d = Dialog::new(kind, Value::Object(obj)); grid_pin_to_anchor(&mut d); d @@ -65,6 +116,13 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { } // Double-clicking a stop on the gradient annotator: its popover, next to the stop's chip. "gradientStop" => Dialog::new(kind, json!({"index": p.get("index").cloned().unwrap_or(json!(0)), "x": x, "y": y, "tab": "color"})), + // Brush Options › Key Color eyedropper: update the open dialog, do not replace it. + "brushKeyColor" => { + if let Some(c) = p.get("color") { + super::brush_art_options::apply_key_color_sample(app, c); + } + return; + } // Double-clicking a width point with the Width tool: Width Point Edit. super::width_point::KIND => { if let Err(e) = super::width_point::open(app, &p) { @@ -104,3 +162,8 @@ pub fn open_tool_dialog(app: &mut VectorcraftApp, kind: &str, p: Value) { }; app.ui.dialog = Some(d); } + +/// A stored length option (points) as a dialog's typed length, `"N pt"`; 100 pt when unset. +fn pt(v: Option<&Value>) -> Value { + json!(format!("{} pt", v.and_then(Value::as_f64).filter(|n| n.is_finite()).unwrap_or(100.0))) +} diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 8e0c63dcf..f2f7035a0 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -4,7 +4,7 @@ //! group to icons at the top of the icon column (`window.collapseDock`); the arrow above the column //! then expands it again. -use egui::{CornerRadius, Rect, Sense, Stroke, Ui, vec2}; +use egui::{CornerRadius, Pos2, Rect, Sense, Stroke, Ui, vec2}; use serde_json::json; use crate::state::{DockTab, ICON_PANEL_GROUPS, ICON_PANELS}; @@ -123,16 +123,18 @@ fn panel_icon(app: &mut VectorcraftApp, ui: &mut Ui, id: &'static str, label: &s if resp.clicked() { app.ui.open_panel = if open { None } else { Some(id.to_string()) }; } - crate::panel_docking::legacy_tab(app, ui, id, &resp); + crate::panel_docking::legacy_tab(app, ui, id, &resp, false); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + crate::panel_docking::prune_collapsed(&mut app.ui); if crate::panel_docking::show(app, ui) { return; } apply_pending_dock_width(app, ui.ctx(), LEGACY_DOCK_PANEL, clamp_legacy_dock_width); let t = Tokens::get(ui.ctx()); let shown = floating::shown_tab(&app.ui); + ui.ctx().data_mut(|d| d.insert_temp(floating::root_groups_id(), Vec::::new())); // Main tabbed group, unless it is collapsed to icons or all its panels float. let group = shown.filter(|_| !app.ui.dock_collapsed).map(|active| { egui::Panel::right(LEGACY_DOCK_PANEL) @@ -145,16 +147,28 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if collapse_header(ui, false) { toggle(app, true); } + // What a group dropped on it joins ([`floating::Drop::Root`]). + let rect = ui.available_rect_before_wrap(); + let group = floating::RootGroup { + panels: floating::docked_tabs(&app.ui).map(|tab| tab.info().0).collect(), + shown: active.info().0, + rect, + strip: Rect::from_min_size(rect.min, vec2(rect.width(), 33.0)), + }; + ui.ctx().data_mut(|d| d.insert_temp(floating::root_groups_id(), vec![group])); if tab_strip(app, ui, active) { // It floats from this frame on. return; } + // Collapsed to its tabs (a double-click on one). + if app.ui.docking_collapsed.contains(app.ui.dock_tab.info().0) { + return; + } egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match active { DockTab::Properties => { - egui::ScrollArea::vertical() - .id_salt("props") - .auto_shrink([false, false]) - .show(ui, |ui| panels::properties::show(app, ui)); + widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]), |ui| { + panels::properties::show(app, ui) + }); } DockTab::Layers => panels::layers::show(app, ui), DockTab::Libraries => panels::libraries(app, ui), @@ -220,9 +234,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); } -/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a tab dragged out of -/// the strip floats on its own, and the strip right of the tabs floats the whole group. True when -/// the shown panel floats now. +/// The tabbed group's tab strip, showing `active`: a click on a tab shows it, a double-click on a +/// tab or on the strip right of the tabs collapses the group to its tabs or expands it, a tab +/// dragged out of the strip floats on its own, and the strip right of the tabs floats the whole +/// group. True when the shown panel floats now. fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { let t = Tokens::get(ui.ctx()); let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover()); @@ -242,21 +257,32 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { let menu = Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(16.0, 16.0)); let area = Rect::from_min_max(strip.min, egui::pos2((menu.left() - 4.0).max(strip.left()), strip.bottom() - 1.0)); let out = panel_tabs(ui, "docktab", area, &names, selected, theme::semibold(12.5), true); - for (i, resp) in out.responses { - let Some(&tab) = tabs.get(i) else { continue }; + let pointer = ui.input(|i| i.pointer.interact_pos()); + for (i, resp) in &out.responses { + let Some(&tab) = tabs.get(*i) else { continue }; let id = tab.info().0; - if resp.clicked() { + if let Some((from, to)) = tab_reorder_on_release(strip, pointer, resp, &out.responses) { + apply_dock_tab_reorder(app, &tabs, from, to); + } else if resp.double_clicked() { + if let Err(error) = app.run("window.panel.collapse", json!({ "panel": id })) { + app.ui.status = error; + } + } else if resp.clicked() { app.ui.dock_tab = tab; } - crate::panel_docking::legacy_tab(app, ui, id, &resp); + crate::panel_docking::legacy_tab_in_strip(app, ui, id, resp, true, Some(strip)); } if let Some(tab) = out.picked.and_then(|i| tabs.get(i)).copied() { app.ui.dock_tab = tab; } let rest = Rect::from_min_max(egui::pos2(out.right, strip.top()), egui::pos2(menu.left() - 4.0, strip.bottom())); if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::drag()); - if resp.dragged() && outside { + let resp = ui.interact(rest, ui.id().with("dock-group-bar"), Sense::click_and_drag()); + if resp.double_clicked() { + if let Err(error) = app.run("window.panel.collapse", json!({ "panel": active.info().0 })) { + app.ui.status = error; + } + } else if resp.dragged() && outside { tear = Some((floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(), active.info().0, strip.left())); } resp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(tl!("Drag to float the panel group")); @@ -271,6 +297,38 @@ fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { /// App styling and overflow menus around the shared tab fitting/interaction components. Docking /// and floating groups consume the responses themselves, preserving their different drag rules. +/// Drag a tab onto another tab in the same strip to reorder (pointer still on the strip at release), +/// as in Illustrator's workspace basics +/// (). +pub(crate) fn tab_reorder_on_release( + strip: Rect, + pointer: Option, + source: &egui::Response, + tabs: &[(usize, egui::Response)], +) -> Option<(usize, usize)> { + if !source.drag_stopped() { + return None; + } + let pointer = pointer.filter(|p| p.is_finite())?; + if !strip.expand(4.0).contains(pointer) { + return None; + } + if source.drag_delta().length() < 2.0 { + return None; + } + let from = tabs.iter().find(|(_, r)| r.id == source.id).map(|(i, _)| *i)?; + let to = tabs.iter().find(|(_, r)| r.rect.contains(pointer)).map(|(i, _)| *i)?; + (from != to).then_some((from, to)) +} + +fn apply_dock_tab_reorder(app: &mut VectorcraftApp, visible: &[DockTab], from: usize, to: usize) { + let Some(from_tab) = visible.get(from) else { return }; + let Some(to_tab) = visible.get(to) else { return }; + if let Err(error) = crate::panel_docking::strip_tab_reorder(app, from_tab.info().0, to_tab.info().0) { + app.ui.status = error; + } +} + pub(crate) struct PanelTabs { pub responses: Vec<(usize, egui::Response)>, pub picked: Option, @@ -363,8 +421,10 @@ pub fn floating_panel(app: &mut VectorcraftApp, ctx: &egui::Context) { return; }; let tab = DockTab::from_id(id); - // Expanded, the group shows its panels in the dock itself; a floating panel shows in its group. - if tab.is_some() && !app.ui.dock_collapsed || floating::group_of(&app.ui, id).is_some() { + // Expanded, the group shows its panels in the dock itself, unless one was dropped on the icon + // column; a floating panel shows in its group. + let in_rail = app.ui.docking_icons.contains(id) && app.ui.docking.as_ref().is_some_and(|layout| !layout.contains(&id.to_string())); + if tab.is_some() && !app.ui.dock_collapsed && !in_rail || floating::group_of(&app.ui, id).is_some() { return; } // A library panel (in a collapsed dock) is titled by its library's name. @@ -456,12 +516,11 @@ pub(crate) fn panel_body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: // At least `tall` when the sections are taller: the flyout's area keeps the size its // content needs, and egui sizes a new area at most 400 points high, so a scroll area // that only took the room it was given never grew past it. - egui::ScrollArea::vertical() - .id_salt("props-flyout") - .max_height(tall) - .min_scrolled_height(tall) - .auto_shrink([false, true]) - .show(ui, |ui| panels::properties::show(app, ui)); + widgets::panel_scroll( + ui, + egui::ScrollArea::vertical().id_salt("props-flyout").max_height(tall).min_scrolled_height(tall).auto_shrink([false, true]), + |ui| panels::properties::show(app, ui), + ); } Some(DockTab::Layers) => { ui.set_max_height(tall); @@ -626,6 +685,92 @@ mod tests { assert!(icons.last().unwrap().bottom() <= 400.0, "scrolled into view: {:?}", icons.last()); } + /// #3093: in a window too short for the Properties panel, its scroll bar takes a column of its + /// own instead of floating over the panel's right edge, so it covers none of the controls + /// there, such as the arrow of an image's Image Trace button. + #[test] + fn the_properties_scroll_bar_covers_no_control() { + use egui::accesskit::{Action, Role}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + app.run("object.rasterize", json!({})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 600.0)); + let mut nodes = vec![]; + for k in 0..4 { + let input = egui::RawInput { time: Some(f64::from(k) * 0.1), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.unwrap().nodes; + } + let rect = |n: &egui::accesskit::Node| { + n.bounds().map(|b| egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32))) + }; + // The Properties panel's is the right-most (the icon column's slim strip bar is left of it). + let bar = nodes + .iter() + .filter(|(_, n)| n.role() == Role::ScrollBar) + .filter_map(|(_, n)| rect(n)) + .max_by(|a, b| a.left().total_cmp(&b.left())) + .filter(|bar| bar.left() > screen.right() - DOCK_WIDTH); + let bar = bar.expect("the Properties panel scrolls in a 600 pt window"); + let mut covered = vec![]; + for (_, n) in &nodes { + let Some(r) = rect(n) else { continue }; + let control = n.role() != Role::ScrollBar && (n.supports_action(Action::Click) || n.supports_action(Action::Focus)); + if control && r.intersect(bar).is_positive() { + covered.push(format!("{:?} {:?} at {r:?}", n.role(), n.label())); + } + } + assert!(covered.is_empty(), "under the scroll bar at {bar:?}: {covered:#?}"); + } + + /// #3443: the Layers list's scroll bar sits beside the rows, not over the target and selection + /// marks at their right edge. + #[test] + fn the_layers_scroll_bar_covers_no_row() { + use egui::accesskit::Role; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + for k in 0..40 { + app.run("shape.rectangle", json!({"x": k, "y": k, "width": 20, "height": 20})).unwrap(); + } + app.run("window.panel", json!({"panel": "layers"})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 600.0)); + let mut nodes = vec![]; + for k in 0..4 { + let input = egui::RawInput { time: Some(f64::from(k) * 0.1), screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show(&mut app, ui)); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.unwrap().nodes; + } + let rect = |n: &egui::accesskit::Node| { + n.bounds().map(|b| egui::Rect::from_min_max(egui::pos2(b.x0 as f32, b.y0 as f32), egui::pos2(b.x1 as f32, b.y1 as f32))) + }; + let bar = nodes + .iter() + .filter(|(_, n)| n.role() == Role::ScrollBar) + .filter_map(|(_, n)| rect(n)) + .max_by(|a, b| a.left().total_cmp(&b.left())) + .filter(|bar| bar.left() > screen.right() - DOCK_WIDTH) + .expect("40 layers scroll in a 600 pt window"); + let rows: Vec = nodes + .iter() + .filter(|(_, n)| n.role() != Role::ScrollBar) + .filter_map(|(_, n)| rect(n)) + .filter(|r| r.top() >= bar.top() && r.bottom() <= bar.bottom() && r.right() > bar.left() - 40.0) + .collect(); + assert!(!rows.is_empty(), "the rows beside the bar are in the tree"); + let covered: Vec<_> = rows.iter().filter(|r| r.intersect(bar).is_positive()).collect(); + assert!(covered.is_empty(), "under the scroll bar at {bar:?}: {covered:#?}"); + } + const SCREEN: egui::Vec2 = vec2(1600.0, 1000.0); /// A frame of the dock and its flyout on a 1600 × 1000 window. @@ -801,6 +946,28 @@ mod tests { assert_eq!(h.app.ui.open_panel, None, "a click in the docked group puts {open:?} away"); } + #[test] + fn window_panel_move_reorders_legacy_dock_tabs_and_persists_order() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + assert!(app.ui.docking.is_none()); + app.run("window.panel.move", json!({ "panel": "libraries", "anchor": "properties", "before": "properties" })).unwrap(); + let order: Vec<_> = floating::docked_tabs(&app.ui).map(|t| t.info().0).collect(); + assert_eq!(order, ["libraries", "properties", "layers"]); + let saved: crate::state::UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap(); + assert_eq!(saved.dock_tab_order, app.ui.dock_tab_order); + } + + #[test] + fn partial_dock_tab_order_appends_tabs_missing_from_preferences() { + let ui = + crate::state::UiState { dock_tab: DockTab::Layers, dock_tab_order: vec![DockTab::Layers, DockTab::Properties], ..Default::default() }; + let order = floating::resolved_dock_tab_order(&ui); + assert_eq!(order.len(), DockTab::ALL.len()); + assert_eq!(order[..2], [DockTab::Layers, DockTab::Properties]); + assert_eq!(order[2], DockTab::Libraries); + } + #[test] fn the_collapse_command_sets_or_toggles_the_dock() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/document_drag.rs b/crates/ui-egui/src/document_drag.rs new file mode 100644 index 000000000..2ca57ce48 --- /dev/null +++ b/crates/ui-egui/src/document_drag.rs @@ -0,0 +1,195 @@ +//! A selection dragged across document tabs remains editable and leaves its source intact. + +use egui::{Context, Id, Response}; +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_geom::Point; + +use crate::{VectorcraftApp, canvas, widgets}; + +const HOVER_SECONDS: f64 = 0.4; + +#[derive(Clone)] +struct Origin { + uid: u64, + grab: Point, +} + +#[derive(Clone)] +struct Artwork { + origin: Origin, + ids: Vec, + revision: u64, + target: u64, +} + +#[derive(Clone)] +struct Hover { + uid: u64, + since: f64, +} + +/// Key for the source document and original canvas grab point. +fn origin_id() -> Id { + Id::new("document-drag-origin") +} +/// Key for the tab identity and dwell start time of the current hover. +fn hover_id() -> Id { + Id::new("document-drag-hover") +} +/// Key for the pointer position latched after tab activation to prevent cascading switches. +fn activated_at_id() -> Id { + Id::new("document-drag-activated-at") +} + +/// Record the Selection drag source and grab point, clearing any previous tab-hover timer. +pub(crate) fn begin(app: &VectorcraftApp, ctx: &Context, grab: Point) { + ctx.data_mut(|d| { + d.remove::(origin_id()); + d.remove::(hover_id()); + d.remove::(activated_at_id()); + if app.session.tool_id() == "selection" + && let Some(st) = app.session.active() + { + d.insert_temp(origin_id(), Origin { uid: st.uid, grab }); + } + }); +} + +/// Called before panels. Cancelled or abandoned transfers must never reach a later mouse-up. +pub(crate) fn cleanup(app: &VectorcraftApp, ctx: &Context) { + // Activating a tab can scroll the strip or change title widths. A tab moving under a + // stationary pointer must not start another hover; actual pointer movement rearms it. + let at = ctx.input(|i| i.pointer.interact_pos()); + ctx.data_mut(|d| { + if d.get_temp::(activated_at_id()) != at { + d.remove::(activated_at_id()); + } + }); + if let Some(art) = egui::DragAndDrop::payload::(ctx) { + let valid = app.session.documents().iter().any(|d| d.uid == art.origin.uid && d.revision == art.revision) + && app.session.active().is_some_and(|d| d.uid == art.target); + let cancel = ctx.input(|i| { + !i.focused + || i.key_pressed(egui::Key::Escape) + || (!i.pointer.primary_down() && !i.pointer.primary_released()) + || i.multi_touch().is_some() + || i.events.iter().any(|e| matches!(e, egui::Event::Touch { phase: egui::TouchPhase::Cancel, .. })) + }); + let outside_release = + ctx.input(|i| i.pointer.primary_released() && !app.canvas_rect.is_some_and(|r| i.pointer.interact_pos().is_some_and(|p| r.contains(p)))); + if !valid || cancel || outside_release || app.ui.dialog.is_some() { + egui::DragAndDrop::clear_payload(ctx); + } + } + if !ctx.input(|i| i.pointer.primary_down()) { + ctx.data_mut(|d| { + d.remove::(origin_id()); + d.remove::(hover_id()); + d.remove::(activated_at_id()); + }); + } +} + +/// Hover uses `contains_pointer`: the source canvas still owns egui's dragged widget. +pub(crate) fn hover_tab(app: &mut VectorcraftApp, resp: &Response, index: usize, close: egui::Rect) -> bool { + let ctx = &resp.ctx; + let eligible = resp.contains_pointer() + && ctx + .input(|i| i.pointer.primary_down() && !i.key_pressed(egui::Key::Escape) && i.pointer.interact_pos().is_some_and(|p| !close.contains(p))) + && app.ui.dialog.is_none(); + if !eligible || app.session.active_index() == Some(index) || ctx.data(|d| d.get_temp::(activated_at_id()).is_some()) { + return false; + } + let transfer = egui::DragAndDrop::has_payload_of_type::(ctx); + let art = egui::DragAndDrop::payload::(ctx).is_some_and(|p| matches!(*p, widgets::PanelDrag::Art(_))); + if !transfer && !art { + return false; + } + let Some(uid) = app.session.documents().get(index).map(|d| d.uid) else { + return false; + }; + let now = ctx.input(|i| i.time); + let hover = ctx.data(|d| d.get_temp::(hover_id())).filter(|h| h.uid == uid).unwrap_or(Hover { uid, since: now }); + ctx.data_mut(|d| d.insert_temp(hover_id(), hover.clone())); + let remaining = HOVER_SECONDS - (now - hover.since); + if remaining > 0.0 { + ctx.request_repaint_after(std::time::Duration::try_from_secs_f64(remaining).unwrap_or(std::time::Duration::from_millis(400))); + return true; + } + if !transfer { + let Some(origin) = ctx.data(|d| d.get_temp::(origin_id())) else { + return true; + }; + if !app + .session + .active() + .is_some_and(|d| d.uid == origin.uid && d.interaction.as_ref().is_some_and(|i| i.label == "Move" || i.label == "Copy")) + { + return true; + } + if let Err(e) = app.session.cancel_interaction() { + app.status(e.to_string()); + return true; + } + // Alt-drag previews use temporary duplicate ids; only the restored originals travel. + let ids = app.session.active().map(|d| d.selection.objects.clone()).unwrap_or_default(); + if ids.is_empty() { + return true; + } + canvas::end_document_drag(ctx); + let revision = app.session.active().map(|d| d.revision).unwrap_or_default(); + egui::DragAndDrop::set_payload(ctx, Artwork { origin, ids, revision, target: uid }); + } + if transfer && let Some(art) = egui::DragAndDrop::payload::(ctx) { + let mut art = (*art).clone(); + art.target = uid; + egui::DragAndDrop::set_payload(ctx, art); + } + app.ui.home = None; + app.session.set_active(index); + ctx.request_repaint(); + let at = ctx.input(|i| i.pointer.interact_pos()); + ctx.data_mut(|d| { + d.remove::(hover_id()); + if let Some(at) = at { + d.insert_temp(activated_at_id(), at); + } + }); + true +} + +/// Reset the dwell timer when no eligible inactive tab is under the artwork. +pub(crate) fn finish_tabs(ctx: &Context, hovering: bool) { + if !hovering { + ctx.data_mut(|d| d.remove::(hover_id())); + } +} + +/// Release artwork into the active canvas; returning to the source resumes Move or Alt-copy. +pub(crate) fn drop_on_canvas(app: &mut VectorcraftApp, resp: &Response, xf: &canvas::Xf) { + let Some(payload) = egui::DragAndDrop::payload::(&resp.ctx) else { + return; + }; + let same = app.session.active().is_some_and(|d| d.uid == payload.origin.uid); + let copy = resp.ctx.input(|i| i.modifiers.alt); + if resp.contains_pointer() { + resp.ctx.set_cursor_icon(if same && !copy { egui::CursorIcon::Move } else { egui::CursorIcon::Copy }); + } + if let Some(art) = resp.dnd_release_payload::() + && let Some(at) = resp.ctx.input(|i| i.pointer.interact_pos()).map(|p| xf.to_doc(p)) + { + let result = if same { + // Back in the source tab, resume the ordinary move (Alt copies). + app.run("object.move", json!({"ids": art.ids, "dx": at.x-art.origin.grab.x, "dy": at.y-art.origin.grab.y, "copy": copy})) + } else { + app.run( + "edit.dropSelection", + json!({"sourceDocument": art.origin.uid, "ids": art.ids, "grab": [art.origin.grab.x, art.origin.grab.y], "at": [at.x, at.y]}), + ) + }; + if let Err(e) = result { + app.status(e); + } + } +} diff --git a/crates/ui-egui/src/export_progress.rs b/crates/ui-egui/src/export_progress.rs index b14916347..0a406254f 100644 --- a/crates/ui-egui/src/export_progress.rs +++ b/crates/ui-egui/src/export_progress.rs @@ -1,8 +1,9 @@ //! The export progress dialog: while an export runs in the background (Background Export) a modal //! window shows what is being exported, how far it has got (artboard by artboard for raster -//! exports of several artboards; busy for one artboard and for formats made in one go, which -//! report no steps) and two buttons: Cancel (`file.cancelExport`, also -//! Esc) stops it and writes nothing, Continue in Background (`file.exportInBackground`) closes the +//! exports of several artboards, file by file for Export for Screens; busy for one artboard and for +//! formats made in one go, which report no steps) and two buttons: Cancel (`file.cancelExport`, also +//! Esc) stops it and writes nothing (Export for Screens keeps the files it wrote before the +//! Cancel), Continue in Background (`file.exportInBackground`) closes the //! dialog and leaves the export to the status bar. //! //! It opens after [`DELAY`] so a quick export doesn't flash it, and waits while another dialog is @@ -22,8 +23,9 @@ fn seen_id() -> egui::Id { egui::Id::new("export-progress-seen") } -/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one). -fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize)> { +/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one, +/// whether the steps are files). +fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, bool)> { let jobs: Vec<_> = app.background.jobs.iter().filter(|j| !j.hidden && !j.cancelled()).filter(|j| j.progress.is_some()).collect(); let first = jobs.first()?; let since = ctx.data_mut(|m| { @@ -38,25 +40,26 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, } let (done, total) = first.progress.as_ref().map(|p| p.counts()).unwrap_or_default(); let frac = first.progress.as_ref().and_then(|p| p.fraction()).unwrap_or(0.0) as f32; - Some((first.label.clone(), done, total, frac, jobs.len() - 1)) + let files = first.progress.as_ref().is_some_and(|p| p.counts_files()); + Some((first.label.clone(), done, total, frac, jobs.len() - 1, files)) } +/// Draws the window for the first export running in the background that wasn't hidden or +/// cancelled, once it has run [`DELAY`] seconds and no other dialog is open, and runs the command +/// of the button pressed. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); - let Some((label, done, total, frac, more)) = shown(app, now, ctx) else { return }; + let Some((label, done, total, frac, more, files)) = shown(app, now, ctx) else { return }; if app.ui.dialog.is_some() { return; } let t = Tokens::get(ctx); let heading = format!("{}…", crate::i18n::msg(&label)); - let mut action = None; - modal::show(ctx, &heading, egui::Id::new("export-progress"), -40.0, 20, |ui| { - ui.set_width(360.0); - modal::heading(ui, &heading); - ui.add_space(12.0); + let pressed = window(ctx, "export-progress", &heading, |ui| { // One step says nothing a spinner doesn't: the bar would sit at 0 % until the file is written. if total > 1 { - let step = crate::i18n::fmt(tl!("Artboard {n} of {total}"), &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]); + let step = if files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") }; + let step = crate::i18n::fmt(step, &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]); // The count on its own line: drawn over the bar it would straddle the fill's edge. ui.horizontal(|ui| { ui.label(egui::RichText::new(step).color(t.text_dim)); @@ -67,36 +70,69 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.add_space(4.0); ui.add(egui::ProgressBar::new(frac).desired_width(360.0).desired_height(6.0).fill(t.accent)); } else { - ui.horizontal(|ui| { - ui.add(egui::Spinner::new().size(14.0).color(t.accent)); - ui.label(egui::RichText::new(tl!("Encoding and writing the file…")).color(t.text_dim)); - }); + busy(ui, tl!("Encoding and writing the file…")); } if more > 0 { ui.add_space(6.0); let queued = crate::i18n::tn(more as u64, "{n} more export waiting", "{n} more exports waiting"); ui.label(egui::RichText::new(queued).size(11.5).color(t.text_dim)); } + }); + let id = match pressed { + Some(Pressed::Cancel) => "file.cancelExport", + Some(Pressed::Hide) => "file.exportInBackground", + None => return, + }; + if let Err(e) = app.run(id, json!({})) { + app.status(e); + } +} + +/// A button of a progress [`window`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pressed { + /// Cancel, or Esc: stop the work. + Cancel, + /// Continue in Background: close the window; the work goes on, shown in the status bar. + Hide, +} + +/// A modal window (`id`) headed `heading`, in the UI language: `body` draws how far the work has +/// got, then come Cancel (also Esc) and Continue in Background → the one pressed. Work that may +/// take a while in the background shows it: exports, and heavy plug-in filters +/// ([`crate::plugin_runs`]). +pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&mut egui::Ui)) -> Option { + let mut pressed = None; + modal::show(ctx, heading, egui::Id::new(id), -40.0, 20, |ui| { + ui.set_width(360.0); + modal::heading(ui, heading); + ui.add_space(12.0); + body(ui); ui.add_space(16.0); let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::secondary_button(ui, "Cancel").clicked() { - action = Some("file.cancelExport"); + pressed = Some(Pressed::Cancel); } ui.add_space(8.0); if widgets::secondary_button(ui, "Continue in Background").clicked() { - action = Some("file.exportInBackground"); + pressed = Some(Pressed::Hide); } }); }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - action = Some("file.cancelExport"); - } - if let Some(id) = action - && let Err(e) = app.run(id, json!({})) - { - app.status(e); + pressed = Some(Pressed::Cancel); } + pressed +} + +/// Work that counts no steps, in a [`window`]: a spinner and `what` it does, in the UI language. +pub fn busy(ui: &mut egui::Ui, what: &str) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(14.0).color(t.accent)); + ui.label(egui::RichText::new(what).color(t.text_dim)); + }); } #[cfg(test)] @@ -193,4 +229,42 @@ mod tests { background::wait_all(&mut app); assert!(app.background.jobs.is_empty()); } + + #[test] + fn an_export_of_files_counts_files() { + let go = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut app = app(go.clone()); + let p = Progress::of_files(); + let reporting = p.clone(); + background::run_with_progress( + &mut app, + true, + "Exporting icons".into(), + None, + Some(p.clone()), + move |w| { + vectorcraft_engine::cmd::fileio::progress::scope(&reporting, || { + vectorcraft_doc::cancel::progress(1, 3, "file 2 of 3"); + w("/icons/b.png", b"x").map(|()| json!({})) + }) + }, + |_, r| r, + ) + .unwrap(); + // The worker reports the step before it waits on the writer. + for _ in 0..10_000 { + if p.counts() == (1, 3) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, 0.0); + frame(&mut app, &ctx, 1.0); + let shown = frame(&mut app, &ctx, 1.1); + assert!(shown.contains("File 2 of 3") && !shown.contains("Artboard"), "{shown}"); + go.store(true, std::sync::atomic::Ordering::SeqCst); + background::wait_all(&mut app); + } } diff --git a/crates/ui-egui/src/floating.rs b/crates/ui-egui/src/floating.rs index a302536c5..5701d5890 100644 --- a/crates/ui-egui/src/floating.rs +++ b/crates/ui-egui/src/floating.rs @@ -3,10 +3,12 @@ //! A dock tab, an icon of the icon column or a popped-out panel's title dragged out of the dock //! floats that panel; the strip right of the dock's tabs floats the whole tabbed group. A floating //! group moves by its title bar (or the strip right of its tabs, or the tab of a lone panel), and a -//! tab dragged out of a group floats on its own. Dropped on the dock, which lights up while the -//! pointer is over it, a group's panels go back to where they live in the dock (the tabbed group or -//! the icon column); dropped on another group's title bar or tabs, they stack with that group. The -//! × of a group puts its panels back in the dock too. The Tools panel floats the same way by its +//! tab dragged out of a group floats on its own. Dropped on the tabs of one of the dock's groups +//! (lit up), a group's panels join that group as tabs; dropped on the upper or lower half of it, +//! they make a new group above or below it in the dock. Dropped on the icon column, which lights +//! up, they become icons there, the tabbed group's panels too; dropped anywhere else on the dock, +//! they go back to where they live in it. Dropped on another floating group's title bar or tabs, +//! they stack with that group. The × of a group puts its panels back in the dock too. The Tools panel floats the same way by its //! title bar ([`crate::toolbar`]) and docks again on the window's left edge. //! //! Dropped on the upper or lower half of another group (a line shows where), a group joins it in @@ -27,7 +29,7 @@ use serde_json::{Value, json}; use crate::state::{DockTab, FloatingPanels, UiState, all_panels}; use crate::theme::{self, Tokens}; -use crate::{VectorcraftApp, dock, icons, panels}; +use crate::{VectorcraftApp, dock, icons, panels, widgets}; /// Height of a floating group's title bar (its grip and ×). pub const TITLE: f32 = 14.0; @@ -80,6 +82,47 @@ pub(crate) enum Drop { Above(&'static str), /// In the dock as a new column at this place (1: next to the icon column, counting left). Column(u32), + /// In the dock's group showing this panel: as tabs, or as a new group above or below it. + Root(&'static str, RootZone), + /// In the icon column, as icons (`window.panel.dock {icon: true}`). + Icons, +} + +/// Where a drop on one of the dock's groups puts the moved panels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RootZone { + /// With the group, as tabs. + Tab, + /// In a new group right above it. + Top, + /// In a new group right below it. + Bottom, +} + +impl RootZone { + /// The `zone` of `window.panel.dock` / `window.panel.move`. + pub(crate) fn id(self) -> &'static str { + match self { + RootZone::Tab => "tab", + RootZone::Top => "top", + RootZone::Bottom => "bottom", + } + } +} + +/// One of the dock's groups as last drawn (egui temp memory under [`root_groups_id`]): its panels, +/// the one it shows, its bounds and its tab strip. A group dropped on it joins it ([`Drop::Root`]). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct RootGroup { + pub panels: Vec<&'static str>, + pub shown: &'static str, + pub rect: Rect, + pub strip: Rect, +} + +/// The dock's groups as last drawn, top to bottom. +pub(crate) fn root_groups_id() -> Id { + Id::new("floating-root-groups") } fn move_id() -> Id { @@ -138,6 +181,16 @@ pub fn group_of(ui: &UiState, id: &str) -> Option { ui.floating_panels.iter().position(|g| g.panels.iter().any(|p| p == id)) } +/// Reorder tabs in a floating group by index, keeping the active tab on the same panel. +pub(crate) fn reorder_group_tab_indices(g: &mut FloatingPanels, from: usize, to: usize) { + if from != to && from < g.panels.len() && to < g.panels.len() { + let active = g.panels.get(g.active).cloned(); + let moved = g.panels.remove(from); + g.panels.insert(to, moved); + g.active = active.and_then(|id| g.panels.iter().position(|p| *p == id)).unwrap_or(0); + } +} + /// The floating groups drawn with group `gi`, top to bottom: the groups of its set, or it alone. pub fn set_of(ui: &UiState, gi: usize) -> Vec { match ui.floating_panels.get(gi).and_then(|g| g.column) { @@ -209,9 +262,30 @@ fn ids_of(g: &FloatingPanels) -> Vec<&'static str> { g.panels.iter().filter_map(|p| panel(p)).map(|(id, _)| id).collect() } +/// Saved dock tab order, appending any tabs missing from older preferences. +pub(crate) fn resolved_dock_tab_order(ui: &UiState) -> Vec { + if ui.dock_tab_order.is_empty() { + DockTab::ALL.to_vec() + } else { + let mut order = ui.dock_tab_order.clone(); + for tab in DockTab::ALL { + if !order.contains(&tab) { + order.push(tab); + } + } + order + } +} + +/// Persist dock tab order when it differs from the default visible strip. +pub(crate) fn set_dock_tab_order(ui: &mut UiState, visible: Vec) { + let default = DockTab::ALL.iter().copied().filter(|t| group_of(ui, t.info().0).is_none()).collect::>(); + ui.dock_tab_order = if visible == default { Vec::new() } else { visible }; +} + /// The dock tabs left in the dock, in tab order. pub fn docked_tabs(ui: &UiState) -> impl Iterator + '_ { - DockTab::ALL.into_iter().filter(|t| group_of(ui, t.info().0).is_none()) + resolved_dock_tab_order(ui).into_iter().filter(|t| group_of(ui, t.info().0).is_none()) } /// The dock tab the tabbed group shows: the chosen one, or the first left when it floats. @@ -428,7 +502,31 @@ pub(crate) fn drop_target(app: &VectorcraftApp, ctx: &egui::Context, what: Movin let y = ctx.data(|d| d.get_temp::(group_rect_id(last.0))).map_or(col.bottom(), |r| r.bottom()).min(col.bottom() - 2.0); return Some((Drop::Below(last.0), Rect::from_x_y_ranges(col.x_range(), (y - 2.0)..=(y + 2.0)))); } - let dock = zone(dock_rect_id()).filter(|_| app.ui.dock && app.ui.screen_mode < 3)?; + let shown = app.ui.dock && app.ui.screen_mode < 3; + // The icon column takes the panels as icons. (Mid-frame the dock's rect may not take it in yet.) + if let Some(icons) = zone(icons_rect_id()).filter(|_| shown) { + return Some((Drop::Icons, icons)); + } + let dock = zone(dock_rect_id()).filter(|_| shown)?; + // One of the dock's groups: its tabs take the panels as tabs, its upper or lower half puts them + // in a new group above or below it. The group they came from (being dragged by a tab) takes + // nothing: the shared dock handles a tab moved within it. + let moved: Vec<&str> = std::iter::once(id).chain(ids_in(&app.ui, &moving)).collect(); + let groups = ctx.data(|d| d.get_temp::>(root_groups_id())).unwrap_or_default(); + if let Some(group) = groups.iter().find(|g| g.rect.contains(at)) { + let anchor = Some(group.shown).filter(|p| !moved.contains(p)).or_else(|| group.panels.iter().copied().find(|p| !moved.contains(p))); + if let Some(anchor) = anchor { + if group.strip.contains(at) { + return Some((Drop::Root(anchor, RootZone::Tab), group.strip)); + } + let mid = group.rect.center().y; + return Some(if at.y < mid { + (Drop::Root(anchor, RootZone::Top), Rect::from_x_y_ranges(group.rect.x_range(), group.rect.top()..=mid)) + } else { + (Drop::Root(anchor, RootZone::Bottom), Rect::from_x_y_ranges(group.rect.x_range(), mid..=group.rect.bottom())) + }); + } + } // Panels that aren't tabs of the tabbed group (or all of them, with it collapsed) go back to // their icons: light up the icon column, where they land, rather than the tabs. let tabbed = !app.ui.dock_collapsed @@ -461,8 +559,8 @@ fn insertion_line(ctx: &egui::Context, line: Rect) { pub(crate) fn preview(ctx: &egui::Context, target: Drop, rect: Rect) { match target { Drop::Below(_) | Drop::Above(_) | Drop::Column(_) => insertion_line(ctx, rect), - Drop::Stack(_) => tab_zone(ctx, rect), - Drop::Dock => highlight(ctx, rect), + Drop::Stack(_) | Drop::Root(..) => tab_zone(ctx, rect), + Drop::Dock | Drop::Icons => highlight(ctx, rect), } } @@ -485,6 +583,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { app.ui.docking = m.original.docking; app.ui.docking_hidden = m.original.docking_hidden; app.ui.docking_icons = m.original.docking_icons; + app.ui.docking_collapsed = m.original.docking_collapsed; app.ui.floating_panels = m.original.floating_panels; app.ui.open_panel = m.original.open_panel; app.ui.dock_tab = m.original.dock_tab; @@ -517,7 +616,7 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { } match target { Some((Drop::Below(_) | Drop::Above(_) | Drop::Column(_), line)) => insertion_line(ctx, line), - Some((Drop::Stack(_), tabs)) => tab_zone(ctx, tabs), + Some((Drop::Stack(_) | Drop::Root(..), tabs)) => tab_zone(ctx, tabs), Some((_, zone)) => highlight(ctx, zone), None => {} } @@ -534,7 +633,30 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { let set = set_of(&app.ui, gi); let ids = ids_in(&app.ui, &set); let active = set.first().and_then(|&i| shown(&app.ui, i)).unwrap_or(id); + // Icon panels dropped on the icon column go back to their icons, as on the rest of the dock. + let drop = match drop { + Drop::Icons if ids.iter().all(|p| crate::state::ICON_PANELS.iter().any(|entry| entry.0 == *p)) => Drop::Dock, + drop => drop, + }; + if matches!(drop, Drop::Root(..) | Drop::Icons) { + // The shared layout puts them in the dock's tree, group by group: the first where it was + // dropped, the others with it as tabs. + let leads: Vec<&'static str> = set.iter().filter_map(|&i| shown(&app.ui, i)).collect(); + for (k, lead) in leads.iter().enumerate() { + let params = match (drop, leads.first()) { + (Drop::Root(anchor, zone), _) if k == 0 => json!({"panel": lead, "group": true, "anchor": anchor, "zone": zone.id()}), + (Drop::Root(..), Some(first)) => json!({"panel": lead, "group": true, "anchor": first, "zone": "tab"}), + _ => json!({"panel": lead, "group": true, "icon": true}), + }; + if let Err(error) = app.run("window.panel.dock", params) { + app.ui.status = error; + break; + } + } + return; + } match drop { + Drop::Root(..) | Drop::Icons => {} Drop::Dock => { dock(&mut app.ui, &ids, active); // One that went back to its icon pops out of it, so it doesn't seem to vanish. @@ -556,7 +678,8 @@ pub fn track(app: &mut VectorcraftApp, ctx: &egui::Context) { enum Action { /// Show the group's `k`th tab. Activate(usize, usize), - /// A double-click on a tab collapses the group to its tabs, or expands it. + /// A double-click on a tab or the strip right of the tabs collapses the group to its tabs, or + /// expands it. Collapse(usize), /// Its × puts the panels of the set's groups back in the dock. Close(Vec), @@ -566,6 +689,8 @@ enum Action { Detach(usize, f32), /// Float this panel out of its group; its tab began this far right. Tear(&'static str, f32), + /// Reorder tabs in floating group `gi` from index `from` to `to`. + Reorder(usize, usize, usize), /// Float the docked column of this group, its top-left corner this far from the pointer. Undock(usize, Vec2), } @@ -624,6 +749,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) return; } let before = app.ui.clone(); + let skip_reconcile = matches!(action, Some(Action::Reorder(_, _, _))); match action { Some(Action::Activate(gi, k)) => { if let Some(g) = app.ui.floating_panels.get_mut(gi) @@ -654,6 +780,19 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) } Some(Action::Detach(gi, left)) => detach(app, ctx, gi, left), Some(Action::Tear(id, left)) => tear(app, ctx, &[id], id, strip_grab(ctx, left)), + Some(Action::Reorder(gi, from, to)) => { + let ids = app.ui.floating_panels.get(gi).and_then(|g| { + let panel = g.panels.get(from)?; + let before = g.panels.get(to)?; + Some((panel.clone(), before.clone())) + }); + if let Some((panel, before)) = ids + && let Err(error) = crate::panel_docking::strip_tab_reorder(app, &panel, &before) + { + app.ui.status = error; + } + return; + } Some(Action::Undock(gi, grab)) => { let Some(at) = ctx.input(|i| i.pointer.interact_pos()) else { return }; let Some(id) = set_of(&app.ui, gi).first().and_then(|&i| shown(&app.ui, i)) else { return }; @@ -669,7 +808,7 @@ fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, action: Option) } None => {} } - if let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) { + if !skip_reconcile && let Err(error) = crate::panel_docking::reconcile_native(app, before.clone()) { app.ui.status = error; } ctx.data_mut(|data| { @@ -773,7 +912,7 @@ pub(crate) fn docked_columns(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> Vec // The open panels share the column's height, each at least `MIN_TALL`: more than fit // scroll, with the column. let tall = ((ui.available_height() - n * STRIP - (n - 1.0) * GAP - 24.0 * open) / open).max(MIN_TALL); - egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]).show(ui, |ui| { + widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt(("dock-column-scroll", first)).auto_shrink([false, false]), |ui| { ui.spacing_mut().item_spacing = Vec2::ZERO; let width = ui.available_width(); let top = ui.cursor().top(); @@ -877,9 +1016,9 @@ fn draw_groups( ui.painter().rect_filled(gap, 0.0, t.tab_strip); gap.top() }; - // Tab strip: a click shows a tab, a double-click collapses the group, a drag out of the - // strip floats a tab on its own; a lone tab or the strip right of the tabs moves the group - // (out of its set). + // Tab strip: a click shows a tab, a double-click on a tab or on the strip right of the tabs + // collapses the group, a drag out of the strip floats a tab on its own; a lone tab or the + // strip right of the tabs moves the group (out of its set). let (strip, _) = ui.allocate_exact_size(vec2(width, STRIP), Sense::hover()); ui.painter().rect_filled(strip, 0.0, t.panel_darker); let lone = g.ids.len() == 1; @@ -889,12 +1028,14 @@ fn draw_groups( let names: Vec<_> = g.ids.iter().zip(&labels).map(|(&id, label)| (id, label.as_str())).collect(); let selected = g.ids.iter().position(|&id| id == g.active).unwrap_or(0); let out = dock::panel_tabs(ui, &format!("native-set-tabs-{}", g.gi), tab_area, &names, selected, theme::semibold(12.0), false); - for (j, resp) in out.responses { - let Some(&id) = g.ids.get(j) else { continue }; - if resp.double_clicked() { + for (j, resp) in &out.responses { + let Some(&id) = g.ids.get(*j) else { continue }; + if let Some((from, to)) = dock::tab_reorder_on_release(strip, pointer, resp, &out.responses) { + *action = Some(Action::Reorder(g.gi, from, to)); + } else if resp.double_clicked() { *action = Some(Action::Collapse(g.gi)); } else if resp.clicked() { - *action = Some(Action::Activate(g.gi, j)); + *action = Some(Action::Activate(g.gi, *j)); } else if lone && resp.drag_started() { *action = Some(match alone { Some(pos) => Action::Move(id, pos), @@ -909,8 +1050,12 @@ fn draw_groups( } let rest = Rect::from_min_max(pos2(out.right, strip.top()), pos2(menu.left() - 4.0, strip.bottom())); if rest.width() > 0.0 { - let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::drag()); - if resp.drag_started() { + // Click as well as drag, so a press there no longer starts the move at once and a + // double-click can collapse the group. + let resp = ui.interact(rest, ui.id().with(("strip", g.gi)), Sense::click_and_drag()); + if resp.double_clicked() { + *action = Some(Action::Collapse(g.gi)); + } else if resp.drag_started() { *action = Some(match alone { Some(pos) => Action::Move(g.active, pos), None => Action::Detach(g.gi, strip.left()), @@ -1240,6 +1385,16 @@ mod tests { } } + #[test] + fn floating_group_tabs_reorder_horizontally_within_the_strip() { + let mut h = Harness::new(); + h.drag(h.icons()[2].center(), pos2(500.0, 250.0)); + h.drag(h.icons()[0].center(), h.shared_tab("swatches").center()); + assert_eq!(h.groups(), [["swatches", "color"]]); + h.app.run("window.panel.move", json!({ "panel": "color", "anchor": "swatches", "before": "swatches" })).unwrap(); + assert_eq!(h.groups(), [["color", "swatches"]]); + } + #[test] fn a_dock_tab_dragged_out_floats_and_dropped_on_the_dock_docks_again() { let mut h = Harness::new(); @@ -1325,24 +1480,55 @@ mod tests { assert_eq!(h.dock_tabs().len(), 4); } + /// A floating icon panel dropped on the icon column lights it up, goes back and pops out of its icon. #[test] - fn a_panel_dropped_on_the_dock_lights_up_and_pops_out_of_its_icon() { + fn a_panel_dropped_on_the_icon_column_lights_it_up_and_pops_out_of_its_icon() { let mut h = Harness::new(); let count = h.icons().len(); h.drag(h.icons()[0].center(), pos2(500.0, 400.0)); assert_eq!(h.groups(), [["color"]]); - // Over the tabbed group: Color has no tab there, so the icon column lights up, not the tabs. let (dock, column) = (h.temp_rect(dock_rect_id()), h.temp_rect(icons_rect_id())); - let over = h.dock_tabs()[1].center(); + let over = column.center(); h.hold(h.title("color"), over); - assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Dock, column))); + assert_eq!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Icons, column))); assert!(column.width() < dock.width(), "the column, not the whole dock: {column:?} {dock:?}"); // Dropped, it's back in the column and pops out of its icon rather than vanishing. h.release(over); - assert!(h.groups().is_empty()); + assert!(h.groups().is_empty(), "{}", h.app.ui.status); assert_eq!((h.icons().len(), h.app.ui.open_panel.as_deref()), (count, Some("color"))); } + /// A floating panel dropped on the dock's tabs joins them; on the group's lower half it makes a group below. + #[test] + fn a_panel_dropped_on_the_dock_tabs_joins_them_and_on_its_lower_half_makes_a_group_below() { + let mut h = Harness::new(); + h.drag(h.icons()[0].center(), pos2(500.0, 400.0)); + assert_eq!(h.groups(), [["color"]]); + // Over the tabbed group's tabs: they light up, and Color joins them. + let over = h.dock_tabs()[1].center(); + h.hold(h.title("color"), over); + assert!(matches!(drop_target(&h.app, &h.ctx, Moving::Panel("color"), over), Some((Drop::Root(_, RootZone::Tab), _)))); + h.release(over); + assert!(h.groups().is_empty()); + assert_eq!(h.dock_tabs().len(), 4); + assert!(h.app.ui.open_panel.is_none()); + // Color Guide on the group's lower half: a group of its own below the tabbed group. + h.drag(h.icons()[0].center(), pos2(500.0, 400.0)); + assert_eq!(h.groups(), [["colorGuide"]]); + let dock = h.ctx.data(|d| d.get_temp::>(root_groups_id())).unwrap()[0].rect; + let lower = pos2(dock.center().x, dock.bottom() - 60.0); + h.hold(h.title("colorGuide"), lower); + assert!(matches!(drop_target(&h.app, &h.ctx, Moving::Panel("colorGuide"), lower), Some((Drop::Root(_, RootZone::Bottom), _)))); + h.release(lower); + assert!(h.groups().is_empty()); + let groups = h.ctx.data(|d| d.get_temp::>(root_groups_id())).unwrap(); + assert_eq!( + groups.iter().map(|g| g.panels.clone()).collect::>(), + [vec!["properties", "layers", "libraries", "color"], vec!["colorGuide"]] + ); + assert!(groups[0].rect.bottom() <= groups[1].rect.top() + 1.0); + } + #[test] fn a_popped_out_panel_dragged_by_its_title_floats() { let mut h = Harness::new(); @@ -1596,6 +1782,35 @@ mod tests { assert!(!h.app.ui.floating_panels[0].collapsed); } + /// Illustrator also collapses the group from the blank part of the strip, see + /// . + #[test] + fn a_double_click_on_the_strip_right_of_the_tabs_collapses_a_floating_group_in_place() { + let mut h = Harness::new(); + h.app.run("window.panel.float", json!({"panel": "layers", "x": 500, "y": 150})).unwrap(); + h.app.run("window.panel.float", json!({"panel": "stroke", "below": "layers"})).unwrap(); + h.settle(); + let owned = |h: &Harness| format!("{:?}", sets(&h.app.ui.floating_panels)); + let before = owned(&h); + let open = h.temp_rect(group_rect_id("layers")); + // Left of the panel menu, well right of the lone Layers tab. + let blank = pos2(open.right() - 40.0, open.top() + 1.0 + TITLE + STRIP / 2.0); + let double = |h: &mut Harness| { + h.time += 1.0; + h.frame(vec![Event::PointerMoved(blank), button(blank, true)]); + h.frame(vec![button(blank, false)]); + h.frame(vec![button(blank, true)]); + h.frame(vec![button(blank, false)]); + h.settle(); + }; + double(&mut h); + assert!(h.app.ui.floating_panels[0].collapsed, "collapsed"); + assert_eq!(owned(&h), before, "still in its set"); + assert_eq!(h.temp_rect(group_rect_id("layers")).min, open.min, "not moved"); + double(&mut h); + assert!(!h.app.ui.floating_panels[0].collapsed, "expanded"); + } + #[test] fn sets_by_command_are_checked_and_kept_in_workspaces_and_preferences() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 3629760a7..9011b8fd6 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -4,9 +4,11 @@ //! or reached with ↑/↓ previews its font on the selected text (one live interaction, nothing in //! the history); a click or Enter applies it as one step, Escape or closing the menu puts the text //! back. The Glyphs panel and Find Font only pick a font ([`picked`]): nothing is previewed on the -//! document. A star marks a favourite family, and the ★ filter lists only those. Preferences › -//! Type › Enable in-menu font previews and Font Preview Size turn the samples off or size the -//! rows. +//! document. A star marks a favourite family, and the ★ filter lists only those. The Font +//! Technology filter's Variable lists only variable font families. Preferences › Type › Enable +//! in-menu font previews and Font Preview Size turn the samples off or size the rows. Recently +//! used families appear first, in the order they were last applied, within the same search and +//! filters as the remaining families. use std::collections::HashMap; use std::sync::Arc; @@ -44,12 +46,14 @@ pub struct MenuLook<'a> { pub row: f32, /// The starred families. pub favorites: &'a [String], + /// Recently applied families, newest first, limited by Number of Recent Fonts. + pub recent: &'a [String], } impl Default for MenuLook<'static> { /// Samples on, English names, medium rows, no favourites. fn default() -> Self { - Self { samples: true, english_names: true, row: ROW, favorites: &[] } + Self { samples: true, english_names: true, row: ROW, favorites: &[], recent: &[] } } } @@ -65,6 +69,7 @@ impl<'a> MenuLook<'a> { english_names: app.session.prefs.font_names_in_english, row, favorites: &app.ui.favorite_fonts, + recent: app.recent_fonts(), } } } @@ -97,6 +102,12 @@ fn script_label(i: usize) -> &'static str { } } +/// The Font Technology filter's one choice: variable fonts. An adjective for fonts, not the +/// Variables panel's noun (`変数`), hence its context. +fn variable_label() -> &'static str { + crate::i18n::tr_ctx(crate::i18n::current(), "font-technology", "Variable") +} + /// Does a family of `traits` pass script filter `script` ([`SCRIPTS`])? Latin: neither Japanese /// nor Thai. fn script_matches(script: usize, traits: vectorcraft_text::FontTraits) -> bool { @@ -123,27 +134,65 @@ fn class_label(c: FontClass) -> &'static str { /// A row of the list: a family, or one of the styles of an expanded family. #[derive(Clone, Debug, PartialEq)] enum Row { + Heading(&'static str), Family(String), Style(String, String), } impl Row { - fn family(&self) -> &str { + fn family(&self) -> Option<&str> { match self { - Row::Family(f) | Row::Style(f, _) => f, + Row::Heading(_) => None, + Row::Family(f) | Row::Style(f, _) => Some(f), } } fn style(&self) -> Option<&str> { match self { - Row::Family(_) => None, + Row::Heading(_) | Row::Family(_) => None, Row::Style(_, s) => Some(s), } } - fn pick(&self) -> (String, Option) { - (self.family().to_string(), self.style().map(str::to_string)) + fn pick(&self) -> Option<(String, Option)> { + Some((self.family()?.to_string(), self.style().map(str::to_string))) } } +/// Recent families keep their application order, once each, and only while still available and +/// passing the filters. The other families keep the database's order. Headings share the virtual +/// list's row height, but never act as fonts or take keyboard focus. +fn grouped_rows(families: &[String], recent: &[String], expanded: &[String], styles: impl Fn(&str) -> Vec) -> Vec { + let mut first: Vec<&String> = vec![]; + for name in recent { + if let Some(f) = families.iter().find(|f| f.eq_ignore_ascii_case(name)) + && !first.iter().any(|other| other.eq_ignore_ascii_case(f)) + { + first.push(f); + } + } + let mut rows = vec![]; + let append = |rows: &mut Vec, f: &String| { + rows.push(Row::Family(f.clone())); + if expanded.iter().any(|e| e.eq_ignore_ascii_case(f)) { + rows.extend(styles(f).into_iter().map(|s| Row::Style(f.clone(), s))); + } + }; + // Build the family rows before adding headings, so the same expansion logic serves both groups. + for f in &first { + append(&mut rows, f); + } + let boundary = rows.len(); + for f in families.iter().filter(|f| !first.contains(f)) { + append(&mut rows, f); + } + if boundary > 0 { + if boundary < rows.len() { + rows.insert(boundary, Row::Heading("Fonts")); + } + rows.insert(0, Row::Heading("Recent Fonts")); + } + rows +} + /// Keep keyboard/pointer focus on the same font after filtering, loading new fonts or /// expanding a family's styles. A row index on its own can now name a different font. fn remap_highlight(before: &[Row], selected: Option, after: &[Row]) -> Option { @@ -151,9 +200,10 @@ fn remap_highlight(before: &[Row], selected: Option, after: &[Row]) -> Op after.iter().position(|candidate| candidate == row) } -/// What the rows were listed for: the query, the script, kind and favourites filters, Show Font -/// Names in English, the expanded families, the favourites and the font list's generation. -type RowsKey = (String, usize, Option, bool, bool, Vec, Vec, u64); +/// What the rows were listed for: the query, the script, kind, font technology (variable only) and +/// favourites filters, Show Font Names in English, the expanded families, favourites, recent +/// history and font list generation. +type RowsKey = (String, usize, Option, bool, bool, bool, Vec, Vec, Vec, u64); /// The menu's state between frames. #[derive(Clone, Default)] @@ -168,10 +218,11 @@ struct MenuState { /// The highlighted row and the query and filters it belongs to. highlight: Option, query: String, - /// The filters (kept from one opening to the next): script (0..[`SCRIPTS`]), kind, and - /// favourites only. + /// The filters (kept from one opening to the next): script (0..[`SCRIPTS`]), kind, font + /// technology (variable fonts only) and favourites only. script: usize, class: Option, + variable_only: bool, favorites_only: bool, /// Families whose styles are listed (lowercase). expanded: Vec, @@ -245,7 +296,7 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp ui.memory_mut(|m| m.request_focus(field.with("edit"))); } let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase(); - // The filters: script, then kind. + // The filters: script, then kind, then font technology. let mut refilter = false; ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing = vec2(4.0, 2.0); @@ -273,6 +324,14 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp } } }); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 2.0); + ui.label(egui::RichText::new(tl!("Font Technology")).size(11.0).color(t.text_dim)); + if ui.selectable_label(st.variable_only, egui::RichText::new(variable_label()).size(11.0)).clicked() { + st.variable_only = !st.variable_only; + refilter = true; + } + }); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let db = vectorcraft_text::FontDb::global(); // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック), @@ -281,8 +340,18 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp let current = current.as_str(); let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); // The generation is read first: fonts that load meanwhile make the next frame list them. - let key: RowsKey = - (query.clone(), st.script, st.class, st.favorites_only, look.english_names, st.expanded.clone(), look.favorites.to_vec(), db.generation()); + let key: RowsKey = ( + query.clone(), + st.script, + st.class, + st.variable_only, + st.favorites_only, + look.english_names, + st.expanded.clone(), + look.favorites.to_vec(), + look.recent.to_vec(), + db.generation(), + ); if st.rows_key.as_ref() != Some(&key) { let wanted = |f: &str| { let label = db.family_display_name(f, look.english_names); @@ -292,19 +361,14 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp if st.favorites_only && !favorite(f) { return false; } - if st.script == 0 && st.class.is_none() { + if st.script == 0 && st.class.is_none() && !st.variable_only { return true; } let traits = db.family_traits(f); - script_matches(st.script, traits) && st.class.is_none_or(|c| c == traits.class) + script_matches(st.script, traits) && st.class.is_none_or(|c| c == traits.class) && (!st.variable_only || traits.variable) }; - let mut rows: Vec = vec![]; - for f in db.menu_family_list().iter().filter(|f| wanted(f)) { - rows.push(Row::Family(f.clone())); - if st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f)) { - rows.extend(db.styles(f).into_iter().map(|s| Row::Style(f.clone(), s))); - } - } + let families: Vec<_> = db.menu_family_list().iter().filter(|f| wanted(f)).cloned().collect(); + let rows = grouped_rows(&families, look.recent, &st.expanded, |f| db.styles(f)); // Filter changes (including un-starring a favourite) may remove rows; expanding // another family or a background font scan can insert rows before the highlight. // Never preview an unrelated family because the old numeric index now points to it. @@ -322,23 +386,24 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp } let mut keyed = false; if (up || down) && !rows.is_empty() { - let last = rows.len() - 1; - st.highlight = Some(match (st.highlight, down) { - (None, _) => 0, - (Some(i), true) => (i + 1).min(last), - (Some(i), false) => i.saturating_sub(1), - }); + let selectable = |(_, r): &(usize, &Row)| r.family().is_some(); + let next = match (st.highlight, down) { + (None, _) => rows.iter().enumerate().find(selectable), + (Some(i), true) => rows.iter().enumerate().skip(i.saturating_add(1)).find(selectable), + (Some(i), false) => rows.iter().enumerate().take(i).rev().find(selectable), + }; + st.highlight = next.map(|(i, _)| i).or(st.highlight); keyed = true; } // → opens the highlighted family's styles, ← closes them (from the family or one of them). - if let Some(row) = st.highlight.and_then(|i| rows.get(i)).cloned().filter(|_| left || right) { - let f = row.family().to_lowercase(); + if let Some(family) = st.highlight.and_then(|i| rows.get(i)).and_then(Row::family).filter(|_| left || right) { + let f = family.to_lowercase(); let open = st.expanded.contains(&f); - if right && !open && db.styles(row.family()).len() > 1 { + if right && !open && db.styles(family).len() > 1 { st.expanded.push(f); } else if left && open { st.expanded.retain(|e| *e != f); - st.highlight = rows.iter().position(|r| *r == Row::Family(row.family().to_string())); + st.highlight = rows.iter().position(|r| *r == Row::Family(family.to_string())); } keyed = true; } @@ -353,8 +418,11 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp // menu's room (its max rect) below them. let room = (ui.max_rect().bottom() - ui.next_widget_position().y).clamp(120.0, 600.0); let mut area = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room.min(row_h * rows.len() as f32)); - // The highlight in view: centred as the menu opens, scrolled just enough as the keys move it. - if let Some(top) = st.highlight.map(|i| i as f32 * row_h) { + // Open at the recent section even when the current family is farther down the list. + // Otherwise centre the current font; subsequent key presses keep the highlight in view. + if opening && matches!(rows.first(), Some(Row::Heading("Recent Fonts"))) { + area = area.vertical_scroll_offset(0.0); + } else if let Some(top) = st.highlight.map(|i| i as f32 * row_h) { let target = if opening { Some(top - (room - row_h) * 0.5) } else if keyed && top < st.scrolled { @@ -379,6 +447,23 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp let shown = area.show_rows(ui, row_h, rows.len(), |ui, range| { for i in range { let Some(row) = rows.get(i) else { continue }; + if let Row::Heading(label) = row { + let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::hover()); + // Existing translated menu labels; the heading cannot preview or apply a font. + let label = match *label { + "Recent Fonts" => tl!("Recent Fonts"), + _ => tl!("Fonts"), + }; + ui.painter_at(rect).text( + rect.left_center() + vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.0), + t.text_dim, + ); + continue; + } + let Some(family) = row.family() else { continue }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); if resp.hovered() && ui.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO || inp.smooth_scroll_delta != egui::Vec2::ZERO) { hovered = Some(i); @@ -424,15 +509,16 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp let name = match row { Row::Style(_, style) => style.clone(), Row::Family(f) => db.family_display_name(f, look.english_names), + Row::Heading(_) => continue, }; ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 2.0), rect.max - vec2(STAR + 2.0, 2.0)); if !look.samples { - } else if let Some(tex) = sample_texture(ui.ctx(), row.family(), row.style(), sample, sample_rect.height()) { + } else if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, sample_rect.height()) { paint_sample(ui, sample_rect, &tex, t.text); } if resp.clicked() && !on_triangle && !on_star { - chosen = Some(row.pick()); + chosen = row.pick(); } } }); @@ -453,14 +539,14 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp return Some(FontPick::Favorite(f)); } if enter && chosen.is_none() { - chosen = st.highlight.and_then(|i| rows.get(i)).or(rows.first()).map(Row::pick); + chosen = st.highlight.and_then(|i| rows.get(i)).and_then(Row::pick).or_else(|| rows.iter().find_map(Row::pick)); } if let Some((f, s)) = chosen { ui.close(); return Some(FontPick::Chosen(f, s)); } // The highlighted row is previewed; back on the current family, the preview goes. - let lit = st.highlight.and_then(|i| rows.get(i)).map(Row::pick); + let lit = st.highlight.and_then(|i| rows.get(i)).and_then(Row::pick); match lit { Some((f, None)) if f == current => st.previewed.take().map(|_| FontPick::EndPreview), Some(pick) if st.previewed.as_ref() != Some(&pick) => { @@ -779,7 +865,7 @@ mod tests { assert_eq!(super::script_label(3), "Thai"); let zh = crate::i18n::Lang::from_code("zh-hans").unwrap(); crate::i18n::set_current(zh); - assert_eq!(super::script_label(1), "汉字"); + assert_eq!(super::script_label(1), crate::i18n::tr_ctx(zh, "font-script", "Japanese")); crate::i18n::set_current(crate::i18n::Lang::EN); assert_eq!(super::script_label(1), "Japanese"); } @@ -829,6 +915,146 @@ mod tests { Row::Family(name.to_string()) } + #[test] + fn recent_fonts_are_first_available_unique_and_expandable() { + let names: Vec<_> = ["Alpha", "Bravo", "Charlie"].map(str::to_string).into(); + let recent: Vec<_> = ["Charlie", "Missing", "BRAVO", "charlie"].map(str::to_string).into(); + let rows = super::grouped_rows(&names, &recent, &["bravo".into()], |_| vec!["Bold".into()]); + assert_eq!( + rows, + [ + Row::Heading("Recent Fonts"), + family("Charlie"), + family("Bravo"), + Row::Style("Bravo".into(), "Bold".into()), + Row::Heading("Fonts"), + family("Alpha") + ] + ); + // A filter removing a recent family also removes it from the recent section. + let filtered = super::grouped_rows(&names[..1], &recent, &[], |_| vec![]); + assert_eq!(filtered, [family("Alpha")]); + assert!(super::grouped_rows(&[], &recent, &[], |_| vec![]).is_empty()); + } + + #[test] + fn recent_section_rebuilds_and_arrows_skip_its_headings() { + let names = vectorcraft_text::FontDb::global().menu_family_list(); + if names.len() < 3 { + return; + } + let recent = vec![names[2].clone(), names[1].clone()]; + let ctx = egui::Context::default(); + let mut st = super::MenuState::default(); + let mut frame = |events, recent: &[String]| { + let mut picked = None; + let input = egui::RawInput { + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + picked = super::list( + ui, + egui::Id::new("recent-test"), + &mut st, + &names[2], + None, + super::MenuLook { samples: false, recent, ..Default::default() }, + ); + }); + out.textures_delta.clear(); + (picked, st.rows.to_vec()) + }; + for _ in 0..3 { + frame(vec![], &recent); + } + let key = |key| { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }).to_vec() + }; + assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None))); + assert_eq!(frame(key(egui::Key::ArrowDown), &recent).0, Some(super::FontPick::Preview(names[0].clone(), None)), "skip Fonts heading"); + assert_eq!(frame(key(egui::Key::ArrowUp), &recent).0, Some(super::FontPick::Preview(names[1].clone(), None))); + // Updating recent history while the menu is open must not retarget the highlight. + let updated = vec![names[1].clone()]; + let (_, rows) = frame(vec![], &updated); + assert_eq!(rows[1], family(&names[1])); + assert_eq!(frame(key(egui::Key::Enter), &updated).0, Some(super::FontPick::Chosen(names[1].clone(), None))); + } + + #[test] + fn recent_fonts_obey_search_and_favorite_filters() { + let names = vectorcraft_text::FontDb::global().menu_family_list(); + if names.len() < 3 { + return; + } + let recent = vec![names[1].clone(), names[0].clone()]; + let favorites = vec![names[0].clone()]; + let ctx = egui::Context::default(); + let mut st = super::MenuState { favorites_only: true, ..Default::default() }; + let mut frame = |events| { + let mut pick = None; + let mut out = ctx.run_ui( + egui::RawInput { + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), + ..Default::default() + }, + |ui| { + pick = super::list( + ui, + egui::Id::new("filtered-recent"), + &mut st, + &names[2], + None, + super::MenuLook { samples: false, recent: &recent, favorites: &favorites, ..Default::default() }, + ); + }, + ); + out.textures_delta.clear(); + (pick, st.rows.to_vec()) + }; + for _ in 0..3 { + frame(vec![]); + } + assert_eq!(frame(vec![]).1, [Row::Heading("Recent Fonts"), family(&names[0])]); + let key = [true, false] + .map(|pressed| egui::Event::Key { key: egui::Key::ArrowDown, physical_key: None, pressed, repeat: false, modifiers: Default::default() }) + .to_vec(); + assert_eq!(frame(key).0, Some(super::FontPick::Preview(names[0].clone(), None)), "skip Recent Fonts heading"); + let (pick, rows) = frame(vec![egui::Event::Text("NoSuchFontFor3515".into())]); + assert!(rows.is_empty(), "search must filter the recent section too: {rows:?}"); + assert_eq!(pick, Some(super::FontPick::EndPreview)); + } + + #[test] + fn opening_on_another_font_keeps_the_recent_section_visible() { + let names = vectorcraft_text::FontDb::global().menu_family_list(); + let Some(current) = names.last() else { return }; + let recent = vec![names[0].clone()]; + let ctx = egui::Context::default(); + let mut st = super::MenuState::default(); + let mut visible = false; + for _ in 0..3 { + let mut out = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))), ..Default::default() }, + |ui| { + super::list( + ui, + egui::Id::new("recent-at-top"), + &mut st, + current, + None, + super::MenuLook { samples: false, recent: &recent, ..Default::default() }, + ); + }, + ); + out.textures_delta.clear(); + visible = out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text() == "Recent Fonts")); + } + assert!(visible, "opening the picker must show its recent section"); + } + #[test] fn keeps_the_same_family_when_other_rows_are_inserted_or_removed() { let before = [family("Alpha"), family("Bravo"), family("Charlie")]; @@ -851,4 +1077,101 @@ mod tests { assert_eq!(remap_highlight(&before, Some(1), &after), Some(2)); assert_eq!(remap_highlight(&after, Some(1), &before), None); } + + /// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun. + #[test] + fn the_variable_filter_is_named_as_fonts_are() { + use crate::i18n::{Lang, tr, tr_ctx}; + let (ja, zh) = (Lang::from_code("ja").unwrap(), Lang::from_code("zh-hans").unwrap()); + assert_eq!((tr_ctx(ja, "font-technology", "Variable"), tr(ja, "Variable")), ("バリアブル", "変数")); + assert_eq!((tr_ctx(zh, "font-technology", "Variable"), tr(zh, "Variable")), ("可变", "变量")); + assert_eq!(tr(ja, "Font Technology"), "フォントテクノロジー"); + assert_eq!(super::variable_label(), "Variable"); + } + + /// Font Technology › Variable lists only variable font families, together with the other + /// filters; clicked again, every family is back. + #[test] + fn the_variable_filter_lists_only_variable_families() { + use vectorcraft_text::FontClass; + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + let db = vectorcraft_text::FontDb::global(); + db.add_font(two_axis_font().unwrap()); + let ctx = egui::Context::default(); + let mut st = super::MenuState::default(); + let mut favorites: Vec = vec![]; + let frame = |st: &mut super::MenuState, events, favorites: &[String]| { + let mut out = ctx.run_ui( + egui::RawInput { + events, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), + ..Default::default() + }, + |ui| { + super::list( + ui, + egui::Id::new("variable-filter"), + st, + "Source Sans 3", + None, + super::MenuLook { samples: false, favorites, ..Default::default() }, + ); + }, + ); + out.textures_delta.clear(); + let button = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "Variable" => Some(t.visual_bounding_rect().center()), + _ => None, + }); + let families: Vec = st + .rows + .iter() + .filter_map(|r| match r { + Row::Family(f) => Some(f.clone()), + _ => None, + }) + .collect(); + (families, button) + }; + let has = |list: &[String], f: &str| list.iter().any(|x| x == f); + let click = |st: &mut super::MenuState, favorites: &[String]| { + let (_, at) = frame(st, vec![], favorites); + let at = at.expect("the Variable filter is drawn"); + let button = + |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(st, vec![egui::Event::PointerMoved(at), button(true)], favorites); + frame(st, vec![button(false)], favorites); + frame(st, vec![], favorites).0 + }; + for _ in 0..3 { + frame(&mut st, vec![], &favorites); + } + let all = frame(&mut st, vec![], &favorites).0; + assert!(has(&all, TWO_AXIS_FAMILY) && has(&all, "Source Sans 3") && has(&all, "JetBrains Mono"), "{all:?}"); + let variable = click(&mut st, &favorites); + assert!(st.variable_only, "the click turns the filter on"); + assert!(has(&variable, TWO_AXIS_FAMILY), "{variable:?}"); + assert!(!has(&variable, "Source Sans 3") && !has(&variable, "Inter") && !has(&variable, "JetBrains Mono"), "static families: {variable:?}"); + assert!(variable.iter().all(|f| db.family_traits(f).variable), "{variable:?}"); + // With the kind, script and favourites filters: each must pass. + st.class = Some(FontClass::Sans); + assert!(has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY)); + st.class = Some(FontClass::Monospaced); + assert!(!has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY), "a sans serif family under Monospaced"); + st.class = None; + st.script = 3; + assert!(!has(&frame(&mut st, vec![], &favorites).0, TWO_AXIS_FAMILY), "a family without Thai under Thai"); + st.script = 0; + st.favorites_only = true; + favorites.push("Source Sans 3".into()); + assert!(frame(&mut st, vec![], &favorites).0.is_empty(), "the one favourite is static"); + favorites.push(TWO_AXIS_FAMILY.into()); + assert_eq!(frame(&mut st, vec![], &favorites).0, [TWO_AXIS_FAMILY]); + st.favorites_only = false; + // Off again: every family is listed. + let again = click(&mut st, &favorites); + assert!(!st.variable_only); + assert_eq!(again, frame(&mut st, vec![], &[]).0); + assert!(has(&again, TWO_AXIS_FAMILY) && has(&again, "Source Sans 3") && has(&again, "JetBrains Mono"), "{again:?}"); + } } diff --git a/crates/ui-egui/src/frame_rate.rs b/crates/ui-egui/src/frame_rate.rs new file mode 100644 index 000000000..1ba0c56a3 --- /dev/null +++ b/crates/ui-egui/src/frame_rate.rs @@ -0,0 +1,111 @@ +//! The status bar's frame rate: frames per second while the window draws continuously. +//! +//! An idle window draws only when something wakes it, and each wake-up draws a couple of frames a +//! few milliseconds apart (the swapchain has a free image, so the second doesn't wait for vsync). +//! An average of `1 / dt` over any two frames therefore climbed above the display's refresh rate at +//! rest and fell back to it as soon as the pointer moved, which looked like a slowdown. Here the +//! gaps the window spent idle start a new measurement, and the rate is the number of frames over +//! the last second of continuous drawing. + +use std::collections::VecDeque; + +/// The stretch of continuous drawing the rate is taken over, in seconds. +const WINDOW: f64 = 1.0; +/// Continuous drawing needed before the readout changes, in seconds: shorter bursts (a wake-up's +/// frames) keep the last rate. +const MIN_SPAN: f64 = 0.5; +/// Time between frames, beyond the previous frame's own work, that means the window was idle, in +/// seconds. Waiting for vsync takes at most one refresh (33 ms at 30 Hz), so a longer wait is the +/// window waiting for input, not for the display. +const IDLE: f64 = 0.1; + +#[derive(Debug, Default)] +pub struct FrameRate { + /// Seconds between consecutive frames of the current stretch of drawing, oldest first. + intervals: VecDeque, + /// Their sum. + span: f64, + /// When the last frame started. + last: Option, + /// The readout: frames per second, 0 until the window has drawn continuously for [`MIN_SPAN`]. + fps: f64, +} + +impl FrameRate { + /// A frame starts at `now` (seconds, monotonic); the previous frame's own work took `work` + /// seconds. → the frame rate to show. + pub fn frame(&mut self, now: f64, work: f64) -> f64 { + let Some(last) = self.last.replace(now) else { return self.fps }; + let dt = now - last; + if !dt.is_finite() || dt <= 0.0 { + // A second pass of the same frame, or a clock that went back: nothing to measure. + return self.fps; + } + if dt - work.max(0.0) > IDLE { + self.intervals.clear(); + self.span = 0.0; + return self.fps; + } + self.intervals.push_back(dt); + self.span += dt; + while self.span > WINDOW + && let Some(old) = self.intervals.pop_front() + { + self.span -= old; + } + if self.span >= MIN_SPAN { + self.fps = self.intervals.len() as f64 / self.span; + } + self.fps + } +} + +#[cfg(test)] +mod tests { + use super::FrameRate; + + /// Feed frames starting at `t` every `dt` seconds for `secs`, each taking `work`. → the time + /// after the last frame and the readout. + fn run(r: &mut FrameRate, mut t: f64, dt: f64, secs: f64, work: f64) -> (f64, f64) { + let mut fps = 0.0; + let end = t + secs; + while t < end { + fps = r.frame(t, work); + t += dt; + } + (t, fps) + } + + #[test] + fn idle_wake_ups_keep_the_rate_the_display_allowed() { + let mut r = FrameRate::default(); + // The pointer moves: the window draws at the 30 Hz display's rate. + let (mut t, fps) = run(&mut r, 0.0, 1.0 / 30.0, 3.0, 0.001); + assert!((fps - 30.0).abs() < 0.5, "{fps}"); + // It rests: a wake-up every half second draws two frames 4 ms apart. + for _ in 0..20 { + t += 0.5; + r.frame(t, 0.001); + let fps = r.frame(t + 0.004, 0.001); + assert!((fps - 30.0).abs() < 0.5, "an idle window read {fps} fps"); + } + // It moves again on a 60 Hz display: the readout follows once it has drawn for a while. + let (_, fps) = run(&mut r, t + 1.0, 1.0 / 60.0, 1.5, 0.001); + assert!((fps - 60.0).abs() < 0.5, "{fps}"); + } + + #[test] + fn slow_frames_read_slow() { + // Each frame works 200 ms: the long gaps are its own work, not idling. + let mut r = FrameRate::default(); + let (_, fps) = run(&mut r, 0.0, 0.21, 3.0, 0.2); + assert!((fps - 1.0 / 0.21).abs() < 0.05, "{fps}"); + } + + #[test] + fn no_reading_before_half_a_second_of_drawing() { + let mut r = FrameRate::default(); + let (_, fps) = run(&mut r, 0.0, 1.0 / 60.0, 0.4, 0.001); + assert_eq!(fps, 0.0); + } +} diff --git a/crates/ui-egui/src/graphics.rs b/crates/ui-egui/src/graphics.rs index e246f987e..88c37fef9 100644 --- a/crates/ui-egui/src/graphics.rs +++ b/crates/ui-egui/src/graphics.rs @@ -182,6 +182,8 @@ mod tests { assert_eq!(CACHE.with(|c| c.borrow().clone()), [8], "and filled again"); } + /// After the graphics are lost, the app uploads its canvas and previews to the new context and + /// applies its theme and fonts there again. #[test] fn the_app_uploads_everything_again_in_a_new_context() { let mut app = app(); @@ -212,7 +214,8 @@ mod tests { assert_eq!(texture_name(&new, canvas).as_deref(), Some("canvas"), "uploaded to the new context"); assert_eq!(texture_name(&new, preview(&new)).as_deref(), Some("preview-8x8@1:loss"), "cached previews are made again"); assert_eq!(new.global_style().visuals.panel_fill, old.global_style().visuals.panel_fill, "the theme is applied again"); - assert!(new.fonts(|f| f.definitions().font_data.contains_key("SourceSans3")), "and the fonts installed"); + let families = new.fonts(|f| f.definitions().families.clone()); + assert!(families.contains_key(&egui::FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into())), "and the fonts installed"); } #[test] diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 1f8e7ec87..610d7b398 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -32,6 +32,13 @@ Channel 4 Канал 4 Cell Size Размер на клетката Fine Dots Финни точки + + Simple Прост + Light Rough Светъл груб + Dark Rough Тъмен груб + Wide Sharp Широк остър + Wide Blurry Широк размазан + Sparkle Блясък Medium Dots Средни точки Grainy Dots Зърнести точки Coarse Dots Едри точки @@ -173,6 +180,8 @@ Anti-aliased Artwork Загладено произведение Anti-aliasing: Заглаждане: Any Kind Всякакъв вид + Font Technology Технология на шрифта +font-technology Variable Променливи Appearance Външен вид Appearance Attribute Атрибут на външния вид Appearance Item Елемент на външния вид @@ -257,6 +266,11 @@ Average… Усредняване… Axis: Ос: Background Color: Цвят на фона: + Show Center Mark Показване на централен знак + Show Cross Hairs Показване на кръстосани линии + Show Video Safe Areas Показване на безопасните видеозони + Video Ruler Pixel Aspect Ratio: Пропорция на пиксела на видеолинийките: + Hide Video Rulers Скриване на видеолинийките Background Contents Съдържание на фона Background Contents: Съдържание на фона: Background: Фон: @@ -294,6 +308,7 @@ Blur Размазване Bottom Долу Bottom in Front Най-долу отпред + Bottom-Justified Tab Табулация с долно подравняване Bottom: Долу: Bounds Ограничителна рамка Bounds (Fill Proportionally) Граници (пропорционално запълване) @@ -324,6 +339,13 @@ Built-in presets don't change: New… starts an editable copy. Вградените предварителни настройки не се променят: „Ново…“ създава редактируемо копие. Bulge… Издуване… Bullet Булет + Bullets Булети + Bullets and Numbering Булети и номериране + Apply Bullets Прилагане на булети + Filled Dot Плътна точка + Dash Тире + Hollow Circle Празен кръг + Filled Square Плътен квадрат Butt Cap Плосък край Byte Order: Подредба на байтовете: Calligraphic Калиграфски @@ -697,7 +719,7 @@ Delete Selected Item Изтриване на избран елемент Delete Selection Изтриване на селекцията Delete Slices Изтриване на отрязъци - Delete Spine Point Изтриване на точка от гръбна линия + Delete Spine Point Изтриване на точка от оста Delete Stop Изтриване на репер Delete Swatch Изтриване на образец Delete Symbol Изтриване на символ @@ -1032,6 +1054,7 @@ Eyedropper Picks Up: Пипетата взема: Eyedropper Tool Инструмент „Пипета“ Eyedropper: click the art to sample the stop's colour Пипета: щракнете върху графиката, за да вземете мостра от цвета на стъпката + Eyedropper: click the art to sample the key color Пипета: щракнете върху графиката, за да вземете мостра от основния цвят Favorites Любими Feather… Раздробяване… Feet Футове @@ -1040,6 +1063,11 @@ Fidelity: Точност: Figure Space Цифрово разстояние Figure: Фигура: +# Artwork drag between document tabs. + Drop Artwork Поставяне на графика +@msg {key} must be [x, y] with finite coordinates {key} трябва да е [x, y] с крайни координати +@msg ids must contain 1–10000 object ids ids трябва да съдържа от 1 до 10000 идентификатора на обекти + File Файл File Dimensions Размери на файла File Format: Файлов формат: @@ -1175,6 +1203,8 @@ Large Gaps Големи пролуки Gap Preview Color: Цвят за преглед на пролуките: Close Gaps With Paths Затваряне на пролуките с пътища + Show Live Paint Gaps Показване на пролуките на динамичното оцветяване + Hide Live Paint Gaps Скриване на пролуките на динамичното оцветяване Select Fills Избиране на запълвания Select Strokes Избиране на щрихове Gaussian Blur… Гаусово размазване… @@ -1281,6 +1311,7 @@ Hand-Drawn Ръчно рисувани Handles Дръжки Hard Light Твърда светлина + Hard Mix Твърдо смесване Harmony Rules: Правила за хармония: Harmony Sets Набори от хармонии Height Височина @@ -1329,7 +1360,7 @@ Clear History Изчистване на историята Set History Limit… Задаване на ограничение на историята… History States Състояния на историята -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Прилагане на набор от данни Cancel Pattern Editing Отказ от редактирането на мостра Copy Appearance Копиране на външния вид @@ -1786,6 +1817,9 @@ Modified: Модифицирано: Modify Модифициране Mojikumi Моджикуми + Insert Kashidas Вмъкване на кашиди + Long Дълго + Stylistic Стилистично Mojikumi Set Набор Mojikumi Kinsoku Set Набор Kinsoku Hard Твърдо @@ -1820,6 +1854,7 @@ Move Slices Преместване на резени Move Spine Point Преместване на точка от оста Reshape Spine Segment Промяна на формата на сегмент от оста + Move Brush Преместване на четка Move Swatch Преместване на образец Move Up Преместване нагоре Move… Преместване… @@ -1875,6 +1910,13 @@ Tints and Shades Оттенъци и нюанси Hue Shift Промяна на нюанса Key Color: Основен цвят: + Tips Съвети + Colorization Tips Съвети за оцветяване + The samples show this brush on a blue stroke. Образците показват тази четка върху син щрих. + The art keeps its own colors. Графиката запазва собствените си цветове. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Черното става цвят на щриха, а бялото остава бяло. Другите цветове стават оттенъци на цвета на щриха и колкото по-светъл е един цвят, толкова по-светъл е оттенъкът му. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Черното остава черно, а бялото остава бяло. Другите цветове стават тъмни нюанси на цвета на щриха, когато са тъмни, и негови оттенъци, когато са светли. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Основният цвят става цвят на щриха. Другите цветове се завъртат по цветовото колело толкова, колкото и основният цвят, и запазват насищането и яркостта си. Roundness: Закръгленост: Fixed Фиксирано Pressure Натиск @@ -1895,6 +1937,14 @@ Round Fan Кръгъл ветрилообразен Flat Fan Плосък ветрилообразен New Brush… Нова четка… + New Brush Нова четка + Calligraphic Brush Калиграфска четка + Scatter Brush Разпръсната четка + Art Brush Артистична четка + Bristle Brush Четка с косъм + Pattern Brush Четка с мостра + Select art to make a scatter, art or pattern brush. Изберете графика, за да създадете разпръсната четка, артистична четка или четка с мостра. + Brush Art Графика на четка New Calligraphic Brush Нова калиграфска четка New Character Style Нов стил на знак New Color Group Нова цветова група @@ -1925,6 +1975,7 @@ New View Нов изглед New View… Нов изглед… New Window Нов прозорец + New Without Dialog Нов без диалогов прозорец New Workspace Ново работно пространство New Workspace… Ново работно пространство… New {kind} Style… Нов стил ({kind})… @@ -2131,7 +2182,7 @@ Package Пакет Package collects a saved document with its linked files and fonts. Пакетът събира запазен документ заедно с неговите свързани файлове и шрифтове. Package created Пакетът е създаден - Packaged {n} file(s) in {folder}{note} Пакетирани са {n} файла в {folder}{note} + Packaged {n} file(s) in {folder} {note} Пакетирани са {n} файла в {folder}{note} Packaged {n} file(s){note} Пакетирани са {n} файла{note} Package… Пакет… Page Страница @@ -2190,7 +2241,7 @@ Percent: Процент: Perceptual Scales Перцептивни скали Performance Производителност - Performance & Storage Производителност и съхранение + Plug-ins & Scratch Disks Приставки & Временни дискове Permissions Права Permissions password: Парола за права: Perspective Перспектива @@ -2310,6 +2361,7 @@ Proof Setup Настройка на пробен печат Properties Свойства Proportional Metrics Пропорционални метрики + Character Direction Посока на знаците Digits Цифри Arabic Арабски Hindi Хинди @@ -2503,6 +2555,7 @@ Reorder Пренареждане Reorder Appearance Item Пренареждане на елемент от външния вид Repeat Повтаряне + Repeat Objects Обекти с повтаряне Repeat Options Опции за повтаряне Repeat Pathfinder Повтаряне на Pathfinder Repeat Tab Повтаряне на табулатора @@ -2510,7 +2563,7 @@ Replace Замяна Replace Font Замяна на шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Заменете го с друг файл или го игнорирайте: неговите изображения ще продължат да показват записания с документа преглед. - Replace Spine Замяна на скелет + Replace Spine Замяна на оста Replace Symbol Замяна на символ Replace With Font Замяна с шрифт Replaces the saved preset of that name. Заменя записаната предварителна настройка с това име. @@ -2556,7 +2609,7 @@ Reverse Path Direction Обръщане посоката на пътя Reverse Path Direction Off Изключване на обръщането на посоката на пътя Reverse Path Direction On Включване на обръщането на посоката на пътя - Reverse Spine Обръщане на гръбнака + Reverse Spine Обръщане на оста Revert Връщане към запазеното Revert to the saved version of “{name}”? Връщане към запазената версия на „{name}“? Replace “{name}”, the file this document was opened from? Замяна на „{name}“, файла, от който беше отворен този документ? @@ -2711,6 +2764,7 @@ Scale Cursor Proportional to UI Мащабиране на показалеца пропорционално на потребителския интерфейс Scale Lineweights Мащабиране на дебелината на линиите Scale Strokes & Effects Мащабиране на щрихи и ефекти + Scale Text Attributes Мащабиране на текстови атрибути Scale to Fit Artboard Мащабиране за побиране в работната област Scale Tool Инструмент „Мащабиране“ Scale: Мащаб: @@ -2970,6 +3024,7 @@ Snapping Tolerance Толеранс на прилепване Social Square Post 1080×1080 Публикация за квадрат в социални мрежи 1080×1080 Soft Light Мека светлина + Soft Mix Мека смес Solid Плътен Sort by Kind Сортиране по вид Sort by Name Сортиране по име @@ -2980,6 +3035,7 @@ Space Before Paragraph Разстояние преди абзац Spaces Интервали Spacing Guides Водачи за разстояние + Invoke Distance Guides Извикване на водачи за отстояния Spacing: Разстояние: Spanish Испански Specified Distance Упоменато разстояние @@ -3117,7 +3173,6 @@ Show all open documents Показване на всички отворени документи Symbol Instance Екземпляр на символ Symbol Libraries Библиотеки със символи - Symbol Libraries (on the roadmap) Библиотеки със символи (в план-графика) Symbol Screener Tool Инструмент „Филтър на символи“ Symbol Scruncher Tool Инструмент „Свиване на символи“ Symbol Shifter Tool Инструмент „Преместване на символи“ @@ -3210,6 +3265,7 @@ Tools Инструменти Top Горно Top in Front Най-горе отпред + Top-Justified Tab Табулация с горно подравняване Top: Горно: Total Width: Обща ширина: Touch Type Tool Инструмент „Сензорен текст“ @@ -3244,6 +3300,13 @@ Transparent Background Прозрачен фон Transverse (the page a quarter turn on the paper) Напречно (страницата е завъртена на четвърт оборот върху хартията) Trap… Застъпване… + Trap Застъпване + Height/Width Височина/ширина + Tint Reduction Намаляване на тона + Traps with Process Color Застъпвания с триадни цветове + Reverse Traps Обратни застъпвания + Precision Точност + Remove Redundant Points Премахване на излишните точки Trim Изрязване Trim mark weight: Дебелина на маркировките за изрязване: Trim marks Маркировки за изрязване @@ -3475,7 +3538,7 @@ Zoom Tool Инструмент „Мащаб“ Zoom with Mouse Wheel Мащабиране с колелцето на мишката Scrub Numeric Fields by Dragging Плъзгане на числени полета чрез влачене - {count} file(s) in {folder}{note}. Show the package folder? {count} файл(а) в {folder}{note}. Показване на папката на пакета? + {count} file(s) in {folder} {note}. Show the package folder? {count} файл(а) в {folder}{note}. Показване на папката на пакета? {count} linked file(s) not found {count} свързан(и) файл(а) не са намерени fonts not copied: {fonts} некопирани шрифтове: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} свързано(и) изображение(я) е/са променено(и) след запазването на този документ. Показване на новите версии? @@ -3547,7 +3610,8 @@ imageTrace Outline Контур to sample a color за вземане на проба от цвят across a selected object to set the gradient direction през избран обект за задаване посоката на градиента to select an artboard | за избор на работна област | - on the canvas to create one върху платното за създаване на такава + on the canvas to create one ( върху платното за създаване на такава ( + selects several, or draws one inside an artboard) избира няколко или създава нова в работна област) a stroke to add a width point | щрих за добавяне на точка за дебелина | to change one side | за промяна на една страна | to select more points | за избор на още точки | @@ -3661,6 +3725,7 @@ axis Both И двете @msg Couldn't copy to the system clipboard: {e} Неуспешно копиране в системния клипборд: {e} @msg Couldn't open {name}: {e} Неуспешно отваряне на {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Неуспешно поставяне на {name}: документът, върху който беше пуснат, е затворен +@msg Couldn't place {name}: the document it was placed in was closed Неуспешно поставяне на {name}: документът, в който се поставяше, е затворен @msg Couldn't read {name}: {e} Неуспешно четене на {name}: {e} @msg Couldn't save recovery data: {e} Неуспешно запазване на данните за възстановяване: {e} @msg Create Outlines: select text objects Създаване на контури: изберете текстови обекти @@ -3697,13 +3762,14 @@ axis Both И двете @msg Opened {url} Отворено: {url} @msg Outline Stroke: no stroked paths selected Преобразуване на щрих в контур: няма избрани пътища с щрих @msg PDF settings are not an object Настройките за PDF не са обект +@msg PDF security settings are not an object Настройките за сигурност на PDF не са обект @msg PDF writer error: {0} Грешка на PDF записа: {0} -@msg Packaged {n} file(s) in {folder}{note} Пакетирани са {n} файла в {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Пакетирани са {n} файла в {folder}{note} @msg Packaged {n} file(s){note} Пакетирани са {n} файла{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Прегледът в браузър изисква настолното приложение (уеб версията изтегля файловете вместо това) @msg Release Guides: no guides Освобождаване на водачи: няма водачи @msg Release: no layer to put the rectangle in Освобождаване: няма слой, в който да се постави правоъгълникът -@msg Replace Spine: select a blend and a path Замяна на гръбнак: изберете плавно преливане и път +@msg Replace Spine: select a blend and a path Замяна на оста: изберете плавно преливане и път @msg select two or more objects to distribute изберете два или повече обекта за разпределение @msg select paths or anchor points изберете пътища или опорни точки @msg SVG parse error: {0} Грешка при анализ на SVG: {0} @@ -3766,6 +3832,25 @@ axis Both И двете @msg a PostScript error ({name} in `{op}`: {at}) PostScript грешка ({name} в `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript грешка ({name} в `{op}`: {at}), в {procs} @msg a PostScript error ({name}) PostScript грешка ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) PostScript грешка ({name} в `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} PostScript грешка ({name} в `{op}`; {meaning}), в {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript грешка ({name} в `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} PostScript грешка ({name} в `{op}`: {at}; {meaning}), в {procs} +@msg a PostScript error ({name}; {meaning}) PostScript грешка ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin програмата затваря речник, който не е отворила +@msg the program opens a file, which the reader can't do програмата отваря файл, което четецът не може да прави +@msg the program restores a saved state that was already restored програмата възстановява запазено състояние, което вече е възстановено +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none данните, които програмата чете, са повредени или прекъснати, или програмата иска позиция във файл, който няма такава +@msg the program goes past one of the reader's limits програмата надхвърля едно от ограниченията на четеца +@msg the path has no point to continue from пътят няма точка, от която да продължи +@msg a value is outside the range the operator takes стойност е извън обхвата, който операторът приема +@msg the operator needs more values than the stack holds операторът се нуждае от повече стойности, отколкото има в стека +@msg part of the program isn't valid PostScript text част от програмата не е валиден текст на PostScript +@msg a value has the wrong type for the operator стойност е от неправилен тип за оператора +@msg the program runs, deletes or renames a file, which the reader can't do програмата изпълнява, изтрива или преименува файл, което четецът не може да прави +@msg the program asks for a resource that isn't defined програмата изисква ресурс, който не е дефиниран +@msg a calculation has no result, as in a division by zero изчисление няма резултат, както при деление на нула +@msg the operator looks for a mark on the stack and finds none операторът търси маркер в стека и не намира такъв @msg a bitmap is empty битовата карта е празна @msg a bitmap is too large to read битовата карта е твърде голяма за четене @msg a colour index is 0 to {_1}, not {n} цветовият индекс е от 0 до {_1}, а не {n} @@ -3798,6 +3883,7 @@ axis Both И двете @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` оста трябва да бъде "vertical", "horizontal" или ъгъл в градуси, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: прозрачен, бял, черен или цвят като "#ff8800" @msg bad color {v} грешен цвят {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio трябва да бъде число от 0,1 до 10 @msg bad colour `{hex}` (#rrggbb) грешен цвят `{hex}` (#rrggbb) @msg bad page tree повредено дърво на страниците @msg bad shortcut грешен клавишен бърз път @@ -3844,7 +3930,7 @@ axis Both И двете @msg give the path `id` and the width point `index` дайте пътя `id` и точката на ширина `index` @msg guides are locked водачите са заключени @msg handle must be in or out handle трябва да е in или out -@msg image '{key}' has no pixels and was left out изображението '{key}' няма пиксели и беше пропуснато +@msg an image has no pixels and was left out изображение без пиксели беше пропуснато @msg image data `{_1}` is missing данните за изображение `{_1}` липсват @msg image would be too large; lower the resolution изображението ще бъде твърде голямо; намалете разделителната способност @msg index must be a number or first, previous, next or last индексът трябва да е число или first, previous, next или last @@ -3871,7 +3957,7 @@ axis Both И двете @msg missing index липсва индекс @msg missing number `{key}` липсва число `{key}` @msg missing integer `{key}` липсва цяло число `{key}` -@msg multiple windows land with M11.5 множество прозорци се зареждат с M11.5 +@msg New Window isn't available yet: a document opens in one window Нов прозорец още не е наличен: документът се отваря в един прозорец @msg name the file именувайте файла @msg no CSS to copy: select objects with CSS of their own няма CSS за копиране: изберете обекти със собствен CSS @msg no Live Paint edge near that point няма ръб за интерактивно оцветяване близо до тази точка @@ -3889,7 +3975,9 @@ axis Both И двете @msg no blend options няма опции за преливане @msg no bounds няма граници @msg no brush named `{name}` няма четка с име `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} най-голяма дължина на името на четка с косъм (знаци): {max}; в `{name}` знаците са {count} @msg no brush selected няма избрана четка +@msg brushes move only within their type четките се преместват само в рамките на своя тип @msg no catalog няма каталог @msg no changes since the last save няма промени от последното запазване @msg no clock to keep a heartbeat by няма часовник за поддържане на ритъм @@ -3943,6 +4031,7 @@ axis Both И двете @msg no such action няма такова действие @msg no such anchor няма такава опорна точка @msg no such artboard няма такава работна област +@msg the artboard is locked работната област е заключена @msg no such document няма такъв документ @msg no such layer or object няма такъв слой или обект @msg no such object {0} няма такъв обект {0} @@ -3998,6 +4087,7 @@ axis Both И двете @msg path is already closed пътят вече е затворен @msg paths contain invalid coordinates пътищата съдържат невалидни координати @msg paths: give at least one file пътища: посочете поне един файл +@msg replace: the object to replace was changed or deleted замяна: обектът за замяна е променен или изтрит @msg pattern missing липсва мостра @msg patterns nested too deeply are filled with mid-grey мострите, вложени прекалено дълбоко, се запълват със средно сиво @msg percent must be above 0 and at most 6400, not {p} процентът трябва да е над 0 и най-много 6400, а не {p} @@ -4151,6 +4241,7 @@ axis Both И двете @msg unknown workspace (one of {_1}) непознато работно пространство (едно от {_1}) @msg unsupported: {0} не се поддържа: {0} @msg variables are locked променливите са заключени +@msg variables that aren't text were left out: {kinds} променливите, които не са текстови, бяха пропуснати: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файловете от версия {0} не могат да бъдат компресирани: само версия {COMPRESSED_SINCE} и по-нови отварят компресирани файлове @msg width and height are at least 1 pixel ширината и височината са най-малко 1 пиксел @msg workspace name can't be empty името на работното пространство не може да бъде празно @@ -4190,6 +4281,8 @@ axis Both И двете @msg Saving Запазване @msg Saving {_1} Записване на {_1} @msg Exporting {_1} Експортиране на {_1} +@msg Packaging {_1} Пакетиране на {_1} +@msg Placing {_1} Вмъкване на {_1} @msg Saving recovery data for {_1} Записване на данни за възстановяване на {_1} @msg Discarded {_1} recovered document(s) Изхвърлени са {_1} възстановени документ(и) @msg Restored {_1} recovered document(s): save them to keep them Възстановени са {_1} документ(и): запазете ги, за да ги съхраните @@ -4308,7 +4401,9 @@ axis Both И двете @msg automation filesystem access is not granted: read authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за четене: {path} @msg automation filesystem access is not granted: write authority is absent: {path} достъпът на автоматизацията до файловата система не е разрешен: липсва право за запис: {path} @msg automation path rejected: outside the read root {root}: {path} пътят за автоматизация е отхвърлен: извън корена за четене {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} пътят за автоматизация е отхвърлен: извън корените за четене {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} пътят за автоматизация е отхвърлен: извън корена за запис {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} пътят за автоматизация е отхвърлен: извън корените за запис {roots}: {path} @msg automation path rejected: path is empty пътят за автоматизация е отхвърлен: пътят е празен @msg automation path rejected: a link that leads nowhere: {path} пътят за автоматизация е отхвърлен: връзка, която не води доникъде: {path} @msg automation path rejected: cannot resolve {path}: {e} пътят за автоматизация е отхвърлен: не може да се разреши {path}: {e} @@ -4414,6 +4509,7 @@ effect Soft Меко Activate Panel Активиране на панела Change Panel Layout Промяна на оформлението на панела Close Panel Затваряне на панела + Collapse Panel Group Свиване на групата панели Move Panel Преместване на панела Group with Групиране с Resize panels Преоразмеряване на панели @@ -4424,6 +4520,12 @@ effect Soft Меко @msg before must name a panel параметърът „преди“ трябва да указва панел @msg docking action contains an unknown panel or an excessive split path действието по закачане съдържа непознат панел или прекомерен път на разделяне @msg group must be true or false групата трябва да е истина или лъжа +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панелът е плаващ; свийте групата му с window.panel.float {panel, collapsed} +@msg panel is not in a dock group панелът не е в група на дока +@msg only a dock group above or below others collapses, not one beside another свива се само група на дока над или под други, не и такава до друга +@msg icon must be true or false icon трябва да е истина или лъжа +@msg icon only docks a panel in the icon column, with no anchor icon закачва панел само в колоната с икони, без anchor +@msg column docks a set as a dock column, with no anchor or icon column закачва набор като колона на дока, без anchor или icon @msg move requires a destination panel преместването изисква целеви панел @msg move requires an existing destination panel преместването изисква съществуващ целеви панел @msg onto must name a floating panel параметърът „върху“ трябва да указва плаващ панел @@ -4498,12 +4600,24 @@ lighting Light Светлина Reset Ruler Origin Нулиране началото на линийката Artistic Художествени Film Grain… Филмова зърнистост… + Colored Pencil… Цветен молив… + Colored Pencil Цветен молив Cutout… Апликация… Cutout Апликация + Dry Brush… Суха четка… + Dry Brush Суха четка Neon Glow… Неоново сияние… + Palette Knife… Нож за палитра… Neon Glow Неоново сияние + Paint Daubs… Мазки с боя… + Paint Daubs Мазки с боя + Palette Knife Нож за палитра Poster Edges… Плакатни ръбове… + Rough Pastels… Груби пастели… Poster Edges Плакатни ръбове + Rough Pastels Груби пастели + Underpainting… Подбоя… + Underpainting Подбоя Levels Нива Edge Simplicity Простота на ръбовете Edge Fidelity Точност на ръбовете @@ -4632,6 +4746,7 @@ lighting Light Светлина Brown Кафяв Business Card 3.5 × 2 in Визитка 3,5 × 2 инча Business Card 85 × 55 mm Визитка 85 × 55 мм + Business Card 91 × 55 mm Визитка 91 × 55 мм CRLF (Windows) CRLF (Windows) Cap Height Височина на главни букви Chevron Шеврон @@ -5012,6 +5127,9 @@ theme Appearance Външен вид Softness Мекота Ambient Light Околна светлина Light behind the object Светлина зад обекта + 3D Lighting Preset Предварителна настройка за 3D осветление + Standard Стандартно + Diffuse Дифузно # 3D light management. Lights Светлини Add Light Добавяне на светлина @@ -5025,6 +5143,7 @@ theme Appearance Външен вид Painting Живопис Printing and Proofing Печат и пробно отпечатване Tracing Трасиране + Touch Докосване Typography Типография VectorCraft VectorCraft Web Уеб @@ -5088,6 +5207,7 @@ theme Appearance Външен вид @msg Measured anchor no longer exists Измерената опорна точка вече не съществува @msg Measured segment no longer exists Измереният сегмент вече не съществува @msg Non-finite dimension Размерът не е крайно число +@msg Scale Text Attributes: selected type has no scale to bake Мащабиране на текстови атрибути: избраният текст няма мащаб за прилагане @msg Scale must be drawing:real, for example 1:10 Мащабът трябва да е чертеж:реалност, например 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Съотношението на мащаба трябва да е между 1:1000000 и 1000000:1 @msg Second measured segment no longer exists Вторият измерен сегмент вече не съществува @@ -5102,6 +5222,15 @@ theme Appearance Външен вид Isolate Selected Object Изолиране на избрания обект Keep selected Запазване на избора Merge only with selection Сливане само с избраното + Consider Open Filled Paths as Closed Отворените запълнени пътища се смятат за затворени + In Merge Mode, Clicking Stroke Splits the Path В режим на сливане щракване върху щриха разделя пътя + Pick Color From: Цвят от: + Artwork Графика + Cursor Swatch Preview Преглед на образеца до курсора + Selection: Селекция: + Straight Line Права линия + Highlight: Открояване: + Stroke When Editable Щрих, когато е редактируем Sample Color from Screen Взимане на цвят от екрана The active document changed while the screen color sampler was open Активният документ се промени, докато взимането на цвят от екрана беше отворено This system has no screen color sampler Тази система няма инструмент за взимане на цвят от екрана @@ -5119,6 +5248,7 @@ theme Appearance Външен вид Make Image Dynamic Динамично изображение Import Data Sets… Импортиране на набори от данни… Export Data Sets… Експортиране на набори от данни… + Load Variable Library… Зареждане на библиотека с променливи… Make Graph Dynamic Динамична диаграма Batch… Пакет… Source Folder: Изходна папка: @@ -5156,6 +5286,12 @@ theme Appearance Външен вид Sketch Скица Halftone Pattern… Полутонов шаблон… Halftone Pattern Полутонов шаблон + Torn Edges… Разкъсани краища… + Torn Edges Разкъсани краища + Conté Crayon… Креда Конте… + Conté Crayon Креда Конте + Foreground Level Ниво на преден план + Background Level Ниво на фон Pattern Type Тип шаблон Dot Точка # User Interface › Color Scheme @@ -5174,6 +5310,7 @@ theme Appearance Външен вид Watercolor… Акварел… Watercolor Акварел Brush Size Размер на четката + Texture Coverage Покритие на текстура Definition Дефиниция Brush Detail Детайл на четката Shadow Intensity Интензивност на сенките @@ -5195,8 +5332,15 @@ theme Appearance Външен вид @msg “{name}” is not a file „{name}“ не е файл Enable Guides for 9-Slice Scaling Включване на водачи за 9-slice мащабиране Persistent Постоянна + Locate Library… Намиране на библиотека… Library Panel Options Опции на панела с библиотека + Snapping Прилепване + Snap to Tangent Прилепване към допирателна + Snap to Perpendicular Прилепване към перпендикуляр +@msg unknown snap option `{key}` (use {options}) неизвестна опция за прилепване `{key}` (използвайте {options}) + Gap Length: Дължина на пропуска: Artboard {n} of {total} Работна област {n} от {total} + File {n} of {total} Файл {n} от {total} Encoding and writing the file… Кодиране и записване на файла… @plural {n} more export waiting|{n} more exports waiting Още {n} експортиране в изчакване|Още {n} експортирания в изчакване Continue in Background Продължаване във фонов режим @@ -5212,6 +5356,14 @@ theme Appearance Външен вид Full Justify Пълно двустранно Minimum must not exceed Desired, nor Desired Maximum. Минималната стойност не може да надвишава желаната, нито желаната – максималната. Justification Подравняване + Add filters from the list above Добавете филтри от списъка по-горе + Add to Stack Добавяне към стека + Applied Filters Приложени филтри + Categories Категории + Choose a filter Изберете филтър + Effect Gallery Галерия с ефекти + Filters Филтри + Select artwork to apply effects Изберете графика, за да приложите ефекти @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them променливите и наборите от данни на файла не бяха прочетени (променливи: {variables}, набори от данни: {data_sets}): панелът „Променливи“ не ги показва # Select by Color (select.color) Select by Color Избиране по цвят @@ -5233,3 +5385,90 @@ theme Appearance Външен вид Plaster Гипс Note Paper… Хартия за писма… Note Paper Хартия за писма + Water Paper… Мокра хартия… + Water Paper Мокра хартия + Ruby… Руби… + Ruby Руби + Geometric Guides Геометрични водачи + Ease In Плавно начало + Ease Out Плавен край + Ease In Out Плавно начало и край + Ease Out In Плавен край и начало + Ramp: Рампа: + Shift: Изместване: + Fresco… Фреска… + Fresco Фреска + Effect Gallery… Галерия с ефекти… + Effect Gallery Dialog Диалог „Галерия с ефекти“ + Arc Segment Tool Options Опции на инструмента „Дъгов сегмент“ + Base Along: Основа по: + Closed Затворен + Convex Изпъкнал + Fill Arc Запълване на дъга + Length X-Axis: Дължина по X: + Length Y-Axis: Дължина по Y: + Slope: Наклон: +axis X Axis X ос +axis Y Axis Y ос +@msg Packaged {n} file(s) in {folder} Пакетирани са {n} файла в {folder} +@msg Packaged {n} file(s) {note} Пакетирани са {n} файла {note} +@msg Packaged {n} file(s) Пакетирани са {n} файла + Packaged {n} file(s) Пакетирани са {n} файла + Packaged {n} file(s) in {folder} Пакетирани са {n} файла в {folder} + Packaged {n} file(s) {note} Пакетирани са {n} файла {note} + {count} file(s) in {folder}. Show the package folder? {count} файл(а) в {folder}. Показване на папката на пакета? + Clockwise По часовниковата стрелка + Dither Дитъринг + Fine noise hides banding in the gradient fill Фин шум скрива ивиците в градиентната запълка +@msg `dither` applies to gradient fills (stroke: false) `dither` се прилага към градиентни запълки (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` се прилага към градиентни запълки (paint.setFill) +@msg `dither` must be true or false `dither` трябва да бъде true или false +@msg `method` must be classic or perceptual `method` трябва да е classic или perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Да се инсталира ли „{name}“, който иска голям изчислителен бюджет? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Всяко изпълнение може да продължи до {minutes} минути и да свърши {times} пъти повече работа, отколкото е позволено на другите приставки. То се изпълнява във фонов режим и можете да го откажете. + It may take minutes, so it runs in the background, where you can cancel it. Това може да отнеме минути, затова се изпълнява във фонов режим, където можете да го откажете. + The plug-in is computing… Приставката изчислява… + Inspect Plug-in Проверка на приставка + Cancel Plug-in Run Отказ от изпълнението на приставката +# Actions panel: action sets (#3712). + New Set… Нов набор… + New Set Нов набор + Set Options… Опции на набора… + Set Options Опции на набора + Create New Set Създаване на нов набор + Stop Global Edit Край на глобалното редактиране + Global Edit Options Опции за глобално редактиране + Match Съвпадение + On Artboards В работните области + All Artboards Всички работни области + Include Objects On Canvas Включване на обекти от платното + Result(s): {count} Резултат(и): {count} + Add to Symbols Добавяне към символи + Basic Shapes Основни фигури + Open Symbol Library Отваряне на библиотека със символи + Save Symbol Library Запазване на библиотека със символи + Save Symbol Library… Запази библиотека със символи… + Save Symbols… Запази символи… + Selected Symbols Only Само избрани символи + Symbol Libraries Menu Меню с библиотеки със символи + Symbol Library Библиотека със символи + User Symbol Library 1 Потребителска библиотека със символи 1 + User Symbol Library 2 Потребителска библиотека със символи 2 + User Symbol Library 3 Потребителска библиотека със символи 3 + User Symbol Library 4 Потребителска библиотека със символи 4 + User Symbol Library 5 Потребителска библиотека със символи 5 + User Symbol Library 6 Потребителска библиотека със символи 6 + User Symbol Library 7 Потребителска библиотека със символи 7 + User Symbol Library 8 Потребителска библиотека със символи 8 + User Symbol Library 9 Потребителска библиотека със символи 9 + User Symbol Library 10 Потребителска библиотека със символи 10 + {w} × {h} pt {w} × {h} pt +@msg Trap: select two or more objects filled with solid colours Застъпване: изберете два или повече обекта, запълнени с плътни цветове +@msg Trap: no edges of different colours meet among the selected objects Застъпване: сред избраните обекти няма допиращи се ръбове с различни цветове +@msg thickness must be from 0.01 to 5000 pt thickness трябва да е от 0,01 до 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth трябва да е от 1 до 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction трябва да е от 0 до 100 % + Rectangular Grid Tool Options Опции на инструмента „Правоъгълна мрежа“ + Polar Grid Tool Options Опции на инструмента „Полярна мрежа“ + Fill Line Запълване на линия diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f4009b231..5110aa03e 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -13,7 +13,7 @@ # režim míchání (blending mode), přednastavení (preset), rozložit (expand), uvolnit (release), # vektorizace obrazu (Image Trace). Counts without a plural row read "Label: {n}". # -# Coverage: every menu string, tl! literal, plural, status and error message (enforced in tests: +# Coverage: every menu string, tl! literal, plural, status and error message (`untranslated_report` lists gaps: # `complete_menus` and `COMPLETE_MESSAGES`). Left in English on purpose (`MENU_KEEP_AS_IS` in the # tests): the product name, language names, OpenType, the built-in workspace names and the # bracketed perspective grid preset names. Keep placeholders, ellipses and the leading/trailing @@ -34,8 +34,14 @@ Medium Light Středně světlý Light Světlý Quit VectorCraft Ukončit VectorCraft +# Artwork drag between document tabs. + Drop Artwork Umístit grafiku +@msg {key} must be [x, y] with finite coordinates {key} musí být [x, y] s konečnými souřadnicemi +@msg ids must contain 1–10000 object ids ids musí obsahovat 1–10000 identifikátorů objektů + File Soubor New… Nový… + New Without Dialog Nový bez dialogového okna New from Template… Nový ze šablony… Open… Otevřít… Open Recent Files Otevřít nedávné soubory @@ -264,6 +270,8 @@ Large Gaps Velké mezery Gap Preview Color: Barva náhledu mezer: Close Gaps With Paths Uzavřít mezery cestami + Show Live Paint Gaps Zobrazit mezery živé malby + Hide Live Paint Gaps Skrýt mezery živé malby Select Fills Vybírat výplně Select Strokes Vybírat tahy Image Trace Vektorizace obrazu @@ -314,6 +322,13 @@ Glyphs Glyfy Insert Special Character Vložit speciální znak Bullet Odrážka + Bullets Odrážky + Bullets and Numbering Odrážky a číslování + Apply Bullets Použít odrážky + Filled Dot Plná tečka + Dash Pomlčka + Hollow Circle Prázdný kruh + Filled Square Plný čtverec Copyright Symbol Symbol copyrightu Ellipsis Výpustka Paragraph Symbol Symbol odstavce @@ -773,7 +788,7 @@ Units Jednotky Guides & Grid Vodítka a mřížka Hyphenation Dělení slov - Performance & Storage Výkon a úložiště + Plug-ins & Scratch Disks Zásuvné moduly & Odkládací disky User Interface Uživatelské rozhraní Performance Výkon File Handling Práce se soubory @@ -826,6 +841,7 @@ Activate Panel Aktivovat panel Change Panel Layout Změnit rozložení panelů Close Panel Zavřít panel + Collapse Panel Group Sbalit skupinu panelů Move Panel Přesunout panel Group with Seskupit s Resize panels Změnit velikost panelů @@ -1133,7 +1149,8 @@ lighting Light Světlo to sample a color nabere barvu across a selected object to set the gradient direction přes vybraný objekt nastaví směr přechodu to select an artboard | vybere pracovní plochu | - on the canvas to create one na plátně vytvoří novou + on the canvas to create one ( na plátně vytvoří novou ( + selects several, or draws one inside an artboard) vybere více nebo vytvoří novou uvnitř pracovní plochy) a stroke to add a width point | tahu přidá bod šířky | to change one side | změní jednu stranu | to select more points | vybere další body | @@ -1389,6 +1406,7 @@ lighting Light Světlo Leading Proklad No Break Nezalamovat Proportional Metrics Proporcionální metrika + Character Direction Směr znaků Digits Číslice Arabic Arabské Hindi Hindské @@ -1457,6 +1475,7 @@ lighting Light Světlo {count} more rules: Export writes every one. Další pravidla ({count}): Export zapíše všechna. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Vynecháno nepojmenovaných objektů: {count}. Pojmenujte je, nebo v nabídce panelu zapněte Nepojmenované objekty. All Categories Všechny kategorie + Repeat Objects Objekty opakování Save… Uložit… Selection Only Pouze výběr {title} (selection) {title} (výběr) @@ -1490,6 +1509,7 @@ lighting Light Světlo Edit the selected point's colour in the Color panel Upravit barvu vybraného bodu v panelu Barva Edit the stop Upravit zarážku Eyedropper: click the art to sample the stop's colour Kapátko: kliknutím na kresbu naberete barvu zarážky + Eyedropper: click the art to sample the key color Kapátko: kliknutím na kresbu naberete klíčovou barvu Gradient across stroke Přechod napříč tahem Gradient along stroke Přechod podél tahu Gradient Fill: click to apply, drag onto art Přechodová výplň: kliknutím použijete, přetáhněte na kresbu @@ -1524,7 +1544,7 @@ lighting Light Světlo Clear History Vymazat historii Set History Limit… Nastavit limit historie… Open a document to see its history. Otevřete dokument, abyste viděli jeho historii. -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Použít sadu dat Cancel Pattern Editing Zrušit úpravy vzorku Copy Appearance Kopírovat vzhled @@ -1753,6 +1773,9 @@ lighting Light Světlo Left Indent Odsazení zleva Left-to-Right Paragraph Direction Směr odstavce zleva doprava Line-end Punctuation Half Width Interpunkce na konci řádku poloviční šířky + Insert Kashidas Vložit kašídy + Long Dlouhý + Stylistic Stylistický Mojikumi Set Sada Mojikumi Kinsoku Set Sada Kinsoku Hard Pevné @@ -1776,6 +1799,13 @@ lighting Light Světlo Repeat Pathfinder Zopakovat operaci s cestami Repeat {name} Zopakovat {name} Trap… Přesah barev… + Trap Přesah barev + Height/Width Výška/šířka + Tint Reduction Snížení tónu + Traps with Process Color Přesahy s procesními barvami + Reverse Traps Obrácené přesahy + Precision Přesnost + Remove Redundant Points Odstranit nadbytečné body Bottom in Front Spodní vpředu Brick by Column Cihly po sloupcích Brick by Row Cihly po řádcích @@ -1902,8 +1932,8 @@ lighting Light Světlo {kind}, {model} {kind}, {model} No symbols in this document V tomto dokumentu nejsou žádné symboly Select art and click New Symbol to make one. Vyberte kresbu a symbol vytvořte tlačítkem Nový symbol. - Symbol Libraries (on the roadmap) Knihovny symbolů (v plánu) Align On: Zarovnat na: + Bottom-Justified Tab Tabulátor zarovnaný dolů Center-Justified Tab Tabulátor zarovnaný na střed Above Text Nad textem Position Panel Above Text Umístit panel nad text @@ -1915,6 +1945,7 @@ lighting Light Světlo No text selected Není vybrán žádný text Repeat Tab Opakovat tabulátor Right-Justified Tab Tabulátor zarovnaný doprava + Top-Justified Tab Tabulátor zarovnaný nahoru Select a text object to set its tab stops. Vyberte textový objekt, abyste mohli nastavit jeho zarážky tabulátoru. Select text Vyberte text Snap to Unit Přichytávat k jednotkám @@ -2032,6 +2063,13 @@ lighting Light Světlo Tints and Shades Světlé a tmavé tóny Hue Shift Posun odstínu Key Color: Klíčová barva: + Tips Tipy + Colorization Tips Tipy k obarvení + The samples show this brush on a blue stroke. Ukázky zobrazují tento štětec na modrém tahu. + The art keeps its own colors. Kresba si ponechá své vlastní barvy. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Černá se změní na barvu tahu a bílá zůstane bílá. Ostatní barvy se změní na světlé tóny barvy tahu, a čím je barva světlejší, tím světlejší je její tón. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Černá zůstane černá a bílá zůstane bílá. Ostatní barvy se změní na tmavé tóny barvy tahu, když jsou tmavé, a na její světlé tóny, když jsou světlé. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Klíčová barva se změní na barvu tahu. Ostatní barvy se posunou po barevném kruhu o stejný úhel jako klíčová barva a zachovají si sytost a jas. Size: Velikost: Calligraphic Brush Options Možnosti kaligrafického štětce Bristle Brush Options Možnosti štětinového štětce @@ -2258,6 +2296,15 @@ lighting Light Světlo Alt key toggles to Smooth Tool Klávesa Alt přepíná na nástroj Vyhlazení Keep selected Ponechat vybrané Merge only with selection Sloučit jen s výběrem + Consider Open Filled Paths as Closed Považovat otevřené vyplněné cesty za uzavřené + In Merge Mode, Clicking Stroke Splits the Path V režimu sloučení rozdělí kliknutí na tah cestu + Pick Color From: Brát barvu z: + Artwork Kresba + Cursor Swatch Preview Náhled vzorku u kurzoru + Selection: Výběr: + Straight Line Přímka + Highlight: Zvýraznění: + Stroke When Editable Tah, je-li upravitelný The document has no colour swatches. Dokument nemá ve vzorníku žádné barvy. Style Name: Název stylu: Crop To Oříznout na @@ -2351,11 +2398,11 @@ lighting Light Světlo Fonts whose licence doesn't allow embedding are not copied. Písma, jejichž licence nepovoluje vkládání, se nekopírují. Package collects a saved document with its linked files and fonts. Sbalení shromáždí uložený dokument s jeho propojenými soubory a písmy. Package created Balíček vytvořen - Packaged {n} file(s) in {folder}{note} Zabaleno souborů: {n} do {folder}{note} + Packaged {n} file(s) in {folder} {note} Zabaleno souborů: {n} do {folder}{note} Packaged {n} file(s){note} Zabaleno souborů: {n}{note} Relink Linked Files to the Document Znovu propojit propojené soubory s dokumentem Save the document first? Nejprve uložit dokument? - {count} file(s) in {folder}{note}. Show the package folder? Souborů: {count} v {folder}{note}. Zobrazit složku balíčku? + {count} file(s) in {folder} {note}. Show the package folder? Souborů: {count} v {folder}{note}. Zobrazit složku balíčku? {count} linked file(s) not found Nenalezeno propojených souborů: {count} fonts not copied: {fonts} nezkopírovaná písma: {fonts} Built-in presets are read-only: New… starts an editable copy. Vestavěná přednastavení jsou jen pro čtení: Nový… vytvoří kopii, kterou lze upravit. @@ -2402,6 +2449,11 @@ lighting Light Světlo File Dimensions Rozměry souboru Transforms Transformace Background Color: Barva pozadí: + Show Center Mark Zobrazit středovou značku + Show Cross Hairs Zobrazit nitkový kříž + Show Video Safe Areas Zobrazit bezpečné oblasti videa + Video Ruler Pixel Aspect Ratio: Poměr stran pixelu video pravítek: + Hide Video Rulers Skrýt video pravítka Client-side (.html) Na straně klienta (.html) Color Reduction: Redukce barev: Lossless (lossy WebP isn't available yet) Bezeztrátový (ztrátový WebP zatím není k dispozici) @@ -2883,12 +2935,21 @@ lighting Light Světlo Darken Ztmavit Exclusion Vyloučit Hard Light Tvrdé světlo + Hard Mix Tvrdé míchání Lighten Zesvětlit Luminosity Světlost Overlay Překrýt Soft Light Měkké světlo + Soft Mix Měkké míchání Crosshatch Křížové šrafování Fine Dots Jemné tečky + + Simple Jednoduchý + Light Rough Světle hrubý + Dark Rough Tmavě hrubý + Wide Sharp Široce ostrý + Wide Blurry Široce rozmazaný + Sparkle Jiskra Medium Dots Střední tečky Grainy Dots Zrnité tečky Coarse Dots Hrubé tečky @@ -2966,6 +3027,14 @@ theme Appearance Motiv Edit Symbol Upravit symbol Expand Brush Strokes Rozložit tahy štětcem New Brush… Nový štětec… + New Brush Nový štětec + Calligraphic Brush Kaligrafický štětec + Scatter Brush Rozptylový štětec + Art Brush Obrazový štětec + Bristle Brush Štětinový štětec + Pattern Brush Vzorkový štětec + Select art to make a scatter, art or pattern brush. Vyberte kresbu, abyste mohli vytvořit rozptylový, obrazový nebo vzorkový štětec. + Brush Art Kresba štětce New Symbol Nový symbol New Symbol… Nový symbol… Place Symbol Instance Umístit instanci symbolu @@ -3442,6 +3511,7 @@ theme Appearance Motiv Scale Corners Měnit velikost rohů Scale Cursor Proportional to UI Měnit velikost kurzoru úměrně rozhraní Scale Strokes & Effects Měnit velikost tahů a efektů + Scale Text Attributes Měnit velikost atributů textu Scratch Disks Odkládací disky Secondary Sekundární Select Same Tint % Vybírat stejné % tónu @@ -3457,6 +3527,7 @@ theme Appearance Motiv Size/Leading Velikost/proklad Snapping Tolerance Tolerance přichytávání Spacing Guides Vodítka rozestupů + Invoke Distance Guides Vyvolat vodítka vzdálenosti Spanish Španělština Subdivisions Dělení Templates Folder Složka šablon @@ -3542,6 +3613,7 @@ theme Appearance Motiv Delete Swatch Odstranit položku vzorníku Duplicate Swatch Duplikovat položku vzorníku Merge Swatches Sloučit položky vzorníku + Move Brush Přesunout štětec Move Swatch Přesunout položku vzorníku New Color Group Nová skupina barev New Swatch Nová položka vzorníku @@ -3763,6 +3835,8 @@ effect Regular Pravidelný Put back in the dock Vrátit do doku All Scripts Všechny systémy písma Any Kind Libovolný druh + Font Technology Technologie písma +font-technology Variable Variabilní Decorative Dekorativní Favorites Oblíbené Latin Latinka @@ -4039,6 +4113,7 @@ imageTrace Outline Obrys @msg Couldn't copy to the system clipboard: {e} Nelze kopírovat do systémové schránky: {e} @msg Couldn't open {name}: {e} {name} nelze otevřít: {e} @msg Couldn't place {name}: the document it was dropped on was closed {name} nelze umístit: dokument, na který byl přetažen, byl zavřen +@msg Couldn't place {name}: the document it was placed in was closed {name} nelze umístit: dokument, do kterého se umísťoval, byl zavřen @msg Couldn't read {name}: {e} {name} nelze přečíst: {e} @msg Couldn't save recovery data: {e} Data pro obnovení nelze uložit: {e} @msg Create Outlines: select text objects Převést na křivky: vyberte textové objekty @@ -4075,8 +4150,9 @@ imageTrace Outline Obrys @msg Opened {url} Otevřeno {url} @msg Outline Stroke: no stroked paths selected Převést tah na obrys: nejsou vybrány žádné cesty s tahem @msg PDF settings are not an object nastavení PDF nejsou objekt +@msg PDF security settings are not an object nastavení zabezpečení PDF nejsou objekt @msg PDF writer error: {0} chyba zápisu PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} Zabaleno souborů: {n} do {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Zabaleno souborů: {n} do {folder}{note} @msg Packaged {n} file(s){note} Zabaleno souborů: {n}{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Náhled v prohlížeči vyžaduje desktopovou aplikaci (webová verze místo toho stahuje soubory) @msg Release Guides: no guides Uvolnit vodítka: žádná vodítka @@ -4145,6 +4221,25 @@ imageTrace Outline Obrys @msg a PostScript error ({name} in `{op}`: {at}) chyba PostScriptu ({name} v `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} chyba PostScriptu ({name} v `{op}`: {at}), v {procs} @msg a PostScript error ({name}) chyba PostScriptu ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) chyba PostScriptu ({name} v `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} chyba PostScriptu ({name} v `{op}`; {meaning}), v {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) chyba PostScriptu ({name} v `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} chyba PostScriptu ({name} v `{op}`: {at}; {meaning}), v {procs} +@msg a PostScript error ({name}; {meaning}) chyba PostScriptu ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin program ukončuje slovník, který nezačal +@msg the program opens a file, which the reader can't do program otevírá soubor, což čtečka neumí +@msg the program restores a saved state that was already restored program obnovuje uložený stav, který už byl obnoven +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none data, která program čte, jsou poškozená nebo zkrácená, nebo program žádá pozici v souboru, který žádnou nemá +@msg the program goes past one of the reader's limits program překračuje jeden z limitů čtečky +@msg the path has no point to continue from cesta nemá bod, ze kterého by mohla pokračovat +@msg a value is outside the range the operator takes hodnota je mimo rozsah, který operátor přijímá +@msg the operator needs more values than the stack holds operátor potřebuje více hodnot, než obsahuje zásobník +@msg part of the program isn't valid PostScript text část programu není platný text PostScriptu +@msg a value has the wrong type for the operator hodnota má pro operátor nesprávný typ +@msg the program runs, deletes or renames a file, which the reader can't do program spouští, maže nebo přejmenovává soubor, což čtečka neumí +@msg the program asks for a resource that isn't defined program požaduje prostředek, který není definován +@msg a calculation has no result, as in a division by zero výpočet nemá výsledek, například při dělení nulou +@msg the operator looks for a mark on the stack and finds none operátor hledá na zásobníku značku a žádnou nenachází @msg a bitmap is empty bitmapa je prázdná @msg a bitmap is too large to read bitmapa je příliš velká na načtení @msg a colour index is 0 to {_1}, not {n} index barvy je 0 až {_1}, ne {n} @@ -4177,6 +4272,7 @@ imageTrace Outline Obrys @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` osa musí být "vertical", "horizontal" nebo úhel ve stupních, ne `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" pozadí {other}: transparent, white, black nebo barva jako "#ff8800" @msg bad color {v} chybná barva {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio musí být číslo od 0,1 do 10 @msg the Color Picker is not open Výběr barvy není otevřený @msg the Simplify slider is not open Posuvník Zjednodušit není otevřený @msg the screen color sampler is already open nástroj pro nabírání barvy z obrazovky už je otevřený @@ -4227,7 +4323,7 @@ imageTrace Outline Obrys @msg give the path `id` and the width point `index` zadejte `id` cesty a `index` bodu šířky @msg guides are locked vodítka jsou zamčená @msg handle must be in or out handle musí být in nebo out -@msg image '{key}' has no pixels and was left out obrázek '{key}' nemá žádné obrazové body a byl vynechán +@msg an image has no pixels and was left out obrázek bez obrazových bodů byl vynechán @msg image data `{_1}` is missing chybí obrazová data `{_1}` @msg image would be too large; lower the resolution obraz by byl příliš velký; snižte rozlišení @msg index must be a number or first, previous, next or last index musí být číslo nebo first, previous, next či last @@ -4254,7 +4350,7 @@ imageTrace Outline Obrys @msg missing index chybí index @msg missing number `{key}` chybí číslo `{key}` @msg missing integer `{key}` chybí celé číslo `{key}` -@msg multiple windows land with M11.5 více oken přijde s M11.5 +@msg New Window isn't available yet: a document opens in one window Nové okno zatím není k dispozici: dokument se otevírá v jednom okně @msg name the file pojmenujte soubor @msg no CSS to copy: select objects with CSS of their own žádné CSS ke kopírování: vyberte objekty s vlastním CSS @msg no Live Paint edge near that point poblíž tohoto bodu není žádná hrana živé malby @@ -4272,7 +4368,9 @@ imageTrace Outline Obrys @msg no blend options žádné možnosti prolnutí @msg no bounds žádné hranice @msg no brush named `{name}` štětec s názvem `{name}` neexistuje +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} název štětinového štětce může mít nejvýše {max} znaků: `{name}` jich má {count} @msg no brush selected není vybrán žádný štětec +@msg brushes move only within their type štětce lze přesouvat jen v rámci jejich typu @msg no catalog chybí katalog @msg no changes since the last save od posledního uložení nejsou žádné změny @msg no clock to keep a heartbeat by chybí hodiny, podle kterých by šlo udržovat signál aktivity @@ -4291,6 +4389,7 @@ imageTrace Outline Obrys @msg no file system here zde není souborový systém @msg no file writer chybí zápis souborů @msg no files to place žádné soubory k umístění +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` není dokument VectorCraft, PDF ani soubor .ai kompatibilní s PDF @msg no format to export to žádný formát pro export @msg no free recovery area žádná volná oblast pro obnovení @msg no gradient stop or freeform point is selected není vybrána žádná zarážka přechodu ani volný bod @@ -4326,6 +4425,7 @@ imageTrace Outline Obrys @msg no such action taková akce neexistuje @msg no such anchor takový kotevní bod neexistuje @msg no such artboard taková pracovní plocha neexistuje +@msg the artboard is locked pracovní plocha je zamčená @msg no such document takový dokument neexistuje @msg no such layer or object taková vrstva ani objekt neexistuje @msg no such object {0} objekt {0} neexistuje @@ -4381,6 +4481,7 @@ imageTrace Outline Obrys @msg path is already closed cesta už je uzavřená @msg paths contain invalid coordinates cesty obsahují neplatné souřadnice @msg paths: give at least one file paths: zadejte alespoň jeden soubor +@msg replace: the object to replace was changed or deleted replace: objekt k nahrazení byl změněn nebo odstraněn @msg pattern missing chybí vzorek @msg patterns nested too deeply are filled with mid-grey příliš hluboko vnořené vzorky se vyplní středně šedou @msg percent must be above 0 and at most 6400, not {p} procento musí být větší než 0 a nejvýše 6400, ne {p} @@ -4416,6 +4517,10 @@ imageTrace Outline Obrys @msg select a repeat vyberte opakování @msg select an Intertwine group vyberte skupinu propletení @msg select an Objects on Path group vyberte skupinu objektů na cestě +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects cesta uvnitř složené cesty, prolnutí, obálky, opakování nebo skupiny propletení nemůže přijmout objekty +@msg a clipping path can't take objects ořezová cesta nemůže přijmout objekty +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekty uvnitř prolnutí, obálky, opakování nebo skupiny propletení nelze připojit na cestu +@msg a clipping path can't be attached to a path ořezovou cestu nelze připojit na cestu @msg select a stroke with a variable width vyberte tah s proměnnou šířkou @msg select a symbol instance vyberte instanci symbolu @msg select an Image Trace object vyberte objekt vektorizace obrazu @@ -4535,6 +4640,7 @@ imageTrace Outline Obrys @msg unknown workspace (one of {_1}) neznámý pracovní prostor (jeden z {_1}) @msg unsupported: {0} nepodporováno: {0} @msg variables are locked proměnné jsou zamčené +@msg variables that aren't text were left out: {kinds} proměnné, které nejsou textové, byly vynechány: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files soubory verze {0} nelze komprimovat: komprimované soubory otevírá až verze {COMPRESSED_SINCE} a novější @msg width and height are at least 1 pixel šířka i výška jsou alespoň 1 obrazový bod @msg workspace name can't be empty název pracovního prostoru nesmí být prázdný @@ -4546,6 +4652,7 @@ imageTrace Outline Obrys @msg {_1} font families available Dostupné rodiny písem: {_1} @msg {_1} is a live effect (Effect › Plug-ins) {_1} je živý efekt (Efekt › Zásuvné moduly) @msg {_1} is an exporter (File › Export › Export As…) {_1} je exportní zásuvný modul (Soubor › Export › Exportovat jako…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` má {size} bajtů (limit {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} už používá {_2} @msg {_1} linked file(s) can't be read here: their previews show propojené soubory ({_1}) zde nelze přečíst: zobrazují se jejich náhledy @msg {_1} linked image(s) changed on disk: Update Links shows the new versions propojené obrázky ({_1}) se na disku změnily: nové verze zobrazí Aktualizovat propojení @@ -4574,6 +4681,8 @@ imageTrace Outline Obrys @msg Saving Ukládání @msg Saving {_1} Ukládání {_1} @msg Exporting {_1} Exportování {_1} +@msg Packaging {_1} Balení {_1} +@msg Placing {_1} Umísťování {_1} @msg Saving recovery data for {_1} Ukládání dat pro obnovení pro {_1} @msg Discarded {_1} recovered document(s) Zahozeno obnovených dokumentů: {_1} @msg Restored {_1} recovered document(s): save them to keep them Obnoveno dokumentů: {_1}; pokud je chcete zachovat, uložte je @@ -4658,7 +4767,9 @@ imageTrace Outline Obrys @msg automation filesystem access is not granted: read authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění ke čtení: {path} @msg automation filesystem access is not granted: write authority is absent: {path} přístup automatizace k systému souborů není udělen: chybí oprávnění k zápisu: {path} @msg automation path rejected: outside the read root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro čtení {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro čtení {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} cesta automatizace odmítnuta: mimo kořen pro zápis {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} cesta automatizace odmítnuta: mimo kořeny pro zápis {roots}: {path} @msg automation path rejected: path is empty cesta automatizace odmítnuta: cesta je prázdná @msg automation path rejected: a link that leads nowhere: {path} cesta automatizace odmítnuta: odkaz, který nikam nevede: {path} @msg automation path rejected: cannot resolve {path}: {e} cesta automatizace odmítnuta: {path} nelze vyhodnotit: {e} @@ -4681,6 +4792,12 @@ imageTrace Outline Obrys @msg before must name a panel before musí určovat panel @msg docking action contains an unknown panel or an excessive split path akce ukotvení obsahuje neznámý panel nebo příliš dlouhou cestu rozdělení @msg group must be true or false group musí být true nebo false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel je plovoucí; jeho skupinu sbalíte pomocí window.panel.float {panel, collapsed} +@msg panel is not in a dock group panel není ve skupině doku +@msg only a dock group above or below others collapses, not one beside another sbalit lze jen skupinu doku nad nebo pod jinými, ne vedle jiné +@msg icon must be true or false icon musí být true nebo false +@msg icon only docks a panel in the icon column, with no anchor icon jen ukotví panel ve sloupci ikon, bez anchor +@msg column docks a set as a dock column, with no anchor or icon column ukotví sadu jako sloupec doku, bez anchor nebo icon @msg move requires a destination panel přesun vyžaduje cílový panel @msg move requires an existing destination panel přesun vyžaduje existující cílový panel @msg onto must name a floating panel onto musí určovat plovoucí panel @@ -4740,6 +4857,7 @@ imageTrace Outline Obrys Logo 1000×1000 Logo 1000×1000 Business Card 3.5 × 2 in Vizitka 3,5 × 2 in Business Card 85 × 55 mm Vizitka 85 × 55 mm + Business Card 91 × 55 mm Vizitka 91 × 55 mm Letterhead Letter Hlavičkový papír Letter Letterhead A4 Hlavičkový papír A4 Envelope DL Obálka DL @@ -4986,12 +5104,24 @@ imageTrace Outline Obrys DWG (use DXF) DWG (použijte DXF) Artistic Umělecké Film Grain… Filmové zrno… + Colored Pencil… Barevná tužka… + Colored Pencil Barevná tužka Cutout… Výřez… Cutout Výřez + Dry Brush… Suchý štětec… + Dry Brush Suchý štětec Neon Glow… Neonová záře… + Palette Knife… Špachtle… Neon Glow Neonová záře + Paint Daubs… Malířské tahy… + Paint Daubs Malířské tahy + Palette Knife Špachtle Poster Edges… Plakátové okraje… + Rough Pastels… Hrubé pastely… Poster Edges Plakátové okraje + Rough Pastels Hrubé pastely + Underpainting… Podmalba… + Underpainting Podmalba Levels Úrovně Edge Simplicity Jednoduchost okrajů Edge Fidelity Věrnost okrajů @@ -5101,6 +5231,9 @@ mark-style Japanese Japonské Softness Měkkost Ambient Light Okolní světlo Light behind the object Světlo za objektem + 3D Lighting Preset Předvolba 3D osvětlení + Standard Standardní + Diffuse Rozptýlené # 3D light management. Lights Světla Add Light Přidat světlo @@ -5145,6 +5278,7 @@ mark-style Japanese Japonské @msg Measured anchor no longer exists Měřený kotevní bod už neexistuje @msg Measured segment no longer exists Měřený segment už neexistuje @msg Non-finite dimension Kóta není konečné číslo +@msg Scale Text Attributes: selected type has no scale to bake Měnit velikost atributů textu: vybraný text nemá žádnou skalaci, kterou lze použít @msg Scale must be drawing:real, for example 1:10 Měřítko musí mít tvar výkres:skutečnost, například 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Poměr měřítka musí být mezi 1:1000000 a 1000000:1 @msg Second measured segment no longer exists Druhý měřený segment už neexistuje @@ -5162,6 +5296,7 @@ mark-style Japanese Japonské Make Image Dynamic Nastavit obraz jako dynamický Import Data Sets… Importovat sady dat… Export Data Sets… Exportovat sady dat… + Load Variable Library… Načíst knihovnu proměnných… Make Graph Dynamic Nastavit graf jako dynamický Batch… Dávka… Source Folder: Zdrojová složka: @@ -5184,6 +5319,12 @@ mark-style Japanese Japonské Sketch Skica Halftone Pattern… Polotónový vzorek… Halftone Pattern Polotónový vzorek + Torn Edges… Roztržené okraje… + Torn Edges Roztržené okraje + Conté Crayon… Křída Conté… + Conté Crayon Křída Conté + Foreground Level Úroveň popředí + Background Level Úroveň pozadí Pattern Type Typ vzorku Dot Tečka # User Interface › Color Scheme @@ -5202,6 +5343,7 @@ mark-style Japanese Japonské Watercolor… Akvarel… Watercolor Akvarel Brush Size Velikost štětce + Texture Coverage Krytí textury Definition Definice Brush Detail Detail štětce Shadow Intensity Intenzita stínů @@ -5230,13 +5372,21 @@ mark-style Japanese Japonské @msg the image's pixels can't be read pixely obrazu nelze přečíst Persistent Trvalé Library Panel Options Možnosti panelu knihovny + Locate Library… Najít knihovnu… @msg Persistent libraries not found: {files} Trvalé knihovny nebyly nalezeny: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` není panel knihovny (viz window.swatchLibrary) @msg library panel `{given}` isn't open panel knihovny `{given}` není otevřený +@msg library panel `{given}` isn't missing its library knihovna panelu knihovny `{given}` nechybí @msg too many library panels were opened in this session v této relaci bylo otevřeno příliš mnoho panelů knihoven @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz má {_1} × {_2} pixelů, na jedné straně více než {LARGE_TRACE_SIDE}: vektorizuje se, ale pomaleji. Rychleji se vektorizuje s předvolbou nižší věrnosti nebo menší kopie obrazu. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz byl vektorizován v rozlišení {_1} × {_2} pixelů, zmenšený z {_3} × {_4} (nejvýše {_5} megapixelů). + Snapping Přichytávání + Snap to Tangent Přichytávat k tečně + Snap to Perpendicular Přichytávat ke kolmici +@msg unknown snap option `{key}` (use {options}) neznámá možnost přichytávání `{key}` (použijte {options}) + Gap Length: Délka mezery: Artboard {n} of {total} Pracovní plocha {n} z {total} + File {n} of {total} Soubor {n} z {total} Encoding and writing the file… Kódování a zápis souboru… @plural {n} more export waiting|{n} more exports waiting Čeká ještě {n} export|Čekají ještě {n} exporty|Čeká ještě {n} exportů Continue in Background Pokračovat na pozadí @@ -5252,6 +5402,14 @@ mark-style Japanese Japonské Full Justify Zarovnat do bloku Minimum must not exceed Desired, nor Desired Maximum. Minimum nesmí překročit požadovanou hodnotu ani požadovaná hodnota maximum. Justification Zarovnání do bloku + Add filters from the list above Přidejte filtry ze seznamu výše + Add to Stack Přidat na zásobník + Applied Filters Použité filtry + Categories Kategorie + Choose a filter Vyberte filtr + Effect Gallery Galerie efektů + Filters Filtry + Select artwork to apply effects Vyberte grafiku, na kterou chcete použít efekty @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them proměnné a sady dat souboru nebyly načteny (proměnné: {variables}, sady dat: {data_sets}): panel Proměnné je nezobrazuje # Select by Color (select.color) Select by Color Vybrat podle barvy @@ -5273,3 +5431,105 @@ mark-style Japanese Japonské Plaster Sádra Note Paper… Dopisní papír… Note Paper Dopisní papír + Water Paper… Mokrý papír… + Water Paper Mokrý papír + Ruby… Ruby… + Ruby Ruby + Geometric Guides Geometrická vodítka + Ease In Pozvolný začátek + Ease Out Pozvolný konec + Ease In Out Pozvolný začátek a konec + Ease Out In Pozvolný konec a začátek + Ramp: Síla: + Shift: Posun: + Fresco… Freska… + Fresco Freska + Effect Gallery… Galerie efektů… + Effect Gallery Dialog Dialog Galerie efektů + Arc Segment Tool Options Možnosti nástroje Obloukový segment + Base Along: Základna podél: + Closed Uzavřený + Convex Konvexní + Fill Arc Výplň oblouku + Length X-Axis: Délka na ose X: + Length Y-Axis: Délka na ose Y: + Slope: Sklon: +axis X Axis osa X +axis Y Axis osa Y +@msg Packaged {n} file(s) in {folder} Zabaleno souborů: {n} do {folder} +@msg Packaged {n} file(s) {note} Zabaleno souborů: {n} {note} +@msg Packaged {n} file(s) Zabaleno souborů: {n} + Packaged {n} file(s) Zabaleno souborů: {n} + Packaged {n} file(s) in {folder} Zabaleno souborů: {n} do {folder} + Packaged {n} file(s) {note} Zabaleno souborů: {n} {note} + {count} file(s) in {folder}. Show the package folder? Souborů: {count} v {folder}. Zobrazit složku balíčku? + Clockwise Ve směru hodinových ručiček + Dither Rozptýlení + Fine noise hides banding in the gradient fill Jemný šum skryje pruhy ve výplni přechodem +@msg `dither` applies to gradient fills (stroke: false) `dither` platí pro výplně přechodem (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` platí pro výplně přechodem (paint.setFill) +@msg `dither` must be true or false `dither` musí být true nebo false +@msg `method` must be classic or perceptual `method` musí být classic nebo perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Nainstalovat „{name}“, který žádá o velký výpočetní rozpočet? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Každé spuštění může trvat až {minutes} minut a vykonat {times}krát víc práce, než smějí jiné zásuvné moduly. Běží na pozadí a můžete ho zrušit. + It may take minutes, so it runs in the background, where you can cancel it. Může to trvat několik minut, proto běží na pozadí, kde ho můžete zrušit. + The plug-in is computing… Zásuvný modul počítá… + Inspect Plug-in Prozkoumat zásuvný modul + Cancel Plug-in Run Zrušit běh zásuvného modulu +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} doběhl, ale dokument nebo zásuvný modul se mezitím změnil: spusťte ho znovu +@msg Running “{name}”… Spouští se „{name}“… +@msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy` +@msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí +# Actions panel: action sets (#3712). + New Set… Nová sada… + New Set Nová sada + Set Options… Možnosti sady… + Set Options Možnosti sady + Create New Set Vytvořit novou sadu + Stop Global Edit Ukončit globální úpravy + Global Edit Options Možnosti globálních úprav + Match Shoda + On Artboards Na pracovních plochách + All Artboards Všechny pracovní plochy + Include Objects On Canvas Zahrnout objekty na plátně + Result(s): {count} Výsledky: {count} +@msg select one object vyberte jeden objekt +@msg Global Edit works on paths, compound paths and symbol instances Globální úpravy pracují s cestami, složenými cestami a instancemi symbolů +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nebyly nalezeny žádné podobné objekty (otočené a zrcadlené kopie se nevyhledávají); změňte možnosti globálních úprav a zkuste to znovu +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all nebo rozsah, například 1, 3-5, ne {v} + Add to Symbols Přidat do symbolů + Basic Shapes Základní tvary + Open Symbol Library Otevřít knihovnu symbolů + Save Symbol Library Uložit knihovnu symbolů + Save Symbol Library… Uložit knihovnu symbolů… + Save Symbols… Uložit symboly… + Selected Symbols Only Pouze vybrané symboly + Symbol Libraries Menu Nabídka knihoven symbolů + Symbol Library Knihovna symbolů + User Symbol Library 1 Uživatelská knihovna symbolů 1 + User Symbol Library 2 Uživatelská knihovna symbolů 2 + User Symbol Library 3 Uživatelská knihovna symbolů 3 + User Symbol Library 4 Uživatelská knihovna symbolů 4 + User Symbol Library 5 Uživatelská knihovna symbolů 5 + User Symbol Library 6 Uživatelská knihovna symbolů 6 + User Symbol Library 7 Uživatelská knihovna symbolů 7 + User Symbol Library 8 Uživatelská knihovna symbolů 8 + User Symbol Library 9 Uživatelská knihovna symbolů 9 + User Symbol Library 10 Uživatelská knihovna symbolů 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` nemá žádné symboly k umístění +@msg missing `library` (see symbol.libraries) chybí `library` (viz symbol.libraries) +@msg no symbol `{n}` žádný symbol `{n}` +@msg no symbol `{n}` in `{_1}` žádný symbol `{n}` v `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) žádná knihovna symbolů `{key}` (viz symbol.libraries) +@msg the document has no symbols to save dokument nemá žádné symboly k uložení +@msg Trap: select two or more objects filled with solid colours Přesah barev: vyberte dva nebo více objektů vyplněných plnými barvami +@msg Trap: no edges of different colours meet among the selected objects Přesah barev: mezi vybranými objekty se nestýkají žádné okraje různých barev +@msg thickness must be from 0.01 to 5000 pt thickness musí být od 0,01 do 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth musí být od 1 do 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction musí být od 0 do 100 % + Rectangular Grid Tool Options Možnosti nástroje Obdélníková mřížka + Polar Grid Tool Options Možnosti nástroje Polární mřížka + Fill Line Výplň úsečky diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 667ad8e51..29f633771 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -20,8 +20,14 @@ Medium Light Mellemlys Light Lys Quit VectorCraft Slut VectorCraft +# Artwork drag between document tabs. + Drop Artwork Slip illustration +@msg {key} must be [x, y] with finite coordinates {key} skal være [x, y] med endelige koordinater +@msg ids must contain 1–10000 object ids ids skal indeholde 1–10000 objekt-id’er + File Filer New… Ny… + New Without Dialog Ny uden dialog New from Template… Ny fra skabelon… Open… Åbn… Open Recent Files Åbn seneste filer @@ -278,6 +284,13 @@ Glyphs Glyffer Insert Special Character Indsæt specialtegn Bullet Kugletegn + Bullets Kugletegn + Bullets and Numbering Kugletegn og nummerering + Apply Bullets Anvend kugletegn + Filled Dot Udfyldt prik + Dash Tankestreg + Hollow Circle Tom cirkel + Filled Square Udfyldt firkant Copyright Symbol Copyrighttegn Ellipsis Udeladelsesprikker Paragraph Symbol Afsnitstegn @@ -710,7 +723,7 @@ Units Enheder Guides & Grid Hjælpelinjer og gitter Hyphenation Orddeling - Performance & Storage Ydeevne og lager + Plug-ins & Scratch Disks Plug-ins & Midlertidige diske User Interface Brugergrænseflade Performance Ydeevne File Handling Filhåndtering @@ -760,6 +773,7 @@ Activate Panel Aktivér panel Change Panel Layout Skift panelets layout Close Panel Luk panel + Collapse Panel Group Skjul panelgruppe Move Panel Flyt panel Group with Gruppér med Resize panels Tilpas størrelse på paneler @@ -1056,7 +1070,8 @@ lighting Light Lys to sample a color for at tage en farveprøve across a selected object to set the gradient direction hen over et markeret objekt for at angive gradientretningen to select an artboard | for at markere et tegnebræt | - on the canvas to create one på canvaset for at oprette et + on the canvas to create one ( på canvaset for at oprette et ( + selects several, or draws one inside an artboard) markerer flere eller opretter et inde i et tegnebræt) a stroke to add a width point | på en kontur for at tilføje et breddepunkt | to change one side | for at ændre den ene side | to select more points | for at markere flere punkter | @@ -1309,6 +1324,7 @@ lighting Light Lys Leading Linjeafstand No Break Ingen ombrydning Proportional Metrics Proportionale metrikker + Character Direction Tegnretning Digits Cifre Arabic Arabisk Hindi Hindi @@ -1376,6 +1392,7 @@ lighting Light Lys {count} more rules: Export writes every one. {count} flere regler: Eksport skriver hver enkelt. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} unavngivne objekter udeladt: giv dem navne, eller aktivér Unavngivne objekter i panelmenuen. All Categories Alle kategorier + Repeat Objects Gentagelsesobjekter Save… Gem… Selection Only Kun markering {title} (selection) {title} (markering) @@ -1409,6 +1426,7 @@ lighting Light Lys Edit the selected point's colour in the Color panel Redigér det markerede punkts farve i farvepanelet Edit the stop Redigér stoppet Eyedropper: click the art to sample the stop's colour Pipette: klik på grafikken for at prøvetage stoppets farve + Eyedropper: click the art to sample the key color Pipette: klik på grafikken for at prøvetage nøglefarven Gradient across stroke Gradient på tværs af konturen Gradient along stroke Gradient langs konturen Gradient Fill: click to apply, drag onto art Gradientfyld: klik for at anvende, træk hen over grafikken @@ -1572,6 +1590,9 @@ lighting Light Lys Left Indent Venstre indrykning Left-to-Right Paragraph Direction Afsnitsretning fra venstre mod højre Line-end Punctuation Half Width Skilletegn i halv bredde ved linjeslut + Insert Kashidas Indsæt kashidaer + Long Lang + Stylistic Stilistisk Mojikumi Set Mojikumi-sæt Kinsoku Set Kinsoku-sæt Hard Hård @@ -1595,6 +1616,13 @@ lighting Light Lys Repeat Pathfinder Gentag Pathfinder Repeat {name} Gentag {name} Trap… Trap… + Trap Trap + Height/Width Højde/bredde + Tint Reduction Tonereduktion + Traps with Process Color Traps med procesfarver + Reverse Traps Omvendte traps + Precision Præcision + Remove Redundant Points Fjern overflødige punkter Bottom in Front Bund forrest Brick by Column Mursten efter kolonne Brick by Row Mursten efter række @@ -1721,8 +1749,8 @@ lighting Light Lys {kind}, {model} {kind}, {model} No symbols in this document Ingen symboler i dette dokument Select art and click New Symbol to make one. Markér grafik, og klik på Nyt symbol for at oprette et. - Symbol Libraries (on the roadmap) Symbolbiblioteker (på roadmappen) Align On: Justér på: + Bottom-Justified Tab Bundjusteret tabulator Center-Justified Tab Midterjusteret tabulator Above Text Over teksten Position Panel Above Text Placér panel over teksten @@ -1734,6 +1762,7 @@ lighting Light Lys No text selected Ingen tekst markeret Repeat Tab Gentag tabulator Right-Justified Tab Højrejusteret tabulator + Top-Justified Tab Topjusteret tabulator Select a text object to set its tab stops. Markér et tekstobjekt for at angive dets tabulatorstop. Select text Markér tekst Snap to Unit Fastgør til enhed @@ -1849,6 +1878,13 @@ lighting Light Lys Tints and Shades Nuancer og skygger Hue Shift Farvetoneskift Key Color: Nøglefarve: + Tips Tips + Colorization Tips Tips om farvelægning + The samples show this brush on a blue stroke. Eksemplerne viser denne pensel på en blå streg. + The art keeps its own colors. Grafikken beholder sine egne farver. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Sort bliver stregfarven, og hvid forbliver hvid. Andre farver bliver nuancer af stregfarven, og jo lysere en farve er, desto lysere er dens nuance. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Sort forbliver sort, og hvid forbliver hvid. Andre farver bliver skygger af stregfarven, når de er mørke, og nuancer af den, når de er lyse. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Nøglefarven bliver stregfarven. De andre farver flytter sig lige så langt rundt i farvehjulet som nøglefarven og beholder deres mætning og lysstyrke. Size: Størrelse: Calligraphic Brush Options Indstillinger for kalligrafipensel Bristle Brush Options Indstillinger for børstepensel @@ -2161,11 +2197,11 @@ lighting Light Lys Fonts whose licence doesn't allow embedding are not copied. Skrifttyper, hvis licens ikke tillader indlejring, kopieres ikke. Package collects a saved document with its linked files and fonts. Pakning samler et gemt dokument med dets linkede filer og skrifttyper. Package created Pakke oprettet - Packaged {n} file(s) in {folder}{note} Pakkede {n} fil(er) i {folder}{note} + Packaged {n} file(s) in {folder} {note} Pakkede {n} fil(er) i {folder}{note} Packaged {n} file(s){note} Pakkede {n} fil(er){note} Relink Linked Files to the Document Genopret link til dokumentets linkede filer Save the document first? Gem dokumentet først? - {count} file(s) in {folder}{note}. Show the package folder? {count} fil(er) i {folder}{note}. Vis pakkemappen? + {count} file(s) in {folder} {note}. Show the package folder? {count} fil(er) i {folder}{note}. Vis pakkemappen? {count} linked file(s) not found {count} linkede fil(er) ikke fundet fonts not copied: {fonts} skrifttyper ikke kopieret: {fonts} Built-in presets are read-only: New… starts an editable copy. Indbyggede forudindstillinger er skrivebeskyttede: Ny… starter en redigerbar kopi. @@ -2212,6 +2248,11 @@ lighting Light Lys File Dimensions Fildimensioner Transforms Transformationer Background Color: Baggrundsfarve: + Show Center Mark Vis midtermærke + Show Cross Hairs Vis trådkors + Show Video Safe Areas Vis sikre videoområder + Video Ruler Pixel Aspect Ratio: Pixelformat for videolinealer: + Hide Video Rulers Skjul videolinealer Client-side (.html) Klientbaseret (.html) Color Reduction: Farvereduktion: Lossless (lossy WebP isn't available yet) Tabsfri (WebP med tab er ikke tilgængelig endnu) @@ -2665,12 +2706,21 @@ lighting Light Lys Darken Mørkere Exclusion Udelukkelse Hard Light Hårdt lys + Hard Mix Hård blanding Lighten Lysere Luminosity Luminans Overlay Overlejring Soft Light Blødt lys + Soft Mix Blød blanding Crosshatch Krydsskravering Fine Dots Fine prikker + + Simple Enkel + Light Rough Lys ru + Dark Rough Mørk ru + Wide Sharp Bred skarp + Wide Blurry Bred sløret + Sparkle Glimt Medium Dots Mellemstore prikker Grainy Dots Kornede prikker Coarse Dots Grove prikker @@ -2742,6 +2792,14 @@ theme Appearance Udseende Edit Symbol Redigér symbol Expand Brush Strokes Udvid penselstrøg New Brush… Ny pensel… + New Brush Ny pensel + Calligraphic Brush Kalligrafipensel + Scatter Brush Spredningspensel + Art Brush Grafikpensel + Bristle Brush Børstepensel + Pattern Brush Mønsterpensel + Select art to make a scatter, art or pattern brush. Markér grafik for at oprette en spredningspensel, grafikpensel eller mønsterpensel. + Brush Art Penselgrafik New Symbol Nyt symbol New Symbol… Nyt symbol… Place Symbol Instance Placér symbolforekomst @@ -3202,6 +3260,7 @@ theme Appearance Udseende Scale Corners Skalér hjørner Scale Cursor Proportional to UI Skalér markør proportionalt med brugergrænsefladen Scale Strokes & Effects Skalér konturer & effekter + Scale Text Attributes Skalér tekstattributter Scratch Disks Midlertidige diske Secondary Sekundær Select Same Tint % Markér samme farvetone % @@ -3217,6 +3276,7 @@ theme Appearance Udseende Size/Leading Størrelse/linjeafstand Snapping Tolerance Snap-tolerance Spacing Guides Afstandshjælpelinjer + Invoke Distance Guides Aktivér hjælpelinjer for afstandsmåling Spanish Spansk Subdivisions Underinddelinger Templates Folder Skabelonmappe @@ -3302,6 +3362,7 @@ theme Appearance Udseende Delete Swatch Slet farveprøve Duplicate Swatch Duplikér farveprøve Merge Swatches Flet farveprøver + Move Brush Flyt pensel Move Swatch Flyt farveprøve New Color Group Ny farvegruppe New Swatch Ny farveprøve @@ -3519,6 +3580,8 @@ effect Regular Regular Put back in the dock Sæt tilbage i dokken All Scripts Alle scripts Any Kind Enhver type + Font Technology Skrifttypeteknologi +font-technology Variable Variable Decorative Dekorative Favorites Favoritter Latin Latinsk @@ -3783,6 +3846,7 @@ imageTrace Outline Kontur @msg Couldn't copy to the system clipboard: {e} Kunne ikke kopiere til systemets udklipsholder: {e} @msg Couldn't open {name}: {e} Kunne ikke åbne {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Kunne ikke placere {name}: dokumentet, den blev sluppet på, var lukket +@msg Couldn't place {name}: the document it was placed in was closed Kunne ikke placere {name}: dokumentet, den blev placeret i, var lukket @msg Couldn't read {name}: {e} Kunne ikke læse {name}: {e} @msg Couldn't save recovery data: {e} Kunne ikke gemme gendannelsesdata: {e} @msg Create Outlines: select text objects Opret konturer: markér tekstobjekter @@ -3819,8 +3883,9 @@ imageTrace Outline Kontur @msg Opened {url} Åbnede {url} @msg Outline Stroke: no stroked paths selected Konturér kurve: ingen kurver med kontur er markeret @msg PDF settings are not an object PDF-indstillinger er ikke et objekt +@msg PDF security settings are not an object PDF-sikkerhedsindstillinger er ikke et objekt @msg PDF writer error: {0} PDF-skriverfejl: {0} -@msg Packaged {n} file(s) in {folder}{note} Pakkede {n} fil(er) i {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Pakkede {n} fil(er) i {folder}{note} @msg Packaged {n} file(s){note} Pakkede {n} fil(er){note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Forhåndsvisning i browser kræver skrivebordsappen (webversionen downloader filer i stedet) @msg Release Guides: no guides Frigiv hjælpelinjer: ingen hjælpelinjer @@ -3885,6 +3950,25 @@ imageTrace Outline Kontur @msg a PostScript error ({name} in `{op}`: {at}) en PostScript-fejl ({name} i `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} en PostScript-fejl ({name} i `{op}`: {at}), i {procs} @msg a PostScript error ({name}) en PostScript-fejl ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) en PostScript-fejl ({name} i `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} en PostScript-fejl ({name} i `{op}`; {meaning}), i {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) en PostScript-fejl ({name} i `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} en PostScript-fejl ({name} i `{op}`: {at}; {meaning}), i {procs} +@msg a PostScript error ({name}; {meaning}) en PostScript-fejl ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin programmet afslutter en ordbog, det ikke har startet +@msg the program opens a file, which the reader can't do programmet åbner en fil, hvilket læseren ikke kan +@msg the program restores a saved state that was already restored programmet gendanner en gemt tilstand, der allerede er gendannet +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none data, som programmet læser, er beskadigede eller afkortede, eller programmet beder om en position i en fil, der ikke har nogen +@msg the program goes past one of the reader's limits programmet overskrider en af læserens grænser +@msg the path has no point to continue from kurven har intet punkt at fortsætte fra +@msg a value is outside the range the operator takes en værdi ligger uden for det område, operatoren tager imod +@msg the operator needs more values than the stack holds operatoren skal bruge flere værdier, end stakken indeholder +@msg part of the program isn't valid PostScript text en del af programmet er ikke gyldig PostScript-tekst +@msg a value has the wrong type for the operator en værdi har den forkerte type til operatoren +@msg the program runs, deletes or renames a file, which the reader can't do programmet kører, sletter eller omdøber en fil, hvilket læseren ikke kan +@msg the program asks for a resource that isn't defined programmet beder om en ressource, der ikke er defineret +@msg a calculation has no result, as in a division by zero en beregning har intet resultat, som ved division med nul +@msg the operator looks for a mark on the stack and finds none operatoren leder efter et mærke på stakken og finder intet @msg a bitmap is empty en bitmap er tom @msg a bitmap is too large to read en bitmap er for stor til at læse @msg a colour index is 0 to {_1}, not {n} et farveindeks er fra 0 til {_1}, ikke {n} @@ -3915,6 +3999,7 @@ imageTrace Outline Kontur @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` aksen skal være "vertical", "horizontal" eller en vinkel i grader, ikke `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" baggrund {other}: gennemsigtig, hvid, sort eller en farve som "#ff8800" @msg bad color {v} ugyldig farve {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio skal være et tal fra 0,1 til 10 @msg bad colour `{hex}` (#rrggbb) ugyldig farve `{hex}` (#rrggbb) @msg bad page tree ugyldigt sidetræ @msg bad shortcut ugyldig genvej @@ -3958,7 +4043,7 @@ imageTrace Outline Kontur @msg give horizontal or vertical angiv vandret eller lodret @msg give the path `id` and the width point `index` angiv `id` for kurven og `index` for breddepunktet @msg guides are locked hjælpelinjerne er låst -@msg image '{key}' has no pixels and was left out billedet '{key}' har ingen pixels og blev udeladt +@msg an image has no pixels and was left out et billede uden pixels blev udeladt @msg image data `{_1}` is missing billeddata `{_1}` mangler @msg image would be too large; lower the resolution billedet ville blive for stort; sænk opløsningen @msg index must be a number or first, previous, next or last indeks skal være et tal eller first, previous, next eller last @@ -3984,7 +4069,7 @@ imageTrace Outline Kontur @msg missing index indeks mangler @msg missing number `{key}` tallet `{key}` mangler @msg missing integer `{key}` heltallet `{key}` mangler -@msg multiple windows land with M11.5 flere vinduer lander med M11.5 +@msg New Window isn't available yet: a document opens in one window Nyt vindue er ikke tilgængeligt endnu: et dokument åbnes i ét vindue @msg name the file giv filen et navn @msg no CSS to copy: select objects with CSS of their own ingen CSS at kopiere: markér objekter med deres egen CSS @msg no Live Paint edge near that point ingen Live Paint-kant i nærheden af det punkt @@ -4003,6 +4088,7 @@ imageTrace Outline Kontur @msg no bounds ingen grænser @msg no brush named `{name}` ingen pensel med navnet `{name}` @msg no brush selected ingen pensel er markeret +@msg brushes move only within their type pensler kan kun flyttes inden for deres egen type @msg no catalog intet katalog @msg no changes since the last save ingen ændringer siden sidste gemning @msg no clock to keep a heartbeat by intet ur at holde hjerteslag efter @@ -4106,6 +4192,7 @@ imageTrace Outline Kontur @msg path is already closed stien er allerede lukket @msg paths contain invalid coordinates stierne indeholder ugyldige koordinater @msg paths: give at least one file stier: angiv mindst én fil +@msg replace: the object to replace was changed or deleted erstat: objektet, der skulle erstattes, er ændret eller slettet @msg pattern missing mønster mangler @msg patterns nested too deeply are filled with mid-grey mønstre, der er indlejret for dybt, udfyldes med mellemgrå @msg percent must be above 0 and at most 6400, not {p} procent skal være over 0 og højst 6400, ikke {p} @@ -4290,6 +4377,8 @@ imageTrace Outline Kontur @msg Saving Gemmer @msg Saving {_1} Gemmer {_1} @msg Exporting {_1} Eksporterer {_1} +@msg Packaging {_1} Pakker {_1} +@msg Placing {_1} Placerer {_1} @msg Saving recovery data for {_1} Gemmer gendannelsesdata for {_1} @msg Discarded {_1} recovered document(s) Forkastede {_1} gendannet(e) dokument(er) @msg Restored {_1} recovered document(s): save them to keep them Genvandt {_1} gendannet(e) dokument(er): gem dem for at beholde dem @@ -4372,7 +4461,9 @@ imageTrace Outline Kontur @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: læserettighed mangler: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automatiseringens adgang til filsystemet er ikke tildelt: skriverettighed mangler: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringssti afvist: uden for læseroden {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} automatiseringssti afvist: uden for læserødderne {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} automatiseringssti afvist: uden for skriveroden {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} automatiseringssti afvist: uden for skriverødderne {roots}: {path} @msg automation path rejected: path is empty automatiseringssti afvist: stien er tom @msg automation path rejected: a link that leads nowhere: {path} automatiseringssti afvist: et link, der ikke fører nogen steder hen: {path} @msg automation path rejected: cannot resolve {path}: {e} automatiseringssti afvist: kan ikke finde frem til {path}: {e} @@ -4395,6 +4486,12 @@ imageTrace Outline Kontur @msg before must name a panel before skal angive et panel @msg docking action contains an unknown panel or an excessive split path dokningshandlingen indeholder et ukendt panel eller en overdreven opdelingssti @msg group must be true or false group skal være true eller false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panelet flyder; skjul dets gruppe med window.panel.float {panel, collapsed} +@msg panel is not in a dock group panelet er ikke i en dockgruppe +@msg only a dock group above or below others collapses, not one beside another kun en dockgruppe over eller under andre kan skjules, ikke en ved siden af en anden +@msg icon must be true or false icon skal være true eller false +@msg icon only docks a panel in the icon column, with no anchor icon docker kun et panel i ikonkolonnen, uden anchor +@msg column docks a set as a dock column, with no anchor or icon column docker et sæt som en dockkolonne, uden anchor eller icon @msg move requires a destination panel move kræver et destinationspanel @msg move requires an existing destination panel move kræver et eksisterende destinationspanel @msg onto must name a floating panel onto skal angive et flydende panel @@ -4454,6 +4551,7 @@ imageTrace Outline Kontur Logo 1000×1000 Logo 1000×1000 Business Card 3.5 × 2 in Visitkort 3,5 × 2 tommer Business Card 85 × 55 mm Visitkort 85 × 55 mm + Business Card 91 × 55 mm Visitkort 91 × 55 mm Letterhead Letter Brevpapir Letter Letterhead A4 Brevpapir A4 Envelope DL Konvolut DL @@ -4781,6 +4879,9 @@ mark-style Japanese Japansk Softness Blødhed Ambient Light Omgivelseslys Light behind the object Lys bag objektet + 3D Lighting Preset Forudindstilling for 3D-belysning + Standard Standard + Diffuse Diffust # 3D light management. Lights Lyskilder Add Light Tilføj lyskilde @@ -4825,6 +4926,7 @@ mark-style Japanese Japansk Painting Maling Printing and Proofing Udskrivning og prøvetrykning Tracing Sporing + Touch Touch Typography Typografi VectorCraft VectorCraft Web Web @@ -4891,6 +4993,7 @@ mark-style Japanese Japansk @msg Measured anchor no longer exists Det målte ankerpunkt findes ikke længere @msg Measured segment no longer exists Det målte segment findes ikke længere @msg Non-finite dimension Ikke-endeligt mål +@msg Scale Text Attributes: selected type has no scale to bake Skalér tekstattributter: den valgte tekst har ingen skalering at anvende @msg Scale must be drawing:real, for example 1:10 Målestokken skal være tegning:virkelig, for eksempel 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Målestoksforholdet skal være mellem 1:1000000 og 1000000:1 @msg Second measured segment no longer exists Det andet målte segment findes ikke længere @@ -4917,6 +5020,8 @@ mark-style Japanese Japansk Make Image Dynamic Gør billede dynamisk Import Data Sets… Importér datasæt… Export Data Sets… Eksportér datasæt… + Load Variable Library… Indlæs variabelbibliotek… +@msg variables that aren't text were left out: {kinds} variabler, der ikke er tekst, blev udeladt: {kinds} Make Graph Dynamic Gør diagram dynamisk Batch… Batch… Source Folder: Kildemappe: @@ -5061,9 +5166,24 @@ swatch Bright Violet Kraftig violet @msg select the art to export first markér først grafikken, der skal eksporteres @msg no saved selection recalled ingen gemt markering hentet Merge only with selection Flet kun med markering + Consider Open Filled Paths as Closed Behandl åbne udfyldte kurver som lukkede + In Merge Mode, Clicking Stroke Splits the Path I fletningstilstand opdeler et klik på stregen kurven + Pick Color From: Hent farve fra: + Artwork Grafik + Cursor Swatch Preview Forhåndsvisning af farveprøver ved markøren + Selection: Markering: + Straight Line Lige linje + Highlight: Fremhævning: + Stroke When Editable Streg, når den kan redigeres Sketch Skitse Halftone Pattern… Rastermønster… Halftone Pattern Rastermønster + Torn Edges… Flængede kanter… + Torn Edges Flængede kanter + Conté Crayon… Conté-kridt… + Conté Crayon Conté-kridt + Foreground Level Forgrundsniveau + Background Level Baggrundsniveau Pattern Type Mønstertype Dot Prik Export Type: Eksporttype: @@ -5092,6 +5212,8 @@ swatch Bright Violet Kraftig violet Gap Detection Registrering af huller Gap Options Indstillinger for huller Gap Preview Color: Eksempelfarve for huller: + Show Live Paint Gaps Vis huller i live-maling + Hide Live Paint Gaps Skjul huller i live-maling Graph Column Diagramsøjle JavaScript Files JavaScript-filer JavaScript: JavaScript: @@ -5112,12 +5234,24 @@ swatch Bright Violet Kraftig violet Lock Variables Lås variabler Unlock Variables Lås variabler op Change to Artboard Rulers Skift til kunstpladelinealer + Colored Pencil… Farveblyant… + Colored Pencil Farveblyant Cutout… Udklip… + Dry Brush… Tør pensel… Neon Glow… Neonglød… + Palette Knife… Paletkniv… Poster Edges… Plakatkanter… + Rough Pastels… Grove pasteller… Cutout Udklip + Dry Brush Tør pensel Neon Glow Neonglød + Paint Daubs… Malerklatter… + Paint Daubs Malerklatter + Palette Knife Paletkniv Poster Edges Plakatkanter + Rough Pastels Grove pasteller + Underpainting… Undermaling… + Underpainting Undermaling Apply Data Set Anvend datasæt Cancel Pattern Editing Annullér redigering af mønster Copy Appearance Kopiér udseende @@ -5194,8 +5328,15 @@ swatch Bright Violet Kraftig violet Thread Text Sammenkæd tekst Typing Indtastning Persistent Vedvarende + Locate Library… Lokalisér bibliotek… Library Panel Options Indstillinger for bibliotekspanel + Snapping Fastgørelse + Snap to Tangent Fastgør til tangent + Snap to Perpendicular Fastgør vinkelret +@msg unknown snap option `{key}` (use {options}) ukendt fastgørelsesindstilling `{key}` (brug {options}) + Gap Length: Hullængde: Artboard {n} of {total} Tegneflade {n} af {total} + File {n} of {total} Fil {n} af {total} Encoding and writing the file… Koder og skriver filen… @plural {n} more export waiting|{n} more exports waiting {n} eksport mere venter|{n} eksporter mere venter Continue in Background Fortsæt i baggrunden @@ -5211,6 +5352,14 @@ swatch Bright Violet Kraftig violet Full Justify Lige margener Minimum must not exceed Desired, nor Desired Maximum. Minimum må ikke overstige Ønsket, og Ønsket ikke Maksimum. Justification Justering + Add filters from the list above Tilføj filtre fra listen ovenfor + Add to Stack Tilføj til stak + Applied Filters Anvendte filtre + Categories Kategorier + Choose a filter Vælg et filter + Effect Gallery Effektgalleri + Filters Filtre + Select artwork to apply effects Vælg grafik for at anvende effekter # Select by Color (select.color) Select by Color Markér efter farve Smudge Stick… Udtværingsstift… @@ -5220,6 +5369,7 @@ swatch Bright Violet Kraftig violet Watercolor… Akvarel… Watercolor Akvarel Brush Size Penselstørrelse + Texture Coverage Teksturdækning Definition Definition Brush Detail Penseldetalje Shadow Intensity Skyggeintensitet @@ -5251,3 +5401,90 @@ swatch Bright Violet Kraftig violet Plaster Gips Note Paper… Notatpapir… Note Paper Notatpapir + Water Paper… Vådt papir… + Water Paper Vådt papir + Ruby… Ruby… + Ruby Ruby + Geometric Guides Geometriske hjælpelinjer + Ease In Langsom start + Ease Out Langsom slutning + Ease In Out Langsom start og slutning + Ease Out In Langsom slutning og start + Ramp: Styrke: + Shift: Forskydning: + Fresco… Fresko… + Fresco Fresko + Effect Gallery… Effektgalleri… + Effect Gallery Dialog Dialogboksen Effektgalleri + Arc Segment Tool Options Indstillinger for buesegmentværktøj + Base Along: Base langs: + Closed Lukket + Convex Konvex + Fill Arc Udfyld bue + Length X-Axis: Længde X-akse: + Length Y-Axis: Længde Y-akse: + Slope: Hældning: +axis X Axis X-akse +axis Y Axis Y-akse +@msg Packaged {n} file(s) in {folder} Pakkede {n} fil(er) i {folder} +@msg Packaged {n} file(s) {note} Pakkede {n} fil(er) {note} +@msg Packaged {n} file(s) Pakkede {n} fil(er) + Packaged {n} file(s) Pakkede {n} fil(er) + Packaged {n} file(s) in {folder} Pakkede {n} fil(er) i {folder} + Packaged {n} file(s) {note} Pakkede {n} fil(er) {note} + {count} file(s) in {folder}. Show the package folder? {count} fil(er) i {folder}. Vis pakkemappen? + Clockwise Med uret + Dither Dither + Fine noise hides banding in the gradient fill Fin støj skjuler striber i gradientfyldet +@msg `dither` applies to gradient fills (stroke: false) `dither` gælder for gradientfyld (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` gælder for gradientfyld (paint.setFill) +@msg `dither` must be true or false `dither` skal være true eller false +@msg `method` must be classic or perceptual `method` skal være classic eller perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Installér »{name}«, som beder om et stort beregningsbudget? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Hver kørsel kan tage op til {minutes} minutter og udføre {times} gange så meget arbejde, som andre plug-ins må. Den kører i baggrunden, og du kan annullere den. + It may take minutes, so it runs in the background, where you can cancel it. Det kan tage minutter, så det kører i baggrunden, hvor du kan annullere det. + The plug-in is computing… Plug-in'en regner… + Inspect Plug-in Undersøg plug-in + Cancel Plug-in Run Annullér plug-in-kørsel +# Actions panel: action sets (#3712). + New Set… Nyt sæt… + New Set Nyt sæt + Set Options… Indstillinger for sæt… + Set Options Indstillinger for sæt + Create New Set Opret nyt sæt + Stop Global Edit Stop global redigering + Global Edit Options Indstillinger for global redigering + Match Match + On Artboards På tegneflader + All Artboards Alle tegneflader + Include Objects On Canvas Medtag objekter på lærredet + Result(s): {count} Resultat(er): {count} + Add to Symbols Føj til symboler + Basic Shapes Grundlæggende former + Open Symbol Library Åbn symbolbibliotek + Save Symbol Library Gem symbolbibliotek + Save Symbol Library… Gem symbolbibliotek… + Save Symbols… Gem symboler… + Selected Symbols Only Kun valgte symboler + Symbol Libraries Menu Menu med symbolbiblioteker + Symbol Library Symbolbibliotek + User Symbol Library 1 Brugersymbolbibliotek 1 + User Symbol Library 2 Brugersymbolbibliotek 2 + User Symbol Library 3 Brugersymbolbibliotek 3 + User Symbol Library 4 Brugersymbolbibliotek 4 + User Symbol Library 5 Brugersymbolbibliotek 5 + User Symbol Library 6 Brugersymbolbibliotek 6 + User Symbol Library 7 Brugersymbolbibliotek 7 + User Symbol Library 8 Brugersymbolbibliotek 8 + User Symbol Library 9 Brugersymbolbibliotek 9 + User Symbol Library 10 Brugersymbolbibliotek 10 + {w} × {h} pt {w} × {h} pt +@msg Trap: select two or more objects filled with solid colours Trap: Vælg to eller flere objekter udfyldt med ensfarvede farver +@msg Trap: no edges of different colours meet among the selected objects Trap: Ingen kanter i forskellige farver mødes blandt de valgte objekter +@msg thickness must be from 0.01 to 5000 pt thickness skal være fra 0,01 til 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth skal være fra 1 til 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction skal være fra 0 til 100 % + Rectangular Grid Tool Options Indstillinger for rektangulært gitterværktøj + Polar Grid Tool Options Indstillinger for polært gitterværktøj + Fill Line Udfyld linje diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4a192e3c9..899a9b7c4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -15,7 +15,7 @@ # error messages keep the case of their English first word, as they are often shown after "…: ". # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested) — and the status and error messages. Left in English on purpose (`MENU_KEEP_AS_IS` in the +# `untranslated_report` lists gaps) — and the status and error messages. Left in English on purpose (`MENU_KEEP_AS_IS` in the # tests): the product name, language names, OpenType, the built-in workspace names and the bracketed # perspective grid preset names; `de-DE`, `de-AT`, `de-CH` and every other `de-*` locale resolve here. # Photoshop-style raster effects (Effect menu, their dialogs). @@ -169,6 +169,8 @@ Anti-aliased Artwork Geglättete Grafik Anti-aliasing: Glättung: Any Kind Beliebige Art + Font Technology Schrifttechnologie +font-technology Variable Variabel Appearance Aussehen Appearance Attribute Aussehensattribut Appearance Item Aussehenselement @@ -252,6 +254,11 @@ Average… Durchschnitt berechnen… Axis: Achse: Background Color: Hintergrundfarbe: + Show Center Mark Mittelpunktmarke einblenden + Show Cross Hairs Fadenkreuz einblenden + Show Video Safe Areas Videosichere Bereiche einblenden + Video Ruler Pixel Aspect Ratio: Pixel-Seitenverhältnis der Videolineale: + Hide Video Rulers Videolineale ausblenden Background Contents Hintergrundinhalt Background Contents: Hintergrundinhalt: Background: Hintergrund: @@ -289,6 +296,7 @@ Blur Weichzeichnen Bottom Unten Bottom in Front Unten im Vordergrund + Bottom-Justified Tab Unten ausgerichteter Tabulator Bottom: Unten: Bounds Begrenzung Bounds (Fill Proportionally) Begrenzung (proportional füllen) @@ -319,6 +327,13 @@ Built-in presets don't change: New… starts an editable copy. Integrierte Vorgaben ändern sich nicht: „Neu…“ erstellt eine bearbeitbare Kopie. Bulge… Wulst… Bullet Aufzählungszeichen + Bullets Aufzählungszeichen + Bullets and Numbering Aufzählungszeichen und Nummerierung + Apply Bullets Aufzählungszeichen anwenden + Filled Dot Gefüllter Punkt + Dash Strich + Hollow Circle Leerer Kreis + Filled Square Gefülltes Quadrat Butt Cap Abgeflachter Abschluss Byte Order: Bytereihenfolge: Calligraphic Kalligrafie @@ -1036,6 +1051,7 @@ Eyedropper Picks Up: Pipette nimmt auf: Eyedropper Tool Pipette-Werkzeug Eyedropper: click the art to sample the stop's colour Pipette: Klicken Sie auf die Grafik, um die Farbe für den Farbstopp aufzunehmen + Eyedropper: click the art to sample the key color Pipette: Klicken Sie auf die Grafik, um die Hauptfarbe aufzunehmen Favorites Favoriten Feather… Weiche Kante… Feet Fuß @@ -1044,6 +1060,11 @@ Fidelity: Genauigkeit: Figure Space Ziffernleerzeichen Figure: Ziffern: +# Artwork drag between document tabs. + Drop Artwork Grafik ablegen +@msg {key} must be [x, y] with finite coordinates {key} muss [x, y] mit endlichen Koordinaten sein +@msg ids must contain 1–10000 object ids ids muss 1–10000 Objekt-IDs enthalten + File Datei File Dimensions Dateiabmessungen File Format: Dateiformat: @@ -1065,6 +1086,16 @@ Alt key toggles to Smooth Tool Alt-Taste schaltet auf Glätten-Werkzeug um Keep selected Ausgewählt lassen Merge only with selection Nur mit Auswahl zusammenfügen + Consider Open Filled Paths as Closed Offene gefüllte Pfade als geschlossen behandeln + In Merge Mode, Clicking Stroke Splits the Path Beim Zusammenfügen teilt ein Klick auf die Kontur den Pfad + Pick Color From: Farbe aufnehmen aus: + Artwork Bildmaterial + Cursor Swatch Preview Farbfeldvorschau am Zeiger + Selection: Auswahl: + Freeform Freiform + Straight Line Gerade Linie + Highlight: Hervorheben: + Stroke When Editable Kontur, wenn bearbeitbar Fill Color Flächenfarbe Fill New Type Objects With Placeholder Text Neue Textobjekte mit Platzhaltertext füllen Fill Rule Füllregel @@ -1193,6 +1224,8 @@ file-filter Templates Vorlagen Large Gaps Große Lücken Gap Preview Color: Vorschaufarbe für Lücken: Close Gaps With Paths Lücken mit Pfaden schließen + Show Live Paint Gaps Lücken beim interaktiven Malen einblenden + Hide Live Paint Gaps Lücken beim interaktiven Malen ausblenden Select Fills Flächen auswählen Select Strokes Konturen auswählen Gaussian Blur… Gaußscher Weichzeichner… @@ -1299,6 +1332,7 @@ file-filter Templates Vorlagen Hand-Drawn Handgezeichnet Handles Griffe Hard Light Hartes Licht + Hard Mix Hart mischen Harmony Rules: Harmonieregeln: Harmony Sets Harmoniesätze Height Höhe @@ -1347,7 +1381,7 @@ file-filter Templates Vorlagen Clear History Protokoll löschen Set History Limit… Protokollgrenze festlegen… History States Protokollschritte -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Datensatz anwenden Area Type Flächentext Bloat Aufblasen @@ -1835,6 +1869,9 @@ file-filter Templates Vorlagen Modified: Geändert: Modify Ändern Mojikumi Mojikumi + Insert Kashidas Kashidas einfügen + Long Lang + Stylistic Stilistisch Mojikumi Set Mojikumi-Satz Kinsoku Set Kinsoku-Satz Hard Hart @@ -1869,6 +1906,7 @@ file-filter Templates Vorlagen Move Slices Slices verschieben Move Spine Point Achsenpunkt verschieben Reshape Spine Segment Achsensegment umformen + Move Brush Pinsel verschieben Move Swatch Farbfeld verschieben Move Up Nach oben verschieben Move… Verschieben… @@ -1913,6 +1951,14 @@ file-filter Templates Vorlagen Round Fan Rund, Fächer Flat Fan Flach, Fächer New Brush… Neuer Pinsel… + New Brush Neuer Pinsel + Calligraphic Brush Kalligrafiepinsel + Scatter Brush Spezialpinsel + Art Brush Bildpinsel + Bristle Brush Borstenpinsel + Pattern Brush Musterpinsel + Select art to make a scatter, art or pattern brush. Wählen Sie eine Grafik aus, um einen Spezial-, Bild- oder Musterpinsel zu erstellen. + Brush Art Pinselgrafik New Calligraphic Brush Neuer Kalligrafiepinsel New Character Style Neues Zeichenformat New Color Group Neue Farbgruppe @@ -1943,6 +1989,7 @@ file-filter Templates Vorlagen New View Neue Ansicht New View… Neue Ansicht… New Window Neues Fenster + New Without Dialog Neu ohne Dialogfeld New Workspace Neuer Arbeitsbereich New Workspace… Neuer Arbeitsbereich… New {kind} Style… Neues {kind}format… @@ -2149,7 +2196,7 @@ file-filter Templates Vorlagen Package Verpacken Package collects a saved document with its linked files and fonts. „Verpacken“ sammelt ein gespeichertes Dokument mit seinen verknüpften Dateien und Schriften. Package created Paket erstellt - Packaged {n} file(s) in {folder}{note} {n} Datei(en) in {folder} verpackt{note} + Packaged {n} file(s) in {folder} {note} {n} Datei(en) in {folder} verpackt{note} Packaged {n} file(s){note} {n} Datei(en) verpackt{note} Package… Verpacken… Page Seite @@ -2212,7 +2259,7 @@ file-filter Templates Vorlagen Performance Leistung diagnostics render Rendern diagnostics ui Oberfläche - Performance & Storage Leistung und Speicher + Plug-ins & Scratch Disks Plug-ins & Arbeitsvolumes Permissions Berechtigungen Permissions password: Berechtigungskennwort: Perspective Perspektive @@ -2334,6 +2381,7 @@ mark-style Japanese Japanisch Proof Setup Proof einrichten Properties Eigenschaften Proportional Metrics Proportionale Metrik + Character Direction Zeichenrichtung Digits Ziffern Arabic Arabisch Hindi Hindi @@ -2528,6 +2576,7 @@ mark-style Japanese Japanisch Reorder Neu anordnen Reorder Appearance Item Aussehenselement neu anordnen Repeat Wiederholung + Repeat Objects Wiederholungsobjekte Repeat Options Wiederholungsoptionen Repeat Pathfinder Pathfinder wiederholen Repeat Tab Tabulator wiederholen @@ -2739,6 +2788,7 @@ mark-style Japanese Japanisch Scale Cursor Proportional to UI Cursor proportional zur Benutzeroberfläche skalieren Scale Lineweights Linienstärken skalieren Scale Strokes & Effects Konturen und Effekte skalieren + Scale Text Attributes Textattribute skalieren Scale to Fit Artboard Auf Zeichenfläche skalieren Scale Tool Skalieren-Werkzeug Scale: Skalierung: @@ -3012,6 +3062,7 @@ effect Points Punkte Snapping Tolerance Einrasttoleranz Social Square Post 1080×1080 Quadratischer Social-Media-Beitrag 1080×1080 Soft Light Weiches Licht + Soft Mix Weiche Mischung Solid Gefüllt Sort by Kind Nach Art sortieren Sort by Name Nach Name sortieren @@ -3022,6 +3073,7 @@ effect Points Punkte Space Before Paragraph Abstand vor Absatz Spaces Leerzeichen Spacing Guides Abstandshilfslinien + Invoke Distance Guides Distanzhilfslinien aufrufen Spacing: Abstand: Spanish Spanisch Specified Distance Festgelegter Abstand @@ -3142,7 +3194,6 @@ effect Points Punkte Show all open documents Alle geöffneten Dokumente anzeigen Symbol Instance Symbolinstanz Symbol Libraries Symbol-Bibliotheken - Symbol Libraries (on the roadmap) Symbol-Bibliotheken (geplant) Symbol Screener Tool Symbol-transparent-gestalten-Werkzeug Symbol Scruncher Tool Symbol-stauchen-Werkzeug Symbol Shifter Tool Symbol-verschieben-Werkzeug @@ -3235,6 +3286,7 @@ effect Points Punkte Tools Werkzeuge Top Oben Top in Front Oben im Vordergrund + Top-Justified Tab Oben ausgerichteter Tabulator Top: Oben: Total Width: Gesamtbreite: Touch Type Touch-Type @@ -3270,6 +3322,13 @@ effect Points Punkte Transparent Background Transparenter Hintergrund Transverse (the page a quarter turn on the paper) Quer (die Seite um eine Vierteldrehung auf dem Papier) Trap… Überfüllen… + Trap Überfüllen + Height/Width Höhe/Breite + Tint Reduction Farbtonreduzierung + Traps with Process Color Überfüllungen mit Prozessfarben + Reverse Traps Umgekehrte Überfüllungen + Precision Genauigkeit + Remove Redundant Points Überflüssige Punkte entfernen Trim Überlappungsbereich entfernen Trim mark weight: Stärke der Schnittmarken: Trim marks Schnittmarken @@ -3466,7 +3525,7 @@ effect Points Punkte Zoom Tool Zoom-Werkzeug Zoom with Mouse Wheel Mit Mausrad zoomen Scrub Numeric Fields by Dragging Zahlenfelder durch Ziehen ändern - {count} file(s) in {folder}{note}. Show the package folder? {count} Datei(en) in {folder}{note}. Paketordner anzeigen? + {count} file(s) in {folder} {note}. Show the package folder? {count} Datei(en) in {folder} {note}. Paketordner anzeigen? {count} linked file(s) not found {count} verknüpfte Datei(en) nicht gefunden fonts not copied: {fonts} nicht kopierte Schriften: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} verknüpfte(s) Bild(er) wurde(n) seit dem Speichern dieses Dokuments geändert. Die neuen Versionen anzeigen? @@ -3538,7 +3597,8 @@ imageTrace Outline Kontur to sample a color nimmt eine Farbe auf across a selected object to set the gradient direction über ein ausgewähltes Objekt legt die Verlaufsrichtung fest to select an artboard | wählt eine Zeichenfläche aus | - on the canvas to create one auf der Arbeitsfläche erstellt eine neue + on the canvas to create one ( auf der Arbeitsfläche erstellt eine neue ( + selects several, or draws one inside an artboard) wählt mehrere aus oder erstellt eine neue in einer Zeichenfläche) a stroke to add a width point | an einer Kontur fügt einen Breitenpunkt hinzu | to change one side | ändert nur eine Seite | to select more points | wählt weitere Punkte aus | @@ -3635,7 +3695,7 @@ theme Appearance Erscheinungsbild # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` keine Bibliothek `{id}` @msg select some type first wählen Sie zuerst Text aus @@ -3673,6 +3733,7 @@ theme Appearance Erscheinungsbild @msg Couldn't copy to the system clipboard: {e} In die Systemzwischenablage konnte nicht kopiert werden: {e} @msg Couldn't open {name}: {e} {name} konnte nicht geöffnet werden: {e} @msg Couldn't place {name}: the document it was dropped on was closed {name} konnte nicht platziert werden: Das Dokument, auf das die Datei gezogen wurde, wurde geschlossen +@msg Couldn't place {name}: the document it was placed in was closed {name} konnte nicht platziert werden: Das Dokument, in dem die Datei platziert wurde, wurde geschlossen @msg Couldn't read {name}: {e} {name} konnte nicht gelesen werden: {e} @msg Couldn't save recovery data: {e} Wiederherstellungsdaten konnten nicht gespeichert werden: {e} @msg Create Outlines: select text objects In Pfade umwandeln: Wählen Sie Textobjekte aus @@ -3709,8 +3770,9 @@ theme Appearance Erscheinungsbild @msg Opened {url} {url} geöffnet @msg Outline Stroke: no stroked paths selected Konturlinie: keine Pfade mit Kontur ausgewählt @msg PDF settings are not an object die PDF-Einstellungen sind kein Objekt +@msg PDF security settings are not an object die PDF-Sicherheitseinstellungen sind kein Objekt @msg PDF writer error: {0} Fehler beim Schreiben der PDF-Datei: {0} -@msg Packaged {n} file(s) in {folder}{note} {n} Datei(en) in {folder} verpackt{note} +@msg Packaged {n} file(s) in {folder} {note} {n} Datei(en) in {folder} verpackt{note} @msg Packaged {n} file(s){note} {n} Datei(en) verpackt{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) „Vorschau im Browser“ erfordert die Desktop-App (im Web werden stattdessen Dateien heruntergeladen) @msg Release Guides: no guides Hilfslinien zurückwandeln: keine Hilfslinien @@ -3780,6 +3842,25 @@ theme Appearance Erscheinungsbild @msg a PostScript error ({name} in `{op}`: {at}) ein PostScript-Fehler ({name} in `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} ein PostScript-Fehler ({name} in `{op}`: {at}), in {procs} @msg a PostScript error ({name}) ein PostScript-Fehler ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) ein PostScript-Fehler ({name} in `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} ein PostScript-Fehler ({name} in `{op}`; {meaning}), in {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) ein PostScript-Fehler ({name} in `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} ein PostScript-Fehler ({name} in `{op}`: {at}; {meaning}), in {procs} +@msg a PostScript error ({name}; {meaning}) ein PostScript-Fehler ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin das Programm beendet ein Wörterbuch, das es nicht begonnen hat +@msg the program opens a file, which the reader can't do das Programm öffnet eine Datei, was der PostScript-Leser nicht kann +@msg the program restores a saved state that was already restored das Programm stellt einen gesicherten Zustand wieder her, der bereits wiederhergestellt wurde +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none Daten, die das Programm liest, sind beschädigt oder abgeschnitten, oder das Programm fragt nach einer Position in einer Datei, die keine hat +@msg the program goes past one of the reader's limits das Programm überschreitet eine der Grenzen des PostScript-Lesers +@msg the path has no point to continue from der Pfad hat keinen Punkt, an dem er fortgesetzt werden kann +@msg a value is outside the range the operator takes ein Wert liegt außerhalb des Bereichs, den der Operator annimmt +@msg the operator needs more values than the stack holds der Operator braucht mehr Werte, als der Stapel enthält +@msg part of the program isn't valid PostScript text ein Teil des Programms ist kein gültiger PostScript-Text +@msg a value has the wrong type for the operator ein Wert hat den falschen Typ für den Operator +@msg the program runs, deletes or renames a file, which the reader can't do das Programm führt eine Datei aus, löscht sie oder benennt sie um, was der PostScript-Leser nicht kann +@msg the program asks for a resource that isn't defined das Programm fordert eine Ressource an, die nicht definiert ist +@msg a calculation has no result, as in a division by zero eine Berechnung hat kein Ergebnis, etwa bei einer Division durch null +@msg the operator looks for a mark on the stack and finds none der Operator sucht eine Marke auf dem Stapel und findet keine @msg a bitmap is empty eine Bitmap ist leer @msg a bitmap is too large to read eine Bitmap ist zu groß, um gelesen zu werden @msg a colour index is 0 to {_1}, not {n} ein Farbindex liegt zwischen 0 und {_1}, nicht {n} @@ -3812,6 +3893,7 @@ theme Appearance Erscheinungsbild @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis muss "vertical", "horizontal" oder ein Winkel in Grad sein, nicht `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black oder eine Farbe wie "#ff8800" @msg bad color {v} ungültige Farbe {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio muss eine Zahl von 0,1 bis 10 sein @msg the Color Picker is not open der Farbwähler ist nicht geöffnet @msg the Simplify slider is not open der Regler „Vereinfachen“ ist nicht geöffnet @msg the screen color sampler is already open die Farbaufnahme vom Bildschirm ist bereits geöffnet @@ -3862,7 +3944,7 @@ theme Appearance Erscheinungsbild @msg give the path `id` and the width point `index` geben Sie die `id` des Pfads und den `index` des Breitenpunkts an @msg guides are locked die Hilfslinien sind gesperrt @msg handle must be in or out handle muss in oder out sein -@msg image '{key}' has no pixels and was left out das Bild '{key}' hat keine Pixel und wurde ausgelassen +@msg an image has no pixels and was left out ein Bild ohne Pixel wurde ausgelassen @msg image data `{_1}` is missing die Bilddaten `{_1}` fehlen @msg image would be too large; lower the resolution das Bild wäre zu groß; verringern Sie die Auflösung @msg index must be a number or first, previous, next or last index muss eine Zahl oder first, previous, next oder last sein @@ -3889,7 +3971,7 @@ theme Appearance Erscheinungsbild @msg missing index der Index fehlt @msg missing number `{key}` die Zahl `{key}` fehlt @msg missing integer `{key}` die ganze Zahl `{key}` fehlt -@msg multiple windows land with M11.5 mehrere Fenster kommen mit M11.5 +@msg New Window isn't available yet: a document opens in one window Neues Fenster ist noch nicht verfügbar: ein Dokument öffnet sich in einem Fenster @msg name the file benennen Sie die Datei @msg no CSS to copy: select objects with CSS of their own kein CSS zum Kopieren: Wählen Sie Objekte mit eigenem CSS aus @msg no Live Paint edge near that point keine Interaktiv-malen-Kante in der Nähe dieses Punkts @@ -3907,7 +3989,9 @@ theme Appearance Erscheinungsbild @msg no blend options keine Angleichungsoptionen @msg no bounds keine Begrenzung @msg no brush named `{name}` kein Pinsel namens `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} der Name eines Borstenpinsels darf höchstens {max} Zeichen lang sein: `{name}` hat {count} @msg no brush selected kein Pinsel ausgewählt +@msg brushes move only within their type Pinsel lassen sich nur innerhalb ihrer Art verschieben @msg this kind of brush has no options dialog yet für diese Pinselart gibt es noch kein Optionsdialogfeld @msg no catalog kein Katalog @msg no changes since the last save keine Änderungen seit dem letzten Speichern @@ -3927,6 +4011,7 @@ theme Appearance Erscheinungsbild @msg no file system here hier gibt es kein Dateisystem @msg no file writer kein Dateischreiber @msg no files to place keine Dateien zum Platzieren +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` ist weder ein VectorCraft-Dokument noch eine PDF- oder PDF-kompatible .ai-Datei @msg no format to export to kein Format für den Export @msg no free recovery area kein freier Wiederherstellungsbereich @msg no gradient stop or freeform point is selected es ist kein Farbstopp und kein Freiformpunkt ausgewählt @@ -3962,6 +4047,7 @@ theme Appearance Erscheinungsbild @msg no such action keine solche Aktion @msg no such anchor kein solcher Ankerpunkt @msg no such artboard keine solche Zeichenfläche +@msg the artboard is locked die Zeichenfläche ist gesperrt @msg no such document kein solches Dokument @msg no such layer or object keine solche Ebene und kein solches Objekt @msg no such object {0} kein solches Objekt {0} @@ -4018,6 +4104,7 @@ theme Appearance Erscheinungsbild @msg path is already closed der Pfad ist bereits geschlossen @msg paths contain invalid coordinates die Pfade enthalten ungültige Koordinaten @msg paths: give at least one file paths: Geben Sie mindestens eine Datei an +@msg replace: the object to replace was changed or deleted replace: Das zu ersetzende Objekt wurde geändert oder gelöscht @msg pattern missing das Muster fehlt @msg patterns nested too deeply are filled with mid-grey zu tief verschachtelte Muster werden mit mittlerem Grau gefüllt @msg percent must be above 0 and at most 6400, not {p} percent muss über 0 und höchstens 6400 sein, nicht {p} @@ -4053,6 +4140,10 @@ theme Appearance Erscheinungsbild @msg select a repeat wählen Sie eine Wiederholung aus @msg select an Intertwine group wählen Sie eine Verflechtungsgruppe aus @msg select an Objects on Path group wählen Sie eine Gruppe „Objekte auf Pfad“ aus +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects ein Pfad in einem zusammengesetzten Pfad, einer Angleichung, einer Hülle, einer Wiederholung oder einer Verflechtungsgruppe kann keine Objekte aufnehmen +@msg a clipping path can't take objects ein Schnittpfad kann keine Objekte aufnehmen +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path die Objekte in einer Angleichung, einer Hülle, einer Wiederholung oder einer Verflechtungsgruppe können nicht an einen Pfad angehängt werden +@msg a clipping path can't be attached to a path ein Schnittpfad kann nicht an einen Pfad angehängt werden @msg select a stroke with a variable width wählen Sie eine Kontur mit variabler Breite aus @msg select a symbol instance wählen Sie eine Symbolinstanz aus @msg select an Image Trace object wählen Sie ein Bildnachzeichner-Objekt aus @@ -4172,6 +4263,7 @@ theme Appearance Erscheinungsbild @msg unknown workspace (one of {_1}) unbekannter Arbeitsbereich (einer von {_1}) @msg unsupported: {0} nicht unterstützt: {0} @msg variables are locked die Variablen sind gesperrt +@msg variables that aren't text were left out: {kinds} die Variablen, die kein Text sind, wurden ausgelassen: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files Dateien der Version {0} können nicht komprimiert werden: Erst Version {COMPRESSED_SINCE} und neuer öffnen komprimierte Dateien @msg width and height are at least 1 pixel Breite und Höhe betragen mindestens 1 Pixel @msg workspace name can't be empty der Name des Arbeitsbereichs darf nicht leer sein @@ -4183,6 +4275,7 @@ theme Appearance Erscheinungsbild @msg {_1} font families available {_1} Schriftfamilien verfügbar @msg {_1} is a live effect (Effect › Plug-ins) {_1} ist ein Live-Effekt (Effekt › Plug-ins) @msg {_1} is an exporter (File › Export › Export As…) {_1} ist ein Export-Plug-in (Datei › Exportieren › Exportieren als…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` ist {size} Byte groß (Grenze {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} wird bereits von {_2} verwendet @msg {_1} linked file(s) can't be read here: their previews show {_1} verknüpfte Datei(en) können hier nicht gelesen werden: Ihre Vorschauen werden angezeigt @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} verknüpfte(s) Bild(er) wurde(n) auf dem Datenträger geändert: „Verknüpfungen aktualisieren“ zeigt die neuen Versionen @@ -4211,6 +4304,8 @@ theme Appearance Erscheinungsbild @msg Saving Wird gespeichert @msg Saving {_1} {_1} wird gespeichert @msg Exporting {_1} {_1} wird exportiert +@msg Packaging {_1} {_1} wird verpackt +@msg Placing {_1} {_1} wird platziert @msg Saving recovery data for {_1} Wiederherstellungsdaten für {_1} werden gespeichert @msg Discarded {_1} recovered document(s) {_1} wiederhergestellte(s) Dokument(e) verworfen @msg Restored {_1} recovered document(s): save them to keep them {_1} wiederhergestellte(s) Dokument(e) geöffnet: Speichern Sie sie, um sie zu behalten @@ -4331,7 +4426,9 @@ theme Appearance Erscheinungsbild @msg automation filesystem access is not granted: read authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Leseberechtigung fehlt: {path} @msg automation filesystem access is not granted: write authority is absent: {path} Dateisystemzugriff für die Automatisierung ist nicht gewährt: Die Schreibberechtigung fehlt: {path} @msg automation path rejected: outside the read root {root}: {path} Automatisierungspfad abgelehnt: außerhalb des Lesestammordners {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} Automatisierungspfad abgelehnt: außerhalb der Lesestammordner {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} Automatisierungspfad abgelehnt: außerhalb des Schreibstammordners {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} Automatisierungspfad abgelehnt: außerhalb der Schreibstammordner {roots}: {path} @msg automation path rejected: path is empty Automatisierungspfad abgelehnt: Der Pfad ist leer @msg automation path rejected: a link that leads nowhere: {path} Automatisierungspfad abgelehnt: eine Verknüpfung, die nirgendwohin führt: {path} @msg automation path rejected: cannot resolve {path}: {e} Automatisierungspfad abgelehnt: {path} kann nicht aufgelöst werden: {e} @@ -4405,6 +4502,13 @@ theme Appearance Erscheinungsbild Tints Farbtöne Tints and Shades Farbtöne und Schattierungen Hue Shift Farbtonverschiebung + Tips Tipps + Colorization Tips Tipps zur Färbung + The samples show this brush on a blue stroke. Die Beispiele zeigen diesen Pinsel auf einer blauen Kontur. + The art keeps its own colors. Die Grafik behält ihre eigenen Farben. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Schwarz wird zur Konturfarbe und Weiß bleibt weiß. Andere Farben werden zu Farbtönen der Konturfarbe, und je heller eine Farbe ist, desto heller ist ihr Farbton. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Schwarz bleibt schwarz und Weiß bleibt weiß. Andere Farben werden zu Schattierungen der Konturfarbe, wenn sie dunkel sind, und zu Farbtönen davon, wenn sie hell sind. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Die Hauptfarbe wird zur Konturfarbe. Die anderen Farben wandern ebenso weit um den Farbkreis wie die Hauptfarbe und behalten ihre Sättigung und Helligkeit. RGB: RGB: CMYK: CMYK: # PSD files (opened and placed as their merged image). @@ -4489,6 +4593,7 @@ theme Appearance Erscheinungsbild Activate Panel Bedienfeld aktivieren Change Panel Layout Bedienfeldanordnung ändern Close Panel Bedienfeld schließen + Collapse Panel Group Bedienfeldgruppe minimieren Move Panel Bedienfeld verschieben Group with Gruppieren mit Resize panel window Bedienfeldfenster skalieren @@ -4499,6 +4604,12 @@ theme Appearance Erscheinungsbild @msg before must name a panel before muss ein Bedienfeld benennen @msg docking action contains an unknown panel or an excessive split path Die Docking-Aktion enthält ein unbekanntes Bedienfeld oder einen zu langen Teilungspfad @msg group must be true or false group muss true oder false sein +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} Bedienfeld ist abgelöst; seine Gruppe mit window.panel.float {panel, collapsed} minimieren +@msg panel is not in a dock group Bedienfeld ist in keiner Dock-Gruppe +@msg only a dock group above or below others collapses, not one beside another nur eine Dock-Gruppe über oder unter anderen lässt sich minimieren, nicht eine daneben +@msg icon must be true or false icon muss true oder false sein +@msg icon only docks a panel in the icon column, with no anchor icon dockt ein Bedienfeld nur in der Symbolspalte an, ohne anchor +@msg column docks a set as a dock column, with no anchor or icon column dockt einen Satz als Dock-Spalte an, ohne anchor oder icon @msg move requires a destination panel Das Verschieben erfordert ein Zielbedienfeld @msg move requires an existing destination panel Das Verschieben erfordert ein vorhandenes Zielbedienfeld @msg onto must name a floating panel onto muss ein schwebendes Bedienfeld benennen @@ -4573,12 +4684,30 @@ lighting Light Licht Reset Ruler Origin Linealursprung zurücksetzen Artistic Kunstfilter Film Grain… Filmkörnung… + Colored Pencil… Buntstift… + Colored Pencil Buntstift + Simple Einfach + Light Rough Leicht rau + Dark Rough Dunkel rau + Wide Sharp Breit scharf + Wide Blurry Breit unscharf + Sparkle Funkeln Cutout… Ausschnitt… Cutout Ausschnitt + Dry Brush… Trockenpinsel… + Dry Brush Trockenpinsel Neon Glow… Neonglühen… + Palette Knife… Palettenmesser… Neon Glow Neonglühen + Paint Daubs… Tupfer… + Paint Daubs Tupfer + Palette Knife Palettenmesser Poster Edges… Posterkanten… + Rough Pastels… Pastellkreide… Poster Edges Posterkanten + Rough Pastels Pastellkreide + Underpainting… Untermalung… + Underpainting Untermalung Levels Stufen Edge Simplicity Kantenvereinfachung Edge Fidelity Kantentreue @@ -4676,6 +4805,9 @@ effect Soft Weich Softness Weichheit Ambient Light Umgebungslicht Light behind the object Licht hinter dem Objekt + 3D Lighting Preset 3D-Beleuchtungsvorgabe + Standard Standard + Diffuse Diffus # 3D light management. Lights Lichter Add Light Licht hinzufügen @@ -4722,6 +4854,7 @@ effect Soft Weich @msg Measured anchor no longer exists Der gemessene Ankerpunkt existiert nicht mehr @msg Measured segment no longer exists Das gemessene Segment existiert nicht mehr @msg Non-finite dimension Die Bemaßung ist keine endliche Zahl +@msg Scale Text Attributes: selected type has no scale to bake Textattribute skalieren: Der ausgewählte Text weist keine zu übernehmende Skalierung auf @msg Scale must be drawing:real, for example 1:10 Der Maßstab muss Zeichnung:Realität sein, zum Beispiel 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Das Maßstabsverhältnis muss zwischen 1:1000000 und 1000000:1 liegen @msg Second measured segment no longer exists Das zweite gemessene Segment existiert nicht mehr @@ -4740,6 +4873,7 @@ effect Soft Weich Make Image Dynamic Bild dynamisch machen Import Data Sets… Datensätze importieren… Export Data Sets… Datensätze exportieren… + Load Variable Library… Variablenbibliothek laden… Make Graph Dynamic Diagramm dynamisch machen Batch… Stapelverarbeitung… Source Folder: Quellordner: @@ -4763,6 +4897,12 @@ effect Soft Weich Sketch Zeichenfilter Halftone Pattern… Rasterungseffekt… Halftone Pattern Rasterungseffekt + Torn Edges… Zerrissene Kanten… + Torn Edges Zerrissene Kanten + Conté Crayon… Conté-Stift… + Conté Crayon Conté-Stift + Foreground Level Vordergrundstufe + Background Level Hintergrundstufe Pattern Type Musterart Dot Punkt # User Interface › Color Scheme @@ -4781,6 +4921,7 @@ effect Soft Weich Watercolor… Aquarell… Watercolor Aquarell Brush Size Pinselgröße + Texture Coverage Texturabdeckung Definition Definition Brush Detail Pinseldetail Shadow Intensity Schattenintensität @@ -4810,13 +4951,21 @@ effect Soft Weich @msg the image's pixels can't be read die Pixel des Bildes können nicht gelesen werden Persistent Dauerhaft Library Panel Options Optionen für das Bibliotheksbedienfeld + Locate Library… Bibliothek ausfindig machen… @msg Persistent libraries not found: {files} Dauerhafte Bibliotheken nicht gefunden: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ist kein Bibliotheksbedienfeld (siehe window.swatchLibrary) @msg library panel `{given}` isn't open Bibliotheksbedienfeld `{given}` ist nicht geöffnet +@msg library panel `{given}` isn't missing its library dem Bibliotheksbedienfeld `{given}` fehlt seine Bibliothek nicht @msg too many library panels were opened in this session in dieser Sitzung wurden zu viele Bibliotheksbedienfelder geöffnet @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Das Bild hat {_1} × {_2} Pixel, mehr als {LARGE_TRACE_SIDE} auf einer Seite: Es wird nachgezeichnet, aber langsamer. Eine Vorgabe mit geringerer Detailtreue oder eine kleinere Kopie des Bildes geht schneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Das Bild wurde mit {_1} × {_2} Pixeln nachgezeichnet, verkleinert von {_3} × {_4} (höchstens {_5} Megapixel). + Snapping Einrasten + Snap to Tangent An Tangente ausrichten + Snap to Perpendicular An Senkrechter ausrichten +@msg unknown snap option `{key}` (use {options}) unbekannte Einrastoption `{key}` (möglich: {options}) + Gap Length: Lückenlänge: Artboard {n} of {total} Zeichenfläche {n} von {total} + File {n} of {total} Datei {n} von {total} Encoding and writing the file… Datei wird codiert und geschrieben… @plural {n} more export waiting|{n} more exports waiting {n} weiterer Export wartet|{n} weitere Exporte warten Continue in Background Im Hintergrund fortsetzen @@ -4833,6 +4982,14 @@ effect Soft Weich Minimum must not exceed Desired, nor Desired Maximum. Das Minimum darf den optimalen Wert nicht übersteigen, der optimale Wert nicht das Maximum. Minimum Minimum Justification Abstände + Add filters from the list above Filter aus der Liste oben hinzufügen + Add to Stack Zum Stapel hinzufügen + Applied Filters Angewendete Filter + Categories Kategorien + Choose a filter Filter wählen + Effect Gallery Effekt-Galerie + Filters Filter + Select artwork to apply effects Wählen Sie Grafik aus, um Effekte anzuwenden @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them die Variablen und Datensätze der Datei wurden nicht gelesen (Variablen: {variables}, Datensätze: {data_sets}): Das Bedienfeld „Variablen“ listet sie nicht auf # Select by Color (select.color) Select by Color Nach Farbe auswählen @@ -4854,3 +5011,107 @@ effect Soft Weich Plaster Gipsabdruck Note Paper… Prägepapier… Note Paper Prägepapier + Water Paper… Feuchtes Papier… + Water Paper Feuchtes Papier + Ruby… Ruby… + Ruby Ruby + Geometric Guides Geometrische Hilfslinien + Ease In Langsam starten + Ease Out Langsam enden + Ease In Out Langsam starten und enden + Ease Out In Langsam enden und starten + Ramp: Stärke: + Shift: Verschiebung: + Fresco… Fresko… + Fresco Fresko + Effect Gallery… Effekt-Galerie… + Effect Gallery Dialog Dialogfeld „Effekt-Galerie“ + Arc Segment Tool Options Optionen für das Bogensegment-Werkzeug + Base Along: Basis entlang: + Closed Geschlossen + Concave Konkav + Convex Konvex + Fill Arc Bogen füllen + Length X-Axis: Länge X-Achse: + Length Y-Axis: Länge Y-Achse: + Slope: Steigung: +axis X Axis X-Achse +axis Y Axis Y-Achse +@msg Packaged {n} file(s) in {folder} {n} Datei(en) in {folder} verpackt +@msg Packaged {n} file(s) {note} {n} Datei(en) verpackt {note} +@msg Packaged {n} file(s) {n} Datei(en) verpackt + Packaged {n} file(s) {n} Datei(en) verpackt + Packaged {n} file(s) in {folder} {n} Datei(en) in {folder} verpackt + Packaged {n} file(s) {note} {n} Datei(en) verpackt {note} + {count} file(s) in {folder}. Show the package folder? {count} Datei(en) in {folder}. Paketordner anzeigen? + Clockwise Im Uhrzeigersinn + Dither Dithering + Fine noise hides banding in the gradient fill Feines Rauschen verbirgt Streifen in der Verlaufsfläche +@msg `dither` applies to gradient fills (stroke: false) `dither` gilt für Verlaufsflächen (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` gilt für Verlaufsflächen (paint.setFill) +@msg `dither` must be true or false `dither` muss true oder false sein + Perceptual Perzeptiv +@msg `method` must be classic or perceptual `method` muss classic oder perceptual sein +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? „{name}“ installieren, das ein großes Rechenbudget verlangt? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jede Ausführung kann bis zu {minutes} Minuten dauern und {times}-mal so viel Arbeit leisten, wie andere Plug-ins dürfen. Sie läuft im Hintergrund, und Sie können sie abbrechen. + It may take minutes, so it runs in the background, where you can cancel it. Das kann Minuten dauern, daher läuft es im Hintergrund, wo Sie es abbrechen können. + The plug-in is computing… Das Plug-in rechnet… + Inspect Plug-in Plug-in prüfen + Cancel Plug-in Run Plug-in-Ausführung abbrechen +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} ist fertig, aber das Dokument oder das Plug-in hat sich inzwischen geändert: Führen Sie es erneut aus +@msg Running “{name}”… „{name}“ wird ausgeführt… +@msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein +@msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt +# Actions panel: action sets (#3712). + New Set… Neuer Satz… + New Set Neuer Satz + Set Options… Satzoptionen… + Set Options Satzoptionen + Create New Set Neuen Satz erstellen + Stop Global Edit Globale Bearbeitung beenden + Global Edit Options Optionen für globale Bearbeitung + Match Übereinstimmung + On Artboards Auf Zeichenflächen + All Artboards Alle Zeichenflächen + Include Objects On Canvas Objekte auf der Leinwand einschließen + Result(s): {count} Ergebnis(se): {count} +@msg select one object wählen Sie ein Objekt aus +@msg Global Edit works on paths, compound paths and symbol instances Globale Bearbeitung funktioniert mit Pfaden, zusammengesetzten Pfaden und Symbolinstanzen +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again keine ähnlichen Objekte gefunden (gedrehte oder gespiegelte Kopien werden nicht erkannt); ändern Sie die Optionen für globale Bearbeitung und versuchen Sie es erneut +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all oder ein Bereich wie 1, 3-5, nicht {v} + Add to Symbols Zu Symbolen hinzufügen + Basic Shapes Grundformen + Open Symbol Library Symbol-Bibliothek öffnen + Save Symbol Library Symbol-Bibliothek speichern + Save Symbol Library… Symbol-Bibliothek speichern… + Save Symbols… Symbole speichern… + Selected Symbols Only Nur ausgewählte Symbole + Symbol Libraries Menu Menü „Symbol-Bibliotheken“ + Symbol Library Symbol-Bibliothek + User Symbol Library 1 Benutzer-Symbol-Bibliothek 1 + User Symbol Library 2 Benutzer-Symbol-Bibliothek 2 + User Symbol Library 3 Benutzer-Symbol-Bibliothek 3 + User Symbol Library 4 Benutzer-Symbol-Bibliothek 4 + User Symbol Library 5 Benutzer-Symbol-Bibliothek 5 + User Symbol Library 6 Benutzer-Symbol-Bibliothek 6 + User Symbol Library 7 Benutzer-Symbol-Bibliothek 7 + User Symbol Library 8 Benutzer-Symbol-Bibliothek 8 + User Symbol Library 9 Benutzer-Symbol-Bibliothek 9 + User Symbol Library 10 Benutzer-Symbol-Bibliothek 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` hat keine Symbole zum Platzieren +@msg missing `library` (see symbol.libraries) `library` fehlt (siehe symbol.libraries) +@msg no symbol `{n}` kein Symbol `{n}` +@msg no symbol `{n}` in `{_1}` kein Symbol `{n}` in `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) keine Symbol-Bibliothek `{key}` (siehe symbol.libraries) +@msg the document has no symbols to save das Dokument hat keine Symbole zum Speichern +@msg Trap: select two or more objects filled with solid colours Überfüllen: Wählen Sie zwei oder mehr Objekte mit Volltonflächen aus +@msg Trap: no edges of different colours meet among the selected objects Überfüllen: Unter den ausgewählten Objekten treffen keine Kanten verschiedener Farben aufeinander +@msg thickness must be from 0.01 to 5000 pt thickness muss zwischen 0,01 und 5000 pt liegen +@msg heightWidth must be from 1 to 1000 % heightWidth muss zwischen 1 und 1000 % liegen +@msg tintReduction must be from 0 to 100 % tintReduction muss zwischen 0 und 100 % liegen + Rectangular Grid Tool Options Optionen für das Rechteckiges-Raster-Werkzeug + Polar Grid Tool Options Optionen für das Radiales-Raster-Werkzeug + Fill Line Linie füllen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d579c0aa2..98bf3c71b 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -29,6 +29,13 @@ Channel 4 Κανάλι 4 Cell Size Μέγεθος κελιού Fine Dots Λεπτές κουκκίδες + + Simple Απλό + Light Rough Ελαφρύ τραχύ + Dark Rough Σκούρο τραχύ + Wide Sharp Ευρύ οξύ + Wide Blurry Ευρύ θολό + Sparkle Λάμψη Medium Dots Μεσαίες κουκκίδες Grainy Dots Κοκκώδεις κουκκίδες Coarse Dots Χοντρές κουκκίδες @@ -98,7 +105,7 @@ Add Selected Artwork (Alt: as one asset) Προσθήκη επιλεγμένου γραφικού (Alt: ως ένα στοιχείο) Add Selected Artwork as Multiple Assets Προσθήκη επιλεγμένου γραφικού ως πολλών στοιχείων Add Selected Artwork as One Asset Προσθήκη επιλεγμένου γραφικού ως ενός στοιχείου - Add Spine Point Προσθήκη σημείου άξονα + Add Spine Point Προσθήκη σημείου κεντρικής καμπύλης Add Swatches Προσθήκη δειγμάτων χρωμάτων Add to Graphic Styles Προσθήκη στα στυλ γραφικών Add to Profiles Προσθήκη στα προφίλ @@ -167,6 +174,8 @@ Anti-aliased Artwork Γραφικά με εξομαλυμένες ακμές Anti-aliasing: Εξομάλυνση ακμών: Any Kind Οποιοσδήποτε τύπος + Font Technology Τεχνολογία γραμματοσειράς +font-technology Variable Μεταβλητές Appearance Εμφάνιση Appearance Attribute Χαρακτηριστικό εμφάνισης Appearance Item Στοιχείο εμφάνισης @@ -248,6 +257,11 @@ Average… Μέσος όρος… Axis: Άξονας: Background Color: Χρώμα φόντου: + Show Center Mark Εμφάνιση σημαδιού κέντρου + Show Cross Hairs Εμφάνιση σταυρονήματος + Show Video Safe Areas Εμφάνιση ασφαλών περιοχών βίντεο + Video Ruler Pixel Aspect Ratio: Αναλογία διαστάσεων pixel χαράκων βίντεο: + Hide Video Rulers Απόκρυψη χαράκων βίντεο Background Contents Περιεχόμενα φόντου Background Contents: Περιεχόμενο φόντου: Background: Φόντο: @@ -285,6 +299,7 @@ Blur Θόλωση Bottom Κάτω Bottom in Front Κάτω αντικείμενο μπροστά + Bottom-Justified Tab Στηλοθέτης κάτω στοίχισης Bottom: Κάτω: Bounds Όρια Bounds (Fill Proportionally) Όρια (αναλογικό γέμισμα) @@ -312,6 +327,13 @@ Built-in Libraries Ενσωματωμένες βιβλιοθήκες Bulge… Διόγκωση… Bullet Κουκκίδα + Bullets Κουκκίδες + Bullets and Numbering Κουκκίδες και αρίθμηση + Apply Bullets Εφαρμογή κουκκίδων + Filled Dot Γεμάτη κουκκίδα + Dash Παύλα + Hollow Circle Κενός κύκλος + Filled Square Γεμάτο τετράγωνο Butt Cap Επίπεδο άκρο Byte Order: Σειρά byte: Calligraphic Καλλιγραφικό @@ -671,7 +693,7 @@ Delete Selected Item Διαγραφή επιλεγμένου στοιχείου Delete Selection Διαγραφή επιλογής Delete Slices Διαγραφή τομών - Delete Spine Point Διαγραφή σημείου άξονα + Delete Spine Point Διαγραφή σημείου κεντρικής καμπύλης Delete Stop Διαγραφή στάσης ντεγκραντέ Delete Swatch Διαγραφή δείγματος χρώματος Delete Symbol Διαγραφή συμβόλου @@ -998,6 +1020,7 @@ Eyedropper Picks Up: Το σταγονόμετρο λαμβάνει: Eyedropper Tool Σταγονόμετρο (Eyedropper Tool) Eyedropper: click the art to sample the stop's colour Σταγονόμετρο: κλικ στα γραφικά για δειγματοληψία χρώματος σημείου διακοπής + Eyedropper: click the art to sample the key color Σταγονόμετρο: κλικ στα γραφικά για δειγματοληψία βασικού χρώματος Favorites Αγαπημένα Feather… Απαλό περίγραμμα… Feet Πόδια @@ -1006,6 +1029,11 @@ Fidelity: Πιστότητα: Figure Space Κενό ψηφίου Figure: Σχήμα: +# Artwork drag between document tabs. + Drop Artwork Απόθεση γραφικών +@msg {key} must be [x, y] with finite coordinates Το {key} πρέπει να είναι [x, y] με πεπερασμένες συντεταγμένες +@msg ids must contain 1–10000 object ids Το ids πρέπει να περιέχει 1–10000 αναγνωριστικά αντικειμένων + File Αρχείο File Dimensions Διαστάσεις αρχείου File Format: Μορφή αρχείου: @@ -1140,6 +1168,8 @@ Large Gaps Μεγάλα διάκενα Gap Preview Color: Χρώμα προεπισκόπησης διακένων: Close Gaps With Paths Κλείσιμο διακένων με διαδρομές + Show Live Paint Gaps Εμφάνιση διακένων δυναμικής βαφής + Hide Live Paint Gaps Απόκρυψη διακένων δυναμικής βαφής Select Fills Επιλογή γεμισμάτων Select Strokes Επιλογή περιγραμμάτων Gaussian Blur… Θόλωση Γκάους… @@ -1244,6 +1274,7 @@ Hand-Drawn Σχεδιασμένο στο χέρι Handles Λαβές Hard Light Έντονο φως + Hard Mix Έντονη ανάμειξη Harmony Rules: Κανόνες χρωματικής αρμονίας: Harmony Sets Σύνολα χρωματικής αρμονίας Height Ύψος @@ -1292,7 +1323,7 @@ Clear History Εκκαθάριση ιστορικού Set History Limit… Ορισμός ορίου ιστορικού… History States Καταστάσεις ιστορικού -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Εφαρμογή συνόλου δεδομένων Cancel Pattern Editing Ακύρωση επεξεργασίας μοτίβου Copy Appearance Αντιγραφή εμφάνισης @@ -1621,9 +1652,18 @@ Pressure Πίεση Shape: Σχήμα: New Brush… Νέο πινέλο… + New Brush Νέο πινέλο + Calligraphic Brush Καλλιγραφικό πινέλο + Scatter Brush Πινέλο διασποράς + Art Brush Πινέλο σχεδίου + Bristle Brush Πινέλο με τρίχες + Pattern Brush Πινέλο μοτίβου + Select art to make a scatter, art or pattern brush. Επιλέξτε σχέδιο για να δημιουργήσετε πινέλο διασποράς, σχεδίου ή μοτίβου. + Brush Art Σχέδιο πινέλου New Layer… Νέο επίπεδο… New Swatch… Νέο δείγμα χρώματος… New Window Νέο παράθυρο + New Without Dialog Νέο χωρίς παράθυρο διαλόγου New Workspace Νέος χώρος εργασίας New Workspace… Νέος χώρος εργασίας… New… Νέο… @@ -1717,6 +1757,7 @@ Proof Colors Χρώματα δοκιμίου Proof Setup Ρύθμιση δοκιμίου Properties Ιδιότητες + Character Direction Κατεύθυνση χαρακτήρων Digits Ψηφία Punctuation Στίξη Puppet Warp Παραμόρφωση με σημεία ελέγχου @@ -1845,6 +1886,7 @@ Snap to Point Προσκόλληση σε σημείο Snap to Unit Προσκόλληση σε μονάδα Soft Light Απαλό φως + Soft Mix Απαλή ανάμειξη Solid Συνεχές Spacing: Απόσταση: Total: Σύνολο: @@ -1900,6 +1942,13 @@ Transparency Διαφάνεια Transparent Διαφανές Trap… Παγίδευση χρωμάτων… + Trap Παγίδευση χρωμάτων + Height/Width Ύψος/Πλάτος + Tint Reduction Μείωση απόχρωσης + Traps with Process Color Παγιδεύσεις με χρώματα τετραχρωμίας + Reverse Traps Αντίστροφες παγιδεύσεις + Precision Ακρίβεια + Remove Redundant Points Αφαίρεση περιττών σημείων Trim Περικοπή περιθωρίων Twist… Συστροφή… Type Κείμενο @@ -2026,6 +2075,7 @@ axis Both Και τα δύο @msg Couldn't copy to the system clipboard: {e} Δεν ήταν δυνατή η αντιγραφή στο πρόχειρο συστήματος: {e} @msg Couldn't open {name}: {e} Δεν ήταν δυνατό το άνοιγμα του {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Δεν ήταν δυνατή η τοποθέτηση του {name}: το έγγραφο στο οποίο αποθέσατε το αρχείο έκλεισε +@msg Couldn't place {name}: the document it was placed in was closed Δεν ήταν δυνατή η τοποθέτηση του {name}: το έγγραφο στο οποίο τοποθετούνταν το αρχείο έκλεισε @msg Couldn't read {name}: {e} Δεν ήταν δυνατή η ανάγνωση του {name}: {e} @msg Couldn't save recovery data: {e} Δεν ήταν δυνατή η αποθήκευση δεδομένων ανάκτησης: {e} @msg Create Outlines: select text objects Δημιουργία περιγραμμάτων: επιλέξτε αντικείμενα κειμένου @@ -2062,13 +2112,14 @@ axis Both Και τα δύο @msg Opened {url} Άνοιξε το {url} @msg Outline Stroke: no stroked paths selected Μετατροπή περιγράμματος σε διαδρομή: δεν επιλέχθηκαν διαδρομές με περίγραμμα @msg PDF settings are not an object Οι ρυθμίσεις PDF δεν είναι αντικείμενο +@msg PDF security settings are not an object Οι ρυθμίσεις ασφαλείας PDF δεν είναι αντικείμενο @msg PDF writer error: {0} Σφάλμα εγγραφής PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} Συσκευάστηκαν {n} αρχεία στον φάκελο {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Συσκευάστηκαν {n} αρχεία στον φάκελο {folder}{note} @msg Packaged {n} file(s){note} Συσκευάστηκαν {n} αρχεία{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Η προεπισκόπηση σε πρόγραμμα περιήγησης απαιτεί την εφαρμογή υπολογιστή (η έκδοση ιστού πραγματοποιεί λήψη αρχείων) @msg Release Guides: no guides Αποδέσμευση οδηγών: δεν υπάρχουν οδηγοί @msg Release: no layer to put the rectangle in Αποδέσμευση: δεν υπάρχει επίπεδο για να τοποθετηθεί το ορθογώνιο -@msg Replace Spine: select a blend and a path Αντικατάσταση άξονα: επιλέξτε ανάμειξη και διαδρομή +@msg Replace Spine: select a blend and a path Αντικατάσταση κεντρικής καμπύλης: επιλέξτε ανάμειξη και διαδρομή @msg select two or more objects to distribute Επιλέξτε δύο ή περισσότερα αντικείμενα για κατανομή @msg select paths or anchor points Επιλέξτε διαδρομές ή σημεία αγκύρωσης @msg SVG parse error: {0} Σφάλμα ανάλυσης SVG: {0} @@ -2207,6 +2258,7 @@ effect Soft Απαλό Activate Panel Ενεργοποίηση πάνελ Change Panel Layout Αλλαγή διάταξης πάνελ Close Panel Κλείσιμο πάνελ + Collapse Panel Group Σύμπτυξη ομάδας πάνελ Move Panel Μετακίνηση πάνελ Group with Ομαδοποίηση με Resize panels Αλλαγή μεγέθους πάνελ @@ -2245,12 +2297,24 @@ lighting Light Φως Left Diagonal Αριστερή διαγώνιος Artistic Καλλιτεχνικό Film Grain… Κόκκος φιλμ… + Colored Pencil… Έγχρωμο μολύβι… + Colored Pencil Έγχρωμο μολύβι Cutout… Αποκοπή… Cutout Αποκοπή + Dry Brush… Ξηρό πινέλο… + Dry Brush Ξηρό πινέλο Neon Glow… Λάμψη νέον… + Palette Knife… Μαχαίρι παλέτας… Neon Glow Λάμψη νέον + Paint Daubs… Πινελιές βαφής… + Paint Daubs Πινελιές βαφής + Palette Knife Μαχαίρι παλέτας Poster Edges… Άκρες αφίσας… + Rough Pastels… Χοντρά παστέλ… Poster Edges Άκρες αφίσας + Rough Pastels Χοντρά παστέλ + Underpainting… Υποζωγραφία… + Underpainting Υποζωγραφία Levels Επίπεδα Edge Simplicity Απλότητα άκρων Edge Fidelity Πιστότητα άκρων @@ -2545,6 +2609,9 @@ theme Appearance Εμφάνιση Modified since it was read: {path} Τροποποιήθηκε μετά την ανάγνωση: {path} Modified: Τροποποιήθηκε: Mojikumi Mojikumi + Insert Kashidas Εισαγωγή κασίντα + Long Μακρύ + Stylistic Στιλιστικό Mojikumi Set Σύνολο Mojikumi Kinsoku Set Σύνολο Kinsoku Mono Μονοφωνικό @@ -2574,6 +2641,7 @@ theme Appearance Εμφάνιση Move Slices Μετακίνηση τομών Move Spine Point Μετακίνηση σημείου κεντρικής καμπύλης Reshape Spine Segment Αναδιαμόρφωση τμήματος κεντρικής καμπύλης + Move Brush Μετακίνηση πινέλου Move Swatch Μετακίνηση δείγματος χρώματος Move Up Μετακίνηση επάνω My Themes Τα θέματά μου @@ -2616,6 +2684,13 @@ theme Appearance Εμφάνιση Tints and Shades Αποχρώσεις και σκιάσεις Hue Shift Μετατόπιση απόχρωσης Key Color: Βασικό χρώμα: + Tips Συμβουλές + Colorization Tips Συμβουλές χρωματισμού + The samples show this brush on a blue stroke. Τα δείγματα δείχνουν αυτό το πινέλο σε μπλε περίγραμμα. + The art keeps its own colors. Το σχέδιο διατηρεί τα δικά του χρώματα. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Το μαύρο γίνεται το χρώμα του περιγράμματος και το λευκό μένει λευκό. Τα άλλα χρώματα γίνονται αποχρώσεις του χρώματος του περιγράμματος, και όσο πιο ανοιχτό είναι ένα χρώμα, τόσο πιο ανοιχτή είναι η απόχρωσή του. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Το μαύρο μένει μαύρο και το λευκό μένει λευκό. Τα άλλα χρώματα γίνονται σκιάσεις του χρώματος του περιγράμματος όταν είναι σκούρα και αποχρώσεις του όταν είναι ανοιχτά. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Το βασικό χρώμα γίνεται το χρώμα του περιγράμματος. Τα άλλα χρώματα μετακινούνται στον χρωματικό τροχό όσο και το βασικό χρώμα και διατηρούν τον κορεσμό και τη φωτεινότητά τους. Bristle Length: Μήκος τριχών: Bristle Density: Πυκνότητα τριχών: Bristle Thickness: Πάχος τριχών: @@ -2788,7 +2863,7 @@ theme Appearance Εμφάνιση Override Character Color Παράκαμψη χρώματος χαρακτήρων Package collects a saved document with its linked files and fonts. Η συσκευασία συγκεντρώνει ένα αποθηκευμένο έγγραφο μαζί με συνδεδεμένα αρχεία και γραμματοσειρές. Package created Η συσκευασία δημιουργήθηκε - Packaged {n} file(s) in {folder}{note} Συσκευάστηκαν {n} αρχεία στον φάκελο {folder}{note} + Packaged {n} file(s) in {folder} {note} Συσκευάστηκαν {n} αρχεία στον φάκελο {folder}{note} Packaged {n} file(s){note} Συσκευάστηκαν {n} αρχεία{note} Page Σελίδα Page information Πληροφορίες σελίδας @@ -2828,7 +2903,7 @@ theme Appearance Εμφάνιση PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged. Τα PDF/X-1a είναι CMYK: κάθε χρώμα μετατρέπεται στον προορισμό (αν είναι κενό, στο προφίλ CMYK της εξόδου), χωρίς ετικέτα. per slice ανά τομή Perceptual Scales Αντιληπτικές κλίμακες - Performance & Storage Επιδόσεις και αποθήκευση + Plug-ins & Scratch Disks Πρόσθετα & Δίσκοι προσωρινής αποθήκευσης Permissions Δικαιώματα Permissions password: Κωδικός δικαιωμάτων: Perspective Grid Πλέγμα προοπτικής @@ -3069,6 +3144,7 @@ theme Appearance Εμφάνιση Rendering intent: Σκοπός απόδοσης: Reorder Αναδιάταξη Reorder Appearance Item Αναδιάταξη στοιχείου εμφάνισης + Repeat Objects Αντικείμενα επανάληψης Repeat Options Επιλογές επανάληψης Repeat Pathfinder Επανάληψη Pathfinder Repeat Tab Επανάληψη στηλοθέτη @@ -3204,6 +3280,7 @@ theme Appearance Εμφάνιση Scale Cursor Proportional to UI Αναλογική κλιμάκωση δείκτη ως προς το περιβάλλον Scale Lineweights Κλιμάκωση πάχους γραμμών Scale Strokes & Effects Κλιμάκωση περιγραμμάτων και εφέ + Scale Text Attributes Κλιμάκωση ιδιοτήτων κειμένου Scale to Fit Artboard Κλιμάκωση για προσαρμογή στον πίνακα σχεδίασης Scale Tool Κλιμάκωση (Scale Tool) Scallop Tool Κυματιστές άκρες (Scallop Tool) @@ -3406,6 +3483,7 @@ theme Appearance Εμφάνιση Space Before Paragraph Διάστημα πριν από την παράγραφο Spaces Διαστήματα Spacing Guides Οδηγοί αποστάσεων + Invoke Distance Guides Εμφάνιση οδηγών μέτρησης απόστασης Spanish Ισπανικά Specified Distance Καθορισμένη απόσταση Specified Steps Καθορισμένα βήματα @@ -3511,7 +3589,6 @@ theme Appearance Εμφάνιση Show all open documents Εμφάνιση όλων των ανοιχτών εγγράφων Symbol Instance Στιγμιότυπο συμβόλου Symbol Libraries Βιβλιοθήκες συμβόλων - Symbol Libraries (on the roadmap) Βιβλιοθήκες συμβόλων (προγραμματισμένη λειτουργία) Symbol Screener Tool Αραίωση συμβόλων (Symbol Screener Tool) Symbol Scruncher Tool Σύμπτυξη συμβόλων (Symbol Scruncher Tool) Symbol Shifter Tool Μετατόπιση συμβόλων (Symbol Shifter Tool) @@ -3584,6 +3661,7 @@ theme Appearance Εμφάνιση Toolbar: Remove Tool Γραμμή εργαλείων: Αφαίρεση εργαλείου Toolbar: Reset Γραμμή εργαλείων: Επαναφορά Top in Front Επάνω μπροστά + Top-Justified Tab Στηλοθέτης επάνω στοίχισης Total Width: Συνολικό πλάτος: Touch Type Tool Επεξεργασία χαρακτήρων με αφή (Touch Type Tool) Trace Διανυσματοποίηση @@ -3771,7 +3849,7 @@ theme Appearance Εμφάνιση Zoom to Selection Ζουμ στην επιλογή Zoom with Mouse Wheel Ζουμ με τον τροχό του ποντικιού Scrub Numeric Fields by Dragging Ρύθμιση αριθμητικών πεδίων με σύρσιμο - {count} file(s) in {folder}{note}. Show the package folder? {count} αρχεία στον φάκελο {folder}{note}. Να εμφανιστεί ο φάκελος συσκευασίας; + {count} file(s) in {folder} {note}. Show the package folder? {count} αρχεία στον φάκελο {folder}{note}. Να εμφανιστεί ο φάκελος συσκευασίας; {count} linked file(s) not found Δεν βρέθηκαν {count} συνδεδεμένα αρχεία fonts not copied: {fonts} γραμματοσειρές που δεν αντιγράφηκαν: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} συνδεδεμένες εικόνες άλλαξαν μετά την αποθήκευση του εγγράφου. Να εμφανιστούν οι νέες εκδόσεις; @@ -3823,6 +3901,25 @@ theme Appearance Εμφάνιση @msg a PostScript error ({name} in `{op}`: {at}) σφάλμα PostScript ({name} στην εντολή `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} σφάλμα PostScript ({name} στην εντολή `{op}`: {at}), στις διαδικασίες {procs} @msg a PostScript error ({name}) σφάλμα PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) σφάλμα PostScript ({name} στην εντολή `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} σφάλμα PostScript ({name} στην εντολή `{op}`; {meaning}), στις διαδικασίες {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) σφάλμα PostScript ({name} στην εντολή `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} σφάλμα PostScript ({name} στην εντολή `{op}`: {at}; {meaning}), στις διαδικασίες {procs} +@msg a PostScript error ({name}; {meaning}) σφάλμα PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin το πρόγραμμα κλείνει ένα λεξικό που δεν άνοιξε +@msg the program opens a file, which the reader can't do το πρόγραμμα ανοίγει ένα αρχείο, κάτι που ο αναγνώστης δεν μπορεί να κάνει +@msg the program restores a saved state that was already restored το πρόγραμμα επαναφέρει μια αποθηκευμένη κατάσταση που έχει ήδη επαναφερθεί +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none τα δεδομένα που διαβάζει το πρόγραμμα είναι κατεστραμμένα ή περικομμένα, ή το πρόγραμμα ζητά θέση σε ένα αρχείο που δεν έχει θέση +@msg the program goes past one of the reader's limits το πρόγραμμα ξεπερνά ένα από τα όρια του αναγνώστη +@msg the path has no point to continue from η διαδρομή δεν έχει σημείο από το οποίο να συνεχίσει +@msg a value is outside the range the operator takes μια τιμή είναι εκτός του εύρους που δέχεται η εντολή +@msg the operator needs more values than the stack holds η εντολή χρειάζεται περισσότερες τιμές από όσες έχει η στοίβα +@msg part of the program isn't valid PostScript text μέρος του προγράμματος δεν είναι έγκυρο κείμενο PostScript +@msg a value has the wrong type for the operator μια τιμή έχει λάθος τύπο για την εντολή +@msg the program runs, deletes or renames a file, which the reader can't do το πρόγραμμα εκτελεί, διαγράφει ή μετονομάζει ένα αρχείο, κάτι που ο αναγνώστης δεν μπορεί να κάνει +@msg the program asks for a resource that isn't defined το πρόγραμμα ζητά έναν πόρο που δεν έχει οριστεί +@msg a calculation has no result, as in a division by zero ένας υπολογισμός δεν έχει αποτέλεσμα, όπως σε μια διαίρεση με το μηδέν +@msg the operator looks for a mark on the stack and finds none η εντολή αναζητά ένα σημάδι στη στοίβα και δεν βρίσκει κανένα @msg a bitmap is empty το bitmap είναι κενό @msg a bitmap is too large to read το bitmap είναι πολύ μεγάλο για ανάγνωση @msg a colour index is 0 to {_1}, not {n} ο δείκτης χρώματος πρέπει να είναι από 0 έως {_1}, όχι {n} @@ -3855,6 +3952,7 @@ theme Appearance Εμφάνιση @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` ο άξονας πρέπει να είναι "vertical", "horizontal" ή γωνία σε μοίρες, όχι `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" φόντο {other}: transparent, white, black ή χρώμα όπως "#ff8800" @msg bad color {v} μη έγκυρο χρώμα {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 Το videoRulerPixelAspectRatio πρέπει να είναι αριθμός από 0,1 έως 10 @msg bad colour `{hex}` (#rrggbb) μη έγκυρο χρώμα `{hex}` (#rrggbb) @msg bad page tree μη έγκυρο δέντρο σελίδων @msg bad shortcut μη έγκυρη συντόμευση @@ -3901,7 +3999,7 @@ theme Appearance Εμφάνιση @msg give the path `id` and the width point `index` δώστε το `id` διαδρομής και το `index` σημείου πλάτους @msg guides are locked οι οδηγοί είναι κλειδωμένοι @msg handle must be in or out το handle πρέπει να είναι in ή out -@msg image '{key}' has no pixels and was left out η εικόνα '{key}' δεν περιέχει εικονοστοιχεία και παραλείφθηκε +@msg an image has no pixels and was left out παραλείφθηκε εικόνα χωρίς εικονοστοιχεία @msg image data `{_1}` is missing λείπουν τα δεδομένα εικόνας `{_1}` @msg image would be too large; lower the resolution η εικόνα θα ήταν υπερβολικά μεγάλη· μειώστε την ανάλυση @msg index must be a number or first, previous, next or last ο δείκτης πρέπει να είναι αριθμός ή first, previous, next ή last @@ -3928,7 +4026,7 @@ theme Appearance Εμφάνιση @msg missing index λείπει ο δείκτης @msg missing number `{key}` λείπει ο αριθμός `{key}` @msg missing integer `{key}` λείπει ο ακέραιος `{key}` -@msg multiple windows land with M11.5 τα πολλαπλά παράθυρα θα προστεθούν στην έκδοση M11.5 +@msg New Window isn't available yet: a document opens in one window Το Νέο παράθυρο δεν είναι ακόμη διαθέσιμο: ένα έγγραφο ανοίγει σε ένα παράθυρο @msg name the file δώστε όνομα στο αρχείο @msg no CSS to copy: select objects with CSS of their own δεν υπάρχει CSS για αντιγραφή: επιλέξτε αντικείμενα με δικό τους CSS @msg no Live Paint edge near that point δεν υπάρχει ακμή δυναμικής βαφής κοντά σε αυτό το σημείο @@ -3946,7 +4044,9 @@ theme Appearance Εμφάνιση @msg no blend options δεν υπάρχουν επιλογές ανάμειξης @msg no bounds δεν υπάρχουν όρια @msg no brush named `{name}` δεν υπάρχει πινέλο με όνομα `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} το όνομα ενός πινέλου με τρίχες μπορεί να έχει έως {max} χαρακτήρες: το `{name}` έχει {count} @msg no brush selected δεν έχει επιλεγεί πινέλο +@msg brushes move only within their type τα πινέλα μετακινούνται μόνο μέσα στον τύπο τους @msg no catalog δεν υπάρχει κατάλογος @msg no changes since the last save δεν υπάρχουν αλλαγές μετά την τελευταία αποθήκευση @msg no clock to keep a heartbeat by δεν υπάρχει ρολόι για τον παλμό διατήρησης σύνδεσης @@ -3965,6 +4065,7 @@ theme Appearance Εμφάνιση @msg no file system here δεν υπάρχει σύστημα αρχείων εδώ @msg no file writer δεν υπάρχει μηχανισμός εγγραφής αρχείων @msg no files to place δεν υπάρχουν αρχεία για τοποθέτηση +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file Το `{file}` δεν είναι έγγραφο VectorCraft, PDF ή αρχείο .ai συμβατό με PDF @msg no format to export to δεν υπάρχει μορφή εξαγωγής @msg no free recovery area δεν υπάρχει ελεύθερη περιοχή ανάκτησης @msg no gradient stop or freeform point is selected δεν έχει επιλεγεί σημείο χρώματος ντεγκραντέ ή σημείο ελεύθερης μορφής @@ -4000,6 +4101,7 @@ theme Appearance Εμφάνιση @msg no such action δεν υπάρχει τέτοια ενέργεια @msg no such anchor δεν υπάρχει τέτοιο σημείο αγκύρωσης @msg no such artboard δεν υπάρχει τέτοιος πίνακας σχεδίασης +@msg the artboard is locked ο πίνακας σχεδίασης είναι κλειδωμένος @msg no such document δεν υπάρχει τέτοιο έγγραφο @msg no such layer or object δεν υπάρχει τέτοιο επίπεδο ή αντικείμενο @msg no such object {0} δεν υπάρχει τέτοιο αντικείμενο {0} @@ -4056,6 +4158,7 @@ theme Appearance Εμφάνιση @msg path is already closed η διαδρομή είναι ήδη κλειστή @msg paths contain invalid coordinates οι διαδρομές περιέχουν μη έγκυρες συντεταγμένες @msg paths: give at least one file διαδρομές: δώστε τουλάχιστον ένα αρχείο +@msg replace: the object to replace was changed or deleted αντικατάσταση: το αντικείμενο προς αντικατάσταση άλλαξε ή διαγράφηκε @msg pattern missing λείπει το μοτίβο @msg patterns nested too deeply are filled with mid-grey τα πολύ βαθιά ένθετα μοτίβα γεμίζονται με μέτριο γκρι @msg percent must be above 0 and at most 6400, not {p} το ποσοστό πρέπει να είναι μεγαλύτερο από 0 και έως 6400, όχι {p} @@ -4208,6 +4311,7 @@ theme Appearance Εμφάνιση @msg unknown workspace (one of {_1}) άγνωστος χώρος εργασίας (ένας από τους {_1}) @msg unsupported: {0} δεν υποστηρίζεται: {0} @msg variables are locked οι μεταβλητές είναι κλειδωμένες +@msg variables that aren't text were left out: {kinds} οι μεταβλητές που δεν είναι κειμένου παραλείφθηκαν: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files τα αρχεία έκδοσης {0} δεν μπορούν να συμπιεστούν: μόνο η έκδοση {COMPRESSED_SINCE} και νεότερες ανοίγουν συμπιεσμένα αρχεία @msg width and height are at least 1 pixel το πλάτος και το ύψος πρέπει να είναι τουλάχιστον 1 εικονοστοιχείο @msg workspace name can't be empty το όνομα χώρου εργασίας δεν μπορεί να είναι κενό @@ -4219,6 +4323,7 @@ theme Appearance Εμφάνιση @msg {_1} font families available {_1} διαθέσιμες οικογένειες γραμματοσειρών @msg {_1} is a live effect (Effect › Plug-ins) το {_1} είναι δυναμικό εφέ (Εφέ › Πρόσθετα) @msg {_1} is an exporter (File › Export › Export As…) το {_1} είναι πρόσθετο εξαγωγής (Αρχείο › Εξαγωγή › Εξαγωγή ως…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) το `documentData` έχει {size} byte (όριο {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} το {_1} χρησιμοποιείται ήδη από το {_2} @msg {_1} linked file(s) can't be read here: their previews show {_1} συνδεδεμένα αρχεία δεν μπορούν να διαβαστούν εδώ: εμφανίζονται οι προεπισκοπήσεις τους @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} συνδεδεμένες εικόνες άλλαξαν στον δίσκο: η Ενημέρωση συνδέσμων εμφανίζει τις νέες εκδόσεις @@ -4248,6 +4353,8 @@ theme Appearance Εμφάνιση @msg Saving Αποθήκευση σε εξέλιξη @msg Saving {_1} Αποθήκευση {_1} @msg Exporting {_1} Εξαγωγή {_1} +@msg Packaging {_1} Συσκευασία {_1} +@msg Placing {_1} Τοποθέτηση {_1} @msg Saving recovery data for {_1} Αποθήκευση δεδομένων ανάκτησης για {_1} @msg Discarded {_1} recovered document(s) Απορρίφθηκαν {_1} ανακτημένα έγγραφα @msg Restored {_1} recovered document(s): save them to keep them Επαναφέρθηκαν {_1} ανακτημένα έγγραφα: αποθηκεύστε τα για να διατηρηθούν @@ -4360,7 +4467,9 @@ theme Appearance Εμφάνιση @msg automation filesystem access is not granted: read authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα ανάγνωσης: {path} @msg automation filesystem access is not granted: write authority is absent: {path} δεν έχει επιτραπεί πρόσβαση αυτοματισμού στο σύστημα αρχείων: δεν υπάρχει δικαίωμα εγγραφής: {path} @msg automation path rejected: outside the read root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας ανάγνωσης {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών ανάγνωσης {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός ρίζας εγγραφής {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} απορρίφθηκε η διαδρομή αυτοματισμού: εκτός των ριζών εγγραφής {roots}: {path} @msg automation path rejected: path is empty απορρίφθηκε η διαδρομή αυτοματισμού: η διαδρομή είναι κενή @msg automation path rejected: a link that leads nowhere: {path} απορρίφθηκε η διαδρομή αυτοματισμού: σύνδεσμος χωρίς προορισμό: {path} @msg automation path rejected: cannot resolve {path}: {e} απορρίφθηκε η διαδρομή αυτοματισμού: δεν ήταν δυνατή η επίλυση του {path}: {e} @@ -4398,6 +4507,12 @@ theme Appearance Εμφάνιση @msg before must name a panel το before πρέπει να ορίζει πίνακα @msg docking action contains an unknown panel or an excessive split path η ενέργεια σύνδεσης περιέχει άγνωστο πίνακα ή υπερβολικά βαθιά διαδρομή διαίρεσης @msg group must be true or false το group πρέπει να είναι true ή false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} Το πάνελ αιωρείται· συμπτύξτε την ομάδα του με window.panel.float {panel, collapsed} +@msg panel is not in a dock group Το πάνελ δεν βρίσκεται σε ομάδα αγκύρωσης +@msg only a dock group above or below others collapses, not one beside another συμπτύσσεται μόνο ομάδα αγκύρωσης πάνω ή κάτω από άλλες, όχι δίπλα σε άλλη +@msg icon must be true or false το icon πρέπει να είναι true ή false +@msg icon only docks a panel in the icon column, with no anchor το icon αγκυρώνει πάνελ μόνο στη στήλη εικονιδίων, χωρίς anchor +@msg column docks a set as a dock column, with no anchor or icon το column αγκυρώνει ένα σύνολο ως στήλη αγκύρωσης, χωρίς anchor ή icon @msg move requires a destination panel η μετακίνηση απαιτεί πίνακα προορισμού @msg move requires an existing destination panel η μετακίνηση απαιτεί υπάρχοντα πίνακα προορισμού @msg onto must name a floating panel το onto πρέπει να ορίζει αιωρούμενο πίνακα @@ -4517,6 +4632,7 @@ theme Appearance Εμφάνιση Brown Καφέ Business Card 3.5 × 2 in Επαγγελματική κάρτα 3,5 × 2 ίντσες Business Card 85 × 55 mm Επαγγελματική κάρτα 85 × 55 mm + Business Card 91 × 55 mm Επαγγελματική κάρτα 91 × 55 mm CRLF (Windows) CRLF (Windows) Cap Height Ύψος κεφαλαίων Chevron Σχήμα V @@ -4794,7 +4910,8 @@ theme Appearance Εμφάνιση to sample a color για δειγματοληψία χρώματος across a selected object to set the gradient direction κατά μήκος επιλεγμένου αντικειμένου για ορισμό κατεύθυνσης ντεγκραντέ to select an artboard | για επιλογή πίνακα σχεδίασης | - on the canvas to create one στον καμβά για δημιουργία ενός + on the canvas to create one ( στον καμβά για δημιουργία ενός ( + selects several, or draws one inside an artboard) επιλέγει πολλούς ή δημιουργεί νέο μέσα σε πίνακα σχεδίασης) a stroke to add a width point | περίγραμμα για προσθήκη σημείου πλάτους | to change one side | για αλλαγή μίας πλευράς | to select more points | για επιλογή περισσότερων σημείων | @@ -4819,6 +4936,9 @@ theme Appearance Εμφάνιση to set spacing and orientation για ορισμό απόστασης και προσανατολισμού : : Light behind the object Φως πίσω από το αντικείμενο + 3D Lighting Preset Προεπιλογή φωτισμού 3D + Standard Τυπικός + Diffuse Διάχυτος # 3D light management. Lights Φώτα Add Light Προσθήκη φωτός @@ -5039,6 +5159,7 @@ effect Points Σημεία Drawing : real size — 1:10 labels 1 unit on the page as 10. Σχέδιο : πραγματικό μέγεθος — με 1:10 η 1 μονάδα στη σελίδα εμφανίζεται ως 10. Editable Επεξεργάσιμο Export Data Sets… Εξαγωγή συνόλων δεδομένων… + Load Variable Library… Φόρτωση βιβλιοθήκης μεταβλητών… Filter Φίλτρο Filter Layers Panel Rows Φιλτράρισμα γραμμών πίνακα επιπέδων Flatten Ισοπέδωση @@ -5055,6 +5176,15 @@ effect Points Σημεία Isolate Selected Object Απομόνωση επιλεγμένου αντικειμένου Keep selected Διατήρηση επιλογής Merge only with selection Συγχώνευση μόνο με την επιλογή + Consider Open Filled Paths as Closed Οι ανοιχτές γεμισμένες διαδρομές θεωρούνται κλειστές + In Merge Mode, Clicking Stroke Splits the Path Σε λειτουργία συγχώνευσης, το κλικ στο περίγραμμα διαιρεί τη διαδρομή + Pick Color From: Λήψη χρώματος από: + Artwork Γραφικά + Cursor Swatch Preview Προεπισκόπηση δείγματος δίπλα στον δείκτη + Selection: Επιλογή: + Straight Line Ευθεία γραμμή + Highlight: Επισήμανση: + Stroke When Editable Περίγραμμα όταν είναι επεξεργάσιμο Legacy (version 7) Παλαιού τύπου (έκδοση 7) Legacy .ai Options Επιλογές .ai παλαιού τύπου Linked Files Συνδεδεμένα αρχεία @@ -5100,6 +5230,7 @@ effect Points Σημεία @msg Measured anchor no longer exists Το μετρημένο σημείο αγκύρωσης δεν υπάρχει πλέον @msg Measured segment no longer exists Το μετρημένο τμήμα δεν υπάρχει πλέον @msg Non-finite dimension Η διάσταση δεν είναι πεπερασμένος αριθμός +@msg Scale Text Attributes: selected type has no scale to bake Κλιμάκωση ιδιοτήτων κειμένου: το επιλεγμένο κείμενο δεν έχει κλίμακα προς εφαρμογή @msg Scale must be drawing:real, for example 1:10 Η κλίμακα πρέπει να είναι σχέδιο:πραγματικό, για παράδειγμα 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Ο λόγος κλίμακας πρέπει να είναι μεταξύ 1:1000000 και 1000000:1 @msg Second measured segment no longer exists Το δεύτερο μετρημένο τμήμα δεν υπάρχει πλέον @@ -5113,6 +5244,10 @@ effect Points Σημεία @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for δεν εφαρμόστηκε τίποτα από το σύνολο δεδομένων {name}: κανένα αντικείμενο δεν είναι συνδεδεμένο με μεταβλητή για την οποία έχει τιμή @msg select an Intertwine group επιλέξτε μια ομάδα διαπλοκής @msg select an Objects on Path group επιλέξτε μια ομάδα αντικειμένων σε διαδρομή +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects μια διαδρομή μέσα σε σύνθετη διαδρομή, ανάμειξη, περίβλημα, επανάληψη ή ομάδα διαπλοκής δεν μπορεί να δεχτεί αντικείμενα +@msg a clipping path can't take objects μια διαδρομή αποκοπής δεν μπορεί να δεχτεί αντικείμενα +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path τα αντικείμενα μέσα σε ανάμειξη, περίβλημα, επανάληψη ή ομάδα διαπλοκής δεν μπορούν να προσαρτηθούν σε διαδρομή +@msg a clipping path can't be attached to a path μια διαδρομή αποκοπής δεν μπορεί να προσαρτηθεί σε διαδρομή @msg span must be "object" or "selection", got {v} το span πρέπει να είναι "object" ή "selection", δόθηκε {v} @msg span: "selection" places a `gradient`, not a swatch span: το "selection" τοποθετεί ένα `gradient`, όχι δείγμα χρώματος @msg span: "selection" places a gradient: give `gradient` span: το "selection" τοποθετεί ντεγκραντέ: δώστε `gradient` @@ -5136,6 +5271,12 @@ effect Points Σημεία Sketch Σκίτσο Halftone Pattern… Μοτίβο ημιτόνου… Halftone Pattern Μοτίβο ημιτόνου + Torn Edges… Ξεσκισμένες άκρες… + Torn Edges Ξεσκισμένες άκρες + Conté Crayon… Κιμωλία Conté… + Conté Crayon Κιμωλία Conté + Foreground Level Επίπεδο προσκηνίου + Background Level Επίπεδο φόντου Pattern Type Τύπος μοτίβου Dot Κουκκίδα # User Interface › Color Scheme @@ -5154,6 +5295,7 @@ effect Points Σημεία Watercolor… Υδατογραφία… Watercolor Υδατογραφία Brush Size Μέγεθος πινέλου + Texture Coverage Κάλυψη υφής Definition Ευκρίνεια Brush Detail Λεπτομέρεια πινέλου Shadow Intensity Ένταση σκιών @@ -5182,13 +5324,21 @@ effect Points Σημεία @msg the image's pixels can't be read τα εικονοστοιχεία της εικόνας δεν μπορούν να διαβαστούν Persistent Μόνιμη Library Panel Options Επιλογές πίνακα βιβλιοθήκης + Locate Library… Εντοπισμός βιβλιοθήκης… @msg Persistent libraries not found: {files} Δεν βρέθηκαν μόνιμες βιβλιοθήκες: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) Το `{given}` δεν είναι πίνακας βιβλιοθήκης (δείτε window.swatchLibrary) @msg library panel `{given}` isn't open ο πίνακας βιβλιοθήκης `{given}` δεν είναι ανοιχτός +@msg library panel `{given}` isn't missing its library η βιβλιοθήκη του πίνακα βιβλιοθήκης `{given}` δεν λείπει @msg too many library panels were opened in this session άνοιξαν πάρα πολλοί πίνακες βιβλιοθηκών σε αυτή τη συνεδρία @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Η εικόνα είναι {_1} × {_2} εικονοστοιχεία, πάνω από {LARGE_TRACE_SIDE} σε μία πλευρά: ανιχνεύεται, αλλά πιο αργά. Μια προρύθμιση χαμηλότερης πιστότητας ή ένα μικρότερο αντίγραφο της εικόνας ανιχνεύεται γρηγορότερα. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Η εικόνα ανιχνεύτηκε σε {_1} × {_2} εικονοστοιχεία, σμικρυμένη από {_3} × {_4} (το πολύ {_5} megapixel). + Snapping Προσκόλληση + Snap to Tangent Προσκόλληση σε εφαπτομένη + Snap to Perpendicular Προσκόλληση σε κάθετο +@msg unknown snap option `{key}` (use {options}) άγνωστη επιλογή προσκόλλησης `{key}` (χρησιμοποιήστε {options}) + Gap Length: Μήκος κενού: Artboard {n} of {total} Πίνακας σχεδίασης {n} από {total} + File {n} of {total} Αρχείο {n} από {total} Encoding and writing the file… Κωδικοποίηση και εγγραφή του αρχείου… @plural {n} more export waiting|{n} more exports waiting {n} ακόμη εξαγωγή σε αναμονή|{n} ακόμη εξαγωγές σε αναμονή Continue in Background Συνέχεια στο παρασκήνιο @@ -5204,6 +5354,14 @@ effect Points Σημεία Full Justify Στοίχιση και στα δύο άκρα Minimum must not exceed Desired, nor Desired Maximum. Το ελάχιστο δεν πρέπει να υπερβαίνει το επιθυμητό, ούτε το επιθυμητό το μέγιστο. Justification Πλήρης στοίχιση + Add filters from the list above Προσθέστε φίλτρα από τη λίστα παραπάνω + Add to Stack Προσθήκη στη στοίβα + Applied Filters Εφαρμοσμένα φίλτρα + Categories Κατηγορίες + Choose a filter Επιλέξτε φίλτρο + Effect Gallery Συλλογή εφέ + Filters Φίλτρα + Select artwork to apply effects Επιλέξτε γραφικά για να εφαρμόσετε εφέ @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them οι μεταβλητές και τα σύνολα δεδομένων του αρχείου δεν διαβάστηκαν (μεταβλητές: {variables}, σύνολα δεδομένων: {data_sets}): ο πίνακας Μεταβλητές δεν τα εμφανίζει # Select by Color (select.color) Select by Color Επιλογή βάσει χρώματος @@ -5225,3 +5383,105 @@ effect Points Σημεία Plaster Γύψος Note Paper… Χαρτί σημειώσεων… Note Paper Χαρτί σημειώσεων + Water Paper… Βρεγμένο χαρτί… + Water Paper Βρεγμένο χαρτί + Ruby… Ruby… + Ruby Ruby + Geometric Guides Γεωμετρικοί οδηγοί + Ease In Ομαλή αρχή + Ease Out Ομαλό τέλος + Ease In Out Ομαλή αρχή και τέλος + Ease Out In Ομαλό τέλος και αρχή + Ramp: Ένταση: + Shift: Μετατόπιση: + Fresco… Νωπογραφία… + Fresco Νωπογραφία + Effect Gallery… Συλλογή εφέ… + Effect Gallery Dialog Παράθυρο διαλόγου Συλλογή εφέ + Arc Segment Tool Options Επιλογές εργαλείου τμήματος τόξου + Base Along: Βάση κατά: + Closed Κλειστό + Convex Κυρτό + Fill Arc Γέμισμα τόξου + Length X-Axis: Μήκος άξονα X: + Length Y-Axis: Μήκος άξονα Y: + Slope: Κλίση: +axis X Axis Άξονας X +axis Y Axis Άξονας Y +@msg Packaged {n} file(s) in {folder} Συσκευάστηκαν {n} αρχεία στον φάκελο {folder} +@msg Packaged {n} file(s) {note} Συσκευάστηκαν {n} αρχεία {note} +@msg Packaged {n} file(s) Συσκευάστηκαν {n} αρχεία + Packaged {n} file(s) Συσκευάστηκαν {n} αρχεία + Packaged {n} file(s) in {folder} Συσκευάστηκαν {n} αρχεία στον φάκελο {folder} + Packaged {n} file(s) {note} Συσκευάστηκαν {n} αρχεία {note} + {count} file(s) in {folder}. Show the package folder? {count} αρχεία στον φάκελο {folder}. Να εμφανιστεί ο φάκελος συσκευασίας; + Clockwise Δεξιόστροφα + Dither Πρόσθεση θορύβου + Fine noise hides banding in the gradient fill Ο λεπτός θόρυβος κρύβει τις λωρίδες στο γέμισμα ντεγκραντέ +@msg `dither` applies to gradient fills (stroke: false) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) Το `dither` ισχύει για γεμίσματα ντεγκραντέ (paint.setFill) +@msg `dither` must be true or false Το `dither` πρέπει να είναι true ή false +@msg `method` must be classic or perceptual Το `method` πρέπει να είναι classic ή perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Να εγκατασταθεί το «{name}», που ζητά μεγάλο υπολογιστικό προϋπολογισμό; + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Κάθε εκτέλεση μπορεί να διαρκέσει έως {minutes} λεπτά και να κάνει {times} φορές τη δουλειά που επιτρέπεται στα άλλα πρόσθετα. Εκτελείται στο παρασκήνιο και μπορείτε να την ακυρώσετε. + It may take minutes, so it runs in the background, where you can cancel it. Μπορεί να διαρκέσει λεπτά, γι’ αυτό εκτελείται στο παρασκήνιο, όπου μπορείτε να το ακυρώσετε. + The plug-in is computing… Το πρόσθετο υπολογίζει… + Inspect Plug-in Επιθεώρηση προσθέτου + Cancel Plug-in Run Ακύρωση εκτέλεσης προσθέτου +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again Το {_1} ολοκληρώθηκε, αλλά το έγγραφο ή το πρόσθετο άλλαξε στο μεταξύ: εκτελέστε το ξανά +@msg Running “{name}”… Εκτέλεση του «{name}»… +@msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy` +@msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης +# Actions panel: action sets (#3712). + New Set… Νέο σύνολο… + New Set Νέο σύνολο + Set Options… Επιλογές συνόλου… + Set Options Επιλογές συνόλου + Create New Set Δημιουργία νέου συνόλου + Stop Global Edit Τερματισμός καθολικής επεξεργασίας + Global Edit Options Επιλογές καθολικής επεξεργασίας + Match Αντιστοίχιση + On Artboards Σε πίνακες σχεδίασης + All Artboards Όλοι οι πίνακες σχεδίασης + Include Objects On Canvas Συμπερίληψη αντικειμένων στον καμβά + Result(s): {count} Αποτέλεσμα(τα): {count} +@msg select one object επιλέξτε ένα αντικείμενο +@msg Global Edit works on paths, compound paths and symbol instances Η καθολική επεξεργασία λειτουργεί σε διαδρομές, σύνθετες διαδρομές και παρουσίες συμβόλων +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again δεν βρέθηκαν παρόμοια αντικείμενα (τα περιστραμμένα ή κατοπτρισμένα αντίγραφα δεν εντοπίζονται)· αλλάξτε τις επιλογές καθολικής επεξεργασίας και δοκιμάστε ξανά +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ή ένα εύρος όπως 1, 3-5, όχι {v} + Add to Symbols Προσθήκη στα σύμβολα + Basic Shapes Βασικά σχήματα + Open Symbol Library Άνοιγμα βιβλιοθήκης συμβόλων + Save Symbol Library Αποθήκευση βιβλιοθήκης συμβόλων + Save Symbol Library… Αποθήκευση βιβλιοθήκης συμβόλων… + Save Symbols… Αποθήκευση συμβόλων… + Selected Symbols Only Μόνο επιλεγμένα σύμβολα + Symbol Libraries Menu Μενού βιβλιοθηκών συμβόλων + Symbol Library Βιβλιοθήκη συμβόλων + User Symbol Library 1 Βιβλιοθήκη συμβόλων χρήστη 1 + User Symbol Library 2 Βιβλιοθήκη συμβόλων χρήστη 2 + User Symbol Library 3 Βιβλιοθήκη συμβόλων χρήστη 3 + User Symbol Library 4 Βιβλιοθήκη συμβόλων χρήστη 4 + User Symbol Library 5 Βιβλιοθήκη συμβόλων χρήστη 5 + User Symbol Library 6 Βιβλιοθήκη συμβόλων χρήστη 6 + User Symbol Library 7 Βιβλιοθήκη συμβόλων χρήστη 7 + User Symbol Library 8 Βιβλιοθήκη συμβόλων χρήστη 8 + User Symbol Library 9 Βιβλιοθήκη συμβόλων χρήστη 9 + User Symbol Library 10 Βιβλιοθήκη συμβόλων χρήστη 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place Η `{_1}` δεν έχει σύμβολα για τοποθέτηση +@msg missing `library` (see symbol.libraries) λείπει το `library` (δείτε symbol.libraries) +@msg no symbol `{n}` δεν υπάρχει σύμβολο `{n}` +@msg no symbol `{n}` in `{_1}` δεν υπάρχει σύμβολο `{n}` στη `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) δεν υπάρχει βιβλιοθήκη συμβόλων `{key}` (δείτε symbol.libraries) +@msg the document has no symbols to save το έγγραφο δεν έχει σύμβολα για αποθήκευση +@msg Trap: select two or more objects filled with solid colours Παγίδευση χρωμάτων: επιλέξτε δύο ή περισσότερα αντικείμενα με συμπαγές χρώμα γεμίσματος +@msg Trap: no edges of different colours meet among the selected objects Παγίδευση χρωμάτων: δεν συναντώνται άκρες διαφορετικών χρωμάτων στα επιλεγμένα αντικείμενα +@msg thickness must be from 0.01 to 5000 pt το thickness πρέπει να είναι από 0,01 έως 5000 pt +@msg heightWidth must be from 1 to 1000 % το heightWidth πρέπει να είναι από 1 έως 1000 % +@msg tintReduction must be from 0 to 100 % το tintReduction πρέπει να είναι από 0 έως 100 % + Rectangular Grid Tool Options Επιλογές εργαλείου ορθογώνιου πλέγματος + Polar Grid Tool Options Επιλογές εργαλείου πολικού πλέγματος + Fill Line Γέμισμα γραμμής diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5a3d35980..3105cc9f9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -9,7 +9,7 @@ # sentences address the user as "tú". Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Esc). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). Glowing Edges… Bordes resplandecientes… @@ -37,6 +37,13 @@ Channel 4 Canal 4 Cell Size Tamaño de celda Fine Dots Puntos finos + + Simple Simple + Light Rough Rugoso claro + Dark Rough Rugoso oscuro + Wide Sharp Ancho nítido + Wide Blurry Ancho borroso + Sparkle Destello Medium Dots Puntos medios Grainy Dots Puntos granulados Coarse Dots Puntos gruesos @@ -187,6 +194,8 @@ Anti-aliased Artwork Ilustración suavizada Anti-aliasing: Suavizado: Any Kind Cualquier tipo + Font Technology Tecnología de fuente +font-technology Variable Variables Appearance Apariencia Appearance Attribute Atributo de apariencia Appearance Item Elemento de apariencia @@ -270,6 +279,11 @@ Average… Promediar… Axis: Eje: Background Color: Color de fondo: + Show Center Mark Mostrar marca central + Show Cross Hairs Mostrar líneas en cruz + Show Video Safe Areas Mostrar áreas seguras de video + Video Ruler Pixel Aspect Ratio: Proporción de píxel de las reglas de video: + Hide Video Rulers Ocultar reglas de video Background Contents Contenido de fondo Background Contents: Contenido de fondo: Background: Fondo: @@ -307,6 +321,7 @@ Blur Desenfocar Bottom Abajo Bottom in Front Inferior delante + Bottom-Justified Tab Tabulación inferior Bottom: Abajo: Bounds Límites Bounds (Fill Proportionally) Límites (rellenar proporcionalmente) @@ -337,6 +352,13 @@ Built-in presets don't change: New… starts an editable copy. Los preajustes integrados no cambian: Nuevo… crea una copia editable. Bulge… Abultamiento… Bullet Viñeta + Bullets Viñetas + Bullets and Numbering Viñetas y numeración + Apply Bullets Aplicar viñetas + Filled Dot Punto relleno + Dash Guion + Hollow Circle Círculo vacío + Filled Square Cuadrado relleno Butt Cap Extremo a tope Byte Order: Orden de bytes: Calligraphic Caligráfico @@ -1052,6 +1074,7 @@ Eyedropper Picks Up: El cuentagotas toma: Eyedropper Tool Herramienta Cuentagotas Eyedropper: click the art to sample the stop's colour Cuentagotas: haz clic en la ilustración para tomar el color de la parada + Eyedropper: click the art to sample the key color Cuentagotas: haz clic en la ilustración para tomar el color clave Favorites Favoritos Feather… Desvanecer… Feet Pies @@ -1060,6 +1083,11 @@ Fidelity: Fidelidad: Figure Space Espacio de cifra Figure: Cifras: +# Artwork drag between document tabs. + Drop Artwork Soltar ilustración +@msg {key} must be [x, y] with finite coordinates {key} debe ser [x, y] con coordenadas finitas +@msg ids must contain 1–10000 object ids ids debe contener entre 1 y 10000 identificadores de objetos + File Archivo File Dimensions Dimensiones del archivo File Format: Formato de archivo: @@ -1081,6 +1109,15 @@ Alt key toggles to Smooth Tool La tecla Alt cambia a la herramienta Suavizar Keep selected Mantener seleccionado Merge only with selection Combinar solo con la selección + Consider Open Filled Paths as Closed Tratar los trazados abiertos con relleno como cerrados + In Merge Mode, Clicking Stroke Splits the Path En modo de combinación, hacer clic en el trazo divide el trazado + Pick Color From: Tomar el color de: + Artwork Ilustración + Cursor Swatch Preview Vista previa de muestras junto al cursor + Selection: Selección: + Straight Line Línea recta + Highlight: Resaltado: + Stroke When Editable Trazo si es editable Fill Color Color de relleno Fill New Type Objects With Placeholder Text Rellenar nuevos objetos de texto con texto de muestra Fill Rule Regla de relleno @@ -1200,6 +1237,8 @@ Large Gaps Huecos grandes Gap Preview Color: Color de previsualización de huecos: Close Gaps With Paths Cerrar huecos con trazados + Show Live Paint Gaps Mostrar huecos de pintura interactiva + Hide Live Paint Gaps Ocultar huecos de pintura interactiva Select Fills Seleccionar rellenos Select Strokes Seleccionar trazos Gaussian Blur… Desenfoque gaussiano… @@ -1306,6 +1345,7 @@ Hand-Drawn Dibujado a mano Handles Manejadores Hard Light Luz fuerte + Hard Mix Mezcla definida Harmony Rules: Reglas de armonía: Harmony Sets Conjuntos de armonía Height Altura @@ -1355,7 +1395,7 @@ Clear History Borrar historial Set History Limit… Definir límite de historial… History States Estados de historial -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Aplicar conjunto de datos Cancel Pattern Editing Cancelar edición de motivo Copy Appearance Copiar apariencia @@ -1827,6 +1867,9 @@ Modified: Modificado: Modify Modificar Mojikumi Mojikumi + Insert Kashidas Insertar kashidas + Long Larga + Stylistic Estilística Mojikumi Set Conjunto de mojikumi Kinsoku Set Conjunto de kinsoku Hard Fuerte @@ -1861,6 +1904,7 @@ Move Slices Mover sectores Move Spine Point Mover punto del eje Reshape Spine Segment Remodelar segmento del eje + Move Brush Mover pincel Move Swatch Mover muestra Move Up Subir Move… Mover… @@ -1918,6 +1962,13 @@ Tints and Shades Matices y sombras Hue Shift Cambio de tono Key Color: Color clave: + Tips Consejos + Colorization Tips Consejos de coloración + The samples show this brush on a blue stroke. Las muestras presentan este pincel sobre un trazo azul. + The art keeps its own colors. La ilustración conserva sus propios colores. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. El negro pasa a ser el color del trazo y el blanco sigue siendo blanco. Los demás colores pasan a ser matices del color del trazo, y cuanto más claro es un color, más claro es su matiz. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. El negro sigue siendo negro y el blanco sigue siendo blanco. Los demás colores pasan a ser sombras del color del trazo cuando son oscuros y matices de él cuando son claros. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. El color clave pasa a ser el color del trazo. Los demás colores giran en la rueda de colores lo mismo que el color clave y conservan su saturación y su brillo. Roundness: Redondez: Fixed Fijo Pressure Presión @@ -1938,6 +1989,14 @@ Round Fan Abanico redondo Flat Fan Abanico plano New Brush… Nuevo pincel… + New Brush Nuevo pincel + Calligraphic Brush Pincel caligráfico + Scatter Brush Pincel de dispersión + Art Brush Pincel de arte + Bristle Brush Pincel de cerdas + Pattern Brush Pincel de motivo + Select art to make a scatter, art or pattern brush. Selecciona una ilustración para crear un pincel de dispersión, de arte o de motivo. + Brush Art Ilustración del pincel New Calligraphic Brush Nuevo pincel caligráfico New Character Style Nuevo estilo de carácter New Color Group Nuevo grupo de colores @@ -1968,6 +2027,7 @@ New View Nueva vista New View… Nueva vista… New Window Nueva ventana + New Without Dialog Nuevo sin cuadro de diálogo New Workspace Nuevo espacio de trabajo New Workspace… Nuevo espacio de trabajo… New {kind} Style… Nuevo estilo de {kind}… @@ -2174,7 +2234,7 @@ Package Empaquetar Package collects a saved document with its linked files and fonts. Empaquetar reúne un documento guardado con sus archivos vinculados y fuentes. Package created Paquete creado - Packaged {n} file(s) in {folder}{note} Se empaquetaron {n} archivo(s) en {folder}{note} + Packaged {n} file(s) in {folder} {note} Se empaquetaron {n} archivo(s) en {folder}{note} Packaged {n} file(s){note} Se empaquetaron {n} archivo(s){note} Package… Empaquetar… Page Página @@ -2235,7 +2295,7 @@ Percent: Porcentaje: Perceptual Scales Escalas perceptuales Performance Rendimiento - Performance & Storage Rendimiento y almacenamiento + Plug-ins & Scratch Disks Plugins & Discos de memoria virtual Permissions Permisos Permissions password: Contraseña de permisos: Perspective Perspectiva @@ -2355,6 +2415,7 @@ Proof Setup Ajuste de prueba Properties Propiedades Proportional Metrics Métrica proporcional + Character Direction Dirección de caracteres Digits Dígitos Arabic Árabes Hindi Hindi @@ -2549,6 +2610,7 @@ Reorder Reordenar Reorder Appearance Item Reordenar elemento de apariencia Repeat Repetir + Repeat Objects Objetos de repetición Repeat Options Opciones de repetición Repeat Pathfinder Repetir Buscatrazos Repeat Tab Repetir tabulación @@ -2757,6 +2819,7 @@ Scale Cursor Proportional to UI Escalar el cursor en proporción a la interfaz Scale Lineweights Escalar grosores de línea Scale Strokes & Effects Escalar trazos y efectos + Scale Text Attributes Escalar atributos de texto Scale to Fit Artboard Escalar para encajar en la mesa de trabajo Scale Tool Herramienta Escalar Scale: Escala: @@ -3029,6 +3092,7 @@ Snapping Tolerance Tolerancia de ajuste Social Square Post 1080×1080 Publicación cuadrada para redes 1080×1080 Soft Light Luz suave + Soft Mix Mezcla suave Solid Sólido Sort by Kind Ordenar por tipo Sort by Name Ordenar por nombre @@ -3039,6 +3103,7 @@ Space Before Paragraph Espacio antes del párrafo Spaces Espacios Spacing Guides Guías de espaciado + Invoke Distance Guides Invocar guías de distancia Spacing: Espaciado: Spanish Español Specified Distance Distancia especificada @@ -3176,7 +3241,6 @@ Show all open documents Mostrar todos los documentos abiertos Symbol Instance Instancia de símbolo Symbol Libraries Bibliotecas de símbolos - Symbol Libraries (on the roadmap) Bibliotecas de símbolos (en la hoja de ruta) Symbol Screener Tool Herramienta Transparencia de símbolos Symbol Scruncher Tool Herramienta Concentración de símbolos Symbol Shifter Tool Herramienta Desplazamiento de símbolos @@ -3269,6 +3333,7 @@ Tools Herramientas Top Arriba Top in Front Superior delante + Top-Justified Tab Tabulación superior Top: Arriba: Total Width: Anchura total: Touch Type Tool Herramienta Retocar texto @@ -3303,6 +3368,13 @@ Transparent Background Fondo transparente Transverse (the page a quarter turn on the paper) Transversal (la página gira un cuarto de vuelta sobre el papel) Trap… Reventar… + Trap Reventar + Height/Width Altura/anchura + Tint Reduction Reducción de tinta + Traps with Process Color Reventados con colores de cuatricromía + Reverse Traps Invertir reventados + Precision Precisión + Remove Redundant Points Quitar puntos redundantes Trim Cortar Trim mark weight: Grosor de marcas de recorte: Trim marks Marcas de recorte @@ -3534,7 +3606,7 @@ Zoom Tool Herramienta Zoom Zoom with Mouse Wheel Zoom con la rueda del mouse Scrub Numeric Fields by Dragging Ajustar campos numéricos arrastrando - {count} file(s) in {folder}{note}. Show the package folder? {count} archivo(s) en {folder}{note}. ¿Mostrar la carpeta del paquete? + {count} file(s) in {folder} {note}. Show the package folder? {count} archivo(s) en {folder}{note}. ¿Mostrar la carpeta del paquete? {count} linked file(s) not found No se encontraron {count} archivo(s) vinculado(s) fonts not copied: {fonts} fuentes no copiadas: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} imagen(es) vinculada(s) cambiaron desde que se guardó este documento. ¿Mostrar las versiones nuevas? @@ -3606,7 +3678,8 @@ imageTrace Outline Contorno to sample a color para muestrear un color across a selected object to set the gradient direction sobre un objeto seleccionado para definir la dirección del degradado to select an artboard | para seleccionar una mesa de trabajo | - on the canvas to create one en el lienzo para crear una + on the canvas to create one ( en el lienzo para crear una ( + selects several, or draws one inside an artboard) selecciona varias o crea una dentro de una mesa de trabajo) a stroke to add a width point | un trazo para agregar un punto de anchura | to change one side | para cambiar un solo lado | to select more points | para seleccionar más puntos | @@ -3698,7 +3771,7 @@ axis Both Ambos # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` no hay ninguna biblioteca `{id}` @msg select some type first selecciona primero algún texto @@ -3736,6 +3809,7 @@ axis Both Ambos @msg Couldn't copy to the system clipboard: {e} No se pudo copiar al portapapeles del sistema: {e} @msg Couldn't open {name}: {e} No se pudo abrir {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed No se pudo colocar {name}: se cerró el documento en el que se soltó +@msg Couldn't place {name}: the document it was placed in was closed No se pudo colocar {name}: se cerró el documento en el que se estaba colocando @msg Couldn't read {name}: {e} No se pudo leer {name}: {e} @msg Couldn't save recovery data: {e} No se pudieron guardar los datos de recuperación: {e} @msg Create Outlines: select text objects Crear contornos: selecciona objetos de texto @@ -3772,8 +3846,9 @@ axis Both Ambos @msg Opened {url} Se abrió {url} @msg Outline Stroke: no stroked paths selected Contornear trazo: no hay trazados con trazo seleccionados @msg PDF settings are not an object los ajustes de PDF no son un objeto +@msg PDF security settings are not an object los ajustes de seguridad de PDF no son un objeto @msg PDF writer error: {0} error del escritor de PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} Se empaquetaron {n} archivo(s) en {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Se empaquetaron {n} archivo(s) en {folder}{note} @msg Packaged {n} file(s){note} Se empaquetaron {n} archivo(s){note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Previsualizar en el navegador requiere la aplicación de escritorio (la versión web descarga los archivos) @msg Release Guides: no guides Soltar guías: no hay guías @@ -3843,6 +3918,25 @@ axis Both Ambos @msg a PostScript error ({name} in `{op}`: {at}) un error de PostScript ({name} en `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} un error de PostScript ({name} en `{op}`: {at}), en {procs} @msg a PostScript error ({name}) un error de PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) un error de PostScript ({name} en `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} un error de PostScript ({name} en `{op}`; {meaning}), en {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) un error de PostScript ({name} en `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} un error de PostScript ({name} en `{op}`: {at}; {meaning}), en {procs} +@msg a PostScript error ({name}; {meaning}) un error de PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin el programa cierra un diccionario que no abrió +@msg the program opens a file, which the reader can't do el programa abre un archivo, algo que el lector no puede hacer +@msg the program restores a saved state that was already restored el programa restaura un estado guardado que ya se había restaurado +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none los datos que lee el programa están dañados o cortados, o el programa pide una posición en un archivo que no la tiene +@msg the program goes past one of the reader's limits el programa supera uno de los límites del lector +@msg the path has no point to continue from el trazado no tiene un punto desde el que continuar +@msg a value is outside the range the operator takes un valor está fuera del intervalo que admite el operador +@msg the operator needs more values than the stack holds el operador necesita más valores de los que tiene la pila +@msg part of the program isn't valid PostScript text una parte del programa no es texto PostScript válido +@msg a value has the wrong type for the operator un valor tiene un tipo incorrecto para el operador +@msg the program runs, deletes or renames a file, which the reader can't do el programa ejecuta, elimina o cambia el nombre de un archivo, algo que el lector no puede hacer +@msg the program asks for a resource that isn't defined el programa pide un recurso que no está definido +@msg a calculation has no result, as in a division by zero un cálculo no tiene resultado, como en una división por cero +@msg the operator looks for a mark on the stack and finds none el operador busca una marca en la pila y no encuentra ninguna @msg a bitmap is empty un mapa de bits está vacío @msg a bitmap is too large to read un mapa de bits es demasiado grande para leerlo @msg a colour index is 0 to {_1}, not {n} un índice de color va de 0 a {_1}, no {n} @@ -3875,6 +3969,7 @@ axis Both Ambos @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis debe ser "vertical", "horizontal" o un ángulo en grados, no `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" fondo {other}: transparent, white, black o un color como "#ff8800" @msg bad color {v} color no válido {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio debe ser un número de 0,1 a 10 @msg the Color Picker is not open el Selector de color no está abierto @msg the Simplify slider is not open el regulador de Simplificar no está abierto @msg the screen color sampler is already open el muestreador de color de la pantalla ya está abierto @@ -3925,7 +4020,7 @@ axis Both Ambos @msg give the path `id` and the width point `index` indica el `id` del trazado y el `index` del punto de anchura @msg guides are locked las guías están bloqueadas @msg handle must be in or out handle debe ser in u out -@msg image '{key}' has no pixels and was left out la imagen '{key}' no tiene píxeles y se omitió +@msg an image has no pixels and was left out se omitió una imagen sin píxeles @msg image data `{_1}` is missing faltan los datos de imagen `{_1}` @msg image would be too large; lower the resolution la imagen sería demasiado grande; baja la resolución @msg index must be a number or first, previous, next or last index debe ser un número o first, previous, next o last @@ -3952,7 +4047,7 @@ axis Both Ambos @msg missing index falta index @msg missing number `{key}` falta el número `{key}` @msg missing integer `{key}` falta el número entero `{key}` -@msg multiple windows land with M11.5 las ventanas múltiples llegan con M11.5 +@msg New Window isn't available yet: a document opens in one window Nueva ventana aún no está disponible: un documento se abre en una sola ventana @msg name the file ponle nombre al archivo @msg no CSS to copy: select objects with CSS of their own no hay CSS que copiar: selecciona objetos con CSS propio @msg no Live Paint edge near that point no hay ningún borde de pintura interactiva cerca de ese punto @@ -3970,7 +4065,9 @@ axis Both Ambos @msg no blend options no hay opciones de fusión @msg no bounds sin límites @msg no brush named `{name}` no hay ningún pincel llamado `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} el nombre de un pincel de cerdas puede tener como máximo {max} caracteres: `{name}` tiene {count} @msg no brush selected no hay ningún pincel seleccionado +@msg brushes move only within their type los pinceles solo se pueden mover dentro de su tipo @msg no catalog no hay catálogo @msg no changes since the last save no hay cambios desde la última vez que se guardó @msg no clock to keep a heartbeat by no hay reloj para mantener la señal de actividad @@ -3989,6 +4086,7 @@ axis Both Ambos @msg no file system here aquí no hay sistema de archivos @msg no file writer no hay escritor de archivos @msg no files to place no hay archivos para colocar +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` no es un documento de VectorCraft, un PDF ni un archivo .ai compatible con PDF @msg no format to export to no hay formato al que exportar @msg no free recovery area no hay ningún área de recuperación libre @msg no gradient stop or freeform point is selected no hay ninguna parada de degradado ni punto de forma libre seleccionado @@ -4024,6 +4122,7 @@ axis Both Ambos @msg no such action no existe esa acción @msg no such anchor no existe ese punto de ancla @msg no such artboard no existe esa mesa de trabajo +@msg the artboard is locked la mesa de trabajo está bloqueada @msg no such document no existe ese documento @msg no such layer or object no existe esa capa u objeto @msg no such object {0} no existe el objeto {0} @@ -4080,6 +4179,7 @@ axis Both Ambos @msg path is already closed el trazado ya está cerrado @msg paths contain invalid coordinates los trazados contienen coordenadas no válidas @msg paths: give at least one file paths: indica al menos un archivo +@msg replace: the object to replace was changed or deleted replace: se modificó o eliminó el objeto que se iba a reemplazar @msg pattern missing falta el motivo @msg patterns nested too deeply are filled with mid-grey los motivos anidados a demasiada profundidad se rellenan de gris medio @msg percent must be above 0 and at most 6400, not {p} el porcentaje debe ser mayor que 0 y como máximo 6400, no {p} @@ -4115,6 +4215,10 @@ axis Both Ambos @msg select a repeat selecciona una repetición @msg select an Intertwine group selecciona un grupo entrelazado @msg select an Objects on Path group selecciona un grupo de objetos en trazado +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un trazado dentro de un trazado compuesto, una fusión, una envolvente, una repetición o un grupo entrelazado no puede recibir objetos +@msg a clipping path can't take objects un trazado de recorte no puede recibir objetos +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path los objetos dentro de una fusión, una envolvente, una repetición o un grupo entrelazado no se pueden adjuntar a un trazado +@msg a clipping path can't be attached to a path un trazado de recorte no se puede adjuntar a un trazado @msg select a stroke with a variable width selecciona un trazo con anchura variable @msg select a symbol instance selecciona una instancia de símbolo @msg select an Image Trace object selecciona un objeto de calco de imagen @@ -4234,6 +4338,7 @@ axis Both Ambos @msg unknown workspace (one of {_1}) espacio de trabajo desconocido (uno de estos: {_1}) @msg unsupported: {0} no compatible: {0} @msg variables are locked las variables están bloqueadas +@msg variables that aren't text were left out: {kinds} se omitieron las variables que no son de texto: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files los archivos de la versión {0} no se pueden comprimir: solo la versión {COMPRESSED_SINCE} y posteriores abren archivos comprimidos @msg width and height are at least 1 pixel el ancho y el alto son de al menos 1 píxel @msg workspace name can't be empty el nombre del espacio de trabajo no puede estar vacío @@ -4245,6 +4350,7 @@ axis Both Ambos @msg {_1} font families available {_1} familias de fuentes disponibles @msg {_1} is a live effect (Effect › Plug-ins) {_1} es un efecto interactivo (Efecto › Plugins) @msg {_1} is an exporter (File › Export › Export As…) {_1} es un plugin de exportación (Archivo › Exportar › Exportar como…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` ocupa {size} bytes (límite {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} ya lo usa {_2} @msg {_1} linked file(s) can't be read here: their previews show aquí no se pueden leer {_1} archivo(s) vinculado(s): se muestran sus previsualizaciones @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagen(es) vinculada(s) cambiaron en el disco: Actualizar vínculos muestra las versiones nuevas @@ -4274,6 +4380,8 @@ axis Both Ambos @msg Saving Guardando @msg Saving {_1} Guardando {_1} @msg Exporting {_1} Exportando {_1} +@msg Packaging {_1} Empaquetando {_1} +@msg Placing {_1} Colocando {_1} @msg Saving recovery data for {_1} Guardando datos de recuperación de {_1} @msg Discarded {_1} recovered document(s) Se descartaron {_1} documento(s) recuperado(s) @msg Restored {_1} recovered document(s): save them to keep them Se restauraron {_1} documento(s) recuperado(s): guárdalos para conservarlos @@ -4380,7 +4488,9 @@ axis Both Ambos @msg automation filesystem access is not granted: read authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de lectura: {path} @msg automation filesystem access is not granted: write authority is absent: {path} la automatización no tiene acceso al sistema de archivos: falta el permiso de escritura: {path} @msg automation path rejected: outside the read root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de lectura {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de lectura {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} ruta de automatización rechazada: fuera de la carpeta raíz de escritura {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} ruta de automatización rechazada: fuera de las carpetas raíz de escritura {roots}: {path} @msg automation path rejected: path is empty ruta de automatización rechazada: la ruta está vacía @msg automation path rejected: a link that leads nowhere: {path} ruta de automatización rechazada: un vínculo que no lleva a ninguna parte: {path} @msg automation path rejected: cannot resolve {path}: {e} ruta de automatización rechazada: no se puede resolver {path}: {e} @@ -4486,6 +4596,7 @@ axis Both Ambos Activate Panel Activar panel Change Panel Layout Cambiar distribución de paneles Close Panel Cerrar panel + Collapse Panel Group Contraer grupo de paneles Move Panel Mover panel Group with Agrupar con Resize panels Cambiar tamaño de los paneles @@ -4496,6 +4607,12 @@ axis Both Ambos @msg before must name a panel before debe indicar un panel @msg docking action contains an unknown panel or an excessive split path La acción de anclaje contiene un panel desconocido o una ruta de divisiones demasiado larga @msg group must be true or false group debe ser true o false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} el panel flota; contraiga su grupo con window.panel.float {panel, collapsed} +@msg panel is not in a dock group el panel no está en un grupo del acoplamiento +@msg only a dock group above or below others collapses, not one beside another solo se contrae un grupo del acoplamiento situado encima o debajo de otros, no al lado +@msg icon must be true or false icon debe ser true o false +@msg icon only docks a panel in the icon column, with no anchor icon solo acopla un panel en la columna de iconos, sin anchor +@msg column docks a set as a dock column, with no anchor or icon column acopla un conjunto como columna del acoplamiento, sin anchor ni icon @msg move requires a destination panel Para mover se requiere un panel de destino @msg move requires an existing destination panel Para mover se requiere un panel de destino existente @msg onto must name a floating panel onto debe indicar un panel flotante @@ -4574,12 +4691,24 @@ lighting Light Luz Reset Ruler Origin Restablecer el origen de las reglas Artistic Artístico Film Grain… Granulado de película… + Colored Pencil… Lápiz de color… + Colored Pencil Lápiz de color Cutout… Recorte… Cutout Recorte + Dry Brush… Pincel seco… + Dry Brush Pincel seco Neon Glow… Resplandor de neón… + Palette Knife… Espátula… Neon Glow Resplandor de neón + Paint Daubs… Pinceladas… + Paint Daubs Pinceladas + Palette Knife Espátula Poster Edges… Bordes de póster… + Rough Pastels… Pastel rudo… Poster Edges Bordes de póster + Rough Pastels Pastel rudo + Underpainting… Subpintura… + Underpainting Subpintura Levels Niveles Edge Simplicity Simplicidad de bordes Edge Fidelity Fidelidad de bordes @@ -4656,6 +4785,7 @@ effect Regular Regular Logo 1000×1000 Logotipo 1000×1000 Business Card 3.5 × 2 in Tarjeta de presentación de 3.5 × 2 pulgadas Business Card 85 × 55 mm Tarjeta de presentación de 85 × 55 mm + Business Card 91 × 55 mm Tarjeta de presentación de 91 × 55 mm Letterhead Letter Membrete Carta Letterhead A4 Membrete A4 Envelope DL Sobre DL @@ -5042,6 +5172,9 @@ mark-style Japanese Japonesas Softness Suavidad Ambient Light Luz ambiental Light behind the object Luz detrás del objeto + 3D Lighting Preset Ajuste preestablecido de iluminación 3D + Standard Estándar + Diffuse Difusa # 3D light management. Lights Luces Add Light Añadir luz @@ -5086,6 +5219,7 @@ mark-style Japanese Japonesas @msg Measured anchor no longer exists El punto de ancla medido ya no existe @msg Measured segment no longer exists El segmento medido ya no existe @msg Non-finite dimension Cota no finita +@msg Scale Text Attributes: selected type has no scale to bake Escalar atributos de texto: el texto seleccionado no tiene escala que aplicar @msg Scale must be drawing:real, for example 1:10 La escala debe ser dibujo:real, por ejemplo 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 La relación de escala debe estar entre 1:1000000 y 1000000:1 @msg Second measured segment no longer exists El segundo segmento medido ya no existe @@ -5104,6 +5238,7 @@ mark-style Japanese Japonesas Make Image Dynamic Hacer imagen dinámica Import Data Sets… Importar conjuntos de datos… Export Data Sets… Exportar conjuntos de datos… + Load Variable Library… Cargar biblioteca de variables… Make Graph Dynamic Hacer gráfico dinámico Batch… Lote… Source Folder: Carpeta de origen: @@ -5126,6 +5261,12 @@ mark-style Japanese Japonesas Sketch Bosquejar Halftone Pattern… Trama de semitonos… Halftone Pattern Trama de semitonos + Torn Edges… Bordes desgarrados… + Torn Edges Bordes desgarrados + Conté Crayon… Lápiz Conté… + Conté Crayon Lápiz Conté + Foreground Level Nivel de primer plano + Background Level Nivel de fondo Pattern Type Tipo de trama Dot Punto # User Interface › Color Scheme @@ -5144,6 +5285,7 @@ mark-style Japanese Japonesas Watercolor… Acuarela… Watercolor Acuarela Brush Size Tamaño del pincel + Texture Coverage Cobertura de textura Definition Definición Brush Detail Detalle del pincel Shadow Intensity Intensidad de sombras @@ -5172,13 +5314,21 @@ mark-style Japanese Japonesas @msg the image's pixels can't be read no se pueden leer los píxeles de la imagen Persistent Persistente Library Panel Options Opciones del panel de biblioteca + Locate Library… Localizar biblioteca… @msg Persistent libraries not found: {files} No se encontraron bibliotecas persistentes: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` no es un panel de biblioteca (consulte window.swatchLibrary) @msg library panel `{given}` isn't open el panel de biblioteca `{given}` no está abierto +@msg library panel `{given}` isn't missing its library la biblioteca del panel de biblioteca `{given}` no falta @msg too many library panels were opened in this session se abrieron demasiados paneles de biblioteca en esta sesión @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. La imagen tiene {_1} × {_2} píxeles, más de {LARGE_TRACE_SIDE} en un lado: se calcará, pero más despacio. Un ajuste preestablecido de menor fidelidad o una copia más pequeña de la imagen se calcan más rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). La imagen se calcó a {_1} × {_2} píxeles, reducida desde {_3} × {_4} ({_5} megapíxeles como máximo). + Snapping Ajuste + Snap to Tangent Ajustar a tangente + Snap to Perpendicular Ajustar a perpendicular +@msg unknown snap option `{key}` (use {options}) opción de ajuste desconocida `{key}` (use {options}) + Gap Length: Longitud del hueco: Artboard {n} of {total} Mesa de trabajo {n} de {total} + File {n} of {total} Archivo {n} de {total} Encoding and writing the file… Codificando y escribiendo el archivo… @plural {n} more export waiting|{n} more exports waiting {n} exportación más en espera|{n} exportaciones más en espera Continue in Background Continuar en segundo plano @@ -5194,6 +5344,14 @@ mark-style Japanese Japonesas Full Justify Justificar completamente Minimum must not exceed Desired, nor Desired Maximum. El mínimo no puede superar el deseado, ni el deseado el máximo. Justification Justificación + Add filters from the list above Añada filtros de la lista de arriba + Add to Stack Añadir a la pila + Applied Filters Filtros aplicados + Categories Categorías + Choose a filter Elija un filtro + Effect Gallery Galería de efectos + Filters Filtros + Select artwork to apply effects Seleccione gráficos para aplicar efectos @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them no se leyeron las variables ni los conjuntos de datos del archivo (variables: {variables}, conjuntos de datos: {data_sets}): el panel Variables no los muestra # Select by Color (select.color) Select by Color Seleccionar por color @@ -5215,3 +5373,105 @@ mark-style Japanese Japonesas Plaster Yeso Note Paper… Papel con relieve… Note Paper Papel con relieve + Water Paper… Papel mojado… + Water Paper Papel mojado + Ruby… Ruby… + Ruby Ruby + Geometric Guides Guías geométricas + Ease In Entrada suave + Ease Out Salida suave + Ease In Out Entrada y salida suaves + Ease Out In Salida y entrada suaves + Ramp: Intensidad: + Shift: Desplazamiento: + Fresco… Fresco… + Fresco Fresco + Effect Gallery… Galería de efectos… + Effect Gallery Dialog Cuadro de diálogo Galería de efectos + Arc Segment Tool Options Opciones de la herramienta Segmento de arco + Base Along: Base a lo largo de: + Closed Cerrado + Convex Convexo + Fill Arc Rellenar arco + Length X-Axis: Longitud eje X: + Length Y-Axis: Longitud eje Y: + Slope: Pendiente: +axis X Axis Eje X +axis Y Axis Eje Y +@msg Packaged {n} file(s) in {folder} Se empaquetaron {n} archivo(s) en {folder} +@msg Packaged {n} file(s) {note} Se empaquetaron {n} archivo(s) {note} +@msg Packaged {n} file(s) Se empaquetaron {n} archivo(s) + Packaged {n} file(s) Se empaquetaron {n} archivo(s) + Packaged {n} file(s) in {folder} Se empaquetaron {n} archivo(s) en {folder} + Packaged {n} file(s) {note} Se empaquetaron {n} archivo(s) {note} + {count} file(s) in {folder}. Show the package folder? {count} archivo(s) en {folder}. ¿Mostrar la carpeta del paquete? + Clockwise En sentido horario + Dither Tramado + Fine noise hides banding in the gradient fill Un ruido fino oculta las bandas del relleno de degradado +@msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a rellenos de degradado (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a rellenos de degradado (paint.setFill) +@msg `dither` must be true or false `dither` debe ser true o false +@msg `method` must be classic or perceptual `method` debe ser classic o perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? ¿Instalar “{name}”, que pide un presupuesto de cálculo grande? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada ejecución puede tardar hasta {minutes} minutos y hacer {times} veces el trabajo que se permite a otros plugins. Se ejecuta en segundo plano y se puede cancelar. + It may take minutes, so it runs in the background, where you can cancel it. Puede tardar minutos, así que se ejecuta en segundo plano, donde se puede cancelar. + The plug-in is computing… El plugin está calculando… + Inspect Plug-in Inspeccionar plugin + Cancel Plug-in Run Cancelar ejecución del plugin +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} terminó, pero el documento o el plugin cambió entretanto: vuelva a ejecutarlo +@msg Running “{name}”… Ejecutando “{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy` +@msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte +# Actions panel: action sets (#3712). + New Set… Nuevo conjunto… + New Set Nuevo conjunto + Set Options… Opciones de conjunto… + Set Options Opciones de conjunto + Create New Set Crear nuevo conjunto + Stop Global Edit Detener edición global + Global Edit Options Opciones de edición global + Match Coincidir + On Artboards En mesas de trabajo + All Artboards Todas las mesas de trabajo + Include Objects On Canvas Incluir objetos en el lienzo + Result(s): {count} Resultado(s): {count} +@msg select one object seleccione un objeto +@msg Global Edit works on paths, compound paths and symbol instances La edición global funciona con trazados, trazados compuestos e instancias de símbolos +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again no se encontraron objetos similares (las copias giradas o reflejadas no coinciden); cambie las opciones de edición global e inténtelo de nuevo +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervalo como 1, 3-5, no {v} + Add to Symbols Agregar a símbolos + Basic Shapes Formas básicas + Open Symbol Library Abrir biblioteca de símbolos + Save Symbol Library Guardar biblioteca de símbolos + Save Symbol Library… Guardar biblioteca de símbolos… + Save Symbols… Guardar símbolos… + Selected Symbols Only Solo símbolos seleccionados + Symbol Libraries Menu Menú Bibliotecas de símbolos + Symbol Library Biblioteca de símbolos + User Symbol Library 1 Biblioteca de símbolos del usuario 1 + User Symbol Library 2 Biblioteca de símbolos del usuario 2 + User Symbol Library 3 Biblioteca de símbolos del usuario 3 + User Symbol Library 4 Biblioteca de símbolos del usuario 4 + User Symbol Library 5 Biblioteca de símbolos del usuario 5 + User Symbol Library 6 Biblioteca de símbolos del usuario 6 + User Symbol Library 7 Biblioteca de símbolos del usuario 7 + User Symbol Library 8 Biblioteca de símbolos del usuario 8 + User Symbol Library 9 Biblioteca de símbolos del usuario 9 + User Symbol Library 10 Biblioteca de símbolos del usuario 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` no tiene símbolos para colocar +@msg missing `library` (see symbol.libraries) falta `library` (consulte symbol.libraries) +@msg no symbol `{n}` no hay ningún símbolo `{n}` +@msg no symbol `{n}` in `{_1}` no hay ningún símbolo `{n}` en `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) no hay ninguna biblioteca de símbolos `{key}` (consulte symbol.libraries) +@msg the document has no symbols to save el documento no tiene símbolos para guardar +@msg Trap: select two or more objects filled with solid colours Reventar: seleccione dos o más objetos rellenos con colores sólidos +@msg Trap: no edges of different colours meet among the selected objects Reventar: entre los objetos seleccionados no se tocan bordes de colores distintos +@msg thickness must be from 0.01 to 5000 pt thickness debe estar entre 0,01 y 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth debe estar entre 1 y 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction debe estar entre 0 y 100 % + Rectangular Grid Tool Options Opciones de la herramienta Cuadrícula rectangular + Polar Grid Tool Options Opciones de la herramienta Cuadrícula polar + Fill Line Rellenar línea diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index f46d0ba4a..9a0c1d1d7 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -42,8 +42,14 @@ Medium Light Keskivaalea Light Vaalea Quit VectorCraft Lopeta VectorCraft +# Artwork drag between document tabs. + Drop Artwork Pudota kuvitus +@msg {key} must be [x, y] with finite coordinates {key} on annettava muodossa [x, y] äärellisillä koordinaateilla +@msg ids must contain 1–10000 object ids ids-arvon on sisällettävä 1–10000 objektitunnusta + File Tiedosto New… Uusi… + New Without Dialog Uusi ilman valintaikkunaa New from Template… Uusi mallipohjasta… Open… Avaa… Open Recent Files Avaa viimeisimmät tiedostot @@ -271,6 +277,8 @@ Large Gaps Suuret aukot Gap Preview Color: Aukkojen esikatseluväri: Close Gaps With Paths Sulje aukot poluilla + Show Live Paint Gaps Näytä elävän maalauksen aukot + Hide Live Paint Gaps Piilota elävän maalauksen aukot Select Fills Valitse täytöt Select Strokes Valitse viivat Image Trace Kuvan jäljitys @@ -311,6 +319,13 @@ Glyphs Merkit Insert Special Character Lisää erikoismerkki Bullet Luettelomerkki + Bullets Luettelomerkit + Bullets and Numbering Luettelomerkit ja numerointi + Apply Bullets Käytä luettelomerkkejä + Filled Dot Täytetty piste + Dash Ajatusviiva + Hollow Circle Ontto ympyrä + Filled Square Täytetty neliö Copyright Symbol Tekijänoikeusmerkki Ellipsis Kolme pistettä Paragraph Symbol Kappalemerkki @@ -759,7 +774,7 @@ Units Yksiköt Guides & Grid Apuviivat ja ruudukko Hyphenation Tavutus - Performance & Storage Suorituskyky ja tallennustila + Plug-ins & Scratch Disks Laajennukset ja työlevyt User Interface Käyttöliittymä Performance Suorituskyky File Handling Tiedostojen käsittely @@ -809,6 +824,7 @@ Activate Panel Aktivoi paneeli Change Panel Layout Muuta paneelien asettelua Close Panel Sulje paneeli + Collapse Panel Group Kutista paneeliryhmä Move Panel Siirrä paneelia Group with Ryhmitä kohteen kanssa Resize panels Muuta paneelien kokoa @@ -1111,7 +1127,8 @@ lighting Light Valo to sample a color ottaaksesi värinäytteen across a selected object to set the gradient direction valitun objektin poikki asettaaksesi liukuvärin suunnan to select an artboard | valitaksesi piirtoalustan | - on the canvas to create one kankaalla luodaksesi uuden + on the canvas to create one ( kankaalla luodaksesi uuden ( + selects several, or draws one inside an artboard) valitsee useita tai luo uuden piirtoalustan sisään) a stroke to add a width point | viivaa lisätäksesi leveyspisteen | to change one side | muuttaaksesi yhtä puolta | to select more points | valitaksesi lisää pisteitä | @@ -1364,6 +1381,7 @@ lighting Light Valo Leading Riviväli No Break Ei katkoa Proportional Metrics Suhteelliset metriikat + Character Direction Merkkien suunta Digits Numerot Arabic Arabia Hindi Hindi @@ -1431,6 +1449,7 @@ lighting Light Valo {count} more rules: Export writes every one. {count} sääntöä lisää: vienti kirjoittaa ne kaikki. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} nimetöntä objektia jätetty pois: nimeä ne tai ota Nimettömät objektit käyttöön paneelin valikosta. All Categories Kaikki luokat + Repeat Objects Toisto-objektit Save… Tallenna… Selection Only Vain valinta {title} (selection) {title} (valinta) @@ -1464,6 +1483,7 @@ lighting Light Valo Edit the selected point's colour in the Color panel Muokkaa valitun pisteen väriä Väri-paneelissa Edit the stop Muokkaa väripistettä Eyedropper: click the art to sample the stop's colour Pipetti: ota väripisteen väri näytteeksi napsauttamalla kuvitusta + Eyedropper: click the art to sample the key color Pipetti: ota avainväri näytteeksi napsauttamalla kuvitusta Gradient across stroke Liukuväri viivan poikki Gradient along stroke Liukuväri viivaa pitkin Gradient Fill: click to apply, drag onto art Liukuväritäyttö: käytä napsauttamalla, vedä kuvituksen päälle @@ -1498,7 +1518,7 @@ lighting Light Valo Clear History Tyhjennä historia Set History Limit… Aseta historian raja… Open a document to see its history. Avaa asiakirja, niin näet sen historian. -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Käytä tietojoukkoa Cancel Pattern Editing Peruuta kuvion muokkaus Copy Appearance Kopioi ulkoasu @@ -1716,6 +1736,9 @@ lighting Light Valo Left Indent Vasen sisennys Left-to-Right Paragraph Direction Kappaleen suunta vasemmalta oikealle Line-end Punctuation Half Width Rivinlopun välimerkit puolileveinä + Insert Kashidas Lisää kashidat + Long Pitkä + Stylistic Tyylitelty Mojikumi Set Mojikumi-joukko Kinsoku Set Kinsoku-joukko Hard Tiukka @@ -1739,6 +1762,13 @@ lighting Light Valo Repeat Pathfinder Toista polunetsin Repeat {name} Toista {name} Trap… Ylitäyttö… + Trap Ylitäyttö + Height/Width Korkeus/leveys + Tint Reduction Sävyn vähennys + Traps with Process Color Ylitäytöt prosessiväreillä + Reverse Traps Käänteiset ylitäytöt + Precision Tarkkuus + Remove Redundant Points Poista tarpeettomat pisteet Bottom in Front Alin edessä Brick by Column Tiili sarakkeittain Brick by Row Tiili riveittäin @@ -1865,8 +1895,8 @@ lighting Light Valo {kind}, {model} {kind}, {model} No symbols in this document Tässä asiakirjassa ei ole symboleja Select art and click New Symbol to make one. Luo symboli valitsemalla kuvitus ja napsauttamalla Uusi symboli. - Symbol Libraries (on the roadmap) Symbolikirjastot (suunnitteilla) Align On: Tasaa kohteeseen: + Bottom-Justified Tab Alas tasattu sarkain Center-Justified Tab Keskitetty sarkain Above Text Tekstin yläpuolella Position Panel Above Text Sijoita paneeli tekstin yläpuolelle @@ -1878,6 +1908,7 @@ lighting Light Valo No text selected Ei valittua tekstiä Repeat Tab Toista sarkain Right-Justified Tab Oikealle tasattu sarkain + Top-Justified Tab Ylös tasattu sarkain Select a text object to set its tab stops. Valitse tekstiobjekti, jonka sarkainkohdat haluat asettaa. Select text Valitse teksti Snap to Unit Kohdista yksikköön @@ -1993,6 +2024,13 @@ lighting Light Valo Tints and Shades Vaaleat ja tummat sävyt Hue Shift Sävynsiirto Key Color: Avainväri: + Tips Vinkit + Colorization Tips Värityksen vinkit + The samples show this brush on a blue stroke. Näytteissä tämä sivellin on sinisellä viivalla. + The art keeps its own colors. Kuvitus säilyttää omat värinsä. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Mustasta tulee viivan väri, ja valkoinen pysyy valkoisena. Muista väreistä tulee viivan värin vaaleita sävyjä, ja mitä vaaleampi väri on, sitä vaaleampi on sen sävy. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Musta pysyy mustana, ja valkoinen pysyy valkoisena. Tummista väreistä tulee viivan värin tummia sävyjä ja vaaleista sen vaaleita sävyjä. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Avainväristä tulee viivan väri. Muut värit siirtyvät väriympyrällä yhtä paljon kuin avainväri ja säilyttävät kylläisyytensä ja kirkkautensa. Size: Koko: Calligraphic Brush Options Kalligrafisen siveltimen asetukset Bristle Brush Options Harjassiveltimen asetukset @@ -2306,11 +2344,11 @@ lighting Light Valo Fonts whose licence doesn't allow embedding are not copied. Fontteja, joiden lisenssi ei salli upottamista, ei kopioida. Package collects a saved document with its linked files and fonts. Paketointi kokoaa tallennetun asiakirjan sekä sen linkitetyt tiedostot ja fontit. Package created Paketti luotu - Packaged {n} file(s) in {folder}{note} Paketoitu {n} tiedosto(a) kansioon {folder}{note} + Packaged {n} file(s) in {folder} {note} Paketoitiin {n} tiedosto(a) kansioon {folder} {note} Packaged {n} file(s){note} Paketoitu {n} tiedosto(a){note} Relink Linked Files to the Document Linkitä linkitetyt tiedostot uudelleen asiakirjaan Save the document first? Tallennetaanko asiakirja ensin? - {count} file(s) in {folder}{note}. Show the package folder? {count} tiedosto(a) kansiossa {folder}{note}. Näytetäänkö pakettikansio? + {count} file(s) in {folder} {note}. Show the package folder? {count} tiedosto(a) kansiossa {folder} {note}. Näytetäänkö pakettikansio? {count} linked file(s) not found {count} linkitettyä tiedostoa ei löytynyt fonts not copied: {fonts} fontteja ei kopioitu: {fonts} Built-in presets are read-only: New… starts an editable copy. Sisäiset esiasetukset ovat vain luku -tilassa: Uusi… luo muokattavan kopion. @@ -2357,6 +2395,11 @@ lighting Light Valo File Dimensions Tiedoston mitat Transforms Muunnokset Background Color: Taustaväri: + Show Center Mark Näytä keskimerkki + Show Cross Hairs Näytä hiusristikko + Show Video Safe Areas Näytä videon turva-alueet + Video Ruler Pixel Aspect Ratio: Videoviivainten kuvapisteen kuvasuhde: + Hide Video Rulers Piilota videoviivaimet Client-side (.html) Asiakaspuoli (.html) Color Reduction: Värien vähennys: Lossless (lossy WebP isn't available yet) Häviötön (häviöllinen WebP ei ole vielä käytettävissä) @@ -2836,12 +2879,21 @@ lighting Light Valo Darken Tummenna Exclusion Poissulkeminen Hard Light Kova valo + Hard Mix Kova sekoitus Lighten Vaalenna Luminosity Luminanssi Overlay Sulautus Soft Light Pehmeä valo + Soft Mix Pehmeä sekoitus Crosshatch Ristiviivoitus Fine Dots Hienot pisteet + + Simple Yksinkertainen + Light Rough Vaalea karkea + Dark Rough Tumma karkea + Wide Sharp Leveä terävä + Wide Blurry Leveä sumea + Sparkle Kimallus Medium Dots Keskikokoiset pisteet Grainy Dots Rakeiset pisteet Coarse Dots Karkeat pisteet @@ -2916,6 +2968,14 @@ theme Appearance Ulkoasu Edit Symbol Muokkaa symbolia Expand Brush Strokes Laajenna sivellinviivat New Brush… Uusi sivellin… + New Brush Uusi sivellin + Calligraphic Brush Kalligrafinen sivellin + Scatter Brush Sirotesivellin + Art Brush Taidesivellin + Bristle Brush Harjassivellin + Pattern Brush Kuviosivellin + Select art to make a scatter, art or pattern brush. Valitse kuvitus, jotta voit luoda sirote-, taide- tai kuviosiveltimen. + Brush Art Siveltimen kuvitus New Symbol Uusi symboli New Symbol… Uusi symboli… Place Symbol Instance Sijoita symbolin ilmentymä @@ -3377,6 +3437,7 @@ theme Appearance Ulkoasu Scale Corners Skaalaa kulmat Scale Cursor Proportional to UI Skaalaa kohdistin suhteessa käyttöliittymään Scale Strokes & Effects Skaalaa viivat ja tehosteet + Scale Text Attributes Skaalaa tekstin attribuutit Scratch Disks Työlevyt Secondary Toissijainen Select Same Tint % Valitse sama sävyprosentti @@ -3392,6 +3453,7 @@ theme Appearance Ulkoasu Size/Leading Koko/riviväli Snapping Tolerance Tartuntatoleranssi Spacing Guides Välistysapuviivat + Invoke Distance Guides Käytä etäisyysapuviivoja Spanish Espanja Subdivisions Jaot Templates Folder Mallipohjakansio @@ -3477,6 +3539,7 @@ theme Appearance Ulkoasu Delete Swatch Poista värimalli Duplicate Swatch Monista värimalli Merge Swatches Yhdistä värimallit + Move Brush Siirrä sivellintä Move Swatch Siirrä värimallia New Color Group Uusi väriryhmä New Swatch Uusi värimalli @@ -3696,6 +3759,8 @@ effect Regular Normaali Put back in the dock Palauta telakkaan All Scripts Kaikki kirjoitusjärjestelmät Any Kind Mikä tahansa laji + Font Technology Fonttitekniikka +font-technology Variable Muuttuvat Decorative Koristeelliset Favorites Suosikit Latin Latinalainen @@ -3966,6 +4031,7 @@ imageTrace Outline Ääriviiva @msg Couldn't copy to the system clipboard: {e} Järjestelmän leikepöydälle ei voitu kopioida: {e} @msg Couldn't open {name}: {e} Tiedostoa {name} ei voitu avata: {e} @msg Couldn't place {name}: the document it was dropped on was closed Kohdetta {name} ei voitu sijoittaa: asiakirja, johon se pudotettiin, on suljettu +@msg Couldn't place {name}: the document it was placed in was closed Kohdetta {name} ei voitu sijoittaa: asiakirja, johon sitä sijoitettiin, on suljettu @msg Couldn't read {name}: {e} Tiedostoa {name} ei voitu lukea: {e} @msg Couldn't save recovery data: {e} Palautustietoja ei voitu tallentaa: {e} @msg Create Outlines: select text objects Muunna ääriviivoiksi: valitse tekstiobjekteja @@ -4002,13 +4068,14 @@ imageTrace Outline Ääriviiva @msg Opened {url} Avattiin {url} @msg Outline Stroke: no stroked paths selected Muunna viiva ääriviivaksi: viivallisia polkuja ei ole valittu @msg PDF settings are not an object PDF-asetukset eivät ole objekti +@msg PDF security settings are not an object PDF-suojausasetukset eivät ole objekti @msg PDF writer error: {0} PDF-kirjoittimen virhe: {0} -@msg Packaged {n} file(s) in {folder}{note} Paketoitiin {n} tiedosto(a) kansioon {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Paketoitiin {n} tiedosto(a) kansioon {folder} {note} @msg Packaged {n} file(s){note} Paketoitiin {n} tiedosto(a){note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Esikatselu selaimessa vaatii työpöytäsovelluksen (verkkoversio lataa tiedostot sen sijaan) @msg Release Guides: no guides Vapauta apuviivat: ei apuviivoja @msg Release: no layer to put the rectangle in Vapauta: ei tasoa, johon suorakulmion voisi sijoittaa -@msg Replace Spine: select a blend and a path Korvaa runko: valitse sulautus ja polku +@msg Replace Spine: select a blend and a path Korvaa selkäranka: valitse sulautus ja polku @msg select two or more objects to distribute valitse kaksi tai useampia objekteja jaettavaksi tasaisesti @msg select paths or anchor points valitse polkuja tai ankkuripisteitä @msg SVG parse error: {0} SVG-jäsennysvirhe: {0} @@ -4071,6 +4138,25 @@ imageTrace Outline Ääriviiva @msg a PostScript error ({name} in `{op}`: {at}) tapahtui PostScript-virhe ({name} kohdassa `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} tapahtui PostScript-virhe ({name} kohdassa `{op}`: {at}), proseduurissa {procs} @msg a PostScript error ({name}) tapahtui PostScript-virhe ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) tapahtui PostScript-virhe ({name} kohdassa `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} tapahtui PostScript-virhe ({name} kohdassa `{op}`; {meaning}), proseduurissa {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) tapahtui PostScript-virhe ({name} kohdassa `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} tapahtui PostScript-virhe ({name} kohdassa `{op}`: {at}; {meaning}), proseduurissa {procs} +@msg a PostScript error ({name}; {meaning}) tapahtui PostScript-virhe ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin ohjelma päättää sanakirjan, jota se ei aloittanut +@msg the program opens a file, which the reader can't do ohjelma avaa tiedoston, mitä PostScript-lukija ei osaa +@msg the program restores a saved state that was already restored ohjelma palauttaa tallennetun tilan, joka on jo palautettu +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none ohjelman lukemat tiedot ovat vioittuneet tai katkenneet, tai ohjelma pyytää sijaintia tiedostossa, jolla sitä ei ole +@msg the program goes past one of the reader's limits ohjelma ylittää yhden PostScript-lukijan rajoista +@msg the path has no point to continue from polulla ei ole pistettä, josta jatkaa +@msg a value is outside the range the operator takes arvo on operaattorin hyväksymän alueen ulkopuolella +@msg the operator needs more values than the stack holds operaattori tarvitsee enemmän arvoja kuin pinossa on +@msg part of the program isn't valid PostScript text osa ohjelmasta ei ole kelvollista PostScript-tekstiä +@msg a value has the wrong type for the operator arvo on väärää tyyppiä operaattorille +@msg the program runs, deletes or renames a file, which the reader can't do ohjelma suorittaa, poistaa tai nimeää uudelleen tiedoston, mitä PostScript-lukija ei osaa +@msg the program asks for a resource that isn't defined ohjelma pyytää resurssia, jota ei ole määritetty +@msg a calculation has no result, as in a division by zero laskutoimituksella ei ole tulosta, kuten nollalla jaettaessa +@msg the operator looks for a mark on the stack and finds none operaattori etsii pinosta merkkiä eikä löydä sitä @msg a bitmap is empty bittikartta on tyhjä @msg a bitmap is too large to read bittikartta on liian suuri luettavaksi @msg a colour index is 0 to {_1}, not {n} väri-indeksi on väliltä 0–{_1}, ei {n} @@ -4103,6 +4189,7 @@ imageTrace Outline Ääriviiva @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` akselin on oltava "vertical", "horizontal" tai kulma asteina, ei `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" tausta {other}: transparent, white, black tai väri, kuten "#ff8800" @msg bad color {v} virheellinen väri {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio on oltava luku väliltä 0,1–10 @msg bad colour `{hex}` (#rrggbb) virheellinen väri `{hex}` (#rrggbb) @msg bad page tree virheellinen sivupuu @msg bad shortcut virheellinen pikanäppäin @@ -4149,7 +4236,7 @@ imageTrace Outline Ääriviiva @msg give the path `id` and the width point `index` anna polun `id` ja leveyspisteen `index` @msg guides are locked apuviivat on lukittu @msg handle must be in or out handle-arvon on oltava in tai out -@msg image '{key}' has no pixels and was left out kuvassa '{key}' ei ole pikseleitä, joten se jätettiin pois +@msg an image has no pixels and was left out kuva, jossa ei ole pikseleitä, jätettiin pois @msg image data `{_1}` is missing kuvan data `{_1}` puuttuu @msg image would be too large; lower the resolution kuvasta tulisi liian suuri; pienennä tarkkuutta @msg index must be a number or first, previous, next or last indeksin on oltava luku tai first, previous, next tai last @@ -4176,7 +4263,7 @@ imageTrace Outline Ääriviiva @msg missing index indeksi puuttuu @msg missing number `{key}` luku `{key}` puuttuu @msg missing integer `{key}` kokonaisluku `{key}` puuttuu -@msg multiple windows land with M11.5 useat ikkunat tulevat versiossa M11.5 +@msg New Window isn't available yet: a document opens in one window Uusi ikkuna ei ole vielä käytettävissä: asiakirja avautuu yhteen ikkunaan @msg name the file anna tiedostolle nimi @msg no CSS to copy: select objects with CSS of their own ei kopioitavaa CSS:ää: valitse objekteja, joilla on oma CSS @msg no Live Paint edge near that point pisteen lähellä ei ole elävän maalauksen reunaa @@ -4194,7 +4281,9 @@ imageTrace Outline Ääriviiva @msg no blend options ei sulautusasetuksia @msg no bounds ei rajoja @msg no brush named `{name}` ei sivellintä nimeltä `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} harjassiveltimen nimessä voi olla enintään {max} merkkiä: nimessä `{name}` on {count} merkkiä @msg no brush selected sivellintä ei ole valittu +@msg brushes move only within their type siveltimiä voi siirtää vain oman tyyppinsä sisällä @msg no catalog ei luetteloa @msg no changes since the last save ei muutoksia viimeisen tallennuksen jälkeen @msg no clock to keep a heartbeat by ei kelloa sykkeen ylläpitoon @@ -4213,6 +4302,7 @@ imageTrace Outline Ääriviiva @msg no file system here täällä ei ole tiedostojärjestelmää @msg no file writer ei tiedostonkirjoittajaa @msg no files to place ei sijoitettavia tiedostoja +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` ei ole VectorCraft-asiakirja, PDF- eikä PDF-yhteensopiva .ai-tiedosto @msg no format to export to ei vientimuotoa @msg no free recovery area ei vapaata palautusaluetta @msg no gradient stop or freeform point is selected liukuvärin väripistettä tai vapaamuotoista pistettä ei ole valittu @@ -4248,6 +4338,7 @@ imageTrace Outline Ääriviiva @msg no such action toimintoa ei ole @msg no such anchor ankkuria ei ole @msg no such artboard piirtoalustaa ei ole +@msg the artboard is locked piirtoalusta on lukittu @msg no such document asiakirjaa ei ole @msg no such layer or object tasoa tai objektia ei ole @msg no such object {0} objektia {0} ei ole @@ -4303,6 +4394,7 @@ imageTrace Outline Ääriviiva @msg path is already closed polku on jo suljettu @msg paths contain invalid coordinates polut sisältävät virheellisiä koordinaatteja @msg paths: give at least one file paths: anna vähintään yksi tiedosto +@msg replace: the object to replace was changed or deleted replace: korvattavaa objektia on muutettu tai se on poistettu @msg pattern missing kuvio puuttuu @msg patterns nested too deeply are filled with mid-grey liian syvälle sisäkkäiset kuviot täytetään keskiharmaalla @msg percent must be above 0 and at most 6400, not {p} prosenttiarvon on oltava yli 0 ja enintään 6400, ei {p} @@ -4455,6 +4547,7 @@ imageTrace Outline Ääriviiva @msg unknown workspace (one of {_1}) tuntematon työtila (jokin seuraavista: {_1}) @msg unsupported: {0} ei tuettu: {0} @msg variables are locked muuttujat on lukittu +@msg variables that aren't text were left out: {kinds} muut kuin tekstimuuttujat jätettiin pois: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files version {0} tiedostoja ei voi pakata: pakattuja tiedostoja voi avata vain versiosta {COMPRESSED_SINCE} alkaen @msg width and height are at least 1 pixel leveys ja korkeus ovat vähintään 1 pikseli @msg workspace name can't be empty työtilan nimi ei voi olla tyhjä @@ -4466,6 +4559,7 @@ imageTrace Outline Ääriviiva @msg {_1} font families available {_1} fonttiperhettä käytettävissä @msg {_1} is a live effect (Effect › Plug-ins) {_1} on live-tehoste (Tehoste › Laajennukset) @msg {_1} is an exporter (File › Export › Export As…) {_1} on vientilaajennus (Tiedosto › Vie › Vie muodossa…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` on {size} tavua (raja {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} on jo käytössä kohteella {_2} @msg {_1} linked file(s) can't be read here: their previews show {_1} linkitettyä tiedostoa ei voi lukea täällä: niiden esikatselut näytetään @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} linkitettyä kuvaa muuttui levyllä: Päivitä linkit näyttää uudet versiot @@ -4494,6 +4588,8 @@ imageTrace Outline Ääriviiva @msg Saving Tallennetaan @msg Saving {_1} Tallennetaan {_1} @msg Exporting {_1} Viedään {_1} +@msg Packaging {_1} Paketoidaan {_1} +@msg Placing {_1} Sijoitetaan {_1} @msg Saving recovery data for {_1} Tallennetaan palautustietoja: {_1} @msg Discarded {_1} recovered document(s) Hylättiin {_1} palautettu(a) asiakirja(a) @msg Restored {_1} recovered document(s): save them to keep them Palautettiin {_1} asiakirja(a): tallenna ne säilyttääksesi ne @@ -4578,7 +4674,9 @@ imageTrace Outline Ääriviiva @msg automation filesystem access is not granted: read authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: lukuoikeus puuttuu: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automaation tiedostojärjestelmäkäyttöä ei ole myönnetty: kirjoitusoikeus puuttuu: {path} @msg automation path rejected: outside the read root {root}: {path} automaation polku hylättiin: lukujuuren {root} ulkopuolella: {path} +@msg automation path rejected: outside the read roots {roots}: {path} automaation polku hylättiin: lukujuurten {roots} ulkopuolella: {path} @msg automation path rejected: outside the write root {root}: {path} automaation polku hylättiin: kirjoitusjuuren {root} ulkopuolella: {path} +@msg automation path rejected: outside the write roots {roots}: {path} automaation polku hylättiin: kirjoitusjuurten {roots} ulkopuolella: {path} @msg automation path rejected: path is empty automaation polku hylättiin: polku on tyhjä @msg automation path rejected: a link that leads nowhere: {path} automaation polku hylättiin: linkki, joka ei johda mihinkään: {path} @msg automation path rejected: cannot resolve {path}: {e} automaation polku hylättiin: polkua {path} ei voi selvittää: {e} @@ -4601,6 +4699,12 @@ imageTrace Outline Ääriviiva @msg before must name a panel before-arvon on nimettävä paneeli @msg docking action contains an unknown panel or an excessive split path telakointitoiminto sisältää tuntemattoman paneelin tai liian pitkän jakopolun @msg group must be true or false group-arvon on oltava true tai false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} paneeli kelluu; kutista sen ryhmä komennolla window.panel.float {panel, collapsed} +@msg panel is not in a dock group paneeli ei ole telakkaryhmässä +@msg only a dock group above or below others collapses, not one beside another vain muiden ylä- tai alapuolella oleva telakkaryhmä kutistuu, ei toisen vieressä oleva +@msg icon must be true or false icon-arvon on oltava true tai false +@msg icon only docks a panel in the icon column, with no anchor icon telakoi paneelin vain kuvakesarakkeeseen, ilman anchor-arvoa +@msg column docks a set as a dock column, with no anchor or icon column telakoi joukon telakkasarakkeeksi, ilman anchor- tai icon-arvoa @msg move requires a destination panel siirto vaatii kohdepaneelin @msg move requires an existing destination panel siirto vaatii olemassa olevan kohdepaneelin @msg onto must name a floating panel onto-arvon on nimettävä kelluva paneeli @@ -4660,6 +4764,7 @@ imageTrace Outline Ääriviiva Logo 1000×1000 Logo 1000×1000 Business Card 3.5 × 2 in Käyntikortti 3.5 × 2 in Business Card 85 × 55 mm Käyntikortti 85 × 55 mm + Business Card 91 × 55 mm Käyntikortti 91 × 55 mm Letterhead Letter Kirjelomake Letter Letterhead A4 Kirjelomake A4 Envelope DL Kirjekuori DL @@ -4892,12 +4997,24 @@ imageTrace Outline Ääriviiva DWG (use DXF) DWG (käytä DXF:ää) Artistic Taiteelliset Film Grain… Filmin rakeisuus… + Colored Pencil… Värikynä… + Colored Pencil Värikynä Cutout… Leikkaus… Cutout Leikkaus + Dry Brush… Kuiva sivellin… + Dry Brush Kuiva sivellin Neon Glow… Neonhehku… + Palette Knife… Palettiveitsi… Neon Glow Neonhehku + Paint Daubs… Maalitahrat… + Paint Daubs Maalitahrat + Palette Knife Palettiveitsi Poster Edges… Julisteen reunat… + Rough Pastels… Karkeat pastellit… Poster Edges Julisteen reunat + Rough Pastels Karkeat pastellit + Underpainting… Pohjamaalaus… + Underpainting Pohjamaalaus Levels Tasot Edge Simplicity Reunojen yksinkertaisuus Edge Fidelity Reunojen tarkkuus @@ -5001,6 +5118,9 @@ mark-style Japanese Japanilainen Softness Pehmeys Ambient Light Ympäristövalo Light behind the object Valo objektin takana + 3D Lighting Preset 3D-valaistuksen esiasetus + Standard Vakio + Diffuse Hajavalo # 3D light management. Lights Valot Add Light Lisää valo @@ -5051,6 +5171,7 @@ mark-style Japanese Japanilainen Drawing : real size — 1:10 labels 1 unit on the page as 10. Piirustus : todellinen koko — 1:10 merkitsee sivun 1 yksikön arvoksi 10. Editable Muokattava Export Data Sets… Vie tietojoukot… + Load Variable Library… Lataa muuttujakirjasto… Filter Suodata Filter Layers Panel Rows Suodata Tasot-paneelin rivit Flatten Litistä @@ -5067,6 +5188,15 @@ mark-style Japanese Japanilainen Isolate Selected Object Eristä valittu objekti Keep selected Pidä valittuna Merge only with selection Yhdistä vain valintaan + Consider Open Filled Paths as Closed Käsittele avoimia täytettyjä polkuja suljettuina + In Merge Mode, Clicking Stroke Splits the Path Yhdistämistilassa viivan napsauttaminen jakaa polun + Pick Color From: Värin lähde: + Artwork Kuvitus + Cursor Swatch Preview Värimallien esikatselu osoittimen vieressä + Selection: Valinta: + Straight Line Suora viiva + Highlight: Korostus: + Stroke When Editable Viiva, kun muokattavissa Legacy (version 7) Vanha (versio 7) Legacy .ai Options Vanhan .ai-muodon asetukset Linked Files Linkitetyt tiedostot @@ -5112,6 +5242,7 @@ mark-style Japanese Japanilainen @msg Measured anchor no longer exists Mitattua ankkuripistettä ei ole enää @msg Measured segment no longer exists Mitattua segmenttiä ei ole enää @msg Non-finite dimension Mitta ei ole äärellinen luku +@msg Scale Text Attributes: selected type has no scale to bake Skaalaa tekstin attribuutit: valitulla tekstillä ei ole sovellettavaa skaalausta @msg Scale must be drawing:real, for example 1:10 Mittakaavan on oltava muotoa piirustus:todellinen, esimerkiksi 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Mittakaavan suhteen on oltava välillä 1:1000000–1000000:1 @msg Second measured segment no longer exists Toista mitattua segmenttiä ei ole enää @@ -5125,6 +5256,10 @@ mark-style Japanese Japanilainen @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for tietojoukosta {name} ei käytetty mitään: mitään objektia ei ole sidottu muuttujaan, jolle siinä on arvo @msg select an Intertwine group valitse lomitusryhmä @msg select an Objects on Path group valitse Objektit polulla -ryhmä +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects yhdistelmäpolun, sulautuksen, vääristyskuoren, toiston tai lomitusryhmän sisällä olevaan polkuun ei voi kiinnittää objekteja +@msg a clipping path can't take objects leikkauspolkuun ei voi kiinnittää objekteja +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path sulautuksen, vääristyskuoren, toiston tai lomitusryhmän sisällä olevia objekteja ei voi kiinnittää polulle +@msg a clipping path can't be attached to a path leikkauspolkua ei voi kiinnittää polulle @msg span must be "object" or "selection", got {v} span-arvon on oltava "object" tai "selection", saatiin {v} @msg span: "selection" places a `gradient`, not a swatch span: "selection" sijoittaa `gradient`-liukuvärin, ei värimallia @msg span: "selection" places a gradient: give `gradient` span: "selection" sijoittaa liukuvärin: anna `gradient` @@ -5148,6 +5283,12 @@ mark-style Japanese Japanilainen Sketch Luonnos Halftone Pattern… Rasterikuvio… Halftone Pattern Rasterikuvio + Torn Edges… Repeytyneet reunat… + Torn Edges Repeytyneet reunat + Conté Crayon… Conté-liitu… + Conté Crayon Conté-liitu + Foreground Level Edustan taso + Background Level Taustan taso Pattern Type Kuviotyyppi Dot Piste # User Interface › Color Scheme @@ -5166,6 +5307,7 @@ mark-style Japanese Japanilainen Watercolor… Vesiväri… Watercolor Vesiväri Brush Size Siveltimen koko + Texture Coverage Tekstuurin peitto Definition Terävyys Brush Detail Siveltimen yksityiskohta Shadow Intensity Varjojen voimakkuus @@ -5194,13 +5336,21 @@ mark-style Japanese Japanilainen @msg the image's pixels can't be read kuvan pikseleitä ei voi lukea Persistent Pysyvä Library Panel Options Kirjastopaneelin asetukset + Locate Library… Paikanna kirjasto… @msg Persistent libraries not found: {files} Pysyviä kirjastoja ei löytynyt: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` ei ole kirjastopaneeli (katso window.swatchLibrary) @msg library panel `{given}` isn't open kirjastopaneeli `{given}` ei ole auki +@msg library panel `{given}` isn't missing its library kirjastopaneelin `{given}` kirjasto ei puutu @msg too many library panels were opened in this session tässä istunnossa on avattu liian monta kirjastopaneelia @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Kuva on {_1} × {_2} kuvapistettä, yhdeltä sivultaan yli {LARGE_TRACE_SIDE}: se jäljitetään, mutta hitaammin. Pienemmän tarkkuuden esiasetus tai kuvan pienempi kopio jäljittyy nopeammin. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Kuva jäljitettiin kokoon {_1} × {_2} kuvapistettä pienennettynä koosta {_3} × {_4} (enintään {_5} megapikseliä). + Snapping Tartunta + Snap to Tangent Kohdista tangenttiin + Snap to Perpendicular Kohdista kohtisuoraan +@msg unknown snap option `{key}` (use {options}) tuntematon tartuntavalinta `{key}` (käytä: {options}) + Gap Length: Aukon pituus: Artboard {n} of {total} Piirtoalusta {n}/{total} + File {n} of {total} Tiedosto {n}/{total} Encoding and writing the file… Tiedostoa koodataan ja kirjoitetaan… @plural {n} more export waiting|{n} more exports waiting {n} vienti odottaa|{n} vientiä odottaa Continue in Background Jatka taustalla @@ -5216,6 +5366,14 @@ mark-style Japanese Japanilainen Full Justify Täysi tasaus Minimum must not exceed Desired, nor Desired Maximum. Minimi ei saa ylittää haluttua arvoa eikä haluttu arvo maksimia. Justification Tasaus + Add filters from the list above Lisää suodattimia yllä olevasta luettelosta + Add to Stack Lisää pinoon + Applied Filters Käytetyt suodattimet + Categories Luokat + Choose a filter Valitse suodatin + Effect Gallery Tehostegalleria + Filters Suodattimet + Select artwork to apply effects Valitse kuvitus, johon tehosteet lisätään @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them tiedoston muuttujia ja tietojoukkoja ei luettu (muuttujia: {variables}, tietojoukkoja: {data_sets}): Muuttujat-paneeli ei näytä niitä # Select by Color (select.color) Select by Color Valitse värin mukaan @@ -5304,3 +5462,105 @@ swatch Bright Violet Kirkas violetti Plaster Kipsi Note Paper… Kirjepaperi… Note Paper Kirjepaperi + Water Paper… Märkä paperi… + Water Paper Märkä paperi + Ruby… Ruby… + Ruby Ruby + Geometric Guides Geometriset apuviivat + Ease In Hidas alku + Ease Out Hidas loppu + Ease In Out Hidas alku ja loppu + Ease Out In Hidas loppu ja alku + Ramp: Voimakkuus: + Shift: Siirtymä: + Fresco… Fresko… + Fresco Fresko + Effect Gallery… Tehostegalleria… + Effect Gallery Dialog Tehostegalleria-valintaikkuna + Arc Segment Tool Options Kaarisegmenttityökalun asetukset + Base Along: Kanta akselilla: + Closed Suljettu + Convex Kupera + Fill Arc Täytä kaari + Length X-Axis: X-akselin pituus: + Length Y-Axis: Y-akselin pituus: + Slope: Kaltevuus: +axis X Axis X-akseli +axis Y Axis Y-akseli +@msg Packaged {n} file(s) in {folder} Paketoitiin {n} tiedosto(a) kansioon {folder} +@msg Packaged {n} file(s) {note} Paketoitiin {n} tiedosto(a) {note} +@msg Packaged {n} file(s) Paketoitiin {n} tiedosto(a) + Packaged {n} file(s) Paketoitiin {n} tiedosto(a) + Packaged {n} file(s) in {folder} Paketoitiin {n} tiedosto(a) kansioon {folder} + Packaged {n} file(s) {note} Paketoitiin {n} tiedosto(a) {note} + {count} file(s) in {folder}. Show the package folder? {count} tiedosto(a) kansiossa {folder}. Näytetäänkö pakettikansio? + Clockwise Myötäpäivään + Dither Rasterointi + Fine noise hides banding in the gradient fill Hieno kohina piilottaa raidat liukuväritäytöstä +@msg `dither` applies to gradient fills (stroke: false) `dither` koskee liukuväritäyttöjä (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` koskee liukuväritäyttöjä (paint.setFill) +@msg `dither` must be true or false `dither` on oltava true tai false +@msg `method` must be classic or perceptual `method` on oltava classic tai perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Asennetaanko ”{name}”, joka pyytää suurta laskentabudjettia? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Jokainen ajo voi kestää enintään {minutes} minuuttia ja tehdä {times} kertaa niin paljon työtä kuin muut laajennukset saavat. Se toimii taustalla, ja voit peruuttaa sen. + It may take minutes, so it runs in the background, where you can cancel it. Tämä voi kestää minuutteja, joten se toimii taustalla, missä voit peruuttaa sen. + The plug-in is computing… Laajennus laskee… + Inspect Plug-in Tarkastele laajennusta + Cancel Plug-in Run Peruuta laajennuksen ajo +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} valmistui, mutta asiakirja tai laajennus muuttui sillä välin: suorita se uudelleen +@msg Running “{name}”… Suoritetaan ”{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy` +@msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy +# Actions panel: action sets (#3712). + New Set… Uusi sarja… + New Set Uusi sarja + Set Options… Sarjan asetukset… + Set Options Sarjan asetukset + Create New Set Luo uusi sarja + Stop Global Edit Lopeta yleismuokkaus + Global Edit Options Yleismuokkauksen asetukset + Match Vastaavuus + On Artboards Piirtoalustoilla + All Artboards Kaikki piirtoalustat + Include Objects On Canvas Sisällytä kankaalla olevat objektit + Result(s): {count} Tulokset: {count} +@msg select one object valitse yksi objekti +@msg Global Edit works on paths, compound paths and symbol instances Yleismuokkaus toimii poluille, yhdistelmäpoluille ja symbolien esiintymille +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again samankaltaisia objekteja ei löytynyt (kierrettyjä tai peilattuja kopioita ei tunnisteta); muuta yleismuokkauksen asetuksia ja yritä uudelleen +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all tai alue, kuten 1, 3-5, ei {v} + Add to Symbols Lisää symboleihin + Basic Shapes Perusmuodot + Open Symbol Library Avaa symbolikirjasto + Save Symbol Library Tallenna symbolikirjasto + Save Symbol Library… Tallenna symbolikirjasto… + Save Symbols… Tallenna symbolit… + Selected Symbols Only Vain valitut symbolit + Symbol Libraries Menu Symbolikirjastojen valikko + Symbol Library Symbolikirjasto + User Symbol Library 1 Käyttäjän symbolikirjasto 1 + User Symbol Library 2 Käyttäjän symbolikirjasto 2 + User Symbol Library 3 Käyttäjän symbolikirjasto 3 + User Symbol Library 4 Käyttäjän symbolikirjasto 4 + User Symbol Library 5 Käyttäjän symbolikirjasto 5 + User Symbol Library 6 Käyttäjän symbolikirjasto 6 + User Symbol Library 7 Käyttäjän symbolikirjasto 7 + User Symbol Library 8 Käyttäjän symbolikirjasto 8 + User Symbol Library 9 Käyttäjän symbolikirjasto 9 + User Symbol Library 10 Käyttäjän symbolikirjasto 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place Kohteessa `{_1}` ei ole sijoitettavia symboleja +@msg missing `library` (see symbol.libraries) `library` puuttuu (katso symbol.libraries) +@msg no symbol `{n}` ei symbolia `{n}` +@msg no symbol `{n}` in `{_1}` ei symbolia `{n}` kohteessa `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) ei symbolikirjastoa `{key}` (katso symbol.libraries) +@msg the document has no symbols to save dokumentissa ei ole tallennettavia symboleja +@msg Trap: select two or more objects filled with solid colours Ylitäyttö: valitse vähintään kaksi tasaisella värillä täytettyä objektia +@msg Trap: no edges of different colours meet among the selected objects Ylitäyttö: valituissa objekteissa eriväriset reunat eivät kohtaa +@msg thickness must be from 0.01 to 5000 pt thickness-arvon on oltava 0,01–5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth-arvon on oltava 1–1000 % +@msg tintReduction must be from 0 to 100 % tintReduction-arvon on oltava 0–100 % + Rectangular Grid Tool Options Suorakulmaisen ruudukon työkalun asetukset + Polar Grid Tool Options Napakoordinaattiruudukon työkalun asetukset + Fill Line Täytä viiva diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index bd706d407..aa3e999f6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -10,7 +10,7 @@ # « », the typographic apostrophe ’. Shortcut keys use their French key-cap names (Maj, Ctrl, Alt, Échap). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). Glowing Edges… Contour lumineux… @@ -38,6 +38,13 @@ Channel 4 Couche 4 Cell Size Taille de cellule Fine Dots Points fins + + Simple Simple + Light Rough Rugueux clair + Dark Rough Rugueux foncé + Wide Sharp Large net + Wide Blurry Large flou + Sparkle Scintillement Medium Dots Points moyens Grainy Dots Points granuleux Coarse Dots Gros points @@ -188,6 +195,8 @@ Anti-aliased Artwork Illustration lissée Anti-aliasing: Lissage : Any Kind Tout type + Font Technology Technologie de police +font-technology Variable Variables Appearance Aspect Appearance Attribute Attribut d’aspect Appearance Item Élément d’aspect @@ -271,6 +280,11 @@ Average… Alignement… Axis: Axe : Background Color: Couleur d’arrière-plan : + Show Center Mark Afficher le repère central + Show Cross Hairs Afficher le réticule + Show Video Safe Areas Afficher les zones de sécurité vidéo + Video Ruler Pixel Aspect Ratio: Format des pixels des règles vidéo : + Hide Video Rulers Masquer les règles vidéo Background Contents Contenu de l’arrière-plan Background Contents: Contenu de l’arrière-plan : Background: Arrière-plan : @@ -308,6 +322,7 @@ Blur Flou Bottom Bas Bottom in Front Bas au premier plan + Bottom-Justified Tab Tabulation alignée en bas Bottom: Bas : Bounds Limites Bounds (Fill Proportionally) Limites (remplir proportionnellement) @@ -338,6 +353,13 @@ Built-in presets don't change: New… starts an editable copy. Les paramètres prédéfinis intégrés ne changent pas : Nouveau… crée une copie modifiable. Bulge… Renflement… Bullet Puce + Bullets Puces + Bullets and Numbering Puces et numérotation + Apply Bullets Appliquer les puces + Filled Dot Point plein + Dash Tiret + Hollow Circle Cercle vide + Filled Square Carré plein Butt Cap Extrémité plate Byte Order: Ordre des octets : Calligraphic Calligraphique @@ -1052,6 +1074,7 @@ Eyedropper Picks Up: La pipette prélève : Eyedropper Tool Outil Pipette Eyedropper: click the art to sample the stop's colour Pipette : cliquez sur l’illustration pour prélever la couleur de l’étape de dégradé + Eyedropper: click the art to sample the key color Pipette : cliquez sur l’illustration pour prélever la couleur clé Favorites Favoris Feather… Contour progressif… Feet Pieds @@ -1060,6 +1083,11 @@ Fidelity: Fidélité : Figure Space Espace numérique Figure: Chiffres : +# Artwork drag between document tabs. + Drop Artwork Déposer l’illustration +@msg {key} must be [x, y] with finite coordinates {key} doit être [x, y] avec des coordonnées finies +@msg ids must contain 1–10000 object ids ids doit contenir de 1 à 10000 identifiants d’objets + File Fichier File Dimensions Dimensions du fichier File Format: Format de fichier : @@ -1081,6 +1109,15 @@ Alt key toggles to Smooth Tool La touche Alt bascule sur l’outil Arrondi Keep selected Garder sélectionné Merge only with selection Fusionner uniquement avec la sélection + Consider Open Filled Paths as Closed Traiter les tracés ouverts avec fond comme fermés + In Merge Mode, Clicking Stroke Splits the Path En mode fusion, un clic sur le contour scinde le tracé + Pick Color From: Prendre la couleur dans : + Artwork Illustration + Cursor Swatch Preview Aperçu des nuances près du pointeur + Selection: Sélection : + Straight Line Ligne droite + Highlight: Mise en évidence : + Stroke When Editable Contour quand il est modifiable Fill Color Couleur de fond Fill New Type Objects With Placeholder Text Remplir les nouveaux objets texte avec un texte de substitution Fill Rule Règle de remplissage @@ -1199,6 +1236,8 @@ Large Gaps Grands espaces Gap Preview Color: Couleur d’aperçu des espaces : Close Gaps With Paths Fermer les espaces avec des tracés + Show Live Paint Gaps Afficher les espaces de peinture dynamique + Hide Live Paint Gaps Masquer les espaces de peinture dynamique Select Fills Sélectionner les fonds Select Strokes Sélectionner les contours Gaussian Blur… Flou gaussien… @@ -1305,6 +1344,7 @@ Hand-Drawn Dessiné à la main Handles Poignées Hard Light Lumière crue + Hard Mix Mélange maximal Harmony Rules: Règles d’harmonie : Harmony Sets Ensembles d’harmonies Height Hauteur @@ -1354,7 +1394,7 @@ Clear History Effacer l’historique Set History Limit… Définir la limite de l’historique… History States États de l’historique -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Appliquer le jeu de données Cancel Pattern Editing Annuler la modification du motif Copy Appearance Copier l’aspect @@ -1826,6 +1866,9 @@ Modified: Modifié : Modify Modifier Mojikumi Mojikumi + Insert Kashidas Insérer des kashidas + Long Longue + Stylistic Stylistique Mojikumi Set Jeu de mojikumi Kinsoku Set Jeu de kinsoku Hard Fort @@ -1860,6 +1903,7 @@ Move Slices Déplacer les tranches Move Spine Point Déplacer le point de la dorsale Reshape Spine Segment Modeler le segment de la dorsale + Move Brush Déplacer la forme Move Swatch Déplacer la nuance Move Up Monter Move… Déplacer… @@ -1914,6 +1958,13 @@ Tints Teintes Tints and Shades Teintes et ombres Hue Shift Décalage de teinte + Tips Conseils + Colorization Tips Conseils de colorisation + The samples show this brush on a blue stroke. Les exemples montrent cette forme sur un contour bleu. + The art keeps its own colors. L’illustration garde ses propres couleurs. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Le noir devient la couleur du contour et le blanc reste blanc. Les autres couleurs deviennent des teintes de la couleur du contour, et plus une couleur est claire, plus sa teinte est claire. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Le noir reste noir et le blanc reste blanc. Les autres couleurs deviennent des ombres de la couleur du contour quand elles sont foncées et des teintes de celle-ci quand elles sont claires. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. La couleur clé devient la couleur du contour. Les autres couleurs tournent sur le cercle chromatique autant que la couleur clé et gardent leur saturation et leur luminosité. Key Color: Couleur clé : Roundness: Arrondi : Fixed Fixe @@ -1935,6 +1986,14 @@ Round Fan Éventail rond Flat Fan Éventail plat New Brush… Nouvelle forme… + New Brush Nouvelle forme + Calligraphic Brush Forme calligraphique + Scatter Brush Forme diffuse + Art Brush Forme artistique + Bristle Brush Forme Soies + Pattern Brush Forme de motif + Select art to make a scatter, art or pattern brush. Sélectionnez une illustration pour créer une forme diffuse, artistique ou de motif. + Brush Art Illustration de la forme New Calligraphic Brush Nouvelle forme calligraphique New Character Style Nouveau style de caractère New Color Group Nouveau groupe de couleurs @@ -1965,6 +2024,7 @@ New View Nouvelle vue New View… Nouvelle vue… New Window Nouvelle fenêtre + New Without Dialog Nouveau sans boîte de dialogue New Workspace Nouvel espace de travail New Workspace… Nouvel espace de travail… New {kind} Style… Nouveau style de {kind}… @@ -2171,7 +2231,7 @@ Package Assembler Package collects a saved document with its linked files and fonts. Assembler rassemble un document enregistré avec ses fichiers liés et ses polices. Package created Assemblage créé - Packaged {n} file(s) in {folder}{note} {n} fichier(s) assemblé(s) dans {folder}{note} + Packaged {n} file(s) in {folder} {note} {n} fichier(s) assemblé(s) dans {folder}{note} Packaged {n} file(s){note} {n} fichier(s) assemblé(s){note} Package… Assembler… Page Page @@ -2232,7 +2292,7 @@ Percent: Pourcentage : Perceptual Scales Échelles perceptives Performance Performances - Performance & Storage Performances et stockage + Plug-ins & Scratch Disks Modules externes & Disques de travail Permissions Autorisations Permissions password: Mot de passe des autorisations : Perspective Perspective @@ -2352,6 +2412,7 @@ Proof Setup Format d’épreuve Properties Propriétés Proportional Metrics Métrique proportionnelle + Character Direction Direction des caractères Digits Chiffres Arabic Arabes Hindi Hindi @@ -2546,6 +2607,7 @@ Reorder Réorganiser Reorder Appearance Item Réorganiser l’élément d’aspect Repeat Répétition + Repeat Objects Objets de répétition Repeat Options Options de répétition Repeat Pathfinder Répéter Pathfinder Repeat Tab Répéter la tabulation @@ -2754,6 +2816,7 @@ Scale Cursor Proportional to UI Mettre le curseur à l’échelle de l’interface Scale Lineweights Mettre à l’échelle les épaisseurs de trait Scale Strokes & Effects Mettre à l’échelle les contours et les effets + Scale Text Attributes Mettre à l’échelle les attributs de texte Scale to Fit Artboard Mettre à l’échelle pour ajuster au plan de travail Scale Tool Outil Mise à l’échelle Scale: Échelle : @@ -3026,6 +3089,7 @@ Snapping Tolerance Tolérance du magnétisme Social Square Post 1080×1080 Publication carrée pour réseaux sociaux 1080×1080 Soft Light Lumière tamisée + Soft Mix Fusion douce Solid Plein Sort by Kind Trier par type Sort by Name Trier par nom @@ -3036,6 +3100,7 @@ Space Before Paragraph Espace avant le paragraphe Spaces Espaces Spacing Guides Repères d’espacement + Invoke Distance Guides Appeler les repères de distance Spacing: Espacement : Spanish Espagnol Specified Distance Distance définie @@ -3173,7 +3238,6 @@ Show all open documents Afficher tous les documents ouverts Symbol Instance Instance de symbole Symbol Libraries Bibliothèques de symboles - Symbol Libraries (on the roadmap) Bibliothèques de symboles (prévu dans la feuille de route) Symbol Screener Tool Outil Transparence de symboles Symbol Scruncher Tool Outil Espacement de symboles Symbol Shifter Tool Outil Glissement de symboles @@ -3266,6 +3330,7 @@ Tools Outils Top Haut Top in Front Haut au premier plan + Top-Justified Tab Tabulation alignée en haut Top: Haut : Total Width: Largeur totale : Touch Type Tool Outil Retouche de texte @@ -3300,6 +3365,13 @@ Transparent Background Arrière-plan transparent Transverse (the page a quarter turn on the paper) Transversal (la page tournée d’un quart de tour sur le papier) Trap… Recouvrement… + Trap Recouvrement + Height/Width Hauteur/largeur + Tint Reduction Réduction de la teinte + Traps with Process Color Recouvrements en couleurs quadri + Reverse Traps Inverser les recouvrements + Precision Précision + Remove Redundant Points Supprimer les points superflus Trim Détourage Trim mark weight: Épaisseur des traits de coupe : Trim marks Traits de coupe @@ -3531,7 +3603,7 @@ Zoom Tool Outil Zoom Zoom with Mouse Wheel Zoom avec la molette de la souris Scrub Numeric Fields by Dragging Modifier les champs numériques en faisant glisser - {count} file(s) in {folder}{note}. Show the package folder? {count} fichier(s) dans {folder}{note}. Afficher le dossier de l’assemblage ? + {count} file(s) in {folder} {note}. Show the package folder? {count} fichier(s) dans {folder}{note}. Afficher le dossier de l’assemblage ? {count} linked file(s) not found {count} fichier(s) lié(s) introuvable(s) fonts not copied: {fonts} polices non copiées : {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} image(s) liée(s) modifiée(s) depuis l’enregistrement de ce document. Afficher les nouvelles versions ? @@ -3603,7 +3675,8 @@ imageTrace Outline Contour to sample a color pour prélever une couleur across a selected object to set the gradient direction sur un objet sélectionné pour définir la direction du dégradé to select an artboard | pour sélectionner un plan de travail | - on the canvas to create one sur la zone de travail pour en créer un + on the canvas to create one ( sur la zone de travail pour en créer un ( + selects several, or draws one inside an artboard) en sélectionne plusieurs ou en crée un dans un plan de travail) a stroke to add a width point | sur un contour pour ajouter un point de largeur | to change one side | pour modifier un seul côté | to select more points | pour sélectionner d’autres points | @@ -3695,7 +3768,7 @@ axis Both Les deux # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` aucune bibliothèque `{id}` @msg select some type first sélectionnez d’abord du texte @@ -3733,6 +3806,7 @@ axis Both Les deux @msg Couldn't copy to the system clipboard: {e} Impossible de copier dans le presse-papiers du système : {e} @msg Couldn't open {name}: {e} Impossible d’ouvrir {name} : {e} @msg Couldn't place {name}: the document it was dropped on was closed Impossible d’importer {name} : le document sur lequel il a été déposé a été fermé +@msg Couldn't place {name}: the document it was placed in was closed Impossible d’importer {name} : le document dans lequel il était importé a été fermé @msg Couldn't read {name}: {e} Impossible de lire {name} : {e} @msg Couldn't save recovery data: {e} Impossible d’enregistrer les données de récupération : {e} @msg Create Outlines: select text objects Vectoriser : sélectionnez des objets texte @@ -3769,8 +3843,9 @@ axis Both Les deux @msg Opened {url} {url} ouvert @msg Outline Stroke: no stroked paths selected Vectoriser le contour : aucun tracé avec contour sélectionné @msg PDF settings are not an object Les paramètres PDF ne sont pas un objet +@msg PDF security settings are not an object Les paramètres de sécurité PDF ne sont pas un objet @msg PDF writer error: {0} Erreur du générateur PDF : {0} -@msg Packaged {n} file(s) in {folder}{note} {n} fichier(s) assemblé(s) dans {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} {n} fichier(s) assemblé(s) dans {folder}{note} @msg Packaged {n} file(s){note} {n} fichier(s) assemblé(s){note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Aperçu dans le navigateur nécessite l’application de bureau (la version web télécharge les fichiers à la place) @msg Release Guides: no guides Annuler les repères : aucun repère @@ -3840,6 +3915,25 @@ axis Both Les deux @msg a PostScript error ({name} in `{op}`: {at}) une erreur PostScript ({name} dans `{op}` : {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} une erreur PostScript ({name} dans `{op}` : {at}), dans {procs} @msg a PostScript error ({name}) une erreur PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) une erreur PostScript ({name} dans `{op}` ; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} une erreur PostScript ({name} dans `{op}` ; {meaning}), dans {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) une erreur PostScript ({name} dans `{op}` : {at} ; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} une erreur PostScript ({name} dans `{op}` : {at} ; {meaning}), dans {procs} +@msg a PostScript error ({name}; {meaning}) une erreur PostScript ({name} ; {meaning}) +@msg the program ends a dictionary it didn't begin le programme ferme un dictionnaire qu’il n’a pas ouvert +@msg the program opens a file, which the reader can't do le programme ouvre un fichier, ce que le lecteur PostScript ne sait pas faire +@msg the program restores a saved state that was already restored le programme restaure un état enregistré qui a déjà été restauré +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none les données que lit le programme sont endommagées ou tronquées, ou le programme demande une position dans un fichier qui n’en a pas +@msg the program goes past one of the reader's limits le programme dépasse l’une des limites du lecteur PostScript +@msg the path has no point to continue from le tracé n’a aucun point à partir duquel continuer +@msg a value is outside the range the operator takes une valeur est hors de la plage acceptée par l’opérateur +@msg the operator needs more values than the stack holds l’opérateur a besoin de plus de valeurs que la pile n’en contient +@msg part of the program isn't valid PostScript text une partie du programme n’est pas du texte PostScript valide +@msg a value has the wrong type for the operator une valeur est d’un type incorrect pour l’opérateur +@msg the program runs, deletes or renames a file, which the reader can't do le programme exécute, supprime ou renomme un fichier, ce que le lecteur PostScript ne sait pas faire +@msg the program asks for a resource that isn't defined le programme demande une ressource qui n’est pas définie +@msg a calculation has no result, as in a division by zero un calcul n’a pas de résultat, comme dans une division par zéro +@msg the operator looks for a mark on the stack and finds none l’opérateur cherche une marque sur la pile et n’en trouve aucune @msg a bitmap is empty une image bitmap est vide @msg a bitmap is too large to read une image bitmap est trop grande pour être lue @msg a colour index is 0 to {_1}, not {n} un indice de couleur va de 0 à {_1}, pas {n} @@ -3872,6 +3966,7 @@ axis Both Les deux @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis doit être "vertical", "horizontal" ou un angle en degrés, pas `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" arrière-plan {other} : transparent, white, black ou une couleur comme "#ff8800" @msg bad color {v} couleur non valide {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio doit être un nombre de 0,1 à 10 @msg the Color Picker is not open le Sélecteur de couleurs n’est pas ouvert @msg the Simplify slider is not open le curseur Simplifier n’est pas ouvert @msg the screen color sampler is already open l’outil de prélèvement de couleur à l’écran est déjà ouvert @@ -3922,7 +4017,7 @@ axis Both Les deux @msg give the path `id` and the width point `index` indiquez l’`id` du tracé et l’`index` du point de largeur @msg guides are locked les repères sont verrouillés @msg handle must be in or out handle doit valoir in ou out -@msg image '{key}' has no pixels and was left out l’image « {key} » n’a aucun pixel et a été ignorée +@msg an image has no pixels and was left out une image sans aucun pixel a été ignorée @msg image data `{_1}` is missing les données d’image `{_1}` sont manquantes @msg image would be too large; lower the resolution l’image serait trop grande ; réduisez la résolution @msg index must be a number or first, previous, next or last index doit être un nombre ou first, previous, next ou last @@ -3949,7 +4044,7 @@ axis Both Les deux @msg missing index index manquant @msg missing number `{key}` nombre `{key}` manquant @msg missing integer `{key}` nombre entier `{key}` manquant -@msg multiple windows land with M11.5 les fenêtres multiples arrivent avec M11.5 +@msg New Window isn't available yet: a document opens in one window Nouvelle fenêtre n’est pas encore disponible : un document s’ouvre dans une seule fenêtre @msg name the file nommez le fichier @msg no CSS to copy: select objects with CSS of their own aucun CSS à copier : sélectionnez des objets ayant leur propre CSS @msg no Live Paint edge near that point aucun bord de peinture dynamique près de ce point @@ -3967,7 +4062,9 @@ axis Both Les deux @msg no blend options aucune option de dégradé de formes @msg no bounds aucune limite @msg no brush named `{name}` aucune forme nommée `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} le nom d’une forme Soies peut compter au plus {max} caractères, et `{name}` en compte {count} @msg no brush selected aucune forme sélectionnée +@msg brushes move only within their type les formes ne se déplacent qu’à l’intérieur de leur type @msg no catalog aucun catalogue @msg no changes since the last save aucune modification depuis le dernier enregistrement @msg no clock to keep a heartbeat by aucune horloge pour entretenir le signal d’activité @@ -3986,6 +4083,7 @@ axis Both Les deux @msg no file system here aucun système de fichiers ici @msg no file writer aucun module d’écriture de fichiers @msg no files to place aucun fichier à importer +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` n’est ni un document VectorCraft, ni un PDF, ni un fichier .ai compatible PDF @msg no format to export to aucun format d’exportation @msg no free recovery area aucune zone de récupération libre @msg no gradient stop or freeform point is selected aucune étape de dégradé ni point de forme libre n’est sélectionné @@ -4021,6 +4119,7 @@ axis Both Les deux @msg no such action script inexistant @msg no such anchor point d’ancrage inexistant @msg no such artboard plan de travail inexistant +@msg the artboard is locked le plan de travail est verrouillé @msg no such document document inexistant @msg no such layer or object calque ou objet inexistant @msg no such object {0} objet {0} inexistant @@ -4077,6 +4176,7 @@ axis Both Les deux @msg path is already closed le tracé est déjà fermé @msg paths contain invalid coordinates les tracés contiennent des coordonnées non valides @msg paths: give at least one file paths : indiquez au moins un fichier +@msg replace: the object to replace was changed or deleted replace : l’objet à remplacer a été modifié ou supprimé @msg pattern missing motif manquant @msg patterns nested too deeply are filled with mid-grey les motifs imbriqués trop profondément sont remplis de gris moyen @msg percent must be above 0 and at most 6400, not {p} le pourcentage doit être supérieur à 0 et au plus égal à 6400, pas {p} @@ -4112,6 +4212,10 @@ axis Both Les deux @msg select a repeat sélectionnez une répétition @msg select an Intertwine group sélectionnez un groupe entrelacé @msg select an Objects on Path group sélectionnez un groupe d’objets sur tracé +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un tracé à l’intérieur d’un tracé transparent, d’un dégradé de formes, d’une enveloppe, d’une répétition ou d’un groupe entrelacé ne peut pas recevoir d’objets +@msg a clipping path can't take objects un tracé d’écrêtage ne peut pas recevoir d’objets +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path les objets à l’intérieur d’un dégradé de formes, d’une enveloppe, d’une répétition ou d’un groupe entrelacé ne peuvent pas être attachés à un tracé +@msg a clipping path can't be attached to a path un tracé d’écrêtage ne peut pas être attaché à un tracé @msg select a stroke with a variable width sélectionnez un contour de largeur variable @msg select a symbol instance sélectionnez une instance de symbole @msg select an Image Trace object sélectionnez un objet de vectorisation de l’image @@ -4231,6 +4335,7 @@ axis Both Les deux @msg unknown workspace (one of {_1}) espace de travail inconnu (valeurs possibles : {_1}) @msg unsupported: {0} non pris en charge : {0} @msg variables are locked les variables sont verrouillées +@msg variables that aren't text were left out: {kinds} les variables qui ne sont pas du texte ({kinds}) ont été ignorées @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files les fichiers de version {0} ne peuvent pas être compressés : seules les versions {COMPRESSED_SINCE} et ultérieures ouvrent les fichiers compressés @msg width and height are at least 1 pixel la largeur et la hauteur valent au moins 1 pixel @msg workspace name can't be empty le nom de l’espace de travail ne peut pas être vide @@ -4242,6 +4347,7 @@ axis Both Les deux @msg {_1} font families available {_1} familles de polices disponibles @msg {_1} is a live effect (Effect › Plug-ins) {_1} est un effet dynamique (Effet › Plug-ins) @msg {_1} is an exporter (File › Export › Export As…) {_1} est un module externe d’exportation (Fichier › Exporter › Exporter sous…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` fait {size} octets (limite {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} est déjà utilisé par {_2} @msg {_1} linked file(s) can't be read here: their previews show {_1} fichier(s) lié(s) illisible(s) ici : leurs aperçus sont affichés @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} image(s) liée(s) modifiée(s) sur le disque : Mettre à jour les liens affiche les nouvelles versions @@ -4271,6 +4377,8 @@ axis Both Les deux @msg Saving Enregistrement @msg Saving {_1} Enregistrement de {_1} @msg Exporting {_1} Exportation de {_1} +@msg Packaging {_1} Assemblage de {_1} +@msg Placing {_1} Importation de {_1} @msg Saving recovery data for {_1} Enregistrement des données de récupération de {_1} @msg Discarded {_1} recovered document(s) {_1} document(s) récupéré(s) supprimé(s) @msg Restored {_1} recovered document(s): save them to keep them {_1} document(s) récupéré(s) restauré(s) : enregistrez-les pour les conserver @@ -4391,7 +4499,9 @@ axis Both Les deux @msg automation filesystem access is not granted: read authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation de lecture : {path} @msg automation filesystem access is not granted: write authority is absent: {path} l’automatisation n’a pas accès au système de fichiers : aucune autorisation d’écriture : {path} @msg automation path rejected: outside the read root {root}: {path} chemin d’automatisation refusé : hors du dossier racine de lecture {root} : {path} +@msg automation path rejected: outside the read roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines de lecture {roots} : {path} @msg automation path rejected: outside the write root {root}: {path} chemin d’automatisation refusé : hors du dossier racine d’écriture {root} : {path} +@msg automation path rejected: outside the write roots {roots}: {path} chemin d’automatisation refusé : hors des dossiers racines d’écriture {roots} : {path} @msg automation path rejected: path is empty chemin d’automatisation refusé : le chemin est vide @msg automation path rejected: a link that leads nowhere: {path} chemin d’automatisation refusé : un lien qui ne mène nulle part : {path} @msg automation path rejected: cannot resolve {path}: {e} chemin d’automatisation refusé : impossible de résoudre {path} : {e} @@ -4498,6 +4608,7 @@ effect Soft Doux Activate Panel Activer le panneau Change Panel Layout Modifier la disposition des panneaux Close Panel Fermer le panneau + Collapse Panel Group Réduire le groupe de panneaux Move Panel Déplacer le panneau Group with Regrouper avec Resize panels Redimensionner les panneaux @@ -4508,6 +4619,12 @@ effect Soft Doux @msg before must name a panel before doit désigner un panneau @msg docking action contains an unknown panel or an excessive split path L’action d’ancrage contient un panneau inconnu ou un chemin de divisions trop long @msg group must be true or false group doit être true ou false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} le panneau est flottant ; réduisez son groupe avec window.panel.float {panel, collapsed} +@msg panel is not in a dock group le panneau n’est dans aucun groupe du dock +@msg only a dock group above or below others collapses, not one beside another seul un groupe du dock situé au-dessus ou au-dessous d’autres se réduit, pas un groupe à côté +@msg icon must be true or false icon doit être true ou false +@msg icon only docks a panel in the icon column, with no anchor icon ne fait qu’ancrer un panneau dans la colonne d’icônes, sans anchor +@msg column docks a set as a dock column, with no anchor or icon column ancre un ensemble comme colonne du dock, sans anchor ni icon @msg move requires a destination panel Le déplacement nécessite un panneau de destination @msg move requires an existing destination panel Le déplacement nécessite un panneau de destination existant @msg onto must name a floating panel onto doit désigner un panneau flottant @@ -4584,12 +4701,24 @@ lighting Light Lumière Reset Ruler Origin Réinitialiser l’origine des règles Artistic Artistique Film Grain… Grain photographique… + Colored Pencil… Crayon de couleur… + Colored Pencil Crayon de couleur Cutout… Découpage… Cutout Découpage + Dry Brush… Pinceau sec… + Dry Brush Pinceau sec Neon Glow… Néon… + Palette Knife… Couteau à palette… Neon Glow Néon + Paint Daubs… Empâtements… + Paint Daubs Empâtements + Palette Knife Couteau à palette Poster Edges… Bords d’affiche… + Rough Pastels… Pastels secs… Poster Edges Bords d’affiche + Rough Pastels Pastels secs + Underpainting… Sous-peinture… + Underpainting Sous-peinture Levels Niveaux Edge Simplicity Simplicité des contours Edge Fidelity Fidélité des contours @@ -4720,6 +4849,7 @@ lighting Light Lumière Brown Marron Business Card 3.5 × 2 in Carte de visite 3,5 × 2 po Business Card 85 × 55 mm Carte de visite 85 × 55 mm + Business Card 91 × 55 mm Carte de visite 91 × 55 mm CRLF (Windows) CRLF (Windows) Cap Height Hauteur des capitales Chevron Chevron @@ -5101,6 +5231,9 @@ theme Appearance Apparence Softness Douceur Ambient Light Lumière ambiante Light behind the object Lumière derrière l'objet + 3D Lighting Preset Préréglage d’éclairage 3D + Standard Standard + Diffuse Diffus # 3D light management. Lights Lumières Add Light Ajouter une lumière @@ -5144,6 +5277,7 @@ theme Appearance Apparence @msg Measured anchor no longer exists Le point d’ancrage mesuré n’existe plus @msg Measured segment no longer exists Le segment mesuré n’existe plus @msg Non-finite dimension Cote non finie +@msg Scale Text Attributes: selected type has no scale to bake Mettre à l’échelle les attributs de texte : le texte sélectionné n’a pas d’échelle à intégrer @msg Scale must be drawing:real, for example 1:10 L’échelle doit être dessin:réel, par exemple 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Le rapport d’échelle doit être compris entre 1:1000000 et 1000000:1 @msg Second measured segment no longer exists Le second segment mesuré n’existe plus @@ -5161,6 +5295,7 @@ theme Appearance Apparence Make Image Dynamic Rendre l'image dynamique Import Data Sets… Importer des jeux de données… Export Data Sets… Exporter des jeux de données… + Load Variable Library… Charger une bibliothèque de variables… Make Graph Dynamic Rendre le graphe dynamique Batch… Traitement par lots… Source Folder: Dossier source : @@ -5183,6 +5318,12 @@ theme Appearance Apparence Sketch Esquisse Halftone Pattern… Trame de demi-teintes… Halftone Pattern Trame de demi-teintes + Torn Edges… Bords déchirés… + Torn Edges Bords déchirés + Conté Crayon… Crayon Conté… + Conté Crayon Crayon Conté + Foreground Level Niveau de premier plan + Background Level Niveau d’arrière-plan Pattern Type Type de motif Dot Point # User Interface › Color Scheme @@ -5201,6 +5342,7 @@ theme Appearance Apparence Watercolor… Aquarelle… Watercolor Aquarelle Brush Size Taille du pinceau + Texture Coverage Couverture de texture Definition Définition Brush Detail Détail du pinceau Shadow Intensity Intensité des ombres @@ -5229,13 +5371,21 @@ theme Appearance Apparence @msg the image's pixels can't be read impossible de lire les pixels de l’image Persistent Persistante Library Panel Options Options du panneau de bibliothèque + Locate Library… Localiser la bibliothèque… @msg Persistent libraries not found: {files} Bibliothèques persistantes introuvables : {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` n’est pas un panneau de bibliothèque (voir window.swatchLibrary) @msg library panel `{given}` isn't open le panneau de bibliothèque `{given}` n’est pas ouvert +@msg library panel `{given}` isn't missing its library la bibliothèque du panneau de bibliothèque `{given}` ne manque pas @msg too many library panels were opened in this session trop de panneaux de bibliothèque ont été ouverts dans cette session @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’image fait {_1} × {_2} pixels, plus de {LARGE_TRACE_SIDE} sur un côté : elle est vectorisée, mais plus lentement. Un paramètre prédéfini moins fidèle ou une copie plus petite de l’image se vectorise plus vite. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’image a été vectorisée à {_1} × {_2} pixels, réduite de {_3} × {_4} ({_5} mégapixels au plus). + Snapping Magnétisme + Snap to Tangent Magnétisme de la tangente + Snap to Perpendicular Magnétisme de la perpendiculaire +@msg unknown snap option `{key}` (use {options}) option de magnétisme inconnue `{key}` (utilisez {options}) + Gap Length: Longueur de lacune : Artboard {n} of {total} Plan de travail {n} sur {total} + File {n} of {total} Fichier {n} sur {total} Encoding and writing the file… Encodage et écriture du fichier… @plural {n} more export waiting|{n} more exports waiting {n} autre exportation en attente|{n} autres exportations en attente Continue in Background Continuer en arrière-plan @@ -5251,6 +5401,14 @@ theme Appearance Apparence Full Justify Justification complète Minimum must not exceed Desired, nor Desired Maximum. Le minimum ne doit pas dépasser la valeur optimale, ni la valeur optimale le maximum. Justification Justification + Add filters from the list above Ajoutez des filtres à partir de la liste ci-dessus + Add to Stack Ajouter à la pile + Applied Filters Filtres appliqués + Categories Catégories + Choose a filter Choisir un filtre + Effect Gallery Galerie d'effets + Filters Filtres + Select artwork to apply effects Sélectionnez des éléments graphiques pour appliquer des effets @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them les variables et jeux de données du fichier n’ont pas été lus (variables : {variables}, jeux de données : {data_sets}) : le panneau Variables ne les affiche pas # Select by Color (select.color) Select by Color Sélectionner par couleur @@ -5272,3 +5430,105 @@ theme Appearance Apparence Plaster Plâtre Note Paper… Papier gaufré… Note Paper Papier gaufré + Water Paper… Papier humide… + Water Paper Papier humide + Ruby… Ruby… + Ruby Ruby + Geometric Guides Repères géométriques + Ease In Accélération progressive + Ease Out Décélération progressive + Ease In Out Accélération et décélération + Ease Out In Décélération et accélération + Ramp: Intensité : + Shift: Décalage : + Fresco… Fresque… + Fresco Fresque + Effect Gallery… Galerie d'effets… + Effect Gallery Dialog Boîte de dialogue Galerie d'effets + Arc Segment Tool Options Options de l’outil Segment d’arc + Base Along: Base le long de : + Closed Fermé + Convex Convexe + Fill Arc Remplir l’arc + Length X-Axis: Longueur axe X : + Length Y-Axis: Longueur axe Y : + Slope: Pente : +axis X Axis Axe X +axis Y Axis Axe Y +@msg Packaged {n} file(s) in {folder} {n} fichier(s) assemblé(s) dans {folder} +@msg Packaged {n} file(s) {note} {n} fichier(s) assemblé(s) {note} +@msg Packaged {n} file(s) {n} fichier(s) assemblé(s) + Packaged {n} file(s) {n} fichier(s) assemblé(s) + Packaged {n} file(s) in {folder} {n} fichier(s) assemblé(s) dans {folder} + Packaged {n} file(s) {note} {n} fichier(s) assemblé(s) {note} + {count} file(s) in {folder}. Show the package folder? {count} fichier(s) dans {folder}. Afficher le dossier de l’assemblage ? + Clockwise Sens horaire + Dither Simulation + Fine noise hides banding in the gradient fill Un bruit fin masque les bandes du fond en dégradé +@msg `dither` applies to gradient fills (stroke: false) `dither` s’applique aux fonds en dégradé (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` s’applique aux fonds en dégradé (paint.setFill) +@msg `dither` must be true or false `dither` doit valoir true ou false +@msg `method` must be classic or perceptual `method` doit être classic ou perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Installer « {name} », qui demande un budget de calcul important ? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Chaque exécution peut durer jusqu’à {minutes} minutes et faire {times} fois le travail permis aux autres modules externes. Elle s’exécute en arrière-plan, et vous pouvez l’annuler. + It may take minutes, so it runs in the background, where you can cancel it. Cela peut prendre plusieurs minutes : il s’exécute donc en arrière-plan, où vous pouvez l’annuler. + The plug-in is computing… Le module externe calcule… + Inspect Plug-in Examiner le module externe + Cancel Plug-in Run Annuler l’exécution du module externe +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} a terminé, mais le document ou le module externe a changé entre-temps : relancez-le +@msg Running “{name}”… Exécution de « {name} »… +@msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy` +@msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte +# Actions panel: action sets (#3712). + New Set… Nouveau jeu… + New Set Nouveau jeu + Set Options… Options du jeu… + Set Options Options du jeu + Create New Set Créer un jeu + Stop Global Edit Arrêter la modification globale + Global Edit Options Options de modification globale + Match Correspondance + On Artboards Sur les plans de travail + All Artboards Tous les plans de travail + Include Objects On Canvas Inclure les objets sur la toile + Result(s): {count} Résultat(s) : {count} +@msg select one object sélectionnez un seul objet +@msg Global Edit works on paths, compound paths and symbol instances La modification globale s’applique aux tracés, aux tracés transparents et aux occurrences de symboles +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again aucun objet similaire trouvé (les copies pivotées ou symétriques ne sont pas reconnues) ; modifiez les options de modification globale et réessayez +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards` : all ou une plage telle que 1, 3-5, et non {v} + Add to Symbols Ajouter aux symboles + Basic Shapes Formes de base + Open Symbol Library Ouvrir une bibliothèque de symboles + Save Symbol Library Enregistrer la bibliothèque de symboles + Save Symbol Library… Enregistrer la bibliothèque de symboles… + Save Symbols… Enregistrer les symboles… + Selected Symbols Only Symboles sélectionnés uniquement + Symbol Libraries Menu Menu Bibliothèques de symboles + Symbol Library Bibliothèque de symboles + User Symbol Library 1 Bibliothèque de symboles utilisateur 1 + User Symbol Library 2 Bibliothèque de symboles utilisateur 2 + User Symbol Library 3 Bibliothèque de symboles utilisateur 3 + User Symbol Library 4 Bibliothèque de symboles utilisateur 4 + User Symbol Library 5 Bibliothèque de symboles utilisateur 5 + User Symbol Library 6 Bibliothèque de symboles utilisateur 6 + User Symbol Library 7 Bibliothèque de symboles utilisateur 7 + User Symbol Library 8 Bibliothèque de symboles utilisateur 8 + User Symbol Library 9 Bibliothèque de symboles utilisateur 9 + User Symbol Library 10 Bibliothèque de symboles utilisateur 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` n'a aucun symbole à placer +@msg missing `library` (see symbol.libraries) `library` manquant (voir symbol.libraries) +@msg no symbol `{n}` aucun symbole `{n}` +@msg no symbol `{n}` in `{_1}` aucun symbole `{n}` dans `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) aucune bibliothèque de symboles `{key}` (voir symbol.libraries) +@msg the document has no symbols to save le document n'a aucun symbole à enregistrer +@msg Trap: select two or more objects filled with solid colours Recouvrement : sélectionnez au moins deux objets remplis de couleurs unies +@msg Trap: no edges of different colours meet among the selected objects Recouvrement : aucun bord de couleurs différentes ne se touche parmi les objets sélectionnés +@msg thickness must be from 0.01 to 5000 pt thickness doit être compris entre 0,01 et 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth doit être compris entre 1 et 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction doit être compris entre 0 et 100 % + Rectangular Grid Tool Options Options de l’outil Grille rectangulaire + Polar Grid Tool Options Options de l’outil Grille à coordonnées polaires + Fill Line Remplir le trait diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3faa6c2a3..a1f499126 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -18,7 +18,7 @@ # English first word, as they are often shown after "…: ". # # Coverage: the whole interface (every menu string and `tl!` literal, `complete_menus`) and the -# status and error messages (`COMPLETE_MESSAGES`), enforced by the tests. Left in English on +# status and error messages (`COMPLETE_MESSAGES`), gaps listed by `untranslated_report`. Left in English on # purpose (`MENU_KEEP_AS_IS`): the product name, language names, OpenType, the built-in workspace # names and the bracketed perspective grid preset names; `hu-HU` and every other `hu-*` locale # resolve here. @@ -38,7 +38,8 @@ from center | – rajzolás a középpontból | keeps its proportions) – arányok megtartása) near a selected pin to rotate | egy kijelölt tű közelében – forgatás | - on the canvas to create one a vásznon – új rajztábla + on the canvas to create one ( a vásznon – új rajztábla ( + selects several, or draws one inside an artboard) – több kijelölése, vagy új rajztábla egy rajztáblán belül) over paths to roughen their outlines | útvonalakon – a körvonaluk eldurvítása | over paths, and hold still to keep going | útvonalakon – nyomva tartva folytatódik | right or left to zoom in or out | jobbra vagy balra – nagyítás vagy kicsinyítés | @@ -276,6 +277,8 @@ Anti-aliasing: Élsimítás: Any except extracting pages Bármi, kivéve az oldalak kinyerését Any Kind Bármilyen típus + Font Technology Betűtípus-technológia +font-technology Variable Változó Appearance Megjelenés Appearance Attribute Megjelenési attribútum Appearance Item Megjelenési elem @@ -322,6 +325,7 @@ Art Grafikus Art & Illustration Grafika és illusztráció Art and Bright Colors Grafika és élénk színek + Art Brush Grafikus ecset Art Brush Options Grafikus ecset beállításai Art Optimized Grafikára optimalizált Art using a deleted global swatch keeps its colour. A törölt globális színmintát használó grafika megtartja a színét. @@ -371,6 +375,11 @@ B4 B4 B5 B5 Background Color: Háttérszín: + Show Center Mark Középjel megjelenítése + Show Cross Hairs Szálkereszt megjelenítése + Show Video Safe Areas Biztonságos videoterületek megjelenítése + Video Ruler Pixel Aspect Ratio: Videovonalzók képpont-méretaránya: + Hide Video Rulers Videovonalzók elrejtése Background Contents Háttértartalom Background Contents: Háttértartalom: Background: Háttér: @@ -432,6 +441,7 @@ Bottom Left Bal alsó Bottom Right Jobb alsó Bottom to Top Lentről felfelé + Bottom-Justified Tab Alulra igazító tabulátor Bottom-to-Bottom Leading Alulról alulra mért sorköz Bottom: Alsó: Bounding Box Befoglaló keret @@ -455,6 +465,7 @@ Bring Forward Eggyel előre Bring to Front Előre hozás Bristle Sörte + Bristle Brush Sörteecset Bristle Brush Options Sörteecset beállításai Bristle Brush Strokes Sörteecset-vonások Bristle Density: Sörtesűrűség: @@ -463,6 +474,7 @@ Brown Barna Browser Böngésző Brush Affects: Az ecset ezekre hat: + Brush Art Ecsetgrafika Brush Definition Ecsetdefiníció Brush Libraries Ecsetkönyvtárak Brush Libraries (on the roadmap) Ecsetkönyvtárak (tervezett funkció) @@ -476,13 +488,22 @@ Built-in presets don't change: New… starts an editable copy. A beépített előbeállítások nem módosíthatók: az Új… szerkeszthető másolatot hoz létre. Bulge… Kidudorodás… Bullet Felsorolásjel + Bullets Felsorolásjelek + Bullets and Numbering Felsorolásjelek és számozás + Apply Bullets Felsorolásjelek alkalmazása + Filled Dot Kitöltött pont + Dash Gondolatjel + Hollow Circle Üres kör + Filled Square Kitöltött négyzet Burasagari Burasagari Burlap Zsákvászon Business Card 3.5 × 2 in Névjegykártya 3,5 × 2 in Business Card 85 × 55 mm Névjegykártya 85 × 55 mm + Business Card 91 × 55 mm Névjegykártya 91 × 55 mm Butt Cap Lapos vonalvég Byte Order: Bájtsorrend: Calligraphic Kalligrafikus + Calligraphic Brush Kalligrafikus ecset Calligraphic Brush Options Kalligrafikus ecset beállításai Can't open “{name}”. Nem nyitható meg: „{name}”. Can't save “{name}”. Nem menthető: „{name}”. @@ -635,6 +656,7 @@ Close All Összes bezárása Close Library Könyvtár bezárása Close Panel Panel bezárása + Collapse Panel Group Panelcsoport összecsukása Close Path Útvonal lezárása Close paths when ends are within: Útvonalak lezárása, ha a végek távolsága legfeljebb: Clumped Csomós @@ -975,6 +997,7 @@ Diffuse Glow Szórt ragyogás Diffuse Glow… Szórt ragyogás… Diffusion Diffúziós + Character Direction Karakterirány Digits Számjegyek Dim Copies to: Másolatok halványítása: Dim Images to: Képek halványítása: @@ -1336,6 +1359,7 @@ Eyedropper Picks Up: A pipetta felveszi: Eyedropper Tool Pipetta Eyedropper: click the art to sample the stop's colour Pipetta: kattints a grafikára a pont színének mintavételéhez + Eyedropper: click the art to sample the key color Pipetta: kattints a grafikára a kulcsszín mintavételéhez Farsi Perzsa Favorites Kedvencek Feather Tollazat @@ -1346,6 +1370,11 @@ Fidelity: Pontosság: Figure Space Számjegyszóköz Figure: Számjegyek: +# Artwork drag between document tabs. + Drop Artwork Grafika elhelyezése +@msg {key} must be [x, y] with finite coordinates A(z) {key} értékének [x, y] alakúnak kell lennie véges koordinátákkal +@msg ids must contain 1–10000 object ids Az ids értékének 1–10000 objektumazonosítót kell tartalmaznia + File Fájl File Dimensions Fájl méretei File Format: Fájlformátum: @@ -1368,6 +1397,15 @@ Alt key toggles to Smooth Tool Az Alt billentyű a Simítás eszközre vált Keep selected Kijelölés megtartása Merge only with selection Összeolvasztás csak a kijelöléssel + Consider Open Filled Paths as Closed A nyitott kitöltött útvonalak zártnak számítanak + In Merge Mode, Clicking Stroke Splits the Path Összeolvasztáskor a körvonalra kattintás kettévágja az útvonalat + Pick Color From: Szín forrása: + Artwork Grafika + Cursor Swatch Preview Színminta-előnézet a mutató mellett + Selection: Kijelölés: + Straight Line Egyenes vonal + Highlight: Kiemelés: + Stroke When Editable Körvonal, ha szerkeszthető Fill New Type Objects With Placeholder Text Új szövegobjektumok kitöltése helykitöltő szöveggel Fill Rule Kitöltési szabály Fill with Placeholder Text Kitöltés helykitöltő szöveggel @@ -1387,6 +1425,13 @@ Find Next Következő keresése Find Objects Objektumok keresése Fine Dots Finom pontok + + Simple Egyszerű + Light Rough Világos durva + Dark Rough Sötét durva + Wide Sharp Széles éles + Wide Blurry Széles elmosott + Sparkle Csillogás First Első First Baseline Első alapvonal First commit Első commit @@ -1505,6 +1550,8 @@ file-filter Templates Sablonok Large Gaps Nagy hézagok Gap Preview Color: Hézagok előnézeti színe: Close Gaps With Paths Hézagok lezárása útvonalakkal + Show Live Paint Gaps Élő festés hézagainak megjelenítése + Hide Live Paint Gaps Élő festés hézagainak elrejtése Select Fills Kitöltések kijelölése Select Strokes Körvonalak kijelölése Gaussian Blur Gauss-elmosás @@ -1564,12 +1611,24 @@ file-filter Templates Sablonok Select Segments Szakaszok kijelölése Artistic Művészi Film Grain… Filmszemcse… + Colored Pencil… Színes ceruza… + Colored Pencil Színes ceruza Cutout… Kivágás… Cutout Kivágás + Dry Brush… Száraz ecset… + Dry Brush Száraz ecset Neon Glow… Neonfény… + Palette Knife… Palettakés… Neon Glow Neonfény + Paint Daubs… Festékpaca… + Paint Daubs Festékpaca + Palette Knife Palettakés Poster Edges… Poszterszélek… + Rough Pastels… Durva pasztell… Poster Edges Poszterszélek + Rough Pastels Durva pasztell + Underpainting… Alapozás… + Underpainting Alapozás Levels Szintek Edge Simplicity Élek egyszerűsége Edge Fidelity Élek hűsége @@ -1667,6 +1726,7 @@ file-filter Templates Sablonok Handles Fogantyúk Hard Szigorú Hard Light Kemény fény + Hard Mix Kemény keverés Harmony Rules Harmóniaszabályok Harmony Rules: Harmóniaszabályok: Harmony Sets Harmóniakészletek @@ -1728,7 +1788,7 @@ file-filter Templates Sablonok Clear History Előzmények törlése Set History Limit… Előzménykorlát beállítása… History States Előzmények lépésszáma -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Adatkészlet alkalmazása Cancel Pattern Editing Mintázatszerkesztés megszakítása Copy Appearance Megjelenés másolása @@ -2313,6 +2373,9 @@ file-filter Templates Sablonok Modified: Módosítva: Modify Módosítás Mojikumi Mojikumi + Insert Kashidas Kasidák beszúrása + Long Hosszú + Stylistic Stilisztikai Mojikumi Set Mojikumi-készlet Monitor RGB Monitor-RGB Mono Egyszínű @@ -2352,6 +2415,7 @@ file-filter Templates Sablonok Move Rows Sorok áthelyezése Move Slices Szeletek áthelyezése Move Spine Point Gerincpont áthelyezése + Move Brush Ecset áthelyezése Move Swatch Színminta áthelyezése Move to Group Áthelyezés csoportba Move Up Feljebb @@ -2379,6 +2443,7 @@ file-filter Templates Sablonok New Art Has Basic Appearance Új grafika alapmegjelenéssel New Artboard Új rajztábla New Bristle Brush Új sörteecset + New Brush Új ecset New Brush… Új ecset… New Calligraphic Brush Új kalligrafikus ecset New Character Style Új karakterstílus @@ -2418,6 +2483,7 @@ file-filter Templates Sablonok New View Új nézet New View… Új nézet… New Window Új ablak + New Without Dialog Új párbeszédablak nélkül New Workspace Új munkaterület New Workspace… Új munkaterület… New {kind} Style… Új stílus: {kind}… @@ -2660,7 +2726,7 @@ file-filter Templates Sablonok Package Csomagolás Package collects a saved document with its linked files and fonts. A csomagolás egy mentett dokumentumot a csatolt fájljaival és betűtípusaival együtt gyűjt össze. Package created A csomag elkészült - Packaged {n} file(s) in {folder}{note} Becsomagolt fájlok: {n}, mappa: {folder}{note} + Packaged {n} file(s) in {folder} {note} Becsomagolt fájlok: {n}, mappa: {folder}{note} Packaged {n} file(s){note} Becsomagolt fájlok: {n}{note} Package… Csomagolás… Page Oldal @@ -2708,6 +2774,7 @@ file-filter Templates Sablonok Path Length Útvonalak hossza Paths: {paths} Anchors: {anchors} Colors: {colors} Útvonalak: {paths} Horgonypontok: {anchors} Színek: {colors} Pattern Mintázat + Pattern Brush Mintázatecset Pattern Brush Options Mintázatecset beállításai Pattern Editing Mintázatszerkesztés Pattern Options Mintázat beállításai @@ -2737,7 +2804,7 @@ file-filter Templates Sablonok Performance Teljesítmény diagnostics render renderelés diagnostics ui felület - Performance & Storage Teljesítmény és tárolás + Plug-ins & Scratch Disks Bővítmények & Munkalemezek Permissions Engedélyek Permissions password: Engedélyek jelszava: Perspective Perspektíva @@ -3078,6 +3145,7 @@ mark-style Japanese Japán Reorder Átrendezés Reorder Appearance Item Megjelenési elem átrendezése Repeat Ismétlés + Repeat Objects Ismétlésobjektumok Repeat Options Ismétlés beállításai Repeat Pathfinder Útvonalkezelő-művelet ismétlése Repeat Tab Tabulátor ismétlése @@ -3338,6 +3406,7 @@ mark-style Japanese Japán Scale Lineweights Vonalvastagságok méretezése Scale Proportionately Arányos méretezés Scale Strokes & Effects Körvonalak és effektusok méretezése + Scale Text Attributes Szövegattribútumok méretezése Scale Tints Tónusok skálázása Scale to Fit Artboard Méretezés a rajztáblához Scale Tool Méretezés @@ -3351,6 +3420,7 @@ mark-style Japanese Japán Scallop Tool Csipkézés Scans: Letapogatások: Scatter Szóró + Scatter Brush Szóróecset Scatter Brush Options Szóróecset beállításai Scatter Graph Tool Pontdiagram Scatter: Szóródás: @@ -3407,6 +3477,7 @@ mark-style Japanese Japán Select art and choose Object › Pattern › Make. Jelölj ki egy grafikát, és válaszd az Objektum › Mintázat › Létrehozás parancsot. Select art and click New Symbol to make one. Jelölj ki egy grafikát, és kattints az Új szimbólum gombra. Select art and use New Brush to make one. Jelölj ki egy grafikát, és hozz létre belőle ecsetet az Új ecset paranccsal. + Select art to make a scatter, art or pattern brush. Jelölj ki egy grafikát szóró-, grafikus vagy mintázatecset létrehozásához. Select Bound Object Kötött objektum kijelölése Select Data Set Adatkészlet kiválasztása Select Freeform Point Szabad formájú pont kijelölése @@ -3655,6 +3726,7 @@ mark-style Japanese Japán Social Tall Post 1000×1500 Magas közösségimédia-bejegyzés 1000×1500 Soft Lágy Soft Light Lágy fény + Soft Mix Lágy keverés Solid Kitöltött Sort Rendezés Sort by Hue Rendezés színárnyalat szerint @@ -3671,6 +3743,7 @@ mark-style Japanese Japán Spaces Szóközök Spacing Térköz Spacing Guides Térköz-segédvonalak + Invoke Distance Guides Távolság-segédvonalak előhívása Spacing: Térköz: Spanish Spanyol Spatter Fröcskölés @@ -3809,7 +3882,6 @@ mark-style Japanese Japán Symbol Szimbólum Symbol Instance Szimbólumpéldány Symbol Libraries Szimbólumkönyvtárak - Symbol Libraries (on the roadmap) Szimbólumkönyvtárak (tervezett funkció) Symbol Screener Tool Szimbólumhalványító Symbol Scruncher Tool Szimbólumtömörítő Symbol Set Density: Szimbólumkészlet sűrűsége: @@ -3917,6 +3989,13 @@ mark-style Japanese Japán Tint Tónus Tints Tónusok Tints and Shades Tónusok és árnyalatok + Tips Tippek + Colorization Tips Színezési tippek + The samples show this brush on a blue stroke. A minták ezt az ecsetet kék körvonalon mutatják. + The art keeps its own colors. A grafika megtartja a saját színeit. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. A fekete a körvonal színévé válik, a fehér pedig fehér marad. A többi szín a körvonalszín tónusává válik, és minél világosabb egy szín, annál világosabb a tónusa. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. A fekete fekete marad, a fehér pedig fehér marad. A többi szín a körvonalszín sötét árnyalatává válik, ha sötét, és a tónusává, ha világos. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. A kulcsszín a körvonal színévé válik. A többi szín ugyanannyit fordul el a színkörön, mint a kulcsszín, és megtartja a telítettségét és a fényességét. Tiny Lens Apró lencsék Tip extends past the end point A hegy túlnyúlik a végponton Tip on the end point A hegy a végponton van @@ -3942,6 +4021,7 @@ mark-style Japanese Japán Top Left Bal felső Top Right Jobb felső Top to Bottom Fentről lefelé + Top-Justified Tab Felülre igazító tabulátor Top-to-Top Leading Felülről felülre mért sorköz Top: Felső: Total Width: Teljes szélesség: @@ -3983,6 +4063,13 @@ mark-style Japanese Japán Transparent Objects Átlátszó objektumok Transverse (the page a quarter turn on the paper) Keresztirányú (az oldal negyed fordulattal elforgatva a papíron) Trap… Csapdázás… + Trap Csapdázás + Height/Width Magasság/szélesség + Tint Reduction Tónuscsökkentés + Traps with Process Color Csapdázás négyszínnyomó színekkel + Reverse Traps Fordított csapdázás + Precision Pontosság + Remove Redundant Points Felesleges pontok eltávolítása Triad Triád Triad 2 Triád 2 Triad 3 Triád 3 @@ -4248,7 +4335,7 @@ mark-style Japanese Japán Zoom to Selection Nagyítás a kijelölésre Zoom Tool Nagyító Zoom with Mouse Wheel Nagyítás egérgörgővel - {count} file(s) in {folder}{note}. Show the package folder? Fájlok: {count}, mappa: {folder}{note}. Megnyitod a csomag mappáját? + {count} file(s) in {folder} {note}. Show the package folder? Fájlok: {count}, mappa: {folder}{note}. Megnyitod a csomag mappáját? {count} folder(s) or file(s) couldn't be read Nem olvasható mappák vagy fájlok: {count} {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones Kihagyott mappák: {count} (például más alkalmazások, a rendszer és a rejtett mappák) {count} linked file(s) not found Nem található csatolt fájlok: {count} @@ -4386,6 +4473,25 @@ imageTrace Outline Körvonal @msg a PostScript error ({name} in `{op}`: {at}) PostScript-hiba ({name}, itt: `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript-hiba ({name}, itt: `{op}`: {at}), eljárás: {procs} @msg a PostScript error ({name}) PostScript-hiba ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) PostScript-hiba ({name}, itt: `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} PostScript-hiba ({name}, itt: `{op}`; {meaning}), eljárás: {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript-hiba ({name}, itt: `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} PostScript-hiba ({name}, itt: `{op}`: {at}; {meaning}), eljárás: {procs} +@msg a PostScript error ({name}; {meaning}) PostScript-hiba ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin a program lezár egy szótárt, amelyet nem nyitott meg +@msg the program opens a file, which the reader can't do a program fájlt nyit meg, amire a PostScript-olvasó nem képes +@msg the program restores a saved state that was already restored a program egy már visszaállított mentett állapotot állít vissza +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none a program által olvasott adatok sérültek vagy csonkák, vagy a program pozíciót kér egy olyan fájlban, amelynek nincs pozíciója +@msg the program goes past one of the reader's limits a program túllépi a PostScript-olvasó egyik korlátját +@msg the path has no point to continue from az útvonalnak nincs pontja, ahonnan folytatódhatna +@msg a value is outside the range the operator takes egy érték kívül esik az operátor által elfogadott tartományon +@msg the operator needs more values than the stack holds az operátornak több értékre van szüksége, mint amennyi a veremben van +@msg part of the program isn't valid PostScript text a program egy része nem érvényes PostScript-szöveg +@msg a value has the wrong type for the operator egy érték típusa nem megfelelő az operátorhoz +@msg the program runs, deletes or renames a file, which the reader can't do a program fájlt futtat, töröl vagy nevez át, amire a PostScript-olvasó nem képes +@msg the program asks for a resource that isn't defined a program olyan erőforrást kér, amely nincs definiálva +@msg a calculation has no result, as in a division by zero egy számításnak nincs eredménye, például nullával való osztáskor +@msg the operator looks for a mark on the stack and finds none az operátor jelölőt keres a veremben, de nem talál @msg a preset named `{_1}` exists már van ilyen nevű előbeállítás: `{_1}` @msg a swatch or color group named `{n}` exists már van ilyen nevű színminta vagy színcsoport: `{n}` @msg a slice size must be a positive number of points a szelet mérete pozitív pontérték kell legyen @@ -4411,13 +4517,16 @@ imageTrace Outline Körvonal @msg automation path rejected: a link that leads nowhere: {path} az automatizálás útvonala elutasítva: a hivatkozás nem vezet sehova: {path} @msg automation path rejected: cannot resolve {path}: {e} az automatizálás útvonala elutasítva: nem oldható fel: {path}: {e} @msg automation path rejected: outside the read root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökéren ({root}): {path} +@msg automation path rejected: outside the read roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az olvasási gyökereken ({roots}): {path} @msg automation path rejected: outside the write root {root}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökéren ({root}): {path} +@msg automation path rejected: outside the write roots {roots}: {path} az automatizálás útvonala elutasítva: kívül esik az írási gyökereken ({roots}): {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} az automatizálás útvonala elutasítva: szülőmappára lépés egy nem létező mappa alatt: {path} @msg automation path rejected: path is empty az automatizálás útvonala elutasítva: az útvonal üres @msg automation path rejected: reserved device names are not allowed: {path} az automatizálás útvonala elutasítva: fenntartott eszköznév nem használható: {path} @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` az axis értéke "vertical", "horizontal" vagy fokban megadott szög lehet, ez nem az: `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black vagy egy szín, például "#ff8800" @msg bad color {v} hibás szín: {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio: 0,1 és 10 közötti szám kell @msg the Color Picker is not open a Színválasztó nincs megnyitva @msg the Simplify slider is not open az Egyszerűsítés csúszka nincs megnyitva @msg the screen color sampler is already open a képernyős színmintavevő már nyitva van @@ -4469,6 +4578,7 @@ imageTrace Outline Körvonal @msg Couldn't open {name}: {e} Nem sikerült megnyitni: {name}: {e} @msg Couldn't paste from the clipboard: {e} Nem sikerült beilleszteni a vágólapról: {e} @msg Couldn't place {name}: the document it was dropped on was closed Nem sikerült elhelyezni: {name}; a céldokumentumot bezárták +@msg Couldn't place {name}: the document it was placed in was closed Nem sikerült elhelyezni: {name}; a dokumentumot, amelybe került volna, bezárták @msg Couldn't place {name}: {e} Nem sikerült elhelyezni: {name}: {e} @msg Couldn't read {name}: {e} Nem sikerült beolvasni: {name}: {e} @msg Couldn't save recovery data: {e} Nem sikerült menteni a helyreállítási adatokat: {e} @@ -4507,6 +4617,8 @@ imageTrace Outline Körvonal @msg Exported {what} Exportálva: {what} @msg Exported {what} with {_1} note(s): {first} Exportálva: {what}; megjegyzések: {_1}, az első: {first} @msg Exporting {_1} Exportálás: {_1} +@msg Packaging {_1} Csomagolás: {_1} +@msg Placing {_1} Elhelyezés: {_1} @msg file version {0} is newer than this VectorCraft supports ({VERSION}) a fájl verziója újabb, mint amit ez a VectorCraft támogat: {0} (támogatott: {VERSION}) @msg Flatten Transparency: select objects to flatten Átlátszóság lapítása: jelöld ki a lapítandó objektumokat @msg Flatten Transparency: {e} Átlátszóság lapítása: {e} @@ -4520,9 +4632,15 @@ imageTrace Outline Körvonal @msg give the path `id` and the width point `index` add meg az útvonal `id` és a szélességi pont `index` értékét @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it a színátmenet nincs megadva a fájlban: „{name}”; helyette egyszínű kitöltés áll @msg group must be true or false a group értéke true vagy false lehet +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} a panel lebeg; a csoportját a window.panel.float {panel, collapsed} paranccsal csukhatja össze +@msg panel is not in a dock group a panel nincs dokkolt csoportban +@msg only a dock group above or below others collapses, not one beside another csak a többi fölötti vagy alatti dokkolt csoport csukható össze, a mellettük lévő nem +@msg icon must be true or false az icon értéke true vagy false lehet +@msg icon only docks a panel in the icon column, with no anchor az icon csak az ikonoszlopba dokkol egy panelt, anchor nélkül +@msg column docks a set as a dock column, with no anchor or icon a column egy készletet dokkoszlopként dokkol, anchor és icon nélkül @msg guides are locked a segédvonalak zárolva vannak @msg handle must be in or out handle: in vagy out kell -@msg image '{key}' has no pixels and was left out a kép kimaradt, mert nincsenek pixelei: '{key}' +@msg an image has no pixels and was left out kimaradt egy kép, mert nincsenek pixelei @msg image data `{_1}` is missing hiányoznak a képadatok: `{_1}` @msg image is empty a kép üres @msg Image Trace: the traced image is missing Képvektorizálás: a vektorizált kép hiányzik @@ -4593,7 +4711,7 @@ imageTrace Outline Körvonal @msg move requires a destination panel az áthelyezéshez célpanel kell @msg move requires an existing destination panel az áthelyezéshez létező célpanel kell @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode a többcsatornás PSD-fájlok nem olvashatók: mentsd RGB, CMYK vagy szürkeárnyalatos módban -@msg multiple windows land with M11.5 a több ablak az M11.5 mérföldkőben érkezik +@msg New Window isn't available yet: a document opens in one window Az Új ablak még nem érhető el: egy dokumentum egy ablakban nyílik meg @msg name the file adj nevet a fájlnak @msg no active document nincs aktív dokumentum @msg no active perspective plane nincs aktív perspektivikus sík @@ -4607,7 +4725,9 @@ imageTrace Outline Körvonal @msg no blend options nincsenek összemosási beállítások @msg no bounds nincsenek határok @msg no brush named `{name}` nincs ilyen nevű ecset: `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} a sörteecset neve legfeljebb {max} karakter hosszú lehet: `{name}` hossza {count} karakter @msg no brush selected nincs kijelölt ecset +@msg brushes move only within their type az ecsetek csak a saját típusukon belül helyezhetők át @msg no catalog nincs katalógus @msg no changes since the last save a legutóbbi mentés óta nincs módosítás @msg no clock to keep a heartbeat by nincs óra az életjel időzítéséhez @@ -4627,6 +4747,7 @@ imageTrace Outline Körvonal @msg no file system here itt nincs fájlrendszer @msg no file writer nincs fájlíró @msg no files to place nincs elhelyezendő fájl +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file nem VectorCraft-dokumentum, PDF- vagy PDF-kompatibilis .ai-fájl: `{file}` @msg No fonts are missing in this document Ebből a dokumentumból nem hiányzik betűtípus @msg no format to export to nincs exportálási formátum @msg no free recovery area nincs szabad helyreállítási terület @@ -4669,6 +4790,7 @@ imageTrace Outline Körvonal @msg no such action nincs ilyen művelet @msg no such anchor nincs ilyen horgonypont @msg no such artboard nincs ilyen rajztábla +@msg the artboard is locked a rajztábla zárolt @msg no such document nincs ilyen dokumentum @msg no such layer or object nincs ilyen réteg vagy objektum @msg no such object {0} nincs ilyen objektum: {0} @@ -4732,7 +4854,7 @@ imageTrace Outline Körvonal @msg Opened with {_1} note(s): {_2} Megnyitva; megjegyzések: {_1}: {_2} @msg Opened {url} Megnyitva: {url} @msg Outline Stroke: no stroked paths selected Körvonal útvonallá alakítása: nincs kijelölt körvonalas útvonal -@msg Packaged {n} file(s) in {folder}{note} Becsomagolt fájlok: {n}, mappa: {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Becsomagolt fájlok: {n}, mappa: {folder}{note} @msg Packaged {n} file(s){note} Becsomagolt fájlok: {n}{note} @msg page size {w} × {h} oldalméret: {w} × {h} @msg page {0} does not exist (the PDF has {1}) nincs ilyen oldal: {0} (a PDF oldalszáma: {1}) @@ -4743,9 +4865,11 @@ imageTrace Outline Körvonal @msg path is already closed az útvonal már zárt @msg paths contain invalid coordinates az útvonalakban érvénytelen koordináták vannak @msg paths: give at least one file paths: legalább egy fájlt adj meg +@msg replace: the object to replace was changed or deleted replace: a cserélendő objektumot módosították vagy törölték @msg pattern missing hiányzik a mintázat @msg patterns nested too deeply are filled with mid-grey a túl mélyen egymásba ágyazott mintázatok helyén középszürke kitöltés áll @msg PDF settings are not an object a PDF-beállítások nem objektumot alkotnak +@msg PDF security settings are not an object a PDF biztonsági beállításai nem objektumot alkotnak @msg PDF writer error: {0} PDF-író hiba: {0} @msg percent must be above 0 and at most 6400, not {p} a percent értéke 0-nál nagyobb és legfeljebb 6400 lehet, ez nem az: {p} @msg pick a colour, gradient or pattern to save as a swatch válassz színt, színátmenetet vagy mintázatot a színmintaként mentéshez @@ -4804,6 +4928,10 @@ imageTrace Outline Körvonal @msg select a repeat jelölj ki egy ismétlést @msg select an Intertwine group jelölj ki egy összefonási csoportot @msg select an Objects on Path group jelölj ki egy Objektumok útvonalon csoportot +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects összetett útvonalban, összemosásban, burkolóban, ismétlésben vagy összefonási csoportban lévő útvonalhoz nem csatolhatók objektumok +@msg a clipping path can't take objects vágóútvonalhoz nem csatolhatók objektumok +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path összemosásban, burkolóban, ismétlésben vagy összefonási csoportban lévő objektumok nem csatolhatók útvonalhoz +@msg a clipping path can't be attached to a path vágóútvonal nem csatolható útvonalhoz @msg select a stroke with a variable width jelölj ki egy változó szélességű körvonalat @msg select a symbol instance jelölj ki egy szimbólumpéldányt @msg select an envelope jelölj ki egy burkolót @@ -4959,6 +5087,7 @@ imageTrace Outline Körvonal @msg Updated {_1} image(s) Frissített képek: {_1} @msg Updated {_1} linked file(s) changed on disk Frissített, a lemezen módosult csatolt fájlok: {_1} @msg variables are locked a változók zárolva vannak +@msg variables that aren't text were left out: {kinds} a nem szöveges változók kimaradtak: {kinds} @msg VectorCraft doesn't search other apps' or the system's folders: {path} a VectorCraft nem keres más alkalmazások és a rendszer mappáiban: {path} @msg VectorCraft doesn't search the Shared or Public folder in Users: {path} a VectorCraft nem keres a Users alatti Shared és Public mappában: {path} @msg VectorCraft failed to start: {e} A VectorCraft nem tudott elindulni: {e} @@ -4981,6 +5110,7 @@ imageTrace Outline Körvonal @msg {_1} has no {_2} style ennek nincs ilyen stílusa: {_1} ({_2}) @msg {_1} is a live effect (Effect › Plug-ins) élő effektus: {_1} (Effektus › Bővítmények) @msg {_1} is an exporter (File › Export › Export As…) exportáló bővítmény: {_1} (Fájl › Exportálás › Exportálás másként…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) a `documentData` mérete {size} bájt (korlát: {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} már foglalt: {_1} (használja: {_2}) @msg {_1} linked file(s) can't be read here: their previews show Itt nem olvasható csatolt fájlok: {_1}; az előnézetük látszik @msg {_1} linked image(s) changed on disk: Update Links shows the new versions A lemezen módosult csatolt képek: {_1}; a Csatolások frissítése az új változatokat mutatja @@ -5087,6 +5217,9 @@ imageTrace Outline Körvonal Softness Lágyság Ambient Light Környezeti fény Light behind the object Fény az objektum mögött + 3D Lighting Preset 3D világítási készlet + Standard Normál + Diffuse Szórt # 3D light management. Lights Fények Add Light Fény hozzáadása @@ -5130,6 +5263,7 @@ imageTrace Outline Körvonal @msg Measured anchor no longer exists A mért horgonypont már nem létezik @msg Measured segment no longer exists A mért szakasz már nem létezik @msg Non-finite dimension Nem véges méret +@msg Scale Text Attributes: selected type has no scale to bake Szövegattribútumok méretezése: a kijelölt szövegnek nincs alkalmazható méretezése @msg Scale must be drawing:real, for example 1:10 A léptéknek rajz:valós alakúnak kell lennie, például 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 A lépték aránya 1:1000000 és 1000000:1 között lehet @msg Second measured segment no longer exists A második mért szakasz már nem létezik @@ -5148,6 +5282,7 @@ imageTrace Outline Körvonal Make Image Dynamic Kép dinamikussá tétele Import Data Sets… Adatkészletek importálása… Export Data Sets… Adatkészletek exportálása… + Load Variable Library… Változókönyvtár betöltése… Make Graph Dynamic Diagram dinamikussá tétele Batch… Kötegelt feldolgozás… Source Folder: Forrásmappa: @@ -5170,6 +5305,12 @@ imageTrace Outline Körvonal Sketch Vázlat Halftone Pattern… Féltónus minta… Halftone Pattern Féltónus minta + Torn Edges… Szaggatott szélek… + Torn Edges Szaggatott szélek + Conté Crayon… Conté-kréta… + Conté Crayon Conté-kréta + Foreground Level Előtér szintje + Background Level Háttér szintje Pattern Type Mintatípus Dot Pont # User Interface › Color Scheme @@ -5188,6 +5329,7 @@ imageTrace Outline Körvonal Watercolor… Akvarell… Watercolor Akvarell Brush Size Ecsetméret + Texture Coverage Textúra fedettség Definition Kidolgozottság Brush Detail Ecsetrészletesség Shadow Intensity Árnyék intenzitása @@ -5216,13 +5358,21 @@ imageTrace Outline Körvonal @msg the image's pixels can't be read a kép képpontjai nem olvashatók Persistent Állandó Library Panel Options Könyvtárpanel beállításai + Locate Library… Könyvtár megkeresése… @msg Persistent libraries not found: {files} Nem találhatók állandó könyvtárak: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) A(z) `{given}` nem könyvtárpanel (lásd: window.swatchLibrary) @msg library panel `{given}` isn't open a(z) `{given}` könyvtárpanel nincs megnyitva +@msg library panel `{given}` isn't missing its library a(z) `{given}` könyvtárpanel könyvtára nem hiányzik @msg too many library panels were opened in this session ebben a munkamenetben túl sok könyvtárpanel nyílt meg @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A kép {_1} × {_2} képpont, egyik oldalán több mint {LARGE_TRACE_SIDE}: a vektorizálás megtörténik, de lassabban. Kisebb hűségű előbeállítással vagy a kép kisebb másolatával gyorsabb. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A kép vektorizálása {_1} × {_2} képponton történt, {_3} × {_4} méretről kicsinyítve (legfeljebb {_5} megapixel). + Snapping Illesztés + Snap to Tangent Illesztés érintőre + Snap to Perpendicular Illesztés merőlegesre +@msg unknown snap option `{key}` (use {options}) ismeretlen illesztési beállítás: `{key}` (használható: {options}) + Gap Length: Rés hossza: Artboard {n} of {total} Rajztábla: {n} / {total} + File {n} of {total} Fájl: {n} / {total} Encoding and writing the file… A fájl kódolása és írása… @plural {n} more export waiting|{n} more exports waiting Még {n} exportálás várakozik Continue in Background Folytatás a háttérben @@ -5238,6 +5388,14 @@ imageTrace Outline Körvonal Full Justify Teljes sorkizárás Minimum must not exceed Desired, nor Desired Maximum. A minimum nem haladhatja meg a kívánt értéket, sem a kívánt érték a maximumot. Justification Sorkizárás + Add filters from the list above Adjon hozzá szűrőket a fenti listából + Add to Stack Hozzáadás a veremhez + Applied Filters Alkalmazott szűrők + Categories Kategóriák + Choose a filter Válasszon szűrőt + Effect Gallery Effektusgaléria + Filters Szűrők + Select artwork to apply effects Válasszon ki grafikát az effektusok alkalmazásához @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them a fájl változói és adatkészletei nem lettek beolvasva (változók: {variables}, adatkészletek: {data_sets}): a Változók panel nem listázza őket # Select by Color (select.color) Select by Color Kijelölés szín szerint @@ -5259,3 +5417,105 @@ imageTrace Outline Körvonal Plaster Gipsz Note Paper… Jegyzetpapír… Note Paper Jegyzetpapír + Water Paper… Nedves papír… + Water Paper Nedves papír + Ruby… Ruby… + Ruby Ruby + Geometric Guides Geometriai segédvonalak + Ease In Lassú kezdés + Ease Out Lassú befejezés + Ease In Out Lassú kezdés és befejezés + Ease Out In Lassú befejezés és kezdés + Ramp: Erősség: + Shift: Eltolás: + Fresco… Freskó… + Fresco Freskó + Effect Gallery… Effektusgaléria… + Effect Gallery Dialog Effektusgaléria párbeszédpanel + Arc Segment Tool Options Ívszakasz eszköz beállításai + Base Along: Alap mentén: + Closed Zárt + Convex Domború + Fill Arc Ív kitöltése + Length X-Axis: Hossz X tengely: + Length Y-Axis: Hossz Y tengely: + Slope: Lejtés: +axis X Axis X tengely +axis Y Axis Y tengely +@msg Packaged {n} file(s) in {folder} Becsomagolt fájlok: {n}, mappa: {folder} +@msg Packaged {n} file(s) {note} Becsomagolt fájlok: {n} {note} +@msg Packaged {n} file(s) Becsomagolt fájlok: {n} + Packaged {n} file(s) Becsomagolt fájlok: {n} + Packaged {n} file(s) in {folder} Becsomagolt fájlok: {n}, mappa: {folder} + Packaged {n} file(s) {note} Becsomagolt fájlok: {n} {note} + {count} file(s) in {folder}. Show the package folder? Fájlok: {count}, mappa: {folder}. Megnyitod a csomag mappáját? + Clockwise Óramutató járásával megegyező + Dither Árnyalatkeverés + Fine noise hides banding in the gradient fill A finom zaj elrejti a sávokat a színátmenetes kitöltésben +@msg `dither` applies to gradient fills (stroke: false) A `dither` színátmenetes kitöltésekre vonatkozik (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) A `dither` színátmenetes kitöltésekre vonatkozik (paint.setFill) +@msg `dither` must be true or false `dither`: true vagy false kell +@msg `method` must be classic or perceptual a `method` értéke classic vagy perceptual lehet +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Telepíted a(z) „{name}” bővítményt, amely nagy számítási keretet kér? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Egy futás akár {minutes} percig tarthat, és {times}-szor annyi munkát végezhet, mint amennyit más bővítmények. A háttérben fut, és megszakíthatod. + It may take minutes, so it runs in the background, where you can cancel it. Percekig is tarthat, ezért a háttérben fut, ahol megszakíthatod. + The plug-in is computing… A bővítmény számol… + Inspect Plug-in Bővítmény vizsgálata + Cancel Plug-in Run Bővítmény futásának megszakítása +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} lefutott, de közben megváltozott a dokumentum vagy a bővítmény: futtasd újra +@msg Running “{name}”… Futtatás: „{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy` +@msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett +# Actions panel: action sets (#3712). + New Set… Új készlet… + New Set Új készlet + Set Options… Készlet beállításai… + Set Options Készlet beállításai + Create New Set Új készlet létrehozása + Stop Global Edit Globális szerkesztés befejezése + Global Edit Options Globális szerkesztés beállításai + Match Egyezés + On Artboards Rajztáblákon + All Artboards Minden rajztábla + Include Objects On Canvas Vásznon lévő objektumok is + Result(s): {count} Eredmény(ek): {count} +@msg select one object jelöljön ki egy objektumot +@msg Global Edit works on paths, compound paths and symbol instances A globális szerkesztés útvonalakon, összetett útvonalakon és szimbólumpéldányokon működik +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nem található hasonló objektum (az elforgatott vagy tükrözött másolatok nem számítanak egyezésnek); módosítsa a globális szerkesztés beállításait, és próbálja újra +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all vagy egy tartomány, például 1, 3-5, nem {v} + Add to Symbols Hozzáadás a szimbólumokhoz + Basic Shapes Alapalakzatok + Open Symbol Library Szimbólumkönyvtár megnyitása + Save Symbol Library Szimbólumkönyvtár mentése + Save Symbol Library… Szimbólumkönyvtár mentése… + Save Symbols… Szimbólumok mentése… + Selected Symbols Only Csak a kijelölt szimbólumok + Symbol Libraries Menu Szimbólumkönyvtárak menü + Symbol Library Szimbólumkönyvtár + User Symbol Library 1 Felhasználói szimbólumkönyvtár 1 + User Symbol Library 2 Felhasználói szimbólumkönyvtár 2 + User Symbol Library 3 Felhasználói szimbólumkönyvtár 3 + User Symbol Library 4 Felhasználói szimbólumkönyvtár 4 + User Symbol Library 5 Felhasználói szimbólumkönyvtár 5 + User Symbol Library 6 Felhasználói szimbólumkönyvtár 6 + User Symbol Library 7 Felhasználói szimbólumkönyvtár 7 + User Symbol Library 8 Felhasználói szimbólumkönyvtár 8 + User Symbol Library 9 Felhasználói szimbólumkönyvtár 9 + User Symbol Library 10 Felhasználói szimbólumkönyvtár 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place A(z) `{_1}` nem tartalmaz elhelyezhető szimbólumot +@msg missing `library` (see symbol.libraries) hiányzik a `library` (lásd: symbol.libraries) +@msg no symbol `{n}` nincs `{n}` szimbólum +@msg no symbol `{n}` in `{_1}` nincs `{n}` szimbólum itt: `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) nincs `{key}` szimbólumkönyvtár (lásd: symbol.libraries) +@msg the document has no symbols to save a dokumentumban nincs menthető szimbólum +@msg Trap: select two or more objects filled with solid colours Csapdázás: jelöljön ki legalább két, egyszínű kitöltésű objektumot +@msg Trap: no edges of different colours meet among the selected objects Csapdázás: a kijelölt objektumok között nem érintkeznek különböző színű élek +@msg thickness must be from 0.01 to 5000 pt a thickness értéke 0,01 és 5000 pt között lehet +@msg heightWidth must be from 1 to 1000 % a heightWidth értéke 1 és 1000 % között lehet +@msg tintReduction must be from 0 to 100 % a tintReduction értéke 0 és 100 % között lehet + Rectangular Grid Tool Options Téglalaprács beállításai + Polar Grid Tool Options Polárrács beállításai + Fill Line Szakasz kitöltése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 4f36c001c..fb5a20a4d 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -20,8 +20,14 @@ Medium Light Agak terang Light Terang Quit VectorCraft Keluar dari VectorCraft +# Artwork drag between document tabs. + Drop Artwork Letakkan karya +@msg {key} must be [x, y] with finite coordinates {key} harus berupa [x, y] dengan koordinat berhingga +@msg ids must contain 1–10000 object ids ids harus berisi 1–10000 ID objek + File Berkas New… Baru… + New Without Dialog Baru tanpa dialog New from Template… Baru dari templat… Open… Buka… Open Recent Files Buka berkas terbaru @@ -278,6 +284,13 @@ Glyphs Glif Insert Special Character Sisipkan karakter khusus Bullet Poin + Bullets Poin + Bullets and Numbering Poin dan penomoran + Apply Bullets Terapkan poin + Filled Dot Titik penuh + Dash Tanda pisah + Hollow Circle Lingkaran kosong + Filled Square Persegi penuh Copyright Symbol Simbol hak cipta Ellipsis Elipsis Paragraph Symbol Simbol paragraf @@ -710,7 +723,7 @@ Units Satuan Guides & Grid Panduan & kisi Hyphenation Pemenggalan kata - Performance & Storage Kinerja & penyimpanan + Plug-ins & Scratch Disks Plug-in & Disk scratch User Interface Antarmuka pengguna Performance Kinerja File Handling Penanganan berkas @@ -760,6 +773,7 @@ Activate Panel Aktifkan panel Change Panel Layout Ubah tata letak panel Close Panel Tutup panel + Collapse Panel Group Ciutkan grup panel Move Panel Pindahkan panel Group with Grupkan dengan Resize panels Ubah ukuran panel @@ -1056,7 +1070,8 @@ lighting Light Cahaya to sample a color untuk mengambil sampel warna across a selected object to set the gradient direction melintasi objek terpilih untuk mengatur arah gradien to select an artboard | untuk memilih artboard | - on the canvas to create one di kanvas untuk membuatnya + on the canvas to create one ( di kanvas untuk membuatnya ( + selects several, or draws one inside an artboard) memilih beberapa, atau membuat satu di dalam artboard) a stroke to add a width point | stroke untuk menambah titik lebar | to change one side | untuk mengubah satu sisi | to select more points | untuk memilih lebih banyak titik | @@ -1309,6 +1324,7 @@ lighting Light Cahaya Leading Leading No Break Tanpa pemenggalan Proportional Metrics Metrik proporsional + Character Direction Arah karakter Digits Digit Arabic Arab Hindi Hindi @@ -1376,6 +1392,7 @@ lighting Light Cahaya {count} more rules: Export writes every one. {count} aturan lagi: Ekspor menuliskan semuanya. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} objek tanpa nama tidak disertakan: beri nama, atau aktifkan Objek Tanpa Nama di menu panel. All Categories Semua kategori + Repeat Objects Objek pengulangan Save… Simpan… Selection Only Hanya seleksi {title} (selection) {title} (seleksi) @@ -1409,6 +1426,7 @@ lighting Light Cahaya Edit the selected point's colour in the Color panel Edit warna titik terpilih di panel Warna Edit the stop Edit stop Eyedropper: click the art to sample the stop's colour Pipet: klik karya seni untuk mengambil sampel warna stop + Eyedropper: click the art to sample the key color Pipet: klik karya seni untuk mengambil sampel warna kunci Gradient across stroke Gradien melintasi stroke Gradient along stroke Gradien sepanjang stroke Gradient Fill: click to apply, drag onto art Isian gradien: klik untuk menerapkan, seret ke karya seni @@ -1572,6 +1590,9 @@ lighting Light Cahaya Left Indent Indentasi kiri Left-to-Right Paragraph Direction Arah paragraf kiri ke kanan Line-end Punctuation Half Width Tanda baca akhir baris lebar setengah + Insert Kashidas Sisipkan kashida + Long Panjang + Stylistic Stilistik Mojikumi Set Set Mojikumi Kinsoku Set Set Kinsoku Hard Keras @@ -1595,6 +1616,13 @@ lighting Light Cahaya Repeat Pathfinder Ulangi Pathfinder Repeat {name} Ulangi {name} Trap… Trap… + Trap Trap + Height/Width Tinggi/Lebar + Tint Reduction Pengurangan Tint + Traps with Process Color Trap dengan Warna Proses + Reverse Traps Balikkan Trap + Precision Presisi + Remove Redundant Points Hapus Titik Berlebih Bottom in Front Bawah di depan Brick by Column Bata per kolom Brick by Row Bata per baris @@ -1721,8 +1749,8 @@ lighting Light Cahaya {kind}, {model} {kind}, {model} No symbols in this document Tidak ada simbol dalam dokumen ini Select art and click New Symbol to make one. Pilih karya seni dan klik Simbol Baru untuk membuatnya. - Symbol Libraries (on the roadmap) Pustaka simbol (ada di peta jalan) Align On: Ratakan pada: + Bottom-Justified Tab Tab rata bawah Center-Justified Tab Tab rata tengah Above Text Di atas teks Position Panel Above Text Posisikan panel di atas teks @@ -1734,6 +1762,7 @@ lighting Light Cahaya No text selected Tidak ada teks yang dipilih Repeat Tab Ulangi tab Right-Justified Tab Tab rata kanan + Top-Justified Tab Tab rata atas Select a text object to set its tab stops. Pilih objek teks untuk mengatur tab stop-nya. Select text Pilih teks Snap to Unit Snap ke satuan @@ -1849,6 +1878,13 @@ lighting Light Cahaya Tints and Shades Tint dan shade Hue Shift Pergeseran rona Key Color: Warna kunci: + Tips Kiat + Colorization Tips Kiat pewarnaan + The samples show this brush on a blue stroke. Contoh menampilkan kuas ini pada stroke biru. + The art keeps its own colors. Karya seni mempertahankan warnanya sendiri. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Hitam menjadi warna stroke dan putih tetap putih. Warna lain menjadi tint dari warna stroke, dan makin terang suatu warna, makin terang tint-nya. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Hitam tetap hitam dan putih tetap putih. Warna lain menjadi shade dari warna stroke jika gelap dan tint darinya jika terang. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Warna kunci menjadi warna stroke. Warna lain bergeser di roda warna sejauh warna kunci bergeser dan mempertahankan saturasi serta kecerahannya. Size: Ukuran: Calligraphic Brush Options Opsi kuas kaligrafi Bristle Brush Options Opsi kuas bristle @@ -2161,11 +2197,11 @@ lighting Light Cahaya Fonts whose licence doesn't allow embedding are not copied. Font yang lisensinya tidak mengizinkan penyematan tidak disalin. Package collects a saved document with its linked files and fonts. Package mengumpulkan dokumen tersimpan beserta file dan font yang tertaut. Package created Paket dibuat - Packaged {n} file(s) in {folder}{note} {n} file dipaketkan dalam {folder}{note} + Packaged {n} file(s) in {folder} {note} {n} file dipaketkan dalam {folder}{note} Packaged {n} file(s){note} {n} file dipaketkan{note} Relink Linked Files to the Document Tautkan ulang file tertaut ke dokumen Save the document first? Simpan dokumen terlebih dahulu? - {count} file(s) in {folder}{note}. Show the package folder? {count} file dalam {folder}{note}. Tampilkan folder paket? + {count} file(s) in {folder} {note}. Show the package folder? {count} file dalam {folder}{note}. Tampilkan folder paket? {count} linked file(s) not found {count} file tertaut tidak ditemukan fonts not copied: {fonts} font tidak disalin: {fonts} Built-in presets are read-only: New… starts an editable copy. Preset bawaan bersifat hanya-baca: Baru… memulai salinan yang dapat diedit. @@ -2212,6 +2248,11 @@ lighting Light Cahaya File Dimensions Dimensi file Transforms Transformasi Background Color: Warna latar belakang: + Show Center Mark Tampilkan tanda tengah + Show Cross Hairs Tampilkan garis silang + Show Video Safe Areas Tampilkan area aman video + Video Ruler Pixel Aspect Ratio: Rasio aspek piksel penggaris video: + Hide Video Rulers Sembunyikan penggaris video Client-side (.html) Sisi klien (.html) Color Reduction: Reduksi warna: Lossless (lossy WebP isn't available yet) Tanpa kehilangan (WebP lossy belum tersedia) @@ -2665,12 +2706,21 @@ lighting Light Cahaya Darken Menggelapkan Exclusion Pengecualian Hard Light Cahaya keras + Hard Mix Campuran keras Lighten Menerangkan Luminosity Luminositas Overlay Menimpa Soft Light Cahaya lembut + Soft Mix Campuran lembut Crosshatch Arsir silang Fine Dots Titik halus + + Simple Sederhana + Light Rough Kasar terang + Dark Rough Kasar gelap + Wide Sharp Lebar tajam + Wide Blurry Lebar buram + Sparkle Kilau Medium Dots Titik sedang Grainy Dots Titik berbutir Coarse Dots Titik kasar @@ -2742,6 +2792,14 @@ theme Appearance Tampilan Edit Symbol Edit simbol Expand Brush Strokes Perluas guratan kuas New Brush… Kuas baru… + New Brush Kuas baru + Calligraphic Brush Kuas kaligrafi + Scatter Brush Kuas sebar + Art Brush Kuas seni + Bristle Brush Kuas bristle + Pattern Brush Kuas pola + Select art to make a scatter, art or pattern brush. Pilih karya seni untuk membuat kuas sebar, seni, atau pola. + Brush Art Karya seni kuas New Symbol Simbol baru New Symbol… Simbol baru… Place Symbol Instance Tempatkan instansi simbol @@ -3202,6 +3260,7 @@ theme Appearance Tampilan Scale Corners Skalakan sudut Scale Cursor Proportional to UI Skalakan kursor proporsional dengan UI Scale Strokes & Effects Skalakan guratan & efek + Scale Text Attributes Skalakan atribut teks Scratch Disks Disk scratch Secondary Sekunder Select Same Tint % Pilih tint yang sama % @@ -3217,6 +3276,7 @@ theme Appearance Tampilan Size/Leading Ukuran/Leading Snapping Tolerance Toleransi kancing Spacing Guides Panduan jarak + Invoke Distance Guides Panggil panduan pengukuran jarak Spanish Spanyol Subdivisions Subdivisi Templates Folder Folder templat @@ -3302,6 +3362,7 @@ theme Appearance Tampilan Delete Swatch Hapus sampel Duplicate Swatch Duplikatkan sampel Merge Swatches Gabungkan sampel + Move Brush Pindahkan kuas Move Swatch Pindahkan sampel New Color Group Grup warna baru New Swatch Sampel baru @@ -3519,6 +3580,8 @@ effect Regular Reguler Put back in the dock Kembalikan ke dok All Scripts Semua skrip Any Kind Jenis apa pun + Font Technology Teknologi font +font-technology Variable Variabel Decorative Dekoratif Favorites Favorit Latin Latin @@ -3783,6 +3846,7 @@ imageTrace Outline Kontur @msg Couldn't copy to the system clipboard: {e} Tidak dapat menyalin ke papan klip sistem: {e} @msg Couldn't open {name}: {e} Tidak dapat membuka {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Tidak dapat menempatkan {name}: dokumen tempatnya dijatuhkan sudah ditutup +@msg Couldn't place {name}: the document it was placed in was closed Tidak dapat menempatkan {name}: dokumen tempatnya ditempatkan sudah ditutup @msg Couldn't read {name}: {e} Tidak dapat membaca {name}: {e} @msg Couldn't save recovery data: {e} Tidak dapat menyimpan data pemulihan: {e} @msg Create Outlines: select text objects Buat kontur: pilih objek teks @@ -3819,8 +3883,9 @@ imageTrace Outline Kontur @msg Opened {url} Berhasil membuka {url} @msg Outline Stroke: no stroked paths selected Ubah goresan menjadi kontur: tidak ada jalur dengan goresan yang dipilih @msg PDF settings are not an object Pengaturan PDF bukan objek +@msg PDF security settings are not an object Pengaturan keamanan PDF bukan objek @msg PDF writer error: {0} Kesalahan penulis PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} Berhasil mengemas {n} file di {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Berhasil mengemas {n} file di {folder}{note} @msg Packaged {n} file(s){note} Berhasil mengemas {n} file{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Pratinjau di Peramban memerlukan aplikasi desktop (versi web mengunduh file sebagai gantinya) @msg Release Guides: no guides Lepaskan panduan: tidak ada panduan @@ -3885,6 +3950,25 @@ imageTrace Outline Kontur @msg a PostScript error ({name} in `{op}`: {at}) kesalahan PostScript ({name} dalam `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} kesalahan PostScript ({name} dalam `{op}`: {at}), dalam {procs} @msg a PostScript error ({name}) kesalahan PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) kesalahan PostScript ({name} dalam `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} kesalahan PostScript ({name} dalam `{op}`; {meaning}), dalam {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) kesalahan PostScript ({name} dalam `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} kesalahan PostScript ({name} dalam `{op}`: {at}; {meaning}), dalam {procs} +@msg a PostScript error ({name}; {meaning}) kesalahan PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin program mengakhiri kamus yang tidak dimulainya +@msg the program opens a file, which the reader can't do program membuka berkas, yang tidak dapat dilakukan pembaca PostScript +@msg the program restores a saved state that was already restored program memulihkan keadaan tersimpan yang sudah dipulihkan +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none data yang dibaca program rusak atau terpotong, atau program meminta posisi dalam berkas yang tidak memiliki posisi +@msg the program goes past one of the reader's limits program melewati salah satu batas pembaca PostScript +@msg the path has no point to continue from jalur tidak memiliki titik untuk dilanjutkan +@msg a value is outside the range the operator takes sebuah nilai berada di luar rentang yang diterima operator +@msg the operator needs more values than the stack holds operator memerlukan lebih banyak nilai daripada yang ada di tumpukan +@msg part of the program isn't valid PostScript text sebagian program bukan teks PostScript yang valid +@msg a value has the wrong type for the operator sebuah nilai memiliki tipe yang salah untuk operator +@msg the program runs, deletes or renames a file, which the reader can't do program menjalankan, menghapus, atau mengganti nama berkas, yang tidak dapat dilakukan pembaca PostScript +@msg the program asks for a resource that isn't defined program meminta sumber daya yang tidak didefinisikan +@msg a calculation has no result, as in a division by zero sebuah perhitungan tidak memiliki hasil, seperti pada pembagian dengan nol +@msg the operator looks for a mark on the stack and finds none operator mencari penanda di tumpukan dan tidak menemukannya @msg a bitmap is empty bitmap kosong @msg a bitmap is too large to read bitmap terlalu besar untuk dibaca @msg a colour index is 0 to {_1}, not {n} indeks warna 0 hingga {_1}, bukan {n} @@ -3915,6 +3999,7 @@ imageTrace Outline Kontur @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` sumbu harus "vertical", "horizontal", atau sudut dalam derajat, bukan `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparan, putih, hitam, atau warna seperti "#ff8800" @msg bad color {v} warna tidak valid {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio harus berupa angka dari 0,1 sampai 10 @msg bad colour `{hex}` (#rrggbb) warna tidak valid `{hex}` (#rrggbb) @msg bad page tree pohon halaman tidak valid @msg bad shortcut pintasan tidak valid @@ -3958,7 +4043,7 @@ imageTrace Outline Kontur @msg give horizontal or vertical berikan horizontal atau vertikal @msg give the path `id` and the width point `index` berikan jalur `id` dan titik lebar `index` @msg guides are locked panduan terkunci -@msg image '{key}' has no pixels and was left out gambar '{key}' tidak memiliki piksel dan dilewati +@msg an image has no pixels and was left out gambar tanpa piksel dilewati @msg image data `{_1}` is missing data gambar `{_1}` tidak ada @msg image would be too large; lower the resolution gambar akan menjadi terlalu besar; turunkan resolusinya @msg index must be a number or first, previous, next or last index harus berupa angka atau first, previous, next, atau last @@ -3984,7 +4069,7 @@ imageTrace Outline Kontur @msg missing index indeks tidak ada @msg missing number `{key}` angka `{key}` tidak ada @msg missing integer `{key}` bilangan bulat `{key}` tidak ada -@msg multiple windows land with M11.5 beberapa jendela hadir bersama M11.5 +@msg New Window isn't available yet: a document opens in one window Jendela baru belum tersedia: dokumen dibuka dalam satu jendela @msg name the file beri nama file @msg no CSS to copy: select objects with CSS of their own tidak ada CSS untuk disalin: pilih objek yang memiliki CSS sendiri @msg no Live Paint edge near that point tidak ada tepi Live Paint di dekat titik itu @@ -4003,6 +4088,7 @@ imageTrace Outline Kontur @msg no bounds tidak ada batas @msg no brush named `{name}` tidak ada kuas bernama `{name}` @msg no brush selected tidak ada kuas yang dipilih +@msg brushes move only within their type kuas hanya dapat dipindahkan di dalam jenisnya sendiri @msg no catalog tidak ada katalog @msg no changes since the last save tidak ada perubahan sejak penyimpanan terakhir @msg no clock to keep a heartbeat by tidak ada jam untuk menjaga detak @@ -4106,6 +4192,7 @@ imageTrace Outline Kontur @msg path is already closed jalur sudah tertutup @msg paths contain invalid coordinates jalur berisi koordinat yang tidak valid @msg paths: give at least one file jalur: berikan setidaknya satu berkas +@msg replace: the object to replace was changed or deleted ganti: objek yang akan diganti telah diubah atau dihapus @msg pattern missing pola hilang @msg patterns nested too deeply are filled with mid-grey pola yang bersarang terlalu dalam diisi dengan abu-abu tengah @msg percent must be above 0 and at most 6400, not {p} persen harus di atas 0 dan paling banyak 6400, bukan {p} @@ -4290,6 +4377,8 @@ imageTrace Outline Kontur @msg Saving Menyimpan @msg Saving {_1} Menyimpan {_1} @msg Exporting {_1} Mengekspor {_1} +@msg Packaging {_1} Mengemas {_1} +@msg Placing {_1} Menempatkan {_1} @msg Saving recovery data for {_1} Menyimpan data pemulihan untuk {_1} @msg Discarded {_1} recovered document(s) {_1} dokumen hasil pemulihan dibuang @msg Restored {_1} recovered document(s): save them to keep them {_1} dokumen hasil pemulihan dipulihkan: simpan agar tidak hilang @@ -4372,7 +4461,9 @@ imageTrace Outline Kontur @msg automation filesystem access is not granted: read authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas baca tidak ada: {path} @msg automation filesystem access is not granted: write authority is absent: {path} akses sistem berkas otomatisasi tidak diberikan: otoritas tulis tidak ada: {path} @msg automation path rejected: outside the read root {root}: {path} jalur otomatisasi ditolak: di luar akar baca {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} jalur otomatisasi ditolak: di luar akar-akar baca {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} jalur otomatisasi ditolak: di luar akar tulis {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} jalur otomatisasi ditolak: di luar akar-akar tulis {roots}: {path} @msg automation path rejected: path is empty jalur otomatisasi ditolak: jalur kosong @msg automation path rejected: a link that leads nowhere: {path} jalur otomatisasi ditolak: tautan yang tidak mengarah ke mana pun: {path} @msg automation path rejected: cannot resolve {path}: {e} jalur otomatisasi ditolak: tidak dapat menyelesaikan {path}: {e} @@ -4395,6 +4486,12 @@ imageTrace Outline Kontur @msg before must name a panel before harus menyebutkan panel @msg docking action contains an unknown panel or an excessive split path tindakan docking berisi panel tidak dikenal atau jalur pemisahan berlebihan @msg group must be true or false group harus true atau false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel mengambang; ciutkan grupnya dengan window.panel.float {panel, collapsed} +@msg panel is not in a dock group panel tidak berada dalam grup dok +@msg only a dock group above or below others collapses, not one beside another hanya grup dok di atas atau di bawah grup lain yang bisa diciutkan, bukan yang berada di sampingnya +@msg icon must be true or false icon harus true atau false +@msg icon only docks a panel in the icon column, with no anchor icon hanya menambatkan panel di kolom ikon, tanpa anchor +@msg column docks a set as a dock column, with no anchor or icon column menambatkan satu set sebagai kolom dok, tanpa anchor atau icon @msg move requires a destination panel move memerlukan panel tujuan @msg move requires an existing destination panel move memerlukan panel tujuan yang sudah ada @msg onto must name a floating panel onto harus menyebutkan panel mengambang @@ -4454,6 +4551,7 @@ imageTrace Outline Kontur Logo 1000×1000 Logo 1000×1000 Business Card 3.5 × 2 in Kartu nama 3.5 × 2 in Business Card 85 × 55 mm Kartu nama 85 × 55 mm + Business Card 91 × 55 mm Kartu nama 91 × 55 mm Letterhead Letter Kop surat Letter Letterhead A4 Kop surat A4 Envelope DL Amplop DL @@ -4781,6 +4879,9 @@ mark-style Japanese Jepang Softness Kelembutan Ambient Light Cahaya ambient Light behind the object Cahaya di belakang objek + 3D Lighting Preset Preset Pencahayaan 3D + Standard Standar + Diffuse Difus # 3D light management. Lights Lampu Add Light Tambah Lampu @@ -4825,6 +4926,7 @@ mark-style Japanese Jepang Painting Painting Printing and Proofing Printing and Proofing Tracing Tracing + Touch Touch Typography Typography VectorCraft VectorCraft Web Web @@ -4891,6 +4993,7 @@ mark-style Japanese Jepang @msg Measured anchor no longer exists Titik jangkar yang diukur sudah tidak ada @msg Measured segment no longer exists Segmen yang diukur sudah tidak ada @msg Non-finite dimension Dimensi bukan bilangan berhingga +@msg Scale Text Attributes: selected type has no scale to bake Skalakan atribut teks: teks yang dipilih tidak memiliki skala untuk diterapkan @msg Scale must be drawing:real, for example 1:10 Skala harus dalam format gambar:sebenarnya, misalnya 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Rasio skala harus antara 1:1000000 dan 1000000:1 @msg Second measured segment no longer exists Segmen yang diukur kedua sudah tidak ada @@ -4917,6 +5020,8 @@ mark-style Japanese Jepang Make Image Dynamic Jadikan gambar dinamis Import Data Sets… Impor himpunan data… Export Data Sets… Ekspor himpunan data… + Load Variable Library… Muat pustaka variabel… +@msg variables that aren't text were left out: {kinds} variabel yang bukan teks dihilangkan: {kinds} Make Graph Dynamic Jadikan grafik dinamis Batch… Batch… Source Folder: Folder sumber: @@ -5061,9 +5166,24 @@ swatch Bright Violet Violet cerah @msg select the art to export first pilih dulu karya yang akan diekspor @msg no saved selection recalled tidak ada seleksi tersimpan yang dipanggil Merge only with selection Gabungkan hanya dengan seleksi + Consider Open Filled Paths as Closed Anggap jalur terbuka yang terisi sebagai tertutup + In Merge Mode, Clicking Stroke Splits the Path Dalam mode gabung, mengeklik stroke memisahkan jalur + Pick Color From: Ambil warna dari: + Artwork Karya seni + Cursor Swatch Preview Pratinjau swatch di kursor + Selection: Seleksi: + Straight Line Garis lurus + Highlight: Sorotan: + Stroke When Editable Stroke saat dapat diedit Sketch Sketsa Halftone Pattern… Pola halftone… Halftone Pattern Pola halftone + Torn Edges… Tepi robek… + Torn Edges Tepi robek + Conté Crayon… Krayon Conté… + Conté Crayon Krayon Conté + Foreground Level Tingkat latar depan + Background Level Tingkat latar belakang Pattern Type Jenis pola Dot Titik Export Type: Jenis ekspor: @@ -5092,6 +5212,8 @@ swatch Bright Violet Violet cerah Gap Detection Deteksi celah Gap Options Opsi celah Gap Preview Color: Warna pratinjau celah: + Show Live Paint Gaps Tampilkan celah Live Paint + Hide Live Paint Gaps Sembunyikan celah Live Paint Graph Column Kolom grafik JavaScript Files File JavaScript JavaScript: JavaScript: @@ -5112,12 +5234,24 @@ swatch Bright Violet Violet cerah Lock Variables Kunci variabel Unlock Variables Buka kunci variabel Change to Artboard Rulers Ubah ke penggaris artboard + Colored Pencil… Pensil warna… + Colored Pencil Pensil warna Cutout… Potongan… + Dry Brush… Kuas kering… Neon Glow… Pendar neon… + Palette Knife… Pisau palet… Poster Edges… Tepi poster… + Rough Pastels… Pastel kasar… Cutout Potongan + Dry Brush Kuas kering Neon Glow Pendar neon + Paint Daubs… Coretan cat… + Paint Daubs Coretan cat + Palette Knife Pisau palet Poster Edges Tepi poster + Rough Pastels Pastel kasar + Underpainting… Underpainting… + Underpainting Underpainting Apply Data Set Terapkan set data Cancel Pattern Editing Batalkan pengeditan pola Copy Appearance Salin tampilan @@ -5194,8 +5328,15 @@ swatch Bright Violet Violet cerah Thread Text Untai teks Typing Mengetik Persistent Persisten + Locate Library… Temukan pustaka… Library Panel Options Opsi panel pustaka + Snapping Snap + Snap to Tangent Snap ke garis singgung + Snap to Perpendicular Snap tegak lurus +@msg unknown snap option `{key}` (use {options}) opsi snap `{key}` tidak dikenal (gunakan {options}) + Gap Length: Panjang celah: Artboard {n} of {total} Artboard {n} dari {total} + File {n} of {total} Berkas {n} dari {total} Encoding and writing the file… Mengodekan dan menulis berkas… @plural {n} more export waiting|{n} more exports waiting {n} ekspor lagi menunggu Continue in Background Lanjutkan di latar belakang @@ -5211,6 +5352,14 @@ swatch Bright Violet Violet cerah Full Justify Rata Penuh Minimum must not exceed Desired, nor Desired Maximum. Minimum tidak boleh melebihi Diinginkan, dan Diinginkan tidak boleh melebihi Maksimum. Justification Justifikasi + Add filters from the list above Tambahkan filter dari daftar di atas + Add to Stack Tambahkan ke Tumpukan + Applied Filters Filter yang Diterapkan + Categories Kategori + Choose a filter Pilih filter + Effect Gallery Galeri Efek + Filters Filter + Select artwork to apply effects Pilih grafis untuk menerapkan efek # Select by Color (select.color) Select by Color Pilih berdasarkan warna Smudge Stick… Tongkat olesan… @@ -5220,6 +5369,7 @@ swatch Bright Violet Violet cerah Watercolor… Cat air… Watercolor Cat air Brush Size Ukuran kuas + Texture Coverage Cakupan tekstur Definition Definisi Brush Detail Detail kuas Shadow Intensity Intensitas bayangan @@ -5251,3 +5401,90 @@ swatch Bright Violet Violet cerah Plaster Plester Note Paper… Kertas Catatan… Note Paper Kertas Catatan + Water Paper… Kertas Basah… + Water Paper Kertas Basah + Ruby… Ruby… + Ruby Ruby + Geometric Guides Panduan Geometris + Ease In Awal Lambat + Ease Out Akhir Lambat + Ease In Out Awal dan Akhir Lambat + Ease Out In Akhir dan Awal Lambat + Ramp: Kekuatan: + Shift: Pergeseran: + Fresco… Fresko… + Fresco Fresko + Effect Gallery… Galeri Efek… + Effect Gallery Dialog Dialog Galeri Efek + Arc Segment Tool Options Opsi alat segmen busur + Base Along: Dasar sepanjang: + Closed Tertutup + Convex Cembung + Fill Arc Isi busur + Length X-Axis: Panjang sumbu X: + Length Y-Axis: Panjang sumbu Y: + Slope: Kemiringan: +axis X Axis Sumbu X +axis Y Axis Sumbu Y +@msg Packaged {n} file(s) in {folder} Berhasil mengemas {n} file di {folder} +@msg Packaged {n} file(s) {note} Berhasil mengemas {n} file {note} +@msg Packaged {n} file(s) Berhasil mengemas {n} file + Packaged {n} file(s) Berhasil mengemas {n} file + Packaged {n} file(s) in {folder} Berhasil mengemas {n} file di {folder} + Packaged {n} file(s) {note} Berhasil mengemas {n} file {note} + {count} file(s) in {folder}. Show the package folder? {count} file dalam {folder}. Tampilkan folder paket? + Clockwise Searah jarum jam + Dither Dither + Fine noise hides banding in the gradient fill Derau halus menyembunyikan pita warna pada isian gradien +@msg `dither` applies to gradient fills (stroke: false) `dither` berlaku untuk isian gradien (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` berlaku untuk isian gradien (paint.setFill) +@msg `dither` must be true or false `dither` harus true atau false +@msg `method` must be classic or perceptual `method` harus classic atau perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Instal “{name}”, yang meminta anggaran komputasi besar? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Setiap kali dijalankan, plug-in ini bisa memakan waktu hingga {minutes} menit dan melakukan {times} kali pekerjaan yang diizinkan untuk plug-in lain. Plug-in ini berjalan di latar belakang, dan Anda dapat membatalkannya. + It may take minutes, so it runs in the background, where you can cancel it. Ini bisa memakan waktu beberapa menit, jadi berjalan di latar belakang, tempat Anda dapat membatalkannya. + The plug-in is computing… Plug-in sedang menghitung… + Inspect Plug-in Periksa plug-in + Cancel Plug-in Run Batalkan jalannya plug-in +# Actions panel: action sets (#3712). + New Set… Set baru… + New Set Set baru + Set Options… Opsi set… + Set Options Opsi set + Create New Set Buat set baru + Stop Global Edit Hentikan edit global + Global Edit Options Opsi edit global + Match Cocokkan + On Artboards Di artboard + All Artboards Semua artboard + Include Objects On Canvas Sertakan objek di kanvas + Result(s): {count} Hasil: {count} + Add to Symbols Tambahkan ke simbol + Basic Shapes Bentuk dasar + Open Symbol Library Buka pustaka simbol + Save Symbol Library Simpan pustaka simbol + Save Symbol Library… Simpan pustaka simbol… + Save Symbols… Simpan simbol… + Selected Symbols Only Hanya simbol terpilih + Symbol Libraries Menu Menu pustaka simbol + Symbol Library Pustaka simbol + User Symbol Library 1 Pustaka simbol pengguna 1 + User Symbol Library 2 Pustaka simbol pengguna 2 + User Symbol Library 3 Pustaka simbol pengguna 3 + User Symbol Library 4 Pustaka simbol pengguna 4 + User Symbol Library 5 Pustaka simbol pengguna 5 + User Symbol Library 6 Pustaka simbol pengguna 6 + User Symbol Library 7 Pustaka simbol pengguna 7 + User Symbol Library 8 Pustaka simbol pengguna 8 + User Symbol Library 9 Pustaka simbol pengguna 9 + User Symbol Library 10 Pustaka simbol pengguna 10 + {w} × {h} pt {w} × {h} pt +@msg Trap: select two or more objects filled with solid colours Trap: pilih dua objek atau lebih yang diisi warna solid +@msg Trap: no edges of different colours meet among the selected objects Trap: tidak ada tepi berwarna berbeda yang bertemu di antara objek yang dipilih +@msg thickness must be from 0.01 to 5000 pt thickness harus dari 0,01 sampai 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth harus dari 1 sampai 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction harus dari 0 sampai 100 % + Rectangular Grid Tool Options Opsi alat kisi persegi panjang + Polar Grid Tool Options Opsi alat kisi polar + Fill Line Isi garis diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 1c782ba31..5c2ba5757 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -9,7 +9,7 @@ # user as "tu". Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Esc). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). Glowing Edges… Bordi luminosi… @@ -37,6 +37,13 @@ Channel 4 Canale 4 Cell Size Dimensione cella Fine Dots Punti fini + + Simple Semplice + Light Rough Ruvido chiaro + Dark Rough Ruvido scuro + Wide Sharp Ampio nitido + Wide Blurry Ampio sfocato + Sparkle Scintillio Medium Dots Punti medi Grainy Dots Punti granulosi Coarse Dots Punti grossi @@ -187,6 +194,8 @@ Anti-aliased Artwork Grafica con anti-aliasing Anti-aliasing: Anti-aliasing: Any Kind Qualsiasi tipo + Font Technology Tecnologia font +font-technology Variable Variabili Appearance Aspetto Appearance Attribute Attributo aspetto Appearance Item Elemento aspetto @@ -270,6 +279,11 @@ Average… Media… Axis: Asse: Background Color: Colore di sfondo: + Show Center Mark Mostra segno centrale + Show Cross Hairs Mostra mirino + Show Video Safe Areas Mostra aree sicure video + Video Ruler Pixel Aspect Ratio: Proporzioni pixel dei righelli video: + Hide Video Rulers Nascondi righelli video Background Contents Contenuto sfondo Background Contents: Contenuto sfondo: Background: Sfondo: @@ -307,6 +321,7 @@ Blur Sfocatura Bottom In basso Bottom in Front Inferiore davanti + Bottom-Justified Tab Tabulazione allineata in basso Bottom: In basso: Bounds Limiti Bounds (Fill Proportionally) Limiti (riempi proporzionalmente) @@ -337,6 +352,13 @@ Built-in presets don't change: New… starts an editable copy. I preset integrati non cambiano: Nuovo… crea una copia modificabile. Bulge… Rigonfiamento… Bullet Punto elenco + Bullets Punti elenco + Bullets and Numbering Punti elenco e numerazione + Apply Bullets Applica punti elenco + Filled Dot Punto pieno + Dash Trattino + Hollow Circle Cerchio vuoto + Filled Square Quadrato pieno Butt Cap Estremità tronca Byte Order: Ordine dei byte: Calligraphic Calligrafico @@ -1052,6 +1074,7 @@ Eyedropper Picks Up: Il contagocce preleva: Eyedropper Tool Strumento Contagocce Eyedropper: click the art to sample the stop's colour Contagocce: fai clic sulla grafica per campionare il colore del cursore + Eyedropper: click the art to sample the key color Contagocce: fai clic sulla grafica per campionare il colore chiave Favorites Preferiti Feather… Sfuma bordi… Feet Piedi @@ -1060,6 +1083,11 @@ Fidelity: Fedeltà: Figure Space Spazio cifra Figure: Cifre: +# Artwork drag between document tabs. + Drop Artwork Rilascia illustrazione +@msg {key} must be [x, y] with finite coordinates {key} deve essere [x, y] con coordinate finite +@msg ids must contain 1–10000 object ids ids deve contenere da 1 a 10000 identificatori di oggetti + File File File Dimensions Dimensioni file File Format: Formato file: @@ -1081,6 +1109,16 @@ Alt key toggles to Smooth Tool Il tasto Alt attiva lo strumento Smussa Keep selected Mantieni selezionato Merge only with selection Unisci solo con la selezione + Consider Open Filled Paths as Closed Considera chiusi i tracciati aperti con riempimento + In Merge Mode, Clicking Stroke Splits the Path In modalità fusione, un clic sulla traccia divide il tracciato + Pick Color From: Prendi il colore da: + Artwork Grafica + Cursor Swatch Preview Anteprima dei campioni accanto al cursore + Selection: Selezione: + Freeform Libera + Straight Line Linea retta + Highlight: Evidenziazione: + Stroke When Editable Traccia se modificabile Fill Color Colore riempimento Fill New Type Objects With Placeholder Text Riempi i nuovi oggetti di testo con testo segnaposto Fill Rule Regola di riempimento @@ -1209,6 +1247,8 @@ file-filter Templates Modelli Large Gaps Spazi vuoti grandi Gap Preview Color: Colore anteprima spazi vuoti: Close Gaps With Paths Chiudi spazi vuoti con tracciati + Show Live Paint Gaps Mostra spazi vuoti pittura dinamica + Hide Live Paint Gaps Nascondi spazi vuoti pittura dinamica Select Fills Seleziona riempimenti Select Strokes Seleziona tracce Gaussian Blur… Sfocatura gaussiana… @@ -1315,6 +1355,7 @@ file-filter Templates Modelli Hand-Drawn Disegnato a mano Handles Maniglie Hard Light Luce intensa + Hard Mix Miscela intensa Harmony Rules: Regole di armonia: Harmony Sets Gruppi di armonia Height Altezza @@ -1364,7 +1405,7 @@ file-filter Templates Modelli Clear History Cancella cronologia Set History Limit… Imposta limite cronologia… History States Stati cronologia -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Applica set di dati Area Type Testo in area Bloat Gonfia @@ -1853,6 +1894,9 @@ file-filter Templates Modelli Modified: Modificato: Modify Modifica Mojikumi Mojikumi + Insert Kashidas Inserisci kashida + Long Lunga + Stylistic Stilistica Mojikumi Set Set mojikumi Kinsoku Set Set kinsoku Hard Forte @@ -1887,6 +1931,7 @@ file-filter Templates Modelli Move Slices Sposta sezioni Move Spine Point Sposta punto dorsale Reshape Spine Segment Rimodella segmento della dorsale + Move Brush Sposta pennello Move Swatch Sposta campione Move Up Sposta su Move… Sposta… @@ -1944,6 +1989,13 @@ file-filter Templates Modelli Tints and Shades Tinte e ombre Hue Shift Variazione tonalità Key Color: Colore chiave: + Tips Suggerimenti + Colorization Tips Suggerimenti sulla colorazione + The samples show this brush on a blue stroke. Gli esempi mostrano questo pennello su una traccia blu. + The art keeps its own colors. La grafica mantiene i propri colori. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Il nero diventa il colore della traccia e il bianco resta bianco. Gli altri colori diventano tinte del colore della traccia, e più un colore è chiaro, più è chiara la sua tinta. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Il nero resta nero e il bianco resta bianco. Gli altri colori diventano ombre del colore della traccia quando sono scuri e tinte di esso quando sono chiari. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Il colore chiave diventa il colore della traccia. Gli altri colori ruotano sulla ruota dei colori quanto il colore chiave e mantengono la loro saturazione e luminosità. Roundness: Rotondità: Fixed Fisso Pressure Pressione @@ -1964,6 +2016,14 @@ file-filter Templates Modelli Round Fan Ventaglio tondo Flat Fan Ventaglio piatto New Brush… Nuovo pennello… + New Brush Nuovo pennello + Calligraphic Brush Pennello calligrafico + Scatter Brush Pennello a dispersione + Art Brush Pennello artistico + Bristle Brush Pennello a setole + Pattern Brush Pennello motivo + Select art to make a scatter, art or pattern brush. Seleziona la grafica per creare un pennello a dispersione, artistico o motivo. + Brush Art Grafica del pennello New Calligraphic Brush Nuovo pennello calligrafico New Character Style Nuovo stile carattere New Color Group Nuovo gruppo colori @@ -1994,6 +2054,7 @@ file-filter Templates Modelli New View Nuova vista New View… Nuova vista… New Window Nuova finestra + New Without Dialog Nuovo senza finestra di dialogo New Workspace Nuova area di lavoro New Workspace… Nuova area di lavoro… New {kind} Style… Nuovo stile {kind}… @@ -2200,7 +2261,7 @@ file-filter Templates Modelli Package Crea pacchetto Package collects a saved document with its linked files and fonts. Il comando Crea pacchetto raccoglie un documento salvato insieme ai file collegati e ai font. Package created Pacchetto creato - Packaged {n} file(s) in {folder}{note} Pacchetto creato con {n} file in {folder}{note} + Packaged {n} file(s) in {folder} {note} Pacchetto creato con {n} file in {folder} {note} Packaged {n} file(s){note} Pacchetto creato con {n} file{note} Package… Crea pacchetto… Page Pagina @@ -2263,7 +2324,7 @@ file-filter Templates Modelli Performance Prestazioni diagnostics render rendering diagnostics ui interfaccia - Performance & Storage Prestazioni e archiviazione + Plug-ins & Scratch Disks Plug-in & Dischi di memoria virtuale Permissions Autorizzazioni Permissions password: Password autorizzazioni: Perspective Prospettiva @@ -2385,6 +2446,7 @@ mark-style Japanese Giapponese Proof Setup Impostazione prova Properties Proprietà Proportional Metrics Metrica proporzionale + Character Direction Direzione caratteri Digits Cifre Arabic Arabe Hindi Hindi @@ -2579,6 +2641,7 @@ mark-style Japanese Giapponese Reorder Riordina Reorder Appearance Item Riordina elemento aspetto Repeat Ripetizione + Repeat Objects Oggetti di ripetizione Repeat Options Opzioni di ripetizione Repeat Pathfinder Ripeti Elaborazione tracciati Repeat Tab Ripeti tabulazione @@ -2790,6 +2853,7 @@ mark-style Japanese Giapponese Scale Cursor Proportional to UI Scala il cursore in proporzione all'interfaccia Scale Lineweights Scala spessori linea Scale Strokes & Effects Scala tracce ed effetti + Scale Text Attributes Scala attributi di testo Scale to Fit Artboard Scala per adattare alla tavola da disegno Scale Tool Strumento Scala Scale: Scala: @@ -3062,6 +3126,7 @@ mark-style Japanese Giapponese Snapping Tolerance Tolleranza di aggancio Social Square Post 1080×1080 Post quadrato per social 1080×1080 Soft Light Luce soffusa + Soft Mix Miscela soffice Solid Pieno Sort by Kind Ordina per tipo Sort by Name Ordina per nome @@ -3072,6 +3137,7 @@ mark-style Japanese Giapponese Space Before Paragraph Spazio prima del paragrafo Spaces Spazi Spacing Guides Guide di spaziatura + Invoke Distance Guides Richiama guide di distanza Spacing: Spaziatura: Spanish Spagnolo Specified Distance Distanza specificata @@ -3209,7 +3275,6 @@ mark-style Japanese Giapponese Show all open documents Mostra tutti i documenti aperti Symbol Instance Istanza di simbolo Symbol Libraries Librerie simboli - Symbol Libraries (on the roadmap) Librerie simboli (in programma) Symbol Screener Tool Strumento Trasparenza simboli Symbol Scruncher Tool Strumento Addensamento simboli Symbol Shifter Tool Strumento Spostamento simboli @@ -3302,6 +3367,7 @@ mark-style Japanese Giapponese Tools Strumenti Top In alto Top in Front Superiore davanti + Top-Justified Tab Tabulazione allineata in alto Top: In alto: Total Width: Larghezza totale: Touch Type Ritocco testo @@ -3337,6 +3403,13 @@ mark-style Japanese Giapponese Transparent Background Sfondo trasparente Transverse (the page a quarter turn on the paper) Trasversale (la pagina ruotata di un quarto di giro sul foglio) Trap… Trapping… + Trap Trapping + Height/Width Altezza/larghezza + Tint Reduction Riduzione tinta + Traps with Process Color Trapping con colori di quadricromia + Reverse Traps Trapping inverso + Precision Precisione + Remove Redundant Points Rimuovi punti ridondanti Trim Rifila Trim mark weight: Spessore segni di taglio: Trim marks Segni di taglio @@ -3568,7 +3641,7 @@ mark-style Japanese Giapponese Zoom Tool Strumento Zoom Zoom with Mouse Wheel Zoom con la rotellina del mouse Scrub Numeric Fields by Dragging Regola i campi numerici trascinando - {count} file(s) in {folder}{note}. Show the package folder? {count} file in {folder}{note}. Vuoi mostrare la cartella del pacchetto? + {count} file(s) in {folder} {note}. Show the package folder? {count} file in {folder} {note}. Vuoi mostrare la cartella del pacchetto? {count} linked file(s) not found File collegati non trovati: {count} fonts not copied: {fonts} font non copiati: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? Immagini collegate modificate dopo il salvataggio di questo documento: {count}. Vuoi mostrare le nuove versioni? @@ -3641,7 +3714,8 @@ imageTrace Outline Contorno to sample a color per campionare un colore across a selected object to set the gradient direction su un oggetto selezionato per impostare la direzione della sfumatura to select an artboard | per selezionare una tavola da disegno | - on the canvas to create one sull'area di disegno per crearne una + on the canvas to create one ( sull'area di disegno per crearne una ( + selects several, or draws one inside an artboard) ne seleziona più d'una o ne crea una dentro una tavola da disegno) a stroke to add a width point | su una traccia per aggiungere un punto di larghezza | to change one side | per modificare un solo lato | to select more points | per selezionare altri punti | @@ -3737,7 +3811,7 @@ axis Both Entrambi # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` nessuna libreria `{id}` @msg select some type first seleziona prima del testo @@ -3775,6 +3849,7 @@ axis Both Entrambi @msg Couldn't copy to the system clipboard: {e} Impossibile copiare negli appunti di sistema: {e} @msg Couldn't open {name}: {e} Impossibile aprire {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Impossibile inserire {name}: il documento su cui è stato rilasciato è stato chiuso +@msg Couldn't place {name}: the document it was placed in was closed Impossibile inserire {name}: il documento in cui veniva inserito è stato chiuso @msg Couldn't read {name}: {e} Impossibile leggere {name}: {e} @msg Couldn't save recovery data: {e} Impossibile salvare i dati di recupero: {e} @msg Create Outlines: select text objects Crea contorni: seleziona oggetti di testo @@ -3811,8 +3886,9 @@ axis Both Entrambi @msg Opened {url} Aperto {url} @msg Outline Stroke: no stroked paths selected Contorna traccia: nessun tracciato con traccia selezionato @msg PDF settings are not an object Le impostazioni PDF non sono un oggetto +@msg PDF security settings are not an object Le impostazioni di sicurezza PDF non sono un oggetto @msg PDF writer error: {0} Errore del generatore PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} Pacchetto creato con {n} file in {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Pacchetto creato con {n} file in {folder} {note} @msg Packaged {n} file(s){note} Pacchetto creato con {n} file{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Anteprima nel browser richiede l'app desktop (sul web i file vengono invece scaricati) @msg Release Guides: no guides Rilascia guide: nessuna guida @@ -3882,6 +3958,25 @@ axis Both Entrambi @msg a PostScript error ({name} in `{op}`: {at}) un errore PostScript ({name} in `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} un errore PostScript ({name} in `{op}`: {at}), in {procs} @msg a PostScript error ({name}) un errore PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) un errore PostScript ({name} in `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} un errore PostScript ({name} in `{op}`; {meaning}), in {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) un errore PostScript ({name} in `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} un errore PostScript ({name} in `{op}`: {at}; {meaning}), in {procs} +@msg a PostScript error ({name}; {meaning}) un errore PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin il programma chiude un dizionario che non ha aperto +@msg the program opens a file, which the reader can't do il programma apre un file, cosa che il lettore PostScript non sa fare +@msg the program restores a saved state that was already restored il programma ripristina uno stato salvato già ripristinato +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none i dati che il programma legge sono danneggiati o troncati, oppure il programma chiede una posizione in un file che non ne ha +@msg the program goes past one of the reader's limits il programma supera uno dei limiti del lettore PostScript +@msg the path has no point to continue from il tracciato non ha un punto da cui proseguire +@msg a value is outside the range the operator takes un valore è fuori dall'intervallo accettato dall'operatore +@msg the operator needs more values than the stack holds l'operatore richiede più valori di quanti ne contenga la pila +@msg part of the program isn't valid PostScript text una parte del programma non è testo PostScript valido +@msg a value has the wrong type for the operator un valore è di un tipo errato per l'operatore +@msg the program runs, deletes or renames a file, which the reader can't do il programma esegue, elimina o rinomina un file, cosa che il lettore PostScript non sa fare +@msg the program asks for a resource that isn't defined il programma richiede una risorsa non definita +@msg a calculation has no result, as in a division by zero un calcolo non ha risultato, come in una divisione per zero +@msg the operator looks for a mark on the stack and finds none l'operatore cerca un marcatore nella pila e non ne trova nessuno @msg a bitmap is empty una bitmap è vuota @msg a bitmap is too large to read una bitmap è troppo grande per essere letta @msg a colour index is 0 to {_1}, not {n} un indice di colore va da 0 a {_1}, non {n} @@ -3914,6 +4009,7 @@ axis Both Entrambi @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis deve essere "vertical", "horizontal" o un angolo in gradi, non `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" sfondo {other}: transparent, white, black o un colore come "#ff8800" @msg bad color {v} colore non valido {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio deve essere un numero da 0,1 a 10 @msg the Color Picker is not open il Selettore colore non è aperto @msg the Simplify slider is not open il cursore Semplifica non è aperto @msg the screen color sampler is already open il campionatore di colore dello schermo è già aperto @@ -3964,7 +4060,7 @@ axis Both Entrambi @msg give the path `id` and the width point `index` specifica l'`id` del tracciato e l'`index` del punto di larghezza @msg guides are locked le guide sono bloccate @msg handle must be in or out handle deve essere in o out -@msg image '{key}' has no pixels and was left out l'immagine '{key}' non ha pixel ed è stata omessa +@msg an image has no pixels and was left out un'immagine senza pixel è stata omessa @msg image data `{_1}` is missing mancano i dati immagine `{_1}` @msg image would be too large; lower the resolution l'immagine sarebbe troppo grande; riduci la risoluzione @msg index must be a number or first, previous, next or last index deve essere un numero oppure first, previous, next o last @@ -3991,7 +4087,7 @@ axis Both Entrambi @msg missing index manca index @msg missing number `{key}` manca il numero `{key}` @msg missing integer `{key}` manca il numero intero `{key}` -@msg multiple windows land with M11.5 le finestre multiple arriveranno con M11.5 +@msg New Window isn't available yet: a document opens in one window Nuova finestra non è ancora disponibile: un documento si apre in una sola finestra @msg name the file assegna un nome al file @msg no CSS to copy: select objects with CSS of their own nessun CSS da copiare: seleziona oggetti con un CSS proprio @msg no Live Paint edge near that point nessun bordo di pittura dinamica vicino a quel punto @@ -4009,7 +4105,9 @@ axis Both Entrambi @msg no blend options nessuna opzione di fusione @msg no bounds nessun limite @msg no brush named `{name}` nessun pennello denominato `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} il nome di un pennello a setole può avere al massimo {max} caratteri: `{name}` ne ha {count} @msg no brush selected nessun pennello selezionato +@msg brushes move only within their type i pennelli si possono spostare solo all'interno del loro tipo @msg no catalog nessun catalogo @msg no changes since the last save nessuna modifica dall'ultimo salvataggio @msg no clock to keep a heartbeat by nessun orologio con cui mantenere il segnale di attività @@ -4028,6 +4126,7 @@ axis Both Entrambi @msg no file system here nessun file system disponibile qui @msg no file writer nessun modulo di scrittura file @msg no files to place nessun file da inserire +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` non è un documento VectorCraft, un PDF o un file .ai compatibile con PDF @msg no format to export to nessun formato di esportazione @msg no free recovery area nessuna area di recupero libera @msg no gradient stop or freeform point is selected nessun cursore colore o punto a forma libera selezionato @@ -4063,6 +4162,7 @@ axis Both Entrambi @msg no such action azione inesistente @msg no such anchor punto di ancoraggio inesistente @msg no such artboard tavola da disegno inesistente +@msg the artboard is locked la tavola da disegno è bloccata @msg no such document documento inesistente @msg no such layer or object livello o oggetto inesistente @msg no such object {0} oggetto {0} inesistente @@ -4119,6 +4219,7 @@ axis Both Entrambi @msg path is already closed il tracciato è già chiuso @msg paths contain invalid coordinates i tracciati contengono coordinate non valide @msg paths: give at least one file paths: specifica almeno un file +@msg replace: the object to replace was changed or deleted replace: l’oggetto da sostituire è stato modificato o eliminato @msg pattern missing motivo mancante @msg patterns nested too deeply are filled with mid-grey i motivi annidati troppo in profondità vengono riempiti di grigio medio @msg percent must be above 0 and at most 6400, not {p} la percentuale deve essere maggiore di 0 e al massimo 6400, non {p} @@ -4154,6 +4255,10 @@ axis Both Entrambi @msg select a repeat seleziona una ripetizione @msg select an Intertwine group seleziona un gruppo intrecciato @msg select an Objects on Path group seleziona un gruppo di oggetti su tracciato +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects un tracciato all'interno di un tracciato composto, una fusione, un involucro, una ripetizione o un gruppo intrecciato non può ricevere oggetti +@msg a clipping path can't take objects un tracciato di ritaglio non può ricevere oggetti +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path gli oggetti all'interno di una fusione, un involucro, una ripetizione o un gruppo intrecciato non possono essere agganciati a un tracciato +@msg a clipping path can't be attached to a path un tracciato di ritaglio non può essere agganciato a un tracciato @msg select a stroke with a variable width seleziona una traccia con larghezza variabile @msg select a symbol instance seleziona un'istanza di simbolo @msg select an Image Trace object seleziona un oggetto di ricalco immagine @@ -4273,6 +4378,7 @@ axis Both Entrambi @msg unknown workspace (one of {_1}) area di lavoro sconosciuta (una delle seguenti: {_1}) @msg unsupported: {0} non supportato: {0} @msg variables are locked le variabili sono bloccate +@msg variables that aren't text were left out: {kinds} le variabili che non sono di testo sono state escluse: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files i file della versione {0} non possono essere compressi: solo la versione {COMPRESSED_SINCE} e successive aprono file compressi @msg width and height are at least 1 pixel larghezza e altezza sono di almeno 1 pixel @msg workspace name can't be empty il nome dell'area di lavoro non può essere vuoto @@ -4284,6 +4390,7 @@ axis Both Entrambi @msg {_1} font families available {_1} famiglie di font disponibili @msg {_1} is a live effect (Effect › Plug-ins) {_1} è un effetto dinamico (Effetto › Plug-in) @msg {_1} is an exporter (File › Export › Export As…) {_1} è un plug-in di esportazione (File › Esporta › Esporta come…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` occupa {size} byte (limite {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} è già usato da {_2} @msg {_1} linked file(s) can't be read here: their previews show file collegati non leggibili qui: {_1}; vengono mostrate le loro anteprime @msg {_1} linked image(s) changed on disk: Update Links shows the new versions immagini collegate modificate su disco: {_1}; Aggiorna collegamenti mostra le nuove versioni @@ -4313,6 +4420,8 @@ axis Both Entrambi @msg Saving Salvataggio @msg Saving {_1} Salvataggio di {_1} @msg Exporting {_1} Esportazione di {_1} +@msg Packaging {_1} Creazione del pacchetto {_1} +@msg Placing {_1} Inserimento di {_1} @msg Saving recovery data for {_1} Salvataggio dei dati di recupero per {_1} @msg Discarded {_1} recovered document(s) Documenti recuperati scartati: {_1} @msg Restored {_1} recovered document(s): save them to keep them Documenti recuperati ripristinati: {_1}. Salvali per conservarli @@ -4419,7 +4528,9 @@ axis Both Entrambi @msg automation filesystem access is not granted: read authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di lettura: {path} @msg automation filesystem access is not granted: write authority is absent: {path} l'automazione non ha accesso al file system: manca l'autorizzazione di scrittura: {path} @msg automation path rejected: outside the read root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di lettura {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di lettura {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} percorso di automazione rifiutato: fuori dalla cartella radice di scrittura {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} percorso di automazione rifiutato: fuori dalle cartelle radice di scrittura {roots}: {path} @msg automation path rejected: path is empty percorso di automazione rifiutato: il percorso è vuoto @msg automation path rejected: a link that leads nowhere: {path} percorso di automazione rifiutato: un collegamento che non porta da nessuna parte: {path} @msg automation path rejected: cannot resolve {path}: {e} percorso di automazione rifiutato: impossibile risolvere {path}: {e} @@ -4527,6 +4638,7 @@ effect Regular Normale Activate Panel Attiva pannello Change Panel Layout Modifica disposizione dei pannelli Close Panel Chiudi pannello + Collapse Panel Group Comprimi gruppo di pannelli Move Panel Sposta pannello Group with Raggruppa con Resize panels Ridimensiona pannelli @@ -4537,6 +4649,12 @@ effect Regular Normale @msg before must name a panel before deve indicare un pannello @msg docking action contains an unknown panel or an excessive split path L’azione di ancoraggio contiene un pannello sconosciuto o un percorso di divisioni troppo lungo @msg group must be true or false group deve essere true o false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} il pannello è mobile; comprimi il suo gruppo con window.panel.float {panel, collapsed} +@msg panel is not in a dock group il pannello non è in un gruppo del dock +@msg only a dock group above or below others collapses, not one beside another si comprime solo un gruppo del dock sopra o sotto altri, non uno accanto +@msg icon must be true or false icon deve essere true o false +@msg icon only docks a panel in the icon column, with no anchor icon ancora un pannello solo nella colonna delle icone, senza anchor +@msg column docks a set as a dock column, with no anchor or icon column ancora un insieme come colonna del dock, senza anchor né icon @msg move requires a destination panel Lo spostamento richiede un pannello di destinazione @msg move requires an existing destination panel Lo spostamento richiede un pannello di destinazione esistente @msg onto must name a floating panel onto deve indicare un pannello mobile @@ -4615,12 +4733,24 @@ lighting Light Luce Reset Ruler Origin Ripristina origine dei righelli Artistic Artistico Film Grain… Grana della pellicola… + Colored Pencil… Matita colorata… + Colored Pencil Matita colorata Cutout… Ritaglio… Cutout Ritaglio + Dry Brush… Pennello secco… + Dry Brush Pennello secco Neon Glow… Bagliore neon… + Palette Knife… Spatola… Neon Glow Bagliore neon + Paint Daubs… Pennellate… + Paint Daubs Pennellate + Palette Knife Spatola Poster Edges… Bordi poster… + Rough Pastels… Pastelli grezzi… Poster Edges Bordi poster + Rough Pastels Pastelli grezzi + Underpainting… Sottopittura… + Underpainting Sottopittura Levels Livelli Edge Simplicity Semplicità dei bordi Edge Fidelity Fedeltà dei bordi @@ -4710,6 +4840,9 @@ lighting Light Luce Softness Morbidezza Ambient Light Luce ambientale Light behind the object Luce dietro l'oggetto + 3D Lighting Preset Predefinito illuminazione 3D + Standard Standard + Diffuse Diffusa # 3D light management. Lights Luci Add Light Aggiungi luce @@ -4756,6 +4889,7 @@ lighting Light Luce @msg Measured anchor no longer exists Il punto di ancoraggio misurato non esiste più @msg Measured segment no longer exists Il segmento misurato non esiste più @msg Non-finite dimension Quota non finita +@msg Scale Text Attributes: selected type has no scale to bake Scala attributi di testo: il testo selezionato non ha una scala da incorporare @msg Scale must be drawing:real, for example 1:10 La scala deve essere disegno:reale, ad esempio 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Il rapporto di scala deve essere compreso tra 1:1000000 e 1000000:1 @msg Second measured segment no longer exists Il secondo segmento misurato non esiste più @@ -4774,6 +4908,7 @@ lighting Light Luce Make Image Dynamic Rendi immagine dinamica Import Data Sets… Importa set di dati… Export Data Sets… Esporta set di dati… + Load Variable Library… Carica libreria di variabili… Make Graph Dynamic Rendi grafico dinamico Batch… Elaborazione batch… Source Folder: Cartella di origine: @@ -4797,6 +4932,12 @@ lighting Light Luce Sketch Schizzo Halftone Pattern… Pattern mezzetinte… Halftone Pattern Pattern mezzetinte + Torn Edges… Bordi strappati… + Torn Edges Bordi strappati + Conté Crayon… Matita Conté… + Conté Crayon Matita Conté + Foreground Level Livello primo piano + Background Level Livello sfondo Pattern Type Tipo di pattern Dot Punto # User Interface › Color Scheme @@ -4815,6 +4956,7 @@ lighting Light Luce Watercolor… Acquerello… Watercolor Acquerello Brush Size Dimensione pennello + Texture Coverage Copertura texture Definition Definizione Brush Detail Dettaglio pennello Shadow Intensity Intensità ombre @@ -4843,13 +4985,21 @@ lighting Light Luce @msg the image's pixels can't be read impossibile leggere i pixel dell’immagine Persistent Persistente Library Panel Options Opzioni pannello libreria + Locate Library… Individua libreria… @msg Persistent libraries not found: {files} Librerie persistenti non trovate: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` non è un pannello libreria (vedi window.swatchLibrary) @msg library panel `{given}` isn't open il pannello libreria `{given}` non è aperto +@msg library panel `{given}` isn't missing its library la libreria del pannello libreria `{given}` non è mancante @msg too many library panels were opened in this session in questa sessione sono stati aperti troppi pannelli libreria @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. L’immagine è di {_1} × {_2} pixel, più di {LARGE_TRACE_SIDE} su un lato: viene vettorializzata, ma più lentamente. Un predefinito a fedeltà più bassa o una copia più piccola dell’immagine è più veloce. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). L’immagine è stata vettorializzata a {_1} × {_2} pixel, ridotta da {_3} × {_4} (al massimo {_5} megapixel). + Snapping Aggancio + Snap to Tangent Aggancia alla tangente + Snap to Perpendicular Aggancia alla perpendicolare +@msg unknown snap option `{key}` (use {options}) opzione di aggancio sconosciuta `{key}` (usare {options}) + Gap Length: Lunghezza lacuna: Artboard {n} of {total} Tavola da disegno {n} di {total} + File {n} of {total} File {n} di {total} Encoding and writing the file… Codifica e scrittura del file… @plural {n} more export waiting|{n} more exports waiting {n} altra esportazione in attesa|{n} altre esportazioni in attesa Continue in Background Continua in background @@ -4866,6 +5016,14 @@ lighting Light Luce Minimum must not exceed Desired, nor Desired Maximum. Il minimo non può superare il valore desiderato, né il desiderato il massimo. Minimum Minimo Justification Giustificazione + Add filters from the list above Aggiungi filtri dall'elenco sopra + Add to Stack Aggiungi alla pila + Applied Filters Filtri applicati + Categories Categorie + Choose a filter Scegli un filtro + Effect Gallery Galleria effetti + Filters Filtri + Select artwork to apply effects Seleziona la grafica a cui applicare gli effetti @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them le variabili e i set di dati del file non sono stati letti (variabili: {variables}, set di dati: {data_sets}): il pannello Variabili non li elenca # Select by Color (select.color) Select by Color Seleziona per colore @@ -4887,3 +5045,107 @@ lighting Light Luce Plaster Gesso Note Paper… Carta da lettere… Note Paper Carta da lettere + Water Paper… Carta bagnata… + Water Paper Carta bagnata + Ruby… Ruby… + Ruby Ruby + Geometric Guides Guide geometriche + Ease In Inizio graduale + Ease Out Fine graduale + Ease In Out Inizio e fine graduali + Ease Out In Fine e inizio graduali + Ramp: Intensità: + Shift: Spostamento: + Fresco… Affresco… + Fresco Affresco + Effect Gallery… Galleria effetti… + Effect Gallery Dialog Finestra di dialogo Galleria effetti + Arc Segment Tool Options Opzioni strumento Segmento arco + Base Along: Base lungo: + Closed Chiuso + Concave Concavo + Convex Convesso + Fill Arc Riempi arco + Length X-Axis: Lunghezza asse X: + Length Y-Axis: Lunghezza asse Y: + Slope: Pendenza: +axis X Axis Asse X +axis Y Axis Asse Y +@msg Packaged {n} file(s) in {folder} Pacchetto creato con {n} file in {folder} +@msg Packaged {n} file(s) {note} Pacchetto creato con {n} file {note} +@msg Packaged {n} file(s) Pacchetto creato con {n} file + Packaged {n} file(s) Pacchetto creato con {n} file + Packaged {n} file(s) in {folder} Pacchetto creato con {n} file in {folder} + Packaged {n} file(s) {note} Pacchetto creato con {n} file {note} + {count} file(s) in {folder}. Show the package folder? {count} file in {folder}. Vuoi mostrare la cartella del pacchetto? + Clockwise In senso orario + Dither Dithering + Fine noise hides banding in the gradient fill Un rumore fine nasconde le bande nel riempimento sfumato +@msg `dither` applies to gradient fills (stroke: false) `dither` si applica ai riempimenti sfumati (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` si applica ai riempimenti sfumati (paint.setFill) +@msg `dither` must be true or false `dither` deve essere true o false + Perceptual Percettivo +@msg `method` must be classic or perceptual `method` deve essere classic o perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Installare “{name}”, che chiede un budget di calcolo elevato? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Ogni esecuzione può durare fino a {minutes} minuti e svolgere {times} volte il lavoro consentito agli altri plug-in. Viene eseguita in background ed è possibile annullarla. + It may take minutes, so it runs in the background, where you can cancel it. Può richiedere minuti, quindi viene eseguito in background, dove è possibile annullarlo. + The plug-in is computing… Il plug-in sta calcolando… + Inspect Plug-in Esamina plug-in + Cancel Plug-in Run Annulla esecuzione plug-in +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} è terminato, ma nel frattempo il documento o il plug-in è cambiato: va eseguito di nuovo +@msg Running “{name}”… Esecuzione di “{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy` +@msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente +# Actions panel: action sets (#3712). + New Set… Nuovo set… + New Set Nuovo set + Set Options… Opzioni set… + Set Options Opzioni set + Create New Set Crea nuovo set + Stop Global Edit Interrompi modifica globale + Global Edit Options Opzioni modifica globale + Match Corrispondenza + On Artboards Sulle tavole da disegno + All Artboards Tutte le tavole da disegno + Include Objects On Canvas Includi oggetti sulla tela + Result(s): {count} Risultati: {count} +@msg select one object selezionate un oggetto +@msg Global Edit works on paths, compound paths and symbol instances La modifica globale funziona su tracciati, tracciati composti e istanze di simboli +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nessun oggetto simile trovato (le copie ruotate o riflesse non vengono riconosciute); cambiate le opzioni di modifica globale e riprovate +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervallo come 1, 3-5, non {v} + Add to Symbols Aggiungi ai simboli + Basic Shapes Forme di base + Open Symbol Library Apri libreria simboli + Save Symbol Library Salva libreria simboli + Save Symbol Library… Salva libreria simboli… + Save Symbols… Salva simboli… + Selected Symbols Only Solo simboli selezionati + Symbol Libraries Menu Menu Librerie simboli + Symbol Library Libreria simboli + User Symbol Library 1 Libreria simboli utente 1 + User Symbol Library 2 Libreria simboli utente 2 + User Symbol Library 3 Libreria simboli utente 3 + User Symbol Library 4 Libreria simboli utente 4 + User Symbol Library 5 Libreria simboli utente 5 + User Symbol Library 6 Libreria simboli utente 6 + User Symbol Library 7 Libreria simboli utente 7 + User Symbol Library 8 Libreria simboli utente 8 + User Symbol Library 9 Libreria simboli utente 9 + User Symbol Library 10 Libreria simboli utente 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` non ha simboli da inserire +@msg missing `library` (see symbol.libraries) manca `library` (vedi symbol.libraries) +@msg no symbol `{n}` nessun simbolo `{n}` +@msg no symbol `{n}` in `{_1}` nessun simbolo `{n}` in `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) nessuna libreria simboli `{key}` (vedi symbol.libraries) +@msg the document has no symbols to save il documento non ha simboli da salvare +@msg Trap: select two or more objects filled with solid colours Trapping: selezionate due o più oggetti con riempimento in tinta unita +@msg Trap: no edges of different colours meet among the selected objects Trapping: tra gli oggetti selezionati non si incontrano bordi di colori diversi +@msg thickness must be from 0.01 to 5000 pt thickness deve essere compreso tra 0,01 e 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth deve essere compreso tra 1 e 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction deve essere compreso tra 0 e 100 % + Rectangular Grid Tool Options Opzioni strumento Griglia rettangolare + Polar Grid Tool Options Opzioni strumento Griglia polare + Fill Line Riempi linea diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1a46d2ad7..6735831f0 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -11,13 +11,15 @@ Smoothness 滑らかさ # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested) — and the status and error messages (`@msg` rows at the end, `COMPLETE_MESSAGES`, tested). +# `untranslated_report` lists gaps) — and the status and error messages (`@msg` rows at the end, `COMPLETE_MESSAGES`, `untranslated_report` lists gaps). # Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. About VectorCraft VectorCraft について All Scripts すべての文字 Anchors: アンカー: Any Kind すべての種類 + Font Technology フォントテクノロジー +font-technology Variable バリアブル Ascender アセンダ Baseline ベースライン Bottom-to-Bottom Leading 欧文ベースライン基準の行送り @@ -39,6 +41,7 @@ ICF Top/Right 平均字面の上 / 右 ICF Bottom/Left 平均字面の下 / 左 Proportional Metrics プロポーショナルメトリクス + Character Direction 文字の方向 Digits 数字 Arabic アラビア数字 Hindi ヒンディー数字 @@ -48,6 +51,9 @@ Japanese 日本語 Join Our Discord Discord に参加 Line-end Punctuation Half Width 行末約物半角 + Insert Kashidas カシーダを挿入 + Long 長い + Stylistic スタイリスティック Mojikumi Set 文字組み Kinsoku Set 禁則処理 Hard 強い禁則 @@ -71,8 +77,14 @@ Medium Light やや明るい Light 明るい Quit VectorCraft VectorCraft を終了 +# Artwork drag between document tabs. + Drop Artwork アートワークをドロップ +@msg {key} must be [x, y] with finite coordinates {key} は有限の座標を持つ [x, y] である必要があります +@msg ids must contain 1–10000 object ids ids には 1~10000 個のオブジェクト ID を指定してください + File ファイル New… 新規… + New Without Dialog ダイアログなしで新規 New from Template… テンプレートから新規… Open… 開く… Open Recent Files 最近使用したファイルを開く @@ -235,8 +247,8 @@ Blend ブレンド Blend Options… ブレンドオプション… Expand 拡張 - Replace Spine ブレンド軸を置き換え - Reverse Spine ブレンド軸を反転 + Replace Spine スパインを置き換え + Reverse Spine スパインを反転 Reverse Front to Back 前後を反転 Envelope Distort エンベロープ Make with Warp… ワープで作成… @@ -262,6 +274,8 @@ Large Gaps 大きい間隔 Gap Preview Color: 間隔のプレビューカラー: Close Gaps With Paths パスで間隔を閉じる + Show Live Paint Gaps ライブペイントの間隔を表示 + Hide Live Paint Gaps ライブペイントの間隔を隠す Select Fills 塗りを選択 Select Strokes 線を選択 Image Trace 画像トレース @@ -298,6 +312,13 @@ Glyphs 字形 Insert Special Character 特殊文字を挿入 Bullet 箇条書き記号 + Bullets 箇条書き + Bullets and Numbering 箇条書きと番号付け + Apply Bullets 箇条書きを適用 + Filled Dot 黒丸 + Dash ダッシュ + Hollow Circle 白丸 + Filled Square 黒四角 Copyright Symbol 著作権記号 Ellipsis 省略記号 Paragraph Symbol 段落記号 @@ -741,7 +762,8 @@ from center | :中心から描画 | keeps its proportions) :縦横比を固定) near a selected pin to rotate | :選択したピンの近くで回転 | - on the canvas to create one :アートボードを作成 + on the canvas to create one ( :アートボードを作成( + selects several, or draws one inside an artboard) :複数を選択、またはアートボード内に作成) over paths to roughen their outlines | :パスの輪郭を粗くする | over paths, and hold still to keep going | :パス上で押し続けると効果が続く | the art to add a pin | :アートにピンを追加 | @@ -923,6 +945,11 @@ Average 平均 BLANK DOCUMENT PRESETS 空白のドキュメントプリセット Background Color: 背景色: + Show Center Mark 中心マークを表示 + Show Cross Hairs 十字線を表示 + Show Video Safe Areas ビデオセーフエリアを表示 + Video Ruler Pixel Aspect Ratio: ビデオ定規のピクセル縦横比: + Hide Video Rulers ビデオ定規を隠す Background Contents 背景のカラー Background Contents: 背景のカラー: Background: 背景: @@ -1461,6 +1488,7 @@ Eyedropper Picks Up: スポイトの抽出: Eyedropper Tool スポイトツール Eyedropper: click the art to sample the stop's colour スポイト: アートをクリックして分岐点のカラーをサンプル + Eyedropper: click the art to sample the key color スポイト: アートをクリックしてキーカラーをサンプル Feet フィート Feet & Inches フィート・インチ Fewer Elements 要素を減らす @@ -1484,6 +1512,16 @@ Alt key toggles to Smooth Tool Alt キーでスムーズツールに切り替え Keep selected 選択を保持 Merge only with selection 選択範囲とのみ結合 + Consider Open Filled Paths as Closed 塗りのある開いたパスを閉じたパスとみなす + In Merge Mode, Clicking Stroke Splits the Path 結合モードで線をクリックするとパスを分割 + Pick Color From: 色の取得元: + Artwork アートワーク + Cursor Swatch Preview ポインター横のスウォッチプレビュー + Selection: 選択方法: + Freeform 自由形状 + Straight Line 直線 + Highlight: 強調表示: + Stroke When Editable 編集可能な線 Fill: 塗り: Find 検索 Find Font フォントを検索 @@ -1583,6 +1621,7 @@ Hand Tool 手のひらツール Handles ハンドル Hard Light ハードライト + Hard Mix ハードミックス Harmony Rules: ハーモニールール: Height 高さ Height: 高さ: @@ -1610,7 +1649,7 @@ Clear History ヒストリーを消去 Set History Limit… ヒストリーの上限を設定… History States ヒストリー数 -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set データセットを適用 Area Type エリア内文字 Bloat 膨張 @@ -2008,6 +2047,7 @@ Move Slices スライスを移動 Move Spine Point スパインポイントを移動 Reshape Spine Segment スパインセグメントをシェイプ変形 + Move Brush ブラシを移動 Move Swatch スウォッチを移動 Move Up 上へ移動 Move in Perspective 遠近で移動 @@ -2058,6 +2098,13 @@ Tints and Shades 淡彩と陰影 Hue Shift 色相シフト Key Color: キーカラー: + Tips ヒント + Colorization Tips 着色のヒント + The samples show this brush on a blue stroke. サンプルは、このブラシを青い線に適用した状態を表示します。 + The art keeps its own colors. アートは元のカラーのままです。 + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. 黒は線のカラーになり、白は白のままです。その他のカラーは線のカラーの淡彩になり、明るいカラーほど明るい淡彩になります。 + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. 黒は黒のまま、白は白のままです。その他のカラーは、暗い場合は線のカラーの陰影に、明るい場合は淡彩になります。 + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. キーカラーが線のカラーになります。その他のカラーはキーカラーと同じだけカラーホイール上を移動し、彩度と明るさは保たれます。 Roundness: 真円率: Fixed 固定 Pressure 筆圧 @@ -2078,6 +2125,14 @@ Round Fan 丸・扇形 Flat Fan 平・扇形 New Brush… 新規ブラシ… + New Brush 新規ブラシ + Calligraphic Brush 絵筆ブラシ + Scatter Brush 散布ブラシ + Art Brush アートブラシ + Bristle Brush 剛毛ブラシ + Pattern Brush パターンブラシ + Select art to make a scatter, art or pattern brush. 散布ブラシ、アートブラシ、パターンブラシを作成するには、アートを選択します。 + Brush Art ブラシのアート New Calligraphic Brush 新規絵筆ブラシ New Character Style 新規文字スタイル New Color Group 新規カラーグループ @@ -2303,7 +2358,7 @@ Performance パフォーマンス diagnostics render 描画 diagnostics ui UI - Performance & Storage パフォーマンスとストレージ + Plug-ins & Scratch Disks プラグイン & 仮想記憶ディスク Permissions 権限 Permissions password: 権限パスワード: Perspective Grid Options 遠近グリッドオプション @@ -2660,6 +2715,7 @@ mark-style Japanese 日本式 Scale Cursor Proportional to UI カーソルを UI の拡大率に合わせる Scale Lineweights 線の太さを拡大・縮小 Scale Strokes & Effects 線幅と効果を拡大・縮小 + Scale Text Attributes テキスト属性を拡大・縮小 Scale Tool 拡大・縮小ツール Scale to Fit Artboard アートボードに合わせて拡大・縮小 Scale: 拡大・縮小: @@ -2838,6 +2894,7 @@ mark-style Japanese 日本式 Snap to Unit 単位にスナップ Snapping Tolerance スナップ許容値 Soft Light ソフトライト + Soft Mix ソフトミックス Solid 実線 Sort by Kind 種類順に並べ替え Sort by Name 名前順に並べ替え @@ -2846,6 +2903,7 @@ mark-style Japanese 日本式 Space Before Paragraph 段落前のアキ Spaces スペース Spacing Guides 間隔ガイド + Invoke Distance Guides 距離ガイドを呼び出す Spacing: 間隔: Spanish スペイン語 Specify: 指定: @@ -2939,7 +2997,6 @@ mark-style Japanese 日本式 Use Library Item ライブラリ項目を使用 Graphic グラフィック Show all open documents 開いているすべてのドキュメントを表示 - Symbol Libraries (on the roadmap) シンボルライブラリ (今後対応予定) Symbol Screener Tool シンボルスクリーナーツール Symbol Scruncher Tool シンボルスクランチャーツール Symbol Shifter Tool シンボルシフターツール @@ -3024,6 +3081,13 @@ mark-style Japanese 日本式 Transparent Background 背景を透明にする Transverse (the page a quarter turn on the paper) 横向き回転 (用紙上でページを 90 度回転) Trap… トラップ… + Trap トラップ + Height/Width 高さ/幅 + Tint Reduction 色合いの軽減 + Traps with Process Color プロセスカラーでトラップ + Reverse Traps トラップを反転 + Precision 精度 + Remove Redundant Points 余分なポイントを削除 Trim mark weight: トリムマークの線幅: True colour needs version 2004 or later: the nearest of 256 colours is written. True Color にはバージョン 2004 以降が必要です。256 色のうち最も近い色で書き出します。 Turn off Data Recovery for complex documents 複雑なドキュメントではデータ復帰をオフにする @@ -3168,7 +3232,7 @@ mark-style Japanese 日本式 the preset プリセット tile {number} タイル {number} to → - {count} file(s) in {folder}{note}. Show the package folder? {folder} に {count} 個のファイル{note}。パッケージフォルダーを表示しますか? + {count} file(s) in {folder} {note}. Show the package folder? {folder} に {count} 個のファイル{note}。パッケージフォルダーを表示しますか? {count} linked file(s) not found リンクファイルが {count} 個見つかりません fonts not copied: {fonts} コピーされなかったフォント:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 保存後に {count} 個のリンク画像が変更されています。新しいバージョンを表示しますか? @@ -3225,6 +3289,7 @@ gradient-kind Radial 円形 Black and White Logo 白黒ロゴ Bottom 下 Bottom in Front 下が前面 + Bottom-Justified Tab 下揃えタブ Bounds 境界 Bounds (Fill Proportionally) 境界 (縦横比を保って全体を覆う) Bounds (Fit Proportionally) 境界 (縦横比を保って収める) @@ -3444,7 +3509,7 @@ file-filter Templates テンプレート Output Black 出力ブラック Output White 出力ホワイト Output › Mode 出力 › モード - Packaged {n} file(s) in {folder}{note} {folder} に {n} 個のファイルをパッケージしました{note} + Packaged {n} file(s) in {folder} {note} {folder} に {n} 個のファイルをパッケージしました{note} Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note} Page information ページ情報 Paragraph Style 段落スタイル @@ -3476,6 +3541,7 @@ file-filter Templates テンプレート Relinked {n} image(s) {n} 個の画像を再リンクしました Relinked {n} image(s); not in that folder: {} {n} 個の画像を再リンクしました。そのフォルダーに無いもの:{} Relinked {} image(s) {} 個の画像を再リンクしました + Repeat Objects リピートオブジェクト Repeat Options リピートオプション Reset with Mesh メッシュでリセット Reset with Warp ワープでリセット @@ -3549,6 +3615,7 @@ file-filter Templates テンプレート Tip on the end point 先端をパスの端点に合わせる Top 上 Top in Front 上が前面 + Top-Justified Tab 上揃えタブ Transforms 変形 Transparent (Default) 透明 (デフォルト) Trim marks トリムマーク @@ -3723,6 +3790,13 @@ theme Appearance 外観 Channel 4 チャンネル 4 Cell Size セルの大きさ Fine Dots 細かい点 + + Simple シンプル + Light Rough ライトラフ + Dark Rough ダークラフ + Wide Sharp ワイドシャープ + Wide Blurry ワイドブラー + Sparkle スパークル Medium Dots 中程度の点 Grainy Dots 粒状の点 Coarse Dots 粗い点 @@ -3750,7 +3824,7 @@ theme Appearance 外観 # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. # Japanese: no final 。 (these are status lines, and reasons that follow a colon), unless the English ends # with a period; a space between Japanese text and an adjacent Latin word, number or {placeholder}. @@ -3833,7 +3907,7 @@ swatch Bright Violet ビビッドバイオレット @msg the scale is not a usable number 縮尺が有効な数値ではありません @msg the drawing is too large to open 図面が大きすぎて開けません @msg scale must be a number from {_1} to {_2}, not {_3} 縮尺は {_1} から {_2} までの数値にしてください ({_3} は指定できません) -@msg image '{key}' has no pixels and was left out 画像「{key}」はピクセルがないため省略しました +@msg an image has no pixels and was left out ピクセルがない画像を省略しました @msg Embedded {n} image(s) 画像を {n} 枚埋め込みました @msg object {0} cannot have children オブジェクト {0} には子オブジェクトを含められません @msg range `{s}` is empty 範囲 `{s}` が空です @@ -3844,7 +3918,9 @@ swatch Bright Violet ビビッドバイオレット @msg Show All Hidden Attributes: nothing is hidden 非表示の属性をすべて表示: 非表示の属性はありません @msg Expand: no brush strokes selected 拡張: ブラシストロークが選択されていません @msg no brush named `{name}` `{name}` という名前のブラシが見つかりません +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} 剛毛ブラシの名前は最大 {max} 文字です: `{name}` は {count} 文字あります @msg no brush selected ブラシが選択されていません +@msg brushes move only within their type ブラシは同じ種類の中でのみ移動できます @msg no symbol instances selected シンボルインスタンスが選択されていません @msg select a symbol instance シンボルインスタンスを選択してください @msg the art has no bounds アートに境界がありません @@ -3885,11 +3961,13 @@ swatch Bright Violet ビビッドバイオレット @msg Make Guides: select paths ガイドを作成: パスを選択してください @msg Release Guides: no guides ガイドを解除: ガイドがありません @msg variables are locked 変数がロックされています +@msg variables that aren't text were left out: {kinds} テキスト以外の変数は除外されました: {kinds} @msg guides are locked ガイドがロックされています @msg handle must be in or out handle には in または out を指定してください @msg no saved selection recalled 保存した選択範囲を呼び出していません @msg no saved selections 保存した選択範囲はありません @msg no such artboard 指定されたアートボードが見つかりません +@msg the artboard is locked アートボードがロックされています @msg cannot split an open path at its end point オープンパスは端点で分割できません @msg closing needs three anchors 閉じるには 3 つのアンカーポイントが必要です @msg empty brush stroke ブラシストロークが空です @@ -3981,7 +4059,7 @@ swatch Bright Violet ビビッドバイオレット @msg {path}: not a file {path}: ファイルではありません @msg {path}: {e} {path}: {e} @msg can't write .{ext} images .{ext} 画像は書き出せません -@msg Replace Spine: select a blend and a path ブレンド軸を置き換え: ブレンドとパスを選択してください +@msg Replace Spine: select a blend and a path スパインを置き換え: ブレンドとパスを選択してください @msg nothing to wrap まとめるものがありません @msg select a blend ブレンドを選択してください @msg select a blend or give `id` ブレンドを選択するか、`id` を指定してください @@ -4036,6 +4114,10 @@ swatch Bright Violet ビビッドバイオレット @msg select a repeat リピートを選択してください @msg select an Intertwine group 織り交ぜグループを選択してください @msg select an Objects on Path group パス上のオブジェクトグループを選択してください +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 複合パス、ブレンド、エンベロープ、リピートまたは織り交ぜグループ内のパスにはオブジェクトをアタッチできません +@msg a clipping path can't take objects クリッピングパスにはオブジェクトをアタッチできません +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path ブレンド、エンベロープ、リピートまたは織り交ぜグループ内のオブジェクトはパスにアタッチできません +@msg a clipping path can't be attached to a path クリッピングパスはパスにアタッチできません @msg select the art to repeat リピートするアートを選択してください @msg the document has no patterns ドキュメントにパターンがありません @msg no artboard アートボードがありません @@ -4132,6 +4214,25 @@ swatch Bright Violet ビビッドバイオレット @msg a PostScript error ({name} in `{op}`: {at}) PostScript エラー (`{op}` での {name}: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}: {at}) @msg a PostScript error ({name}) PostScript エラー ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) PostScript エラー (`{op}` での {name}、{meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}、{meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript エラー (`{op}` での {name}: {at}、{meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} {procs} 内の PostScript エラー (`{op}` での {name}: {at}、{meaning}) +@msg a PostScript error ({name}; {meaning}) PostScript エラー ({name}、{meaning}) +@msg the program ends a dictionary it didn't begin プログラムが、開始していない辞書を終了しようとしています +@msg the program opens a file, which the reader can't do プログラムがファイルを開こうとしていますが、PostScript 読み込み機能はファイルを開けません +@msg the program restores a saved state that was already restored プログラムが、すでに復元された保存状態を復元しようとしています +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none プログラムが読み込むデータが壊れているか途中で切れているか、プログラムが位置を持たないファイルの位置を求めています +@msg the program goes past one of the reader's limits プログラムが PostScript 読み込み機能の制限を超えています +@msg the path has no point to continue from パスに続きを描く起点がありません +@msg a value is outside the range the operator takes 値がオペレーターの受け付ける範囲外です +@msg the operator needs more values than the stack holds オペレーターに必要な値がスタックに足りません +@msg part of the program isn't valid PostScript text プログラムの一部が有効な PostScript テキストではありません +@msg a value has the wrong type for the operator 値の型がオペレーターに合っていません +@msg the program runs, deletes or renames a file, which the reader can't do プログラムがファイルの実行、削除、名前の変更をしようとしていますが、PostScript 読み込み機能はこれを行えません +@msg the program asks for a resource that isn't defined プログラムが定義されていないリソースを要求しています +@msg a calculation has no result, as in a division by zero ゼロによる除算などで、計算の結果が得られません +@msg the operator looks for a mark on the stack and finds none オペレーターがスタック上のマークを探しましたが、見つかりません @msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know VectorCraft の PostScript 読み込み機能が対応していない `{at}` を使用しています @msg the file was read up to an error ({why}): what follows it was left out ファイルはエラーが発生した箇所まで読み込まれました ({why}): それ以降の内容は除外されました @msg this file's PostScript couldn't be read ({why}): {what} このファイルの PostScript を読み込めませんでした ({why}): {what} @@ -4215,6 +4316,8 @@ swatch Bright Violet ビビッドバイオレット @msg SVG parse error: {0} SVG を解析できませんでした: {0} @msg decimals must be {_1}–{_2} (got {_3}) 小数点以下の桁数は {_1} から {_2} までにしてください (指定値: {_3}) @msg Exporting {_1} {_1} を書き出し中 +@msg Packaging {_1} {_1} をパッケージ中 +@msg Placing {_1} {_1} を配置中 @msg Exported {what} {what} を書き出しました @msg "{0}" is not a colour: write it as #rrggbb 「{0}」はカラーではありません: #rrggbb の形式で指定してください @msg could not decode image: {0} 画像をデコードできませんでした: {0} @@ -4229,6 +4332,7 @@ swatch Bright Violet ビビッドバイオレット @msg {label}… {label}… @msg no blend options ブレンドオプションがありません @msg bad color {v} カラー {v} が正しくありません +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio には 0.1 〜 10 の数値を指定してください @msg the Color Picker is not open カラーピッカーが開いていません @msg the Simplify slider is not open 単純化スライダーが開いていません @msg the screen color sampler is already open 画面のカラーサンプラーはすでに開いています @@ -4261,7 +4365,7 @@ swatch Bright Violet ビビッドバイオレット @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} 個のリンク画像がディスク上で変更されました: 「リンクを更新」で新しいバージョンを表示できます @msg Updated {_1} linked file(s) changed on disk ディスク上で変更された {_1} 個のリンクファイルを更新しました @msg pick a colour, gradient or pattern to save as a swatch スウォッチとして保存するカラー、グラデーション、またはパターンを選択してください -@msg Packaged {n} file(s) in {folder}{note} {n} 個のファイルを {folder} にパッケージしました{note} +@msg Packaged {n} file(s) in {folder} {note} {n} 個のファイルを {folder} にパッケージしました{note} @msg Packaged {n} file(s){note} {n} 個のファイルをパッケージしました{note} @msg choose a location for the package パッケージの保存場所を選択してください @msg missing `folder` `folder` が指定されていません @@ -4274,6 +4378,7 @@ swatch Bright Violet ビビッドバイオレット @msg no plug-in {id} is installed プラグイン {id} はインストールされていません @msg {_1} is a live effect (Effect › Plug-ins) {_1} はライブ効果です (「効果 › プラグイン」から使用してください) @msg {_1} is an exporter (File › Export › Export As…) {_1} は書き出しプラグインです (「ファイル › 書き出し › 形式を指定して書き出し…」から使用してください) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` は {size} バイトです (上限 {MAX_DOCUMENT_DATA}) @msg a preset named `{_1}` exists `{_1}` という名前のプリセットはすでに存在します @msg a swatch or color group named `{n}` exists `{n}` という名前のスウォッチまたはカラーグループはすでに存在します @msg no print preset named `{base}` `{base}` という名前のプリントプリセットが見つかりません @@ -4291,6 +4396,7 @@ swatch Bright Violet ビビッドバイオレット @msg name the file ファイル名を入力してください @msg unknown format 不明な形式です @msg PDF settings are not an object PDF 設定がオブジェクト形式ではありません +@msg PDF security settings are not an object PDF のセキュリティ設定がオブジェクト形式ではありません @msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) `{name}` という名前の保存済み PDF プリセットが見つかりません (組み込みのプリセットは読み取り専用です: それを元に新しいプリセットを作成してください) @msg Saved to User Defined: {_1} 「ユーザー定義」に保存しました: {_1} @msg choose a direction to divide in 分割する方向を選択してください @@ -4341,7 +4447,7 @@ swatch Bright Violet ビビッドバイオレット @msg missing index index が指定されていません @msg missing number `{key}` 数値 `{key}` が指定されていません @msg missing integer `{key}` 整数 `{key}` が指定されていません -@msg multiple windows land with M11.5 複数ウィンドウは M11.5 で対応予定です +@msg New Window isn't available yet: a document opens in one window 新規ウィンドウはまだ使用できません: ドキュメントは 1 つのウィンドウで開きます @msg no PNG encoder PNG エンコーダーがありません @msg no document open ドキュメントが開かれていません @msg no effect applied yet まだ効果を適用していません @@ -4383,8 +4489,11 @@ swatch Bright Violet ビビッドバイオレット @msg no swatch library `{l}` (see swatch.library.list) スウォッチライブラリ `{l}` が見つかりません (swatch.library.list を参照) @msg no user library folder here この環境にはユーザーライブラリフォルダーがありません @msg Couldn't place {name}: the document it was dropped on was closed {name} を配置できませんでした: ドロップ先のドキュメントは閉じられています +@msg Couldn't place {name}: the document it was placed in was closed {name} を配置できませんでした: 配置先のドキュメントは閉じられています @msg no files to place 配置するファイルがありません +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` は VectorCraft ドキュメント、PDF、PDF 互換の .ai ファイルのいずれでもありません @msg paths: give at least one file paths: ファイルを 1 つ以上指定してください +@msg replace: the object to replace was changed or deleted replace: 置き換えるオブジェクトは変更または削除されています @msg Couldn't place {name}: {e} {name} を配置できませんでした: {e} @msg no print service プリントサービスがありません @msg the print job has no data プリントジョブにデータがありません @@ -4457,7 +4566,9 @@ swatch Bright Violet ビビッドバイオレット @msg automation filesystem access is not granted: read authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 読み込み権限がありません: {path} @msg automation filesystem access is not granted: write authority is absent: {path} 自動化にはファイルシステムへのアクセスが許可されていません: 書き込み権限がありません: {path} @msg automation path rejected: outside the read root {root}: {path} 自動化のパスを拒否しました: 読み込みルート {root} の外です: {path} +@msg automation path rejected: outside the read roots {roots}: {path} 自動化のパスを拒否しました: 読み込みルート {roots} のいずれの中でもありません: {path} @msg automation path rejected: outside the write root {root}: {path} 自動化のパスを拒否しました: 書き込みルート {root} の外です: {path} +@msg automation path rejected: outside the write roots {roots}: {path} 自動化のパスを拒否しました: 書き込みルート {roots} のいずれの中でもありません: {path} @msg automation path rejected: path is empty 自動化のパスを拒否しました: パスが空です @msg automation path rejected: a link that leads nowhere: {path} 自動化のパスを拒否しました: リンク先が存在しません: {path} @msg automation path rejected: cannot resolve {path}: {e} 自動化のパスを拒否しました: {path} を解決できません: {e} @@ -4561,11 +4672,12 @@ effect Vertical 垂直 Define a variable first 先に変数を定義してください Text テキスト Visibility 表示状態 -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # Shared panel docking Activate Panel パネルをアクティブにする Change Panel Layout パネルの配置を変更 Close Panel パネルを閉じる + Collapse Panel Group パネルグループを折りたたむ Move Panel パネルを移動 Group with グループ化先 Resize panels パネルのサイズを変更 @@ -4577,6 +4689,12 @@ effect Vertical 垂直 @msg before must name a panel before にパネルを指定してください @msg docking action contains an unknown panel or an excessive split path ドッキング操作に不明なパネルまたは長すぎる分割パスが含まれています @msg group must be true or false group は true または false にしてください +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} パネルはフローティング中です。グループは window.panel.float {panel, collapsed} で折りたたんでください +@msg panel is not in a dock group パネルはドックのグループにありません +@msg only a dock group above or below others collapses, not one beside another 折りたためるのは他のグループの上下にあるドックのグループだけで、横に並んだグループは折りたためません +@msg icon must be true or false icon は true または false である必要があります +@msg icon only docks a panel in the icon column, with no anchor icon はパネルをアイコン列にのみドッキングします(anchor なし) +@msg column docks a set as a dock column, with no anchor or icon column はセットをドック列としてドッキングします(anchor・icon なし) @msg move requires a destination panel 移動先のパネルを指定してください @msg move requires an existing destination panel 移動先には既存のパネルを指定してください @msg onto must name a floating panel onto にフローティングパネルを指定してください @@ -4654,12 +4772,24 @@ lighting Light ライト Reset Ruler Origin 定規の原点をリセット Artistic アーティスティック Film Grain… フィルム粒子… + Colored Pencil… 色鉛筆… + Colored Pencil 色鉛筆 Cutout… カットアウト… Cutout カットアウト + Dry Brush… ドライブラシ… + Dry Brush ドライブラシ Neon Glow… ネオングロー… + Palette Knife… パレットナイフ… Neon Glow ネオングロー + Paint Daubs… ペイントダブ… + Paint Daubs ペイントダブ + Palette Knife パレットナイフ Poster Edges… ポスターエッジ… + Rough Pastels… ラフパステル… Poster Edges ポスターエッジ + Rough Pastels ラフパステル + Underpainting… アンダーペインティング… + Underpainting アンダーペインティング Levels レベル数 Edge Simplicity エッジの単純さ Edge Fidelity エッジの正確さ @@ -4749,6 +4879,9 @@ lighting Light ライト Softness 柔らかさ Ambient Light 環境光 Light behind the object オブジェクトの背後のライト + 3D Lighting Preset 3D ライティングプリセット + Standard 標準 + Diffuse 拡散 # 3D light management. Lights ライト一覧 Add Light ライトを追加 @@ -4796,6 +4929,7 @@ lighting Light ライト @msg Measured anchor no longer exists 測定したアンカーポイントはもう存在しません @msg Measured segment no longer exists 測定したセグメントはもう存在しません @msg Non-finite dimension 寸法が有限ではありません +@msg Scale Text Attributes: selected type has no scale to bake テキスト属性を拡大・縮小: 選択したテキストに取り込むスケールがありません @msg Scale must be drawing:real, for example 1:10 縮尺は 図面:実寸 の形式にしてください (例: 1:10) @msg Scale ratio must be between 1:1000000 and 1000000:1 縮尺の比率は 1:1000000 から 1000000:1 の間にしてください @msg Second measured segment no longer exists 2 番目の測定セグメントはもう存在しません @@ -4814,6 +4948,7 @@ lighting Light ライト Make Image Dynamic 画像を動的にする Import Data Sets… データセットを読み込み… Export Data Sets… データセットを書き出し… + Load Variable Library… 変数ライブラリを読み込み… Make Graph Dynamic グラフを動的にする Batch… バッチ… Source Folder: ソースフォルダー: @@ -4837,6 +4972,12 @@ lighting Light ライト Sketch スケッチ Halftone Pattern… ハーフトーンパターン… Halftone Pattern ハーフトーンパターン + Torn Edges… 破れたエッジ… + Torn Edges 破れたエッジ + Conté Crayon… コンテ… + Conté Crayon コンテ + Foreground Level 前景レベル + Background Level 背景レベル Pattern Type パターンタイプ Dot ドット # User Interface › Color Scheme @@ -4855,6 +4996,7 @@ lighting Light ライト Watercolor… 水彩… Watercolor 水彩 Brush Size ブラシサイズ + Texture Coverage テクスチャのかかり具合 Definition 精細度 Brush Detail ブラシの細かさ Shadow Intensity シャドウの強さ @@ -4883,13 +5025,21 @@ lighting Light ライト @msg the image's pixels can't be read 画像のピクセルを読み取れません Persistent 永続 Library Panel Options ライブラリパネルオプション + Locate Library… ライブラリを検索… @msg Persistent libraries not found: {files} 永続ライブラリが見つかりません: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` はライブラリパネルではありません (window.swatchLibrary を参照) @msg library panel `{given}` isn't open ライブラリパネル `{given}` は開いていません +@msg library panel `{given}` isn't missing its library ライブラリパネル `{given}` のライブラリは欠落していません @msg too many library panels were opened in this session このセッションで開いたライブラリパネルが多すぎます @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 画像は {_1} × {_2} ピクセルで、一辺が {LARGE_TRACE_SIDE} を超えています。トレースはできますが、時間がかかります。忠実度の低いプリセットや画像の小さいコピーを使うと速くなります。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 画像は {_3} × {_4} から縮小した {_1} × {_2} ピクセルでトレースされました (最大 {_5} メガピクセル)。 + Snapping スナップ + Snap to Tangent 接線にスナップ + Snap to Perpendicular 垂線にスナップ +@msg unknown snap option `{key}` (use {options}) 不明なスナップオプション `{key}` ({options} を使用してください) + Gap Length: ギャップの長さ: Artboard {n} of {total} アートボード {n} / {total} + File {n} of {total} ファイル {n} / {total} Encoding and writing the file… ファイルをエンコードして書き込み中… @plural {n} more export waiting|{n} more exports waiting 待機中の書き出し: あと {n} 件 Continue in Background バックグラウンドで続行 @@ -4906,6 +5056,14 @@ lighting Light ライト Minimum must not exceed Desired, nor Desired Maximum. 最小値は最適値を、最適値は最大値を超えることはできません。 Minimum 最小 Justification 文字組み + Add filters from the list above 上のリストからフィルターを追加 + Add to Stack スタックに追加 + Applied Filters 適用したフィルター + Categories カテゴリ + Choose a filter フィルターを選択 + Effect Gallery 効果ギャラリー + Filters フィルター + Select artwork to apply effects 効果を適用するグラフィックを選択してください @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them ファイルの変数とデータセットは読み込まれませんでした (変数: {variables}、データセット: {data_sets})。変数パネルには表示されません # Select by Color (select.color) Select by Color カラーで選択 @@ -4927,3 +5085,129 @@ lighting Light ライト Plaster 石膏 Note Paper… ノート用紙… Note Paper ノート用紙 + Water Paper… ぬれた紙… + Water Paper ぬれた紙 + Japanese Postcard はがき + Japanese Double Postcard 往復はがき + Japanese Envelope #3 長形3号 + Japanese Envelope #4 長形4号 + Kaku #2 角形2号 + Kaku #3 角形3号 + Yonroku-ban 四六判 + Ruby… ルビ… + Ruby ルビ + Geometric Guides 幾何ガイド + Ease In イーズイン + Ease Out イーズアウト + Ease In Out イーズインアウト + Ease Out In イーズアウトイン + Ramp: 強さ: + Shift: シフト: + Fresco… フレスコ… + Fresco フレスコ + +# Keyboard Shortcuts editor (File › Keyboard Shortcuts…) and the Print dialog's overprint note. + Keyboard Shortcuts キーボードショートカット + Set: セット: + Reset to Defaults 初期設定に戻す + All shortcuts reset to defaults. すべてのショートカットを初期設定に戻しました。 + Command コマンド + Tool ツール + Press keys… キーを押してください… + Click, then press the new shortcut (Esc cancels) クリックしてから、新しいショートカットを押します (Esc でキャンセル) + Go to Conflict 競合箇所へ移動 + Remove the shortcut ショートカットを削除 + Use Default 初期設定を使用 + ⚠ {chord} was already used by {names} — it has been removed there. ⚠ {chord} は {names} で使用済みのため、そちらから削除しました。 + Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it). シミュレートでは、オーバープリントプレビューと同じ見え方でオーバープリントを印刷します。分版では常にオーバープリントが保持されます。プリセットを選ぶと、印刷前に透明が分割・統合されます (PostScript ファイルでは常に分割・統合されます)。 + Effect Gallery… 効果ギャラリー… + Effect Gallery Dialog 効果ギャラリーダイアログ + Arc Segment Tool Options 円弧セグメントツールオプション + Base Along: 基準方向: + Closed 閉じる + Concave 凹 + Convex 凸 + Fill Arc 円弧を塗りつぶす + Length X-Axis: X 軸の長さ: + Length Y-Axis: Y 軸の長さ: + Slope: 勾配: +axis X Axis X 軸 +axis Y Axis Y 軸 +@msg Packaged {n} file(s) in {folder} {n} 個のファイルを {folder} にパッケージしました +@msg Packaged {n} file(s) {note} {n} 個のファイルをパッケージしました {note} +@msg Packaged {n} file(s) {n} 個のファイルをパッケージしました + Packaged {n} file(s) {n} 個のファイルをパッケージしました + Packaged {n} file(s) in {folder} {n} 個のファイルを {folder} にパッケージしました + Packaged {n} file(s) {note} {n} 個のファイルをパッケージしました {note} + {count} file(s) in {folder}. Show the package folder? {folder} に {count} 個のファイル。パッケージフォルダーを表示しますか? + Clockwise 時計回り + Dither ディザ + Fine noise hides banding in the gradient fill 細かいノイズでグラデーションの塗りの縞を目立たなくします +@msg `dither` applies to gradient fills (stroke: false) `dither` はグラデーションの塗りに適用されます (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` はグラデーションの塗りに適用されます (paint.setFill) +@msg `dither` must be true or false `dither` には true または false を指定してください + Perceptual 知覚的 +@msg `method` must be classic or perceptual `method` には classic または perceptual を指定してください +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? 大きな計算予算を求める「{name}」をインストールしますか? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 1 回の実行に最大 {minutes} 分かかり、ほかのプラグインに許される {times} 倍の処理を行うことがあります。バックグラウンドで実行され、キャンセルできます。 + It may take minutes, so it runs in the background, where you can cancel it. 数分かかることがあるため、バックグラウンドで実行されます。実行中はキャンセルできます。 + The plug-in is computing… プラグインが計算中… + Inspect Plug-in プラグインを検査 + Cancel Plug-in Run プラグインの実行をキャンセル +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} は完了しましたが、その間にドキュメントまたはプラグインが変更されました。もう一度実行してください +@msg Running “{name}”… 「{name}」を実行中… +@msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません +@msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください +# Actions panel: action sets (#3712). + New Set… 新規セット… + New Set 新規セット + Set Options… セットオプション… + Set Options セットオプション + Create New Set 新規セットを作成 + Stop Global Edit グローバル編集を終了 + Global Edit Options グローバル編集オプション + Match 一致 + On Artboards アートボード上 + All Artboards すべてのアートボード + Include Objects On Canvas カンバス上のオブジェクトを含める + Result(s): {count} 結果: {count} +@msg select one object オブジェクトを 1 つ選択してください +@msg Global Edit works on paths, compound paths and symbol instances グローバル編集はパス、複合パス、シンボルインスタンスに使用できます +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 類似するオブジェクトが見つかりません(回転または反転したコピーは一致しません)。グローバル編集オプションを変更してもう一度試してください +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all または 1, 3-5 のような範囲を指定してください ({v} ではなく) + Add to Symbols シンボルに追加 + Basic Shapes 基本シェイプ + Open Symbol Library シンボルライブラリを開く + Save Symbol Library シンボルライブラリを保存 + Save Symbol Library… シンボルライブラリを保存… + Save Symbols… シンボルを保存… + Selected Symbols Only 選択したシンボルのみ + Symbol Libraries Menu シンボルライブラリメニュー + Symbol Library シンボルライブラリ + User Symbol Library 1 ユーザーシンボルライブラリ 1 + User Symbol Library 2 ユーザーシンボルライブラリ 2 + User Symbol Library 3 ユーザーシンボルライブラリ 3 + User Symbol Library 4 ユーザーシンボルライブラリ 4 + User Symbol Library 5 ユーザーシンボルライブラリ 5 + User Symbol Library 6 ユーザーシンボルライブラリ 6 + User Symbol Library 7 ユーザーシンボルライブラリ 7 + User Symbol Library 8 ユーザーシンボルライブラリ 8 + User Symbol Library 9 ユーザーシンボルライブラリ 9 + User Symbol Library 10 ユーザーシンボルライブラリ 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` に配置できるシンボルがありません +@msg missing `library` (see symbol.libraries) `library` がありません (symbol.libraries を参照) +@msg no symbol `{n}` シンボル `{n}` がありません +@msg no symbol `{n}` in `{_1}` `{_1}` にシンボル `{n}` がありません +@msg no symbol library `{key}` (see symbol.libraries) シンボルライブラリ `{key}` がありません (symbol.libraries を参照) +@msg the document has no symbols to save ドキュメントに保存するシンボルがありません +@msg Trap: select two or more objects filled with solid colours トラップ: 単色で塗りつぶした 2 つ以上のオブジェクトを選択してください +@msg Trap: no edges of different colours meet among the selected objects トラップ: 選択したオブジェクトに異なる色が接する辺がありません +@msg thickness must be from 0.01 to 5000 pt thickness は 0.01~5000 pt にしてください +@msg heightWidth must be from 1 to 1000 % heightWidth は 1~1000 % にしてください +@msg tintReduction must be from 0 to 100 % tintReduction は 0~100 % にしてください + Rectangular Grid Tool Options 長方形グリッドツールオプション + Polar Grid Tool Options 同心円グリッドツールオプション + Fill Line 線を塗りつぶす diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index ba0da0046..f41eb3993 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -27,8 +27,14 @@ Medium Light საშუალოდ ღია Light ღია Quit VectorCraft VectorCraft-იდან გასვლა +# Artwork drag between document tabs. + Drop Artwork ნამუშევრის განთავსება +@msg {key} must be [x, y] with finite coordinates {key} უნდა იყოს [x, y] სასრული კოორდინატებით +@msg ids must contain 1–10000 object ids ids უნდა შეიცავდეს 1–10000 ობიექტის იდენტიფიკატორს + File ფაილი New… ახალი… + New Without Dialog ახალი დიალოგის გარეშე New from Template… შაბლონიდან შექმნა… Open… გახსნა… Open Recent Files ბოლოს გახსნილი ფაილების გახსნა @@ -237,6 +243,13 @@ Glyphs გლიფები Insert Special Character სპეციალური სიმბოლოს ჩასმა Bullet ბურთულა + Bullets ბურთულები + Bullets and Numbering ბურთულები და დანომრვა + Apply Bullets ბურთულების გამოყენება + Filled Dot სავსე წერტილი + Dash ტირე + Hollow Circle ცარიელი წრე + Filled Square სავსე კვადრატი Copyright Symbol საავტორო უფლების სიმბოლო Ellipsis მრავალწერტილი Paragraph Symbol აბზაცის სიმბოლო @@ -652,7 +665,7 @@ Units ერთეულები Guides & Grid დამხმარე ხაზები და ბადე Hyphenation სიტყვების გადატანა - Performance & Storage წარმადობა და მეხსიერება + Plug-ins & Scratch Disks პლაგინები & სამუშაო დისკები User Interface მომხმარებლის ინტერფეისი Performance წარმადობა File Handling ფაილებთან მუშაობა @@ -702,6 +715,7 @@ Activate Panel პანელის გააქტიურება Change Panel Layout პანელების განლაგების შეცვლა Close Panel პანელის დახურვა + Collapse Panel Group პანელების ჯგუფის აკეცვა Move Panel პანელის გადაადგილება Group with ერთად დაჯგუფება Resize panels პანელების ზომის შეცვლა @@ -1225,6 +1239,7 @@ lighting Light სინათლე Leading სტრიქონთა დაშორება No Break გადატანის გარეშე Proportional Metrics პროპორციული მეტრიკა + Character Direction სიმბოლოების მიმართულება Digits ციფრები Arabic არაბული Hindi ჰინდი @@ -1292,6 +1307,7 @@ lighting Light სინათლე {count} more rules: Export writes every one. კიდევ წესები: {count}. ექსპორტი ყველას ჩაწერს. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. გამოტოვებულია უსახელო ობიექტები: {count}. დაარქვით მათ სახელი ან პანელის მენიუში ჩართეთ „უსახელო ობიექტები“. All Categories ყველა კატეგორია + Repeat Objects გამეორების ობიექტები Save… შენახვა… Selection Only მხოლოდ მონიშნული {title} (selection) {title} (მონიშნული) @@ -1325,6 +1341,7 @@ lighting Light სინათლე Edit the selected point's colour in the Color panel მონიშნული წერტილის ფერის რედაქტირება ფერის პანელში Edit the stop გადასვლის წერტილის რედაქტირება Eyedropper: click the art to sample the stop's colour პიპეტი: დაწკაპეთ ნახატზე გადასვლის წერტილის ფერის ასაღებად + Eyedropper: click the art to sample the key color პიპეტი: დაწკაპეთ ნახატზე საკვანძო ფერის ასაღებად Gradient across stroke გრადიენტი კონტურის განივად Gradient along stroke გრადიენტი კონტურის სიგრძეზე Gradient Fill: click to apply, drag onto art გრადიენტული შევსება: დაწკაპეთ გამოსაყენებლად, ჩაავლეთ ნახატზე @@ -1474,6 +1491,9 @@ lighting Light სინათლე Left Indent მარცხენა შეწევა Left-to-Right Paragraph Direction აბზაცის მიმართულება მარცხნიდან მარჯვნივ Line-end Punctuation Half Width სტრიქონის ბოლოს პუნქტუაცია ნახევარი სიგანით + Insert Kashidas ქაშიდების ჩასმა + Long გრძელი + Stylistic სტილისტური Mojikumi Set Mojikumi-ის ნაკრები Kinsoku Set Kinsoku-ის ნაკრები Hard მკაცრი @@ -1497,6 +1517,13 @@ lighting Light სინათლე Repeat Pathfinder ტრაექტორიების ოპერაციის გამეორება Repeat {name} გამეორება: {name} Trap… ტრეპინგი… + Trap ტრეპინგი + Height/Width სიმაღლე/სიგანე + Tint Reduction ტონის შემცირება + Traps with Process Color ტრეპინგი პროცესული ფერებით + Reverse Traps შებრუნებული ტრეპინგი + Precision სიზუსტე + Remove Redundant Points ზედმეტი წერტილების წაშლა Bottom in Front ქვედა წინ Brick by Column აგური სვეტებით Brick by Row აგური სტრიქონებით @@ -1622,8 +1649,8 @@ lighting Light სინათლე {kind}, {model} {kind}, {model} No symbols in this document ამ დოკუმენტში სიმბოლოები არ არის Select art and click New Symbol to make one. მონიშნეთ ნახატი და შესაქმნელად დაწკაპეთ „ახალ სიმბოლოზე“. - Symbol Libraries (on the roadmap) სიმბოლოების ბიბლიოთეკები (სამომავლო გეგმაშია) Align On: სწორება მიმართ: + Bottom-Justified Tab ქვემოთ სწორებული ტაბულაცია Center-Justified Tab ცენტრით სწორებული ტაბულაცია Above Text ტექსტის ზემოთ Position Panel Above Text პანელის ტექსტის ზემოთ განთავსება @@ -1635,6 +1662,7 @@ lighting Light სინათლე No text selected ტექსტი მონიშნული არ არის Repeat Tab ტაბულაციის გამეორება Right-Justified Tab მარჯვნივ სწორებული ტაბულაცია + Top-Justified Tab ზემოთ სწორებული ტაბულაცია Select a text object to set its tab stops. ტაბულაციის გაჩერებების დასაყენებლად მონიშნეთ ტექსტური ობიექტი. Select text მონიშნეთ ტექსტი Snap to Unit ერთეულზე მიბმა @@ -1749,6 +1777,13 @@ lighting Light სინათლე Tints and Shades ტონირებები და ჩრდილები Hue Shift ფერის ტონის წანაცვლება Key Color: საკვანძო ფერი: + Tips რჩევები + Colorization Tips შეფერადების რჩევები + The samples show this brush on a blue stroke. ნიმუშები ამ ფუნჯს ლურჯ კონტურზე აჩვენებს. + The art keeps its own colors. ნახატი საკუთარ ფერებს ინარჩუნებს. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. შავი კონტურის ფერი ხდება, თეთრი კი თეთრად რჩება. სხვა ფერები კონტურის ფერის ტონირებები ხდება და რაც უფრო ღიაა ფერი, მით უფრო ღიაა მისი ტონირება. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. შავი შავად რჩება, თეთრი კი თეთრად რჩება. სხვა ფერები კონტურის ფერის ჩრდილები ხდება, თუ მუქია, და მისი ტონირებები, თუ ღიაა. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. საკვანძო ფერი კონტურის ფერი ხდება. სხვა ფერები ფერთა წრეზე იმდენივეს გადაინაცვლებს, რამდენსაც საკვანძო ფერი, და ინარჩუნებს გაჯერებასა და სიკაშკაშეს. Size: ზომა: Calligraphic Brush Options კალიგრაფიული ფუნჯის პარამეტრები Bristle Brush Options ჯაგრის ფუნჯის პარამეტრები @@ -2057,11 +2092,11 @@ lighting Light სინათლე Fonts whose licence doesn't allow embedding are not copied. შრიფტები, რომელთა ლიცენზია ჩაშენების უფლებას არ იძლევა, არ კოპირდება. Package collects a saved document with its linked files and fonts. შეფუთვა აგროვებს შენახულ დოკუმენტს მის დაკავშირებულ ფაილებთან და შრიფტებთან ერთად. Package created შეფუთვა შეიქმნა - Packaged {n} file(s) in {folder}{note} შეიფუთა ფაილები: {n}, ადგილი: {folder}{note} + Packaged {n} file(s) in {folder} {note} შეიფუთა ფაილები: {n}, ადგილი: {folder}{note} Packaged {n} file(s){note} შეიფუთა ფაილები: {n}{note} Relink Linked Files to the Document დაკავშირებული ფაილების დოკუმენტთან ხელახლა დაკავშირება Save the document first? ჯერ შევინახოთ დოკუმენტი? - {count} file(s) in {folder}{note}. Show the package folder? ფაილები: {count}, ადგილი: {folder}{note}. ვაჩვენოთ შეფუთვის საქაღალდე? + {count} file(s) in {folder} {note}. Show the package folder? ფაილები: {count}, ადგილი: {folder}{note}. ვაჩვენოთ შეფუთვის საქაღალდე? {count} linked file(s) not found ვერ მოიძებნა დაკავშირებული ფაილები: {count} fonts not copied: {fonts} არ დაკოპირდა შრიფტები: {fonts} Built-in presets are read-only: New… starts an editable copy. ჩაშენებული პრესეტები მხოლოდ წასაკითხია: „ახალი…“ ქმნის რედაქტირებად ასლს. @@ -2108,6 +2143,11 @@ lighting Light სინათლე File Dimensions ფაილის ზომები Transforms გარდაქმნები Background Color: ფონის ფერი: + Show Center Mark ცენტრის ნიშნის ჩვენება + Show Cross Hairs ჯვარედინი ხაზების ჩვენება + Show Video Safe Areas ვიდეოს უსაფრთხო არეების ჩვენება + Video Ruler Pixel Aspect Ratio: ვიდეოს სახაზავების პიქსელის თანაფარდობა: + Hide Video Rulers ვიდეოს სახაზავების დამალვა Client-side (.html) კლიენტის მხარე (.html) Color Reduction: ფერების შემცირება: Lossless (lossy WebP isn't available yet) უდანაკარგო (დანაკარგიანი WebP ჯერ მიუწვდომელია) @@ -2478,12 +2518,21 @@ lighting Light სინათლე Darken ჩამუქება Exclusion გამორიცხვა Hard Light მკვეთრი სინათლე + Hard Mix მკაცრი შერევა Lighten გაღიავება Luminosity ნათება Overlay გადაფარვა Soft Light რბილი სინათლე + Soft Mix რბილი შერევა Crosshatch ჯვარედინი დაშტრიხვა Fine Dots წვრილი წერტილები + + Simple მარტივი + Light Rough ღია ხასიათიანი + Dark Rough მუქი ხასიათიანი + Wide Sharp ფართო მკვეთრი + Wide Blurry ფართო ბუნდოვანი + Sparkle ციმციმი Medium Dots საშუალო წერტილები Grainy Dots მარცვლოვანი წერტილები Coarse Dots მსხვილი წერტილები @@ -2555,6 +2604,14 @@ theme Appearance იერსახე Edit Symbol სიმბოლოს რედაქტირება Expand Brush Strokes ფუნჯის შტრიხების გაშლა New Brush… ახალი ფუნჯი… + New Brush ახალი ფუნჯი + Calligraphic Brush კალიგრაფიული ფუნჯი + Scatter Brush გაფანტვის ფუნჯი + Art Brush მხატვრული ფუნჯი + Bristle Brush ჯაგრის ფუნჯი + Pattern Brush პატერნის ფუნჯი + Select art to make a scatter, art or pattern brush. მონიშნეთ ნახატი გაფანტვის, მხატვრული ან პატერნის ფუნჯის შესაქმნელად. + Brush Art ფუნჯის ნახატი New Symbol ახალი სიმბოლო New Symbol… ახალი სიმბოლო… Place Symbol Instance სიმბოლოს ეგზემპლარის განთავსება @@ -3013,6 +3070,7 @@ theme Appearance იერსახე Scale Corners კუთხეების მასშტაბირება Scale Cursor Proportional to UI კურსორის მასშტაბირება ინტერფეისის პროპორციულად Scale Strokes & Effects კონტურებისა და ეფექტების მასშტაბირება + Scale Text Attributes ტექსტის ატრიბუტების მასშტაბირება Scratch Disks სამუშაო დისკები Secondary მეორადი Select Same Tint % იგივე ტონირების % მონიშვნა @@ -3028,6 +3086,7 @@ theme Appearance იერსახე Size/Leading ზომა/სტრიქონთა დაშორება Snapping Tolerance მიბმის დაშვება Spacing Guides შუალედის დამხმარე ხაზები + Invoke Distance Guides მანძილის დამხმარე ხაზების გამოძახება Spanish ესპანური Subdivisions ქვედანაყოფები Templates Folder შაბლონების საქაღალდე @@ -3107,6 +3166,7 @@ theme Appearance იერსახე Delete Swatch ფერის ნიმუშის წაშლა Duplicate Swatch ფერის ნიმუშის დუბლირება Merge Swatches ფერის ნიმუშების შერწყმა + Move Brush ფუნჯის გადატანა Move Swatch ფერის ნიმუშის გადატანა New Color Group ფერების ახალი ჯგუფი New Swatch ფერის ახალი ნიმუში @@ -3303,6 +3363,8 @@ effect Regular ჩვეულებრივი Put back in the dock დოკში დაბრუნება All Scripts ყველა დამწერლობა Any Kind ნებისმიერი ტიპი + Font Technology შრიფტის ტექნოლოგია +font-technology Variable ცვლადი Decorative დეკორატიული Favorites რჩეულები Latin ლათინური @@ -3546,6 +3608,7 @@ effect Regular ჩვეულებრივი @msg Couldn't copy to the system clipboard: {e} სისტემის გაცვლის ბუფერში კოპირება ვერ მოხერხდა: {e} @msg Couldn't open {name}: {e} {name} ვერ გაიხსნა: {e} @msg Couldn't place {name}: the document it was dropped on was closed {name} ვერ განთავსდა: დოკუმენტი, რომელზეც ის ჩამოაგდეს, დაიხურა +@msg Couldn't place {name}: the document it was placed in was closed {name} ვერ განთავსდა: დოკუმენტი, რომელშიც ის თავსდებოდა, დაიხურა @msg Couldn't read {name}: {e} {name} ვერ წაიკითხა: {e} @msg Couldn't save recovery data: {e} აღდგენის მონაცემები ვერ შეინახა: {e} @msg Create Outlines: select text objects კონტურების შექმნა: მონიშნეთ ტექსტური ობიექტები @@ -3581,8 +3644,9 @@ effect Regular ჩვეულებრივი @msg Opened {url} გაიხსნა {url} @msg Outline Stroke: no stroked paths selected კონტურის ფიგურად გარდაქმნა: კონტურიანი ტრაექტორიები მონიშნული არ არის @msg PDF settings are not an object PDF-ის პარამეტრები ობიექტი არ არის +@msg PDF security settings are not an object PDF-ის უსაფრთხოების პარამეტრები ობიექტი არ არის @msg PDF writer error: {0} PDF-ის ჩამწერის შეცდომა: {0} -@msg Packaged {n} file(s) in {folder}{note} შეფუთულია ფაილი ({n}) საქაღალდეში {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} შეფუთულია ფაილი ({n}) საქაღალდეში {folder}{note} @msg Packaged {n} file(s){note} შეფუთულია ფაილი ({n}){note} @msg Preview in Browser needs the desktop app (the web downloads files instead) ბრაუზერში წინასწარ ნახვას დესკტოპის აპი სჭირდება (ვებში ფაილები ჩამოიტვირთება) @msg Release Guides: no guides დამხმარე ხაზების მოხსნა: დამხმარე ხაზები არ არის @@ -3647,6 +3711,25 @@ effect Regular ჩვეულებრივი @msg a PostScript error ({name} in `{op}`: {at}) PostScript-ის შეცდომა ({name} `{op}`-ში: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript-ის შეცდომა ({name} `{op}`-ში: {at}), {procs}-ში @msg a PostScript error ({name}) PostScript-ის შეცდომა ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) PostScript-ის შეცდომა ({name} `{op}`-ში; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} PostScript-ის შეცდომა ({name} `{op}`-ში; {meaning}), {procs}-ში +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript-ის შეცდომა ({name} `{op}`-ში: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} PostScript-ის შეცდომა ({name} `{op}`-ში: {at}; {meaning}), {procs}-ში +@msg a PostScript error ({name}; {meaning}) PostScript-ის შეცდომა ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin პროგრამა ასრულებს ლექსიკონს, რომელიც მას არ დაუწყია +@msg the program opens a file, which the reader can't do პროგრამა ფაილს ხსნის, რაც PostScript-ის წამკითხველს არ შეუძლია +@msg the program restores a saved state that was already restored პროგრამა აღადგენს შენახულ მდგომარეობას, რომელიც უკვე აღდგენილია +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none მონაცემები, რომლებსაც პროგრამა კითხულობს, დაზიანებული ან შეწყვეტილია, ან პროგრამა ითხოვს პოზიციას ფაილში, რომელსაც პოზიცია არ აქვს +@msg the program goes past one of the reader's limits პროგრამა PostScript-ის წამკითხველის ერთ-ერთ ზღვარს სცდება +@msg the path has no point to continue from ტრაექტორიას არ აქვს წერტილი, საიდანაც გაგრძელდება +@msg a value is outside the range the operator takes მნიშვნელობა ოპერატორის მისაღებ დიაპაზონს სცდება +@msg the operator needs more values than the stack holds ოპერატორს სტეკში არსებულზე მეტი მნიშვნელობა სჭირდება +@msg part of the program isn't valid PostScript text პროგრამის ნაწილი სწორი PostScript ტექსტი არ არის +@msg a value has the wrong type for the operator მნიშვნელობას ოპერატორისთვის არასწორი ტიპი აქვს +@msg the program runs, deletes or renames a file, which the reader can't do პროგრამა ფაილს უშვებს, შლის ან გადაარქმევს, რაც PostScript-ის წამკითხველს არ შეუძლია +@msg the program asks for a resource that isn't defined პროგრამა ითხოვს რესურსს, რომელიც განსაზღვრული არ არის +@msg a calculation has no result, as in a division by zero გამოთვლას შედეგი არ აქვს, მაგალითად ნულზე გაყოფისას +@msg the operator looks for a mark on the stack and finds none ოპერატორი სტეკზე ნიშნულს ეძებს და ვერ პოულობს @msg a bitmap is empty ბიტმაპი ცარიელია @msg a bitmap is too large to read ბიტმაპი წასაკითხად ზედმეტად დიდია @msg a colour index is 0 to {_1}, not {n} ფერის ინდექსი არის 0-დან {_1}-მდე და არა {n} @@ -3662,6 +3745,7 @@ effect Regular ჩვეულებრივი @msg a page tree node has no kids გვერდების ხის კვანძს შვილობილები არ აქვს @msg a page's placement is not finite გვერდის განლაგება სასრული არ არის @msg a preset named `{_1}` exists პრესეტი სახელით `{_1}` არსებობს +@msg a swatch or color group named `{n}` exists ფერის ნიმუში ან ფერების ჯგუფი სახელით `{n}` არსებობს @msg a slice size must be a positive number of points ნაჭრის ზომა წერტილების დადებითი რიცხვი უნდა იყოს @msg a stream runs past the end ნაკადი ბოლოს სცილდება @msg a stream's length is wrong ნაკადის სიგრძე არასწორია @@ -3676,6 +3760,7 @@ effect Regular ჩვეულებრივი @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis უნდა იყოს "vertical", "horizontal" ან კუთხე გრადუსებში და არა `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" ფონი {other}: transparent, white, black ან ფერი, მაგალითად "#ff8800" @msg bad color {v} არასწორი ფერი {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio უნდა იყოს რიცხვი 0,1-დან 10-მდე @msg bad colour `{hex}` (#rrggbb) არასწორი ფერი `{hex}` (#rrggbb) @msg bad page tree გვერდების არასწორი ხე @msg bad shortcut არასწორი მალსახმობი @@ -3719,7 +3804,7 @@ effect Regular ჩვეულებრივი @msg give horizontal or vertical მიუთითეთ horizontal ან vertical @msg give the path `id` and the width point `index` მიუთითეთ ტრაექტორიის `id` და სიგანის წერტილის `index` @msg guides are locked დამხმარე ხაზები ჩაკეტილია -@msg image '{key}' has no pixels and was left out გამოსახულებას '{key}' პიქსელები არ აქვს და გამოტოვდა +@msg an image has no pixels and was left out გამოსახულება, რომელსაც პიქსელები არ აქვს, გამოტოვდა @msg image data `{_1}` is missing გამოსახულების მონაცემები `{_1}` აკლია @msg image would be too large; lower the resolution გამოსახულება ზედმეტად დიდი იქნებოდა; შეამცირეთ გარჩევადობა @msg index must be a number or first, previous, next or last index უნდა იყოს რიცხვი ან first, previous, next ან last @@ -3743,7 +3828,7 @@ effect Regular ჩვეულებრივი @msg missing `name` აკლია `name` @msg missing `tool` აკლია `tool` @msg missing index ინდექსი აკლია -@msg multiple windows land with M11.5 რამდენიმე ფანჯარა M11.5-ში დაემატება +@msg New Window isn't available yet: a document opens in one window ახალი ფანჯარა ჯერ მიუწვდომელია: დოკუმენტი ერთ ფანჯარაში იხსნება @msg name the file მიუთითეთ ფაილის სახელი @msg no CSS to copy: select objects with CSS of their own კოპირებისთვის CSS არ არის: მონიშნეთ საკუთარი CSS-ის მქონე ობიექტები @msg no Live Paint edge near that point ამ წერტილთან ცოცხალი შეღებვის კიდე არ არის @@ -3761,7 +3846,9 @@ effect Regular ჩვეულებრივი @msg no blend options გადასვლის პარამეტრები არ არის @msg no bounds საზღვრები არ არის @msg no brush named `{name}` ფუნჯი სახელით `{name}` არ არის +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} ჯაგრის ფუნჯის სახელი შეიძლება შეიცავდეს არაუმეტეს {max} სიმბოლოს: `{name}` შეიცავს {count} სიმბოლოს @msg no brush selected ფუნჯი მონიშნული არ არის +@msg brushes move only within their type ფუნჯების გადატანა მხოლოდ საკუთარი ტიპის ფარგლებში შეიძლება @msg no catalog კატალოგი არ არის @msg no changes since the last save ბოლო შენახვის შემდეგ ცვლილებები არ არის @msg no clock to keep a heartbeat by პულსის შესანარჩუნებელი საათი არ არის @@ -3780,6 +3867,7 @@ effect Regular ჩვეულებრივი @msg no file system here აქ ფაილური სისტემა არ არის @msg no file writer ფაილის ჩამწერი არ არის @msg no files to place განსათავსებელი ფაილები არ არის +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` არ არის VectorCraft-ის დოკუმენტი, PDF ან PDF-თან თავსებადი .ai ფაილი @msg no format to export to ექსპორტის ფორმატი არ არის @msg no free recovery area აღდგენის თავისუფალი არე არ არის @msg no gradient stop or freeform point is selected გრადიენტის გადასვლის წერტილი ან თავისუფალი წერტილი მონიშნული არ არის @@ -3814,6 +3902,7 @@ effect Regular ჩვეულებრივი @msg no such action ასეთი მოქმედება არ არის @msg no such anchor ასეთი საყრდენი წერტილი არ არის @msg no such artboard ასეთი სამუშაო დაფა არ არის +@msg the artboard is locked სამუშაო დაფა ჩაკეტილია @msg no such document ასეთი დოკუმენტი არ არსებობს @msg no such layer or object ასეთი შრე ან ობიექტი არ არის @msg no such object {0} ასეთი ობიექტი {0} არ არის @@ -3864,6 +3953,7 @@ effect Regular ჩვეულებრივი @msg path is already closed ტრაექტორია უკვე შეკრულია @msg paths contain invalid coordinates ტრაექტორიებში არასწორი კოორდინატებია @msg paths: give at least one file paths: მიუთითეთ ერთი ფაილი მაინც +@msg replace: the object to replace was changed or deleted replace: ჩასანაცვლებელი ობიექტი შეიცვალა ან წაიშალა @msg pattern missing პატერნი აკლია @msg patterns nested too deeply are filled with mid-grey ზედმეტად ღრმად ჩადგმული პატერნები საშუალო ნაცრისფრით ივსება @msg percent must be above 0 and at most 6400, not {p} პროცენტი უნდა იყოს 0-ზე მეტი და არაუმეტეს 6400-ისა და არა {p} @@ -4018,6 +4108,7 @@ effect Regular ჩვეულებრივი @msg {_1} font families available ხელმისაწვდომი შრიფტების ოჯახები: {_1} @msg {_1} is a live effect (Effect › Plug-ins) {_1} ცოცხალი ეფექტია (ეფექტი › პლაგინები) @msg {_1} is an exporter (File › Export › Export As…) {_1} ექსპორტის პლაგინია (ფაილი › ექსპორტირება › ექსპორტირება ფორმატში…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} ბაიტია (ლიმიტი {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} უკვე გამოიყენება აქ: {_2} @msg {_1} linked file(s) can't be read here: their previews show მიბმული ფაილების წაკითხვა აქ ვერ ხერხდება ({_1}): ჩანს მათი გადახედვა @msg {_1} linked image(s) changed on disk: Update Links shows the new versions მიბმული გამოსახულებები შეიცვალა დისკზე ({_1}): ბმულების განახლება აჩვენებს ახალ ვერსიებს @@ -4045,6 +4136,8 @@ effect Regular ჩვეულებრივი @msg Saving შენახვა @msg Saving {_1} {_1}-ის შენახვა @msg Exporting {_1} ექსპორტირდება: {_1} +@msg Packaging {_1} იფუთება: {_1} +@msg Placing {_1} თავსდება: {_1} @msg Saving recovery data for {_1} {_1}-ის აღდგენის მონაცემების შენახვა @msg Discarded {_1} recovered document(s) უარყოფილია აღდგენილი დოკუმენტები: {_1} @msg Restored {_1} recovered document(s): save them to keep them აღდგენილია დოკუმენტები: {_1}: შესანარჩუნებლად შეინახეთ ისინი @@ -4122,7 +4215,9 @@ effect Regular ჩვეულებრივი @msg automation filesystem access is not granted: read authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: წაკითხვის უფლება არ არის: {path} @msg automation filesystem access is not granted: write authority is absent: {path} ავტომატიზაციას ფაილურ სისტემაზე წვდომა არ აქვს: ჩაწერის უფლება არ არის: {path} @msg automation path rejected: outside the read root {root}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირის {root} გარეთაა: {path} +@msg automation path rejected: outside the read roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: წაკითხვის ძირების {roots} გარეთაა: {path} @msg automation path rejected: outside the write root {root}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირის {root} გარეთაა: {path} +@msg automation path rejected: outside the write roots {roots}: {path} ავტომატიზაციის გზა უარყოფილია: ჩაწერის ძირების {roots} გარეთაა: {path} @msg automation path rejected: path is empty ავტომატიზაციის გზა უარყოფილია: გზა ცარიელია @msg automation path rejected: a link that leads nowhere: {path} ავტომატიზაციის გზა უარყოფილია: ბმული არსად მიდის: {path} @msg automation path rejected: cannot resolve {path}: {e} ავტომატიზაციის გზა უარყოფილია: {path} ვერ გაირკვა: {e} @@ -4145,6 +4240,12 @@ effect Regular ჩვეულებრივი @msg before must name a panel before უნდა ასახელებდეს პანელს @msg docking action contains an unknown panel or an excessive split path მიმაგრების მოქმედება შეიცავს უცნობ პანელს ან ზედმეტად გრძელ გაყოფის გზას @msg group must be true or false group უნდა იყოს true ან false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} პანელი მცურავია; მისი ჯგუფი აკეცეთ window.panel.float {panel, collapsed}-ით +@msg panel is not in a dock group პანელი არ არის დოკის ჯგუფში +@msg only a dock group above or below others collapses, not one beside another იკეცება მხოლოდ სხვების ზემოთ ან ქვემოთ მდებარე დოკის ჯგუფი, არა სხვის გვერდით მდებარე +@msg icon must be true or false icon უნდა იყოს true ან false +@msg icon only docks a panel in the icon column, with no anchor icon პანელს მხოლოდ ხატულების სვეტში ამაგრებს, anchor-ის გარეშე +@msg column docks a set as a dock column, with no anchor or icon column ნაკრებს დოკის სვეტად ამაგრებს, anchor-ის ან icon-ის გარეშე @msg move requires a destination panel move-ს სჭირდება სამიზნე პანელი @msg move requires an existing destination panel move-ს სჭირდება არსებული სამიზნე პანელი @msg onto must name a floating panel onto უნდა ასახელებდეს მცურავ პანელს @@ -4196,6 +4297,7 @@ effect Regular ჩვეულებრივი Logo 1000×1000 ლოგო 1000×1000 Business Card 3.5 × 2 in სავიზიტო ბარათი 3.5 × 2 in Business Card 85 × 55 mm სავიზიტო ბარათი 85 × 55 mm + Business Card 91 × 55 mm სავიზიტო ბარათი 91 × 55 mm Letterhead Letter ბლანკი Letter Letterhead A4 ბლანკი A4 Envelope DL კონვერტი DL @@ -4329,7 +4431,7 @@ effect Regular ჩვეულებრივი High Contrast 3 მაღალი კონტრასტი 3 Pentagram პენტაგრამა Perceptual აღქმითი - Relative Colorimetric შეფარდებით კოლორიმეტრული + Relative Colorimetric ფარდობითი კოლორიმეტრიული Absolute Colorimetric აბსოლუტური კოლორიმეტრიული Exact ზუსტი Preserve Tints ტონირებების შენარჩუნება @@ -4692,6 +4794,9 @@ imageTrace Outline კონტური In the File ფაილში Keys კლავიშები Light behind the object სინათლე ობიექტის უკან + 3D Lighting Preset 3D განათების წინასწარი პარამეტრი + Standard სტანდარტული + Diffuse დიფუზური Line {line} სტრიქონი {line} Line {line}: {name}: {status} სტრიქონი {line}: {name}: {status} Needs Attention Only მხოლოდ საყურადღებო @@ -4800,6 +4905,7 @@ imageTrace Outline კონტური @msg Measured anchor no longer exists გაზომილი საყრდენი წერტილი აღარ არსებობს @msg Measured segment no longer exists გაზომილი სეგმენტი აღარ არსებობს @msg Non-finite dimension ზომა სასრული რიცხვი არ არის +@msg Scale Text Attributes: selected type has no scale to bake ტექსტის ატრიბუტების მასშტაბირება: არჩეულ ტექსტს არ აქვს გადასაყვანი მასშტაბი @msg Scale must be drawing:real, for example 1:10 მასშტაბი უნდა იყოს ნახაზი:რეალური, მაგალითად 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 მასშტაბის შეფარდება 1:1000000-სა და 1000000:1-ს შორის უნდა იყოს @msg Second measured segment no longer exists მეორე გაზომილი სეგმენტი აღარ არსებობს @@ -4836,6 +4942,10 @@ imageTrace Outline კონტური Arrange Intertwine Area გადახლართვის არის დალაგება @msg select an Intertwine group მონიშნეთ გადახლართვის ჯგუფი @msg select an Objects on Path group მონიშნეთ ტრაექტორიაზე ობიექტების ჯგუფი +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects შედგენილ ტრაექტორიაში, გადასვლაში, გარსში, გამეორებაში ან გადახლართვის ჯგუფში მყოფ ტრაექტორიაზე ობიექტების მიმაგრება ვერ ხერხდება +@msg a clipping path can't take objects მომჭრელ ტრაექტორიაზე ობიექტების მიმაგრება ვერ ხერხდება +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path გადასვლაში, გარსში, გამეორებაში ან გადახლართვის ჯგუფში მყოფი ობიექტების ტრაექტორიაზე მიმაგრება ვერ ხერხდება +@msg a clipping path can't be attached to a path მომჭრელი ტრაექტორიის ტრაექტორიაზე მიმაგრება ვერ ხერხდება Registration: საბაზისო წერტილი: @msg span must be "object" or "selection", got {v} span უნდა იყოს "object" ან "selection", მიღებულია {v} @msg span: "selection" places a `gradient`, not a swatch span: "selection" ათავსებს `gradient`-ს და არა ფერის ნიმუშს @@ -4845,6 +4955,7 @@ imageTrace Outline კონტური Make Image Dynamic გამოსახულების დინამიკურად ქცევა Import Data Sets… მონაცემთა ნაკრებების იმპორტი… Export Data Sets… მონაცემთა ნაკრებების ექსპორტი… + Load Variable Library… ცვლადების ბიბლიოთეკის ჩატვირთვა… Make Graph Dynamic დიაგრამის დინამიკურად ქცევა Batch… პაკეტური დამუშავება… Source Folder: წყარო საქაღალდე: @@ -4888,6 +4999,15 @@ imageTrace Outline კონტური Time (hhmmss) დრო (hhmmss) PDF Print PDF (ბეჭდვა) Merge only with selection შერწყმა მხოლოდ მონიშნულთან + Consider Open Filled Paths as Closed შევსებული ღია ტრაექტორიების შეკრულად მიჩნევა + In Merge Mode, Clicking Stroke Splits the Path შერწყმის რეჟიმში კონტურზე დაწკაპუნება ყოფს ტრაექტორიას + Pick Color From: ფერის წყარო: + Artwork ნახატი + Cursor Swatch Preview ფერის ნიმუშები მაჩვენებელთან + Selection: მონიშვნა: + Straight Line სწორი ხაზი + Highlight: გამოკვეთა: + Stroke When Editable კონტური, როცა რედაქტირებადია Words Longer Than: სიტყვები, რომელთა სიგრძე აღემატება: After First: პირველის შემდეგ: Before Last: ბოლომდე: @@ -4916,6 +5036,12 @@ imageTrace Outline კონტური Sketch ესკიზი Halftone Pattern… ნახევარტონური პატერნი… Halftone Pattern ნახევარტონური პატერნი + Torn Edges… დაგვერძნილი კიდეები… + Torn Edges დაგვერძნილი კიდეები + Conté Crayon… კონტე ფანქარი… + Conté Crayon კონტე ფანქარი + Foreground Level წინა პლანის დონე + Background Level ფონის დონე Pattern Type პატერნის ტიპი Dot წერტილი Color Scheme ფერის სქემა @@ -4947,7 +5073,11 @@ imageTrace Outline კონტური Create Slice from Selection ნაჭრის შექმნა მონიშნულიდან Create Slices from Guides ნაჭრების შექმნა დამხმარე ხაზებიდან Cutout აპლიკაცია + Colored Pencil… ფერადი ფანქარი… + Colored Pencil ფერადი ფანქარი + Dry Brush მშრალი ფერწანი Cutout… აპლიკაცია… + Dry Brush… მშრალი ფერწანი… Delete All Slices ყველა ნაჭრის წაშლა Delete Data Sets მონაცემთა ნაკრებების წაშლა Delete Graphic Styles გრაფიკული სტილების წაშლა @@ -4979,6 +5109,8 @@ imageTrace Outline კონტური Gap Detection ღრეჩოების აღმოჩენა Gap Options ღრეჩოს პარამეტრები Gap Preview Color: ღრეჩოს ნახვის ფერი: + Show Live Paint Gaps ცოცხალი შეღებვის ღრეჩოების ჩვენება + Hide Live Paint Gaps ცოცხალი შეღებვის ღრეჩოების დამალვა Glow Brightness ნათების სიკაშკაშე Glow Size ნათების ზომა Graph Column დიაგრამის სვეტი @@ -4989,6 +5121,7 @@ imageTrace Outline კონტური Last Artboard ბოლო სამუშაო დაფა Levels დონეები Library Panel Options ბიბლიოთეკის პანელის პარამეტრები + Locate Library… ბიბლიოთეკის მოძებნა… Lights სინათლის წყაროები Link JavaScript Files… JavaScript ფაილების დაკავშირება… Live Shape ცოცხალი ფიგურა @@ -5008,7 +5141,11 @@ imageTrace Outline კონტური Move Slice ნაჭრის გადაადგილება Move Type on a Path ტრაექტორიაზე ტექსტის გადაადგილება Neon Glow ნეონის ნათება + Paint Daubs… საღებავის კვეთები… + Paint Daubs საღებავის კვეთები + Palette Knife პალეტის დანა Neon Glow… ნეონის ნათება… + Palette Knife… პალეტის დანა… New Artboard Options… ახალი სამუშაო დაფის პარამეტრები… New Guide ახალი დამხმარე ხაზი Next Artboard შემდეგი სამუშაო დაფა @@ -5016,7 +5153,11 @@ imageTrace Outline კონტური Persistent მუდმივი Place from Library ბიბლიოთეკიდან განთავსება Poster Edges პოსტერის კიდეები + Rough Pastels მკვრივი პასტელი + Underpainting… ქვეფენის მხატვრობა… + Underpainting ქვეფენის მხატვრობა Poster Edges… პოსტერის კიდეები… + Rough Pastels… მკვრივი პასტელი… Posterization პოსტერიზაცია Previous Artboard წინა სამუშაო დაფა Rearrange Artboards სამუშაო დაფების გადალაგება @@ -5094,6 +5235,7 @@ imageTrace Outline კონტური @msg handle must be in or out handle უნდა იყოს in ან out @msg index must be ≥ 0 index უნდა იყოს ≥ 0 @msg library panel `{given}` isn't open ბიბლიოთეკის პანელი `{given}` გახსნილი არ არის +@msg library panel `{given}` isn't missing its library ბიბლიოთეკის პანელს `{given}` ბიბლიოთეკა არ აკლია @msg mask art ends outside a mask dictionary ნიღბის ნახატი ნიღბის ლექსიკონის გარეთ მთავრდება @msg missing number `{key}` აკლია რიცხვი `{key}` @msg missing integer `{key}` აკლია მთელი რიცხვი `{key}` @@ -5109,13 +5251,17 @@ imageTrace Outline კონტური @msg the spine has no segments ხერხემალს სეგმენტები არ აქვს @msg too many library panels were opened in this session ამ სესიაში ზედმეტად ბევრი ბიბლიოთეკის პანელი გაიხსნა @msg variables are locked ცვლადები ჩაკეტილია +@msg variables that aren't text were left out: {kinds} ცვლადები, რომლებიც ტექსტი არ არის, გამოტოვებულია: {kinds} @msg x and y must be finite x და y სასრული უნდა იყოს @msg {id} opens another app: only the person at the keyboard can run it {id} სხვა აპს ხსნის: მისი გაშვება მხოლოდ კლავიატურასთან მყოფ ადამიანს შეუძლია @msg “{name}” is not a file „{name}“ ფაილი არ არის Cancel Export ექსპორტირების გაუქმება +@plural {n} more export waiting|{n} more exports waiting რიგში მომლოდინე სხვა ექსპორტირება: {n} +@msg Export cancelled ექსპორტირება გაუქმდა Clear History ისტორიის გასუფთავება Continue in Background ფონურ რეჟიმში გაგრძელება Artboard {n} of {total} სამუშაო დაფა {n} / {total} + File {n} of {total} ფაილი {n} / {total} Auto Leading: ავტომატური სტრიქონთა დაშორება: Desired სასურველი Encoding and writing the file… ფაილის კოდირება და ჩაწერა… @@ -5140,6 +5286,11 @@ imageTrace Outline კონტური # Select by Color (select.color) Select by Color მონიშვნა ფერის მიხედვით @msg thumbnail: no page {n} (the file has 1) thumbnail: გვერდი {n} არ არსებობს (ფაილს 1 გვერდი აქვს) + Snapping მიბმა + Snap to Tangent მხებზე მიბმა + Snap to Perpendicular პერპენდიკულარზე მიბმა +@msg unknown snap option `{key}` (use {options}) უცნობი მიბმის პარამეტრი `{key}` (გამოიყენეთ: {options}) + Gap Length: ღრეჩოს სიგრძე: Smudge Stick… დასაზელი ჯოხი… Smudge Stick დასაზელი ჯოხი Sponge… ღრუბელი… @@ -5147,6 +5298,7 @@ imageTrace Outline კონტური Watercolor… აკვარელი… Watercolor აკვარელი Brush Size ფუნჯის ზომა + Texture Coverage ტექსტურის დაფარვა Definition განსაზღვრულობა Brush Detail ფუნჯის დეტალი Shadow Intensity ჩრდილის ინტენსივობა @@ -5180,3 +5332,120 @@ imageTrace Outline კონტური Plaster თაბაშირი Note Paper… ჩანაწერების ქაღალდი… Note Paper ჩანაწერების ქაღალდი + Water Paper… სველი ქაღალდი… + Water Paper სველი ქაღალდი + +# Artboard tool hints (#2497) + on the canvas to create one ( ტილოზე — ახლის შესაქმნელად ( + selects several, or draws one inside an artboard) რამდენიმეს მონიშნავს ან ახალს სამუშაო დაფის შიგნით ხატავს) + Ruby… რუბი… + Ruby რუბი + Geometric Guides გეომეტრიული მიმმართველები + Ease In ნელი დასაწყისი + Ease Out ნელი დასასრული + Ease In Out ნელი დასაწყისი და დასასრული + Ease Out In ნელი დასასრული და დასაწყისი + Ramp: სიძლიერე: + Shift: წანაცვლება: + Fresco… ფრესკა… + Fresco ფრესკა + Effect Gallery ეფექტების გალერეა + Add filters from the list above დაამატეთ ფილტრები ზემოთ მოცემული სიიდან + Add to Stack დასტაში დამატება + Applied Filters გამოყენებული ფილტრები + Categories კატეგორიები + Filters ფილტრები + Select artwork to apply effects აირჩიეთ ნამუშევარი ეფექტების გამოსაყენებლად + Effect Gallery… ეფექტების გალერეა… + Effect Gallery Dialog ეფექტების გალერეა დიალოგი + Arc Segment Tool Options რკალის სეგმენტის ხელსაწყოს პარამეტრები + Base Along: საფუძველი გასწვრივ: + Closed დახურული + Convex ამობზარი + Fill Arc რკალის შევსება + Length X-Axis: სიგრძე X ღერძზე: + Length Y-Axis: სიგრძე Y ღერძზე: + Slope: დახრა: +axis X Axis X ღერძი +axis Y Axis Y ღერძი +@msg Packaged {n} file(s) in {folder} შეფუთულია ფაილი ({n}) საქაღალდეში {folder} +@msg Packaged {n} file(s) {note} შეფუთულია ფაილი ({n}) {note} +@msg Packaged {n} file(s) შეფუთულია ფაილი ({n}) + Packaged {n} file(s) შეფუთულია ფაილი ({n}) + Packaged {n} file(s) in {folder} შეფუთულია ფაილი ({n}) საქაღალდეში {folder} + Packaged {n} file(s) {note} შეფუთულია ფაილი ({n}) {note} + {count} file(s) in {folder}. Show the package folder? ფაილები: {count}, ადგილი: {folder}. ვაჩვენოთ შეფუთვის საქაღალდე? + Create PDF layers from top-level layers PDF-ის ფენების შექმნა ზედა დონის ფენებიდან +@msg this kind of brush has no options dialog yet ამ ტიპის ფუნჯს პარამეტრების დიალოგი ჯერ არ აქვს + Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. გადმოათრიეთ აქ გრაფიკა, ფერები და ტექსტის სტილები, რომ სხვა დოკუმენტებშიც გამოიყენოთ. ბიბლიოთეკები ამ კომპიუტერზე ინახება — ანგარიში არ არის საჭირო. + Choose a filter ფილტრის არჩევა + Clockwise საათის ისრის მიმართულებით + Dither დიზერინგი + Fine noise hides banding in the gradient fill წვრილი ხმაური მალავს ზოლებს გრადიენტულ შევსებაში +@msg `dither` applies to gradient fills (stroke: false) `dither` ვრცელდება გრადიენტულ შევსებებზე (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` ვრცელდება გრადიენტულ შევსებებზე (paint.setFill) +@msg `dither` must be true or false `dither` უნდა იყოს true ან false +@msg `method` must be classic or perceptual `method` უნდა იყოს classic ან perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? დავაყენოთ „{name}“, რომელიც დიდ გამოთვლით ბიუჯეტს ითხოვს? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. თითოეულ გაშვებას შეიძლება {minutes} წუთამდე დასჭირდეს და სხვა პლაგინებისთვის დაშვებულზე {times}-ჯერ მეტი სამუშაო შეასრულოს. ის ფონურ რეჟიმში მუშაობს და შეგიძლიათ გააუქმოთ. + It may take minutes, so it runs in the background, where you can cancel it. ამას შეიძლება წუთები დასჭირდეს, ამიტომ ის ფონურ რეჟიმში მუშაობს, სადაც შეგიძლიათ გააუქმოთ. + The plug-in is computing… პლაგინი ითვლის… + Inspect Plug-in პლაგინის შემოწმება + Cancel Plug-in Run პლაგინის გაშვების გაუქმება +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} დასრულდა, მაგრამ ამასობაში დოკუმენტი ან პლაგინი შეიცვალა: გაუშვით ხელახლა +@msg Running “{name}”… მიმდინარეობს „{name}“-ის გაშვება… +@msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy` +@msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით +# Actions panel: action sets (#3712). + New Set… ახალი ნაკრები… + New Set ახალი ნაკრები + Set Options… ნაკრების პარამეტრები… + Set Options ნაკრების პარამეტრები + Create New Set ახალი ნაკრების შექმნა + Stop Global Edit გლობალური რედაქტირების დასრულება + Global Edit Options გლობალური რედაქტირების პარამეტრები + Match დამთხვევა + On Artboards სამუშაო დაფებზე + All Artboards ყველა სამუშაო დაფა + Include Objects On Canvas ტილოზე არსებული ობიექტების ჩართვა + Result(s): {count} შედეგ(ებ)ი: {count} +@msg select one object აირჩიეთ ერთი ობიექტი +@msg Global Edit works on paths, compound paths and symbol instances გლობალური რედაქტირება მუშაობს კონტურებზე, შედგენილ კონტურებსა და სიმბოლოს ეგზემპლარებზე +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again მსგავსი ობიექტები ვერ მოიძებნა (შემობრუნებული ან არეკლილი ასლები არ ემთხვევა); შეცვალეთ გლობალური რედაქტირების პარამეტრები და სცადეთ ხელახლა +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ან დიაპაზონი, მაგალითად 1, 3-5, და არა {v} + Add to Symbols სიმბოლოებში დამატება + Basic Shapes ძირითადი ფიგურები + Open Symbol Library სიმბოლოების ბიბლიოთეკის გახსნა + Save Symbol Library სიმბოლოების ბიბლიოთეკის შენახვა + Save Symbol Library… სიმბოლოების ბიბლიოთეკის შენახვა… + Save Symbols… სიმბოლოების შენახვა… + Selected Symbols Only მხოლოდ მონიშნული სიმბოლოები + Symbol Libraries Menu სიმბოლოების ბიბლიოთეკების მენიუ + Symbol Library სიმბოლოების ბიბლიოთეკა + User Symbol Library 1 მომხმარებლის სიმბოლოების ბიბლიოთეკა 1 + User Symbol Library 2 მომხმარებლის სიმბოლოების ბიბლიოთეკა 2 + User Symbol Library 3 მომხმარებლის სიმბოლოების ბიბლიოთეკა 3 + User Symbol Library 4 მომხმარებლის სიმბოლოების ბიბლიოთეკა 4 + User Symbol Library 5 მომხმარებლის სიმბოლოების ბიბლიოთეკა 5 + User Symbol Library 6 მომხმარებლის სიმბოლოების ბიბლიოთეკა 6 + User Symbol Library 7 მომხმარებლის სიმბოლოების ბიბლიოთეკა 7 + User Symbol Library 8 მომხმარებლის სიმბოლოების ბიბლიოთეკა 8 + User Symbol Library 9 მომხმარებლის სიმბოლოების ბიბლიოთეკა 9 + User Symbol Library 10 მომხმარებლის სიმბოლოების ბიბლიოთეკა 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}`-ში განსათავსებელი სიმბოლოები არ არის +@msg missing `library` (see symbol.libraries) `library` აკლია (იხ. symbol.libraries) +@msg no symbol `{n}` სიმბოლო `{n}` არ არის +@msg no symbol `{n}` in `{_1}` `{_1}`-ში სიმბოლო `{n}` არ არის +@msg no symbol library `{key}` (see symbol.libraries) სიმბოლოების ბიბლიოთეკა `{key}` არ არის (იხ. symbol.libraries) +@msg the document has no symbols to save დოკუმენტში შესანახი სიმბოლოები არ არის +@msg Trap: select two or more objects filled with solid colours ტრეპინგი: აირჩიეთ ორი ან მეტი ობიექტი, შევსებული ერთგვაროვანი ფერით +@msg Trap: no edges of different colours meet among the selected objects ტრეპინგი: არჩეულ ობიექტებს შორის განსხვავებული ფერის კიდეები არ ეხება ერთმანეთს +@msg thickness must be from 0.01 to 5000 pt thickness უნდა იყოს 0,01-დან 5000 pt-მდე +@msg heightWidth must be from 1 to 1000 % heightWidth უნდა იყოს 1-დან 1000 %-მდე +@msg tintReduction must be from 0 to 100 % tintReduction უნდა იყოს 0-დან 100 %-მდე + Rectangular Grid Tool Options მართკუთხა ბადის ხელსაწყოს პარამეტრები + Polar Grid Tool Options პოლარული ბადის ხელსაწყოს პარამეტრები + Fill Line ხაზის შევსება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 0dd64eb34..ca100edf6 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -14,6 +14,8 @@ All Scripts 모든 스크립트 Anchors: 앵커: Any Kind 모든 종류 + Font Technology 글꼴 기술 +font-technology Variable 가변 Ascender 어센더 Baseline 기준선 Bottom-to-Bottom Leading 아래쪽 기준 행간 @@ -35,6 +37,7 @@ ICF Top/Right ICF 위/오른쪽 ICF Bottom/Left ICF 아래/왼쪽 Proportional Metrics 비례 메트릭 + Character Direction 문자 방향 Digits 숫자 Arabic 아라비아어 Hindi 힌디어 @@ -44,6 +47,9 @@ Japanese 일본어 Join Our Discord Discord 참여하기 Line-end Punctuation Half Width 줄 끝 문장 부호 반각 + Insert Kashidas 카시다 삽입 + Long 길게 + Stylistic 스타일 Mojikumi Set 모지쿠미 세트 Kinsoku Set 긴소쿠 세트 Hard 강하게 @@ -67,8 +73,14 @@ Medium Light 약간 밝게 Light 밝게 Quit VectorCraft VectorCraft 종료 +# Artwork drag between document tabs. + Drop Artwork 아트워크 놓기 +@msg {key} must be [x, y] with finite coordinates {key}은(는) 유한한 좌표의 [x, y]여야 합니다 +@msg ids must contain 1–10000 object ids ids에는 1~10000개의 오브젝트 ID가 있어야 합니다 + File 파일 New… 새로 만들기… + New Without Dialog 대화상자 없이 새로 만들기 New from Template… 템플릿에서 새로 만들기… Open… 열기… Open Recent Files 최근 파일 열기 @@ -258,6 +270,8 @@ Large Gaps 큰 간격 Gap Preview Color: 간격 미리 보기 색상: Close Gaps With Paths 패스로 간격 닫기 + Show Live Paint Gaps 라이브 페인트 간격 표시 + Hide Live Paint Gaps 라이브 페인트 간격 숨기기 Select Fills 칠 선택 Select Strokes 선 선택 Image Trace 이미지 추적 @@ -294,6 +308,13 @@ Glyphs 글리프 Insert Special Character 특수 문자 삽입 Bullet 글머리 기호 + Bullets 글머리 기호 + Bullets and Numbering 글머리 기호 및 번호 매기기 + Apply Bullets 글머리 기호 적용 + Filled Dot 채운 점 + Dash 대시 + Hollow Circle 빈 원 + Filled Square 채운 사각형 Copyright Symbol 저작권 기호 Ellipsis 줄임표 Paragraph Symbol 단락 기호 @@ -736,7 +757,8 @@ from center | 중심에서 | keeps its proportions) 비율 유지) near a selected pin to rotate | 선택한 핀 근처에서 회전 | - on the canvas to create one 캔버스에서 만들기 + on the canvas to create one ( 캔버스에서 만들기 ( + selects several, or draws one inside an artboard) 여러 개 선택 또는 아트보드 안에 만들기) over paths to roughen their outlines | 패스 위에서 윤곽선 거칠게 만들기 | over paths, and hold still to keep going | 패스 위에서 멈춰 있으면 계속 진행 | the art to add a pin | 아트워크를 클릭하여 핀 추가 | @@ -817,7 +839,7 @@ Add Selected Artwork (Alt: as one asset) 선택한 아트워크 추가(Alt: 하나의 에셋으로) Add Selected Artwork as Multiple Assets 선택한 아트워크를 여러 에셋으로 추가 Add Selected Artwork as One Asset 선택한 아트워크를 하나의 에셋으로 추가 - Add Spine Point 중심선 점 추가 + Add Spine Point 스파인 포인트 추가 Add Used Colors 사용한 색상 추가 Add to Graphic Styles 그래픽 스타일에 추가 Add to Profiles 프로필에 추가 @@ -918,6 +940,11 @@ Average 평균 BLANK DOCUMENT PRESETS 빈 문서 프리셋 Background Color: 배경색: + Show Center Mark 중심 표시 보기 + Show Cross Hairs 십자선 보기 + Show Video Safe Areas 비디오 안전 영역 보기 + Video Ruler Pixel Aspect Ratio: 비디오 눈금자 픽셀 종횡비: + Hide Video Rulers 비디오 눈금자 숨기기 Background Contents 배경 내용 Background Contents: 배경 내용: Background: 배경: @@ -1456,6 +1483,7 @@ Eyedropper Picks Up: 스포이드 추출 대상: Eyedropper Tool 스포이드 도구 Eyedropper: click the art to sample the stop's colour 스포이드: 아트웍을 클릭해 정지점의 색상 추출 + Eyedropper: click the art to sample the key color 스포이드: 아트웍을 클릭해 키 색상 추출 Feet 피트 Feet & Inches 피트 및 인치 Fewer Elements 요소 줄이기 @@ -1479,6 +1507,16 @@ Alt key toggles to Smooth Tool Alt 키로 매끄럽게 도구로 전환 Keep selected 선택 유지 Merge only with selection 선택 영역과만 병합 + Consider Open Filled Paths as Closed 칠이 있는 열린 패스를 닫힌 패스로 처리 + In Merge Mode, Clicking Stroke Splits the Path 병합 모드에서 선을 클릭하면 패스 분할 + Pick Color From: 색상 출처: + Artwork 아트워크 + Cursor Swatch Preview 포인터 옆 견본 미리 보기 + Selection: 선택 방식: + Freeform 자유 형태 + Straight Line 직선 + Highlight: 강조: + Stroke When Editable 편집 가능한 선 Fill: 칠: Find 찾기 Find Font 글꼴 찾기 @@ -1578,6 +1616,7 @@ Hand Tool 손 도구 Handles 핸들 Hard Light 하드 라이트 + Hard Mix 하드 믹스 Harmony Rules: 하모니 규칙: Height 높이 Height: 높이: @@ -1605,7 +1644,7 @@ Clear History 히스토리 지우기 Set History Limit… 히스토리 한도 설정… History States 히스토리 상태 -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set 데이터 세트 적용 Area Type 영역 문자 Bloat 부풀리기 @@ -2003,6 +2042,7 @@ Move Slices 슬라이스 이동 Move Spine Point 스파인 포인트 이동 Reshape Spine Segment 스파인 세그먼트 모양 변경 + Move Brush 브러시 이동 Move Swatch 견본 이동 Move Up 위로 이동 Move in Perspective 원근 이동 @@ -2053,6 +2093,13 @@ Tints and Shades 틴트와 셰이드 Hue Shift 색조 이동 Key Color: 키 색상: + Tips 팁 + Colorization Tips 색상화 팁 + The samples show this brush on a blue stroke. 샘플은 이 브러시를 파란색 선에 적용한 모습을 보여 줍니다. + The art keeps its own colors. 아트웍은 원래 색상을 유지합니다. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. 검정은 선 색상이 되고 흰색은 흰색으로 남습니다. 다른 색상은 선 색상의 틴트가 되며, 색상이 밝을수록 틴트도 밝아집니다. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. 검정은 검정으로 남고 흰색은 흰색으로 남습니다. 다른 색상은 어두우면 선 색상의 셰이드가 되고 밝으면 틴트가 됩니다. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. 키 색상은 선 색상이 됩니다. 다른 색상은 키 색상과 같은 만큼 색상환에서 이동하며 채도와 밝기를 유지합니다. Roundness: 원형률: Fixed 고정 Pressure 필압 @@ -2073,6 +2120,14 @@ Round Fan 둥근 부채형 Flat Fan 평평한 부채형 New Brush… 새 브러시… + New Brush 새 브러시 + Calligraphic Brush 캘리그래피 브러시 + Scatter Brush 흩뿌리기 브러시 + Art Brush 아트 브러시 + Bristle Brush 강모 브러시 + Pattern Brush 패턴 브러시 + Select art to make a scatter, art or pattern brush. 흩뿌리기, 아트 또는 패턴 브러시를 만들려면 아트웍을 선택하세요. + Brush Art 브러시 아트 New Calligraphic Brush 새 캘리그래피 브러시 New Character Style 새 문자 스타일 New Color Group 새 색상 그룹 @@ -2298,7 +2353,7 @@ Performance 성능 diagnostics render 렌더링 diagnostics ui UI - Performance & Storage 성능 및 저장 공간 + Plug-ins & Scratch Disks 플러그인 & 스크래치 디스크 Permissions 권한 Permissions password: 권한 암호: Perspective Grid Options 원근 격자 옵션 @@ -2654,6 +2709,7 @@ mark-style Japanese 일본식 Scale Cursor Proportional to UI UI에 비례해 커서 크기 조절 Scale Lineweights 선 두께 크기 조절 Scale Strokes & Effects 선 및 효과 크기 조절 + Scale Text Attributes 텍스트 속성 크기 조절 Scale Tool 크기 조절 도구 Scale to Fit Artboard 아트보드에 맞게 크기 조절 Scale: 크기 조절: @@ -2832,6 +2888,7 @@ mark-style Japanese 일본식 Snap to Unit 단위에 스냅 Snapping Tolerance 스냅 허용 오차 Soft Light 소프트 라이트 + Soft Mix 소프트 혼합 Solid 단색 Sort by Kind 종류별 정렬 Sort by Name 이름별 정렬 @@ -2840,6 +2897,7 @@ mark-style Japanese 일본식 Space Before Paragraph 단락 앞 간격 Spaces 공백 Spacing Guides 간격 안내선 + Invoke Distance Guides 거리 안내선 호출 Spacing: 간격: Spanish Español Specify: 지정: @@ -2933,7 +2991,6 @@ mark-style Japanese 일본식 Use Library Item 라이브러리 항목 사용 Graphic 그래픽 Show all open documents 열린 문서 모두 표시 - Symbol Libraries (on the roadmap) 심볼 라이브러리(예정) Symbol Screener Tool 심볼 스크리너 도구 Symbol Scruncher Tool 심볼 스크런처 도구 Symbol Shifter Tool 심볼 시프터 도구 @@ -3018,6 +3075,13 @@ mark-style Japanese 일본식 Transparent Background 투명 배경 Transverse (the page a quarter turn on the paper) 가로 방향(용지에서 페이지를 90도 돌림) Trap… 트랩… + Trap 트랩 + Height/Width 높이/폭 + Tint Reduction 색조 감소 + Traps with Process Color 프로세스 색상으로 트랩 + Reverse Traps 트랩 반전 + Precision 정밀도 + Remove Redundant Points 불필요한 점 제거 Trim mark weight: 재단선 두께: True colour needs version 2004 or later: the nearest of 256 colours is written. 트루 컬러는 2004 이상 버전이 필요합니다. 256색 중 가장 가까운 색으로 기록됩니다. Turn off Data Recovery for complex documents 복잡한 문서는 데이터 복구 끄기 @@ -3162,7 +3226,7 @@ mark-style Japanese 일본식 the preset 프리셋 tile {number} 타일 {number} to ~ - {count} file(s) in {folder}{note}. Show the package folder? {folder}에 파일 {count}개{note}. 패키지 폴더를 표시하시겠습니까? + {count} file(s) in {folder} {note}. Show the package folder? {folder}에 파일 {count}개{note}. 패키지 폴더를 표시하시겠습니까? {count} linked file(s) not found 연결된 파일 {count}개를 찾을 수 없습니다 fonts not copied: {fonts} 복사되지 않은 글꼴: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 이 문서를 저장한 이후 연결된 이미지 {count}개가 변경되었습니다. 새 버전을 표시하시겠습니까? @@ -3218,6 +3282,7 @@ gradient-kind Radial 방사형 Black and White Logo 흑백 로고 Bottom 아래쪽 Bottom in Front 아래쪽이 앞 + Bottom-Justified Tab 아래쪽 정렬 탭 Bounds 경계 Bounds (Fill Proportionally) 경계(비율 유지하여 채우기) Bounds (Fit Proportionally) 경계(비율 유지하여 맞추기) @@ -3437,7 +3502,7 @@ file-filter Templates 템플릿 Output Black 출력 검정 Output White 출력 흰색 Output › Mode 출력 › 모드 - Packaged {n} file(s) in {folder}{note} {folder}에 파일 {n}개를 패키지했습니다{note} + Packaged {n} file(s) in {folder} {note} {folder}에 파일 {n}개를 패키지했습니다{note} Packaged {n} file(s){note} 파일 {n}개를 패키지했습니다{note} Page information 페이지 정보 Paragraph Style 단락 스타일 @@ -3469,6 +3534,7 @@ file-filter Templates 템플릿 Relinked {n} image(s) 이미지 {n}개를 다시 연결했습니다 Relinked {n} image(s); not in that folder: {} 이미지 {n}개를 다시 연결했습니다. 해당 폴더에 없음: {} Relinked {} image(s) 이미지 {}개를 다시 연결했습니다 + Repeat Objects 반복 개체 Repeat Options 반복 옵션 Reset with Mesh 메시로 재설정 Reset with Warp 왜곡으로 재설정 @@ -3542,6 +3608,7 @@ file-filter Templates 템플릿 Tip on the end point 끝이 끝점에 위치 Top 위쪽 Top in Front 위쪽이 앞 + Top-Justified Tab 위쪽 정렬 탭 Transforms 변형 Transparent (Default) 투명 (기본값) Trim marks 재단 표시 @@ -3708,6 +3775,13 @@ theme Appearance 외관 Channel 4 채널 4 Cell Size 셀 크기 Fine Dots 고운 점 + + Simple 단순 + Light Rough 밝은 거칠기 + Dark Rough 어두운 거칠기 + Wide Sharp 넓고 선명 + Wide Blurry 넓고 흐림 + Sparkle 반짝임 Medium Dots 중간 점 Grainy Dots 거친 입자 점 Coarse Dots 굵은 점 @@ -3765,7 +3839,7 @@ theme Appearance 외관 @msg the scale is not a usable number 배율이 사용할 수 없는 숫자입니다 @msg the drawing is too large to open 도면이 너무 커서 열 수 없습니다 @msg scale must be a number from {_1} to {_2}, not {_3} 배율은 {_1}에서 {_2} 사이의 숫자여야 합니다: {_3} -@msg image '{key}' has no pixels and was left out 이미지 '{key}'에 픽셀이 없어 제외했습니다 +@msg an image has no pixels and was left out 픽셀이 없는 이미지를 제외했습니다 @msg Embedded {n} image(s) 이미지 {n}개를 포함했습니다 @msg object {0} cannot have children 개체 {0}에는 하위 항목을 둘 수 없습니다 @msg range `{s}` is empty 범위 `{s}`이(가) 비어 있습니다 @@ -3776,7 +3850,9 @@ theme Appearance 외관 @msg Show All Hidden Attributes: nothing is hidden 모든 숨겨진 속성 표시: 숨겨진 항목이 없습니다 @msg Expand: no brush strokes selected 확장: 선택한 브러시 선이 없습니다 @msg no brush named `{name}` `{name}` 브러시가 없습니다 +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} 강모 브러시 이름은 최대 {max}자까지 쓸 수 있습니다: `{name}`은(는) {count}자입니다 @msg no brush selected 선택한 브러시가 없습니다 +@msg brushes move only within their type 브러시는 같은 유형 안에서만 이동할 수 있습니다 @msg no symbol instances selected 선택한 심볼 인스턴스가 없습니다 @msg select a symbol instance 심볼 인스턴스를 선택하세요 @msg the art has no bounds 아트웍에 경계가 없습니다 @@ -3817,11 +3893,13 @@ theme Appearance 외관 @msg Make Guides: select paths 안내선 만들기: 패스를 선택하세요 @msg Release Guides: no guides 안내선 해제: 안내선이 없습니다 @msg variables are locked 변수가 잠겨 있습니다 +@msg variables that aren't text were left out: {kinds} 텍스트가 아닌 변수는 제외되었습니다: {kinds} @msg guides are locked 안내선이 잠겨 있습니다 @msg handle must be in or out handle은 in 또는 out이어야 합니다 @msg no saved selection recalled 불러온 저장된 선택 영역이 없습니다 @msg no saved selections 저장된 선택 영역이 없습니다 @msg no such artboard 해당 아트보드가 없습니다 +@msg the artboard is locked 아트보드가 잠겨 있습니다 @msg cannot split an open path at its end point 열린 패스는 끝점에서 나눌 수 없습니다 @msg closing needs three anchors 닫으려면 앵커가 세 개 필요합니다 @msg empty brush stroke 빈 브러시 선입니다 @@ -3968,6 +4046,10 @@ theme Appearance 외관 @msg select a repeat 반복을 선택하세요 @msg select an Intertwine group 교차 그룹을 선택하세요 @msg select an Objects on Path group 패스 위 개체 그룹을 선택하세요 +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 컴파운드 패스, 블렌드, 엔벨로프, 반복 또는 교차 그룹 안의 패스에는 개체를 부착할 수 없습니다 +@msg a clipping path can't take objects 클리핑 패스에는 개체를 부착할 수 없습니다 +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 블렌드, 엔벨로프, 반복 또는 교차 그룹 안의 개체는 패스에 부착할 수 없습니다 +@msg a clipping path can't be attached to a path 클리핑 패스는 패스에 부착할 수 없습니다 @msg select the art to repeat 반복할 아트웍을 선택하세요 @msg the document has no patterns 문서에 패턴이 없습니다 @msg no artboard 아트보드가 없습니다 @@ -4064,6 +4146,25 @@ theme Appearance 외관 @msg a PostScript error ({name} in `{op}`: {at}) PostScript 오류({name}, `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript 오류({name}, `{op}`: {at}), 위치: {procs} @msg a PostScript error ({name}) PostScript 오류({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) PostScript 오류({name}, `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} PostScript 오류({name}, `{op}`; {meaning}), 위치: {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript 오류({name}, `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} PostScript 오류({name}, `{op}`: {at}; {meaning}), 위치: {procs} +@msg a PostScript error ({name}; {meaning}) PostScript 오류({name}; {meaning}) +@msg the program ends a dictionary it didn't begin 프로그램이 시작하지 않은 딕셔너리를 끝내려고 합니다 +@msg the program opens a file, which the reader can't do 프로그램이 파일을 열려고 하지만 PostScript 판독기는 파일을 열 수 없습니다 +@msg the program restores a saved state that was already restored 프로그램이 이미 복원된 저장 상태를 다시 복원하려고 합니다 +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none 프로그램이 읽는 데이터가 손상되었거나 중간에 잘렸거나, 프로그램이 위치가 없는 파일에서 위치를 요청합니다 +@msg the program goes past one of the reader's limits 프로그램이 PostScript 판독기의 한도를 넘었습니다 +@msg the path has no point to continue from 패스에 이어서 그릴 기준점이 없습니다 +@msg a value is outside the range the operator takes 값이 연산자가 받는 범위를 벗어났습니다 +@msg the operator needs more values than the stack holds 연산자에 필요한 값이 스택에 부족합니다 +@msg part of the program isn't valid PostScript text 프로그램 일부가 올바른 PostScript 텍스트가 아닙니다 +@msg a value has the wrong type for the operator 값의 유형이 연산자에 맞지 않습니다 +@msg the program runs, deletes or renames a file, which the reader can't do 프로그램이 파일을 실행, 삭제 또는 이름 변경하려고 하지만 PostScript 판독기는 이를 할 수 없습니다 +@msg the program asks for a resource that isn't defined 프로그램이 정의되지 않은 리소스를 요청합니다 +@msg a calculation has no result, as in a division by zero 0으로 나누기처럼 계산 결과가 없습니다 +@msg the operator looks for a mark on the stack and finds none 연산자가 스택에서 마크를 찾지 못했습니다 @msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know `{at}`을(를) 사용하며, VectorCraft의 PostScript 판독기는 이를 알지 못합니다 @msg the file was read up to an error ({why}): what follows it was left out 오류({why})까지 파일을 읽었으며, 그 뒤의 내용은 제외했습니다 @msg this file's PostScript couldn't be read ({why}): {what} 이 파일의 PostScript를 읽을 수 없습니다({why}): {what} @@ -4147,6 +4248,8 @@ theme Appearance 외관 @msg SVG parse error: {0} SVG 구문 분석 오류: {0} @msg decimals must be {_1}–{_2} (got {_3}) 소수 자릿수는 {_1}~{_2}이어야 합니다({_3} 입력됨) @msg Exporting {_1} {_1} 내보내는 중 +@msg Packaging {_1} {_1} 패키지 만드는 중 +@msg Placing {_1} {_1} 가져오는 중 @msg Exported {what} {what}을(를) 내보냈습니다 @msg "{0}" is not a colour: write it as #rrggbb "{0}"은(는) 색상이 아닙니다: #rrggbb 형식으로 입력하세요 @msg could not decode image: {0} 이미지를 디코딩할 수 없습니다: {0} @@ -4161,6 +4264,7 @@ theme Appearance 외관 @msg {label}… {label}… @msg no blend options 혼합 옵션이 없습니다 @msg bad color {v} 잘못된 색상 {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio은(는) 0.1에서 10 사이의 숫자여야 합니다 @msg the Color Picker is not open 색상 피커가 열려 있지 않습니다 @msg the Simplify slider is not open 단순화 슬라이더가 열려 있지 않습니다 @msg the screen color sampler is already open 화면 색상 추출기가 이미 열려 있습니다 @@ -4193,7 +4297,7 @@ theme Appearance 외관 @msg {_1} linked image(s) changed on disk: Update Links shows the new versions 링크된 이미지 {_1}개가 디스크에서 바뀌었습니다: 링크 업데이트를 하면 새 버전이 표시됩니다 @msg Updated {_1} linked file(s) changed on disk 디스크에서 바뀐 링크된 파일 {_1}개를 업데이트했습니다 @msg pick a colour, gradient or pattern to save as a swatch 견본으로 저장할 색상, 그레이디언트 또는 패턴을 선택하세요 -@msg Packaged {n} file(s) in {folder}{note} {folder}에 파일 {n}개를 패키지했습니다{note} +@msg Packaged {n} file(s) in {folder} {note} {folder}에 파일 {n}개를 패키지했습니다{note} @msg Packaged {n} file(s){note} 파일 {n}개를 패키지했습니다{note} @msg choose a location for the package 패키지 위치를 선택하세요 @msg missing `folder` `folder`가 없습니다 @@ -4206,6 +4310,7 @@ theme Appearance 외관 @msg no plug-in {id} is installed 플러그인 {id}이(가) 설치되어 있지 않습니다 @msg {_1} is a live effect (Effect › Plug-ins) {_1}은(는) 라이브 효과입니다(효과 › 플러그인) @msg {_1} is an exporter (File › Export › Export As…) {_1}은(는) 내보내기 플러그인입니다(파일 › 내보내기 › 다른 이름으로 내보내기…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData`가 {size}바이트입니다(한도 {MAX_DOCUMENT_DATA}) @msg a preset named `{_1}` exists `{_1}`이라는 프리셋이 이미 있습니다 @msg a swatch or color group named `{n}` exists `{n}`이라는 견본 또는 색상 그룹이 이미 있습니다 @msg no print preset named `{base}` `{base}`라는 인쇄 프리셋이 없습니다 @@ -4223,6 +4328,7 @@ theme Appearance 외관 @msg name the file 파일 이름을 지정하세요 @msg unknown format 알 수 없는 형식 @msg PDF settings are not an object PDF 설정이 개체가 아닙니다 +@msg PDF security settings are not an object PDF 보안 설정이 개체가 아닙니다 @msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) `{name}`이라는 저장된 PDF 프리셋이 없습니다(기본 제공 프리셋은 읽기 전용이니 이를 바탕으로 새로 만드세요) @msg Saved to User Defined: {_1} 사용자 정의에 저장했습니다: {_1} @msg choose a direction to divide in 나눌 방향을 선택하세요 @@ -4273,7 +4379,7 @@ theme Appearance 외관 @msg missing index index가 없습니다 @msg missing number `{key}` 숫자 `{key}`이(가) 없습니다 @msg missing integer `{key}` 정수 `{key}`이(가) 없습니다 -@msg multiple windows land with M11.5 여러 창은 M11.5에서 지원됩니다 +@msg New Window isn't available yet: a document opens in one window 새 창은 아직 사용할 수 없습니다: 문서는 하나의 창에서 열립니다 @msg no PNG encoder PNG 인코더가 없습니다 @msg no document open 열린 문서가 없습니다 @msg no effect applied yet 아직 적용된 효과가 없습니다 @@ -4315,8 +4421,11 @@ theme Appearance 외관 @msg no swatch library `{l}` (see swatch.library.list) 견본 라이브러리 `{l}`이(가) 없습니다(swatch.library.list 참조) @msg no user library folder here 여기에는 사용자 라이브러리 폴더가 없습니다 @msg Couldn't place {name}: the document it was dropped on was closed {name}을(를) 가져올 수 없습니다: 놓은 문서가 닫혔습니다 +@msg Couldn't place {name}: the document it was placed in was closed {name}을(를) 가져올 수 없습니다: 가져오던 문서가 닫혔습니다 @msg no files to place 가져올 파일이 없습니다 +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}`은(는) VectorCraft 문서, PDF 또는 PDF 호환 .ai 파일이 아닙니다 @msg paths: give at least one file paths: 파일을 하나 이상 지정하세요 +@msg replace: the object to replace was changed or deleted replace: 바꿀 개체가 변경되었거나 삭제되었습니다 @msg Couldn't place {name}: {e} {name}을(를) 가져올 수 없습니다: {e} @msg no print service 인쇄 서비스가 없습니다 @msg the print job has no data 인쇄 작업에 데이터가 없습니다 @@ -4389,7 +4498,9 @@ theme Appearance 외관 @msg automation filesystem access is not granted: read authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 읽기 권한이 없습니다: {path} @msg automation filesystem access is not granted: write authority is absent: {path} 자동화 파일 시스템 접근이 허용되지 않았습니다: 쓰기 권한이 없습니다: {path} @msg automation path rejected: outside the read root {root}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {root} 밖입니다: {path} +@msg automation path rejected: outside the read roots {roots}: {path} 자동화 경로가 거부되었습니다: 읽기 루트 {roots} 밖입니다: {path} @msg automation path rejected: outside the write root {root}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {root} 밖입니다: {path} +@msg automation path rejected: outside the write roots {roots}: {path} 자동화 경로가 거부되었습니다: 쓰기 루트 {roots} 밖입니다: {path} @msg automation path rejected: path is empty 자동화 경로가 거부되었습니다: 경로가 비어 있습니다 @msg automation path rejected: a link that leads nowhere: {path} 자동화 경로가 거부되었습니다: 어디로도 이어지지 않는 링크입니다: {path} @msg automation path rejected: cannot resolve {path}: {e} 자동화 경로가 거부되었습니다: {path}을(를) 해석할 수 없습니다: {e} @@ -4496,6 +4607,7 @@ effect Vertical 세로 Activate Panel 패널 활성화 Change Panel Layout 패널 레이아웃 변경 Close Panel 패널 닫기 + Collapse Panel Group 패널 그룹 접기 Move Panel 패널 이동 Group with 함께 그룹화 Resize panels 패널 크기 조절 @@ -4507,6 +4619,12 @@ effect Vertical 세로 @msg before must name a panel before는 패널을 지정해야 합니다 @msg docking action contains an unknown panel or an excessive split path 도킹 동작에 알 수 없는 패널이나 지나치게 많은 분할 경로가 있습니다 @msg group must be true or false group은 true 또는 false여야 합니다 +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 패널이 분리되어 있습니다. 그룹은 window.panel.float {panel, collapsed}로 접으세요 +@msg panel is not in a dock group 패널이 도크 그룹에 없습니다 +@msg only a dock group above or below others collapses, not one beside another 다른 그룹의 위나 아래에 있는 도크 그룹만 접을 수 있으며, 옆에 있는 그룹은 접을 수 없습니다 +@msg icon must be true or false icon은 true 또는 false여야 합니다 +@msg icon only docks a panel in the icon column, with no anchor icon은 anchor 없이 패널을 아이콘 열에만 도킹합니다 +@msg column docks a set as a dock column, with no anchor or icon column은 anchor나 icon 없이 세트를 도크 열로 도킹합니다 @msg move requires a destination panel move에는 대상 패널이 필요합니다 @msg move requires an existing destination panel move에는 존재하는 대상 패널이 필요합니다 @msg onto must name a floating panel onto는 떠 있는 패널을 지정해야 합니다 @@ -4578,12 +4696,24 @@ lighting Light 광원 X11 (XWayland) X11 (XWayland) Artistic 예술 효과 Film Grain… 필름 그레인… + Colored Pencil… 색연필… + Colored Pencil 색연필 Cutout… 오려내기… Cutout 오려내기 + Dry Brush… 드라이 브러시… + Dry Brush 드라이 브러시 Neon Glow… 네온 글로… + Palette Knife… 팔레트 나이프… Neon Glow 네온 글로 + Paint Daubs… 페인트 다브… + Paint Daubs 페인트 다브 + Palette Knife 팔레트 나이프 Poster Edges… 포스터 가장자리… + Rough Pastels… 거친 파스텔… Poster Edges 포스터 가장자리 + Rough Pastels 거친 파스텔 + Underpainting… 언더페인팅… + Underpainting 언더페인팅 Levels 레벨 수 Edge Simplicity 가장자리 단순화 Edge Fidelity 가장자리 충실도 @@ -4673,6 +4803,9 @@ lighting Light 광원 Softness 부드러움 Ambient Light 주변광 Light behind the object 개체 뒤의 조명 + 3D Lighting Preset 3D 조명 사전 설정 + Standard 표준 + Diffuse 확산 # 3D light management. Lights 광원 목록 Add Light 광원 추가 @@ -4719,6 +4852,7 @@ lighting Light 광원 @msg Measured anchor no longer exists 측정한 고정점이 더 이상 없습니다 @msg Measured segment no longer exists 측정한 세그먼트가 더 이상 없습니다 @msg Non-finite dimension 유한하지 않은 치수 +@msg Scale Text Attributes: selected type has no scale to bake 텍스트 속성 크기 조절: 선택한 텍스트에 적용할 크기 조절이 없습니다 @msg Scale must be drawing:real, for example 1:10 축척은 도면:실제 형식이어야 합니다(예: 1:10) @msg Scale ratio must be between 1:1000000 and 1000000:1 축척 비율은 1:1000000에서 1000000:1 사이여야 합니다 @msg Second measured segment no longer exists 두 번째 측정 세그먼트가 더 이상 없습니다 @@ -4737,6 +4871,7 @@ lighting Light 광원 Make Image Dynamic 이미지를 동적으로 만들기 Import Data Sets… 데이터 세트 가져오기… Export Data Sets… 데이터 세트 내보내기… + Load Variable Library… 변수 라이브러리 불러오기… Make Graph Dynamic 그래프를 동적으로 만들기 Batch… 일괄 처리… Source Folder: 소스 폴더: @@ -4760,6 +4895,12 @@ lighting Light 광원 Sketch 스케치 Halftone Pattern… 하프톤 패턴… Halftone Pattern 하프톤 패턴 + Torn Edges… 찢어진 가장자리… + Torn Edges 찢어진 가장자리 + Conté Crayon… 콘테 크레용… + Conté Crayon 콘테 크레용 + Foreground Level 전경 레벨 + Background Level 배경 레벨 Pattern Type 패턴 유형 Dot 점 # User Interface › Color Scheme @@ -4778,6 +4919,7 @@ lighting Light 광원 Watercolor… 수채화… Watercolor 수채화 Brush Size 브러시 크기 + Texture Coverage 텍스처 적용 범위 Definition 선명도 Brush Detail 브러시 세부 묘사 Shadow Intensity 그림자 강도 @@ -4806,13 +4948,21 @@ lighting Light 광원 @msg the image's pixels can't be read 이미지의 픽셀을 읽을 수 없습니다 Persistent 지속 Library Panel Options 라이브러리 패널 옵션 + Locate Library… 라이브러리 찾기… @msg Persistent libraries not found: {files} 지속 라이브러리를 찾을 수 없습니다: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}`은(는) 라이브러리 패널이 아닙니다(window.swatchLibrary 참조) @msg library panel `{given}` isn't open 라이브러리 패널 `{given}`이(가) 열려 있지 않습니다 +@msg library panel `{given}` isn't missing its library 라이브러리 패널 `{given}`의 라이브러리가 누락되지 않았습니다 @msg too many library panels were opened in this session 이 세션에서 라이브러리 패널을 너무 많이 열었습니다 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 이미지가 {_1} × {_2}픽셀로 한 변이 {LARGE_TRACE_SIDE}을 넘습니다. 추적은 되지만 더 느립니다. 충실도가 낮은 사전 설정이나 이미지의 작은 사본을 사용하면 더 빠릅니다. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 이미지를 {_3} × {_4}에서 축소한 {_1} × {_2}픽셀로 추적했습니다(최대 {_5}메가픽셀). + Snapping 스냅 + Snap to Tangent 접선에 스냅 + Snap to Perpendicular 수직선에 스냅 +@msg unknown snap option `{key}` (use {options}) 알 수 없는 스냅 옵션 `{key}` ({options} 중에서 사용하세요) + Gap Length: 간격 길이: Artboard {n} of {total} 아트보드 {n} / {total} + File {n} of {total} 파일 {n} / {total} Encoding and writing the file… 파일을 인코딩하고 쓰는 중… @plural {n} more export waiting|{n} more exports waiting 대기 중인 내보내기 {n}개 더 Continue in Background 백그라운드에서 계속 @@ -4829,6 +4979,14 @@ lighting Light 광원 Minimum must not exceed Desired, nor Desired Maximum. 최소값은 희망값을, 희망값은 최대값을 넘을 수 없습니다. Minimum 최소 Justification 양끝 맞춤 + Add filters from the list above 위 목록에서 필터 추가 + Add to Stack 스택에 추가 + Applied Filters 적용된 필터 + Categories 범주 + Choose a filter 필터 선택 + Effect Gallery 효과 갤러리 + Filters 필터 + Select artwork to apply effects 효과를 적용할 그래픽을 선택하세요 @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 파일의 변수와 데이터 세트를 읽지 않았습니다(변수: {variables}, 데이터 세트: {data_sets}). 변수 패널에 표시되지 않습니다 # Select by Color (select.color) Select by Color 색상별 선택 @@ -4850,3 +5008,107 @@ lighting Light 광원 Plaster 석고 Note Paper… 메모지… Note Paper 메모지 + Water Paper… 물 종이… + Water Paper 물 종이 + Ruby… 루비… + Ruby 루비 + Geometric Guides 기하학적 안내선 + Ease In 서서히 시작 + Ease Out 서서히 종료 + Ease In Out 서서히 시작 및 종료 + Ease Out In 서서히 종료 및 시작 + Ramp: 강도: + Shift: 이동: + Fresco… 프레스코… + Fresco 프레스코 + Effect Gallery… 효과 갤러리… + Effect Gallery Dialog 효과 갤러리 대화 상자 + Arc Segment Tool Options 호 세그먼트 도구 옵션 + Base Along: 기준 방향: + Closed 닫힘 + Concave 오목 + Convex 볼록 + Fill Arc 호 채우기 + Length X-Axis: X축 길이: + Length Y-Axis: Y축 길이: + Slope: 기울기: +axis X Axis X축 +axis Y Axis Y축 +@msg Packaged {n} file(s) in {folder} {folder}에 파일 {n}개를 패키지했습니다 +@msg Packaged {n} file(s) {note} 파일 {n}개를 패키지했습니다 {note} +@msg Packaged {n} file(s) 파일 {n}개를 패키지했습니다 + Packaged {n} file(s) 파일 {n}개를 패키지했습니다 + Packaged {n} file(s) in {folder} {folder}에 파일 {n}개를 패키지했습니다 + Packaged {n} file(s) {note} 파일 {n}개를 패키지했습니다 {note} + {count} file(s) in {folder}. Show the package folder? {folder}에 파일 {count}개. 패키지 폴더를 표시하시겠습니까? + Clockwise 시계 방향 + Dither 디더 + Fine noise hides banding in the gradient fill 미세한 노이즈로 그레이디언트 칠의 띠를 감춥니다 +@msg `dither` applies to gradient fills (stroke: false) `dither`는 그레이디언트 칠에 적용됩니다 (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither`는 그레이디언트 칠에 적용됩니다 (paint.setFill) +@msg `dither` must be true or false `dither`은(는) true 또는 false여야 합니다 + Perceptual 지각적 +@msg `method` must be classic or perceptual `method`는 classic 또는 perceptual이어야 합니다 +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? 큰 계산 예산을 요청하는 “{name}”을(를) 설치하시겠습니까? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 한 번 실행에 최대 {minutes}분이 걸리고 다른 플러그인에 허용되는 작업량의 {times}배를 수행할 수 있습니다. 백그라운드에서 실행되며 취소할 수 있습니다. + It may take minutes, so it runs in the background, where you can cancel it. 몇 분이 걸릴 수 있으므로 백그라운드에서 실행되며, 실행 중에 취소할 수 있습니다. + The plug-in is computing… 플러그인이 계산하는 중… + Inspect Plug-in 플러그인 검사 + Cancel Plug-in Run 플러그인 실행 취소 +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1}이(가) 끝났지만 그동안 문서나 플러그인이 변경되었습니다. 다시 실행하세요 +@msg Running “{name}”… “{name}” 실행 중… +@msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다 +@msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요 +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요 +# Actions panel: action sets (#3712). + New Set… 새 세트… + New Set 새 세트 + Set Options… 세트 옵션… + Set Options 세트 옵션 + Create New Set 새 세트 만들기 + Stop Global Edit 전체 편집 중지 + Global Edit Options 전체 편집 옵션 + Match 일치 + On Artboards 아트보드에서 + All Artboards 모든 아트보드 + Include Objects On Canvas 캔버스의 개체 포함 + Result(s): {count} 결과: {count} +@msg select one object 개체 하나를 선택하십시오 +@msg Global Edit works on paths, compound paths and symbol instances 전체 편집은 패스, 컴파운드 패스, 심볼 인스턴스에서 작동합니다 +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 유사한 개체를 찾을 수 없습니다(회전하거나 반사한 복사본은 일치하지 않습니다). 전체 편집 옵션을 변경한 후 다시 시도하십시오 +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all 또는 1, 3-5 같은 범위여야 합니다({v}이(가) 아님) + Add to Symbols 심볼에 추가 + Basic Shapes 기본 모양 + Open Symbol Library 심볼 라이브러리 열기 + Save Symbol Library 심볼 라이브러리 저장 + Save Symbol Library… 심볼 라이브러리 저장… + Save Symbols… 심볼 저장… + Selected Symbols Only 선택한 심볼만 + Symbol Libraries Menu 심볼 라이브러리 메뉴 + Symbol Library 심볼 라이브러리 + User Symbol Library 1 사용자 심볼 라이브러리 1 + User Symbol Library 2 사용자 심볼 라이브러리 2 + User Symbol Library 3 사용자 심볼 라이브러리 3 + User Symbol Library 4 사용자 심볼 라이브러리 4 + User Symbol Library 5 사용자 심볼 라이브러리 5 + User Symbol Library 6 사용자 심볼 라이브러리 6 + User Symbol Library 7 사용자 심볼 라이브러리 7 + User Symbol Library 8 사용자 심볼 라이브러리 8 + User Symbol Library 9 사용자 심볼 라이브러리 9 + User Symbol Library 10 사용자 심볼 라이브러리 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}`에 배치할 심볼이 없습니다 +@msg missing `library` (see symbol.libraries) `library`가 없습니다(symbol.libraries 참조) +@msg no symbol `{n}` 심볼 `{n}`이(가) 없습니다 +@msg no symbol `{n}` in `{_1}` `{_1}`에 심볼 `{n}`이(가) 없습니다 +@msg no symbol library `{key}` (see symbol.libraries) 심볼 라이브러리 `{key}`이(가) 없습니다(symbol.libraries 참조) +@msg the document has no symbols to save 문서에 저장할 심볼이 없습니다 +@msg Trap: select two or more objects filled with solid colours 트랩: 단색으로 칠한 개체를 두 개 이상 선택하십시오 +@msg Trap: no edges of different colours meet among the selected objects 트랩: 선택한 개체 사이에 서로 다른 색상이 맞닿는 가장자리가 없습니다 +@msg thickness must be from 0.01 to 5000 pt thickness는 0.01~5000pt여야 합니다 +@msg heightWidth must be from 1 to 1000 % heightWidth는 1~1000%여야 합니다 +@msg tintReduction must be from 0 to 100 % tintReduction은 0~100%여야 합니다 + Rectangular Grid Tool Options 사각형 격자 도구 옵션 + Polar Grid Tool Options 극좌표 격자 도구 옵션 + Fill Line 선 채우기 diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index a32937faa..690b716d5 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -9,8 +9,8 @@ //! English text, clean-room, see `zh-hant.tsv`). //! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule). //! -//! That is all: the Preferences dropdown, the system-locale match and the catalog tests (parse, -//! placeholders, plural forms, menu coverage) pick it up from the registry. +//! That is all: the Preferences dropdown, the system-locale match, the catalog tests (parse, +//! placeholders, plural forms) and the untranslated report pick it up from the registry. //! //! # Looking strings up //! - [`t`] / the `tl!` macro: a plain string in the language the UI is drawn in. [`tr`] takes the @@ -41,7 +41,7 @@ pub struct LangInfo { pub source: &'static str, /// Plural form index for a count (English: 0 = one, 1 = other; Chinese: always 0). pub plural: fn(u64) -> usize, - /// Must the catalog cover every menu string? (checked by the tests) + /// Does the catalog aim to cover every menu string? The untranslated report lists what it lacks. pub complete_menus: bool, catalog: OnceLock, } @@ -94,7 +94,7 @@ fn plural_polish(n: u64) -> usize { } /// The registry. English first: it is the fallback and the source language. -pub static LANGUAGES: [LangInfo; 24] = [ +pub static LANGUAGES: [LangInfo; 25] = [ LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() }, // Greek: the whole interface and the status and error messages, keeping the same names in // English as Spanish; every `el-*` locale (including `el-GR` and `el-CY`) resolves here. @@ -263,6 +263,10 @@ pub static LANGUAGES: [LangInfo; 24] = [ // English as Spanish; one/other plurals with the partitive after any number but 1 ("2 tasoa"). // Every `fi-*` locale resolves here. LangInfo { code: "fi", name: "Suomi", source: include_str!("fi.tsv"), plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() }, + // Turkish: the whole interface and the status and error messages, keeping the same names in + // English as Spanish; a count takes the singular ("3 katman"), so plurals have one form. Every + // `tr-*` locale (including `tr-TR` and `tr-CY`) resolves here. + LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() }, ]; impl LangInfo { @@ -312,6 +316,16 @@ impl Lang { LANGUAGES.iter().map(Lang) } + /// Every registered language in the order a picker lists them: English first (the source + /// language and the fallback), then the rest by their own name with accents folded, so + /// Čeština sits under C rather than after Z. Names in other scripts follow the Latin ones in + /// code point order: Greek, Cyrillic, Arabic, then the CJK languages. + pub fn for_picker() -> Vec { + let mut rest: Vec = Lang::all().filter(|l| l.code() != Lang::EN.code()).collect(); + rest.sort_by_cached_key(|l| name_sort_key(l.name())); + std::iter::once(Lang::EN).chain(rest).collect() + } + pub fn name(self) -> &'static str { self.0.name } @@ -552,8 +566,11 @@ pub fn label_or_name(lang: Lang, s: &str, built_in: bool) -> &str { if built_in { tr(lang, s) } else { s } } -/// How deep [`message`] translates placeholder values that are messages themselves. -const MESSAGE_DEPTH: usize = 3; +/// How deep [`message`] translates placeholder values that are messages themselves. The status +/// bar's line for an EPS file that can't be opened has five levels: `Couldn't open {name}: {e}`, +/// the loader's `can't open …: {e}` (matched by `{path}: {e}`), the reader's `this PostScript file +/// can't be read ({why}): …`, `a PostScript error (…; {meaning})` and the meaning. +const MESSAGE_DEPTH: usize = 5; /// Translate a status or error message for display (see the module docs). Unknown messages, and /// parts of them (file names, numbers, a reason with no entry), stay as they are. @@ -593,3 +610,40 @@ pub fn tn(n: u64, one: &str, other: &str) -> String { #[cfg(test)] mod tests; + +/// `name` lowercased with the Latin accents that occur in language names folded away, so a +/// byte-wise sort reads alphabetically. Other scripts are left as they are. +fn name_sort_key(name: &str) -> String { + name.chars() + .flat_map(char::to_lowercase) + .map(|c| match c { + 'à'..='å' | 'ā' | 'ă' | 'ą' => 'a', + 'ç' | 'ć' | 'č' => 'c', + 'è'..='ë' | 'ē' | 'ě' | 'ę' | 'ế' | 'ệ' => 'e', + 'ì'..='ï' | 'ī' => 'i', + 'ñ' | 'ń' | 'ň' => 'n', + 'ò'..='ö' | 'ø' | 'ő' => 'o', + 'ś' | 'š' => 's', + 'ù'..='ü' | 'ū' | 'ů' | 'ű' => 'u', + 'ý' | 'ÿ' => 'y', + 'ź' | 'ż' | 'ž' => 'z', + c => c, + }) + .collect() +} + +#[cfg(test)] +mod picker_order_tests { + use super::*; + + #[test] + fn picker_lists_english_first_then_names_alphabetically() { + let order = Lang::for_picker(); + assert_eq!(order.first().map(|l| l.code()), Some(Lang::EN.code())); + assert_eq!(order.len(), Lang::all().count()); + let keys: Vec = order[1..].iter().map(|l| name_sort_key(l.name())).collect(); + assert!(keys.windows(2).all(|w| w[0] <= w[1]), "{keys:?}"); + assert_eq!(name_sort_key("Čeština"), "cestina"); + assert_eq!(name_sort_key("Türkçe"), "turkce"); + } +} diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index ae5956f35..81ac2af5a 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -20,8 +20,14 @@ Medium Light Gemiddeld licht Light Licht Quit VectorCraft VectorCraft afsluiten +# Artwork drag between document tabs. + Drop Artwork Illustratie neerzetten +@msg {key} must be [x, y] with finite coordinates {key} moet [x, y] met eindige coördinaten zijn +@msg ids must contain 1–10000 object ids ids moet 1–10000 object-id’s bevatten + File Bestand New… Nieuw… + New Without Dialog Nieuw zonder dialoogvenster New from Template… Nieuw van sjabloon… Open… Openen… Open Recent Files Recente bestanden openen @@ -292,6 +298,13 @@ Glyphs Glyphs Insert Special Character Speciaal teken invoegen Bullet Opsommingsteken + Bullets Opsommingstekens + Bullets and Numbering Opsommingstekens en nummering + Apply Bullets Opsommingstekens toepassen + Filled Dot Gevulde stip + Dash Streepje + Hollow Circle Holle cirkel + Filled Square Gevuld vierkant Copyright Symbol Copyrightteken Ellipsis Beletselteken Paragraph Symbol Alineateken @@ -724,7 +737,7 @@ Units Eenheden Guides & Grid Hulplijnen en raster Hyphenation Afbreking - Performance & Storage Prestaties en opslag + Plug-ins & Scratch Disks Plug-ins & Scratchschijven User Interface Gebruikersinterface Performance Prestaties File Handling Bestandsbeheer @@ -774,6 +787,7 @@ Activate Panel Paneel activeren Change Panel Layout Paneelindeling wijzigen Close Panel Paneel sluiten + Collapse Panel Group Paneelgroep samenvouwen Move Panel Paneel verplaatsen Group with Groeperen met Resize panels Panelen van formaat wijzigen @@ -1070,7 +1084,8 @@ lighting Light Licht to sample a color om een kleur over te nemen across a selected object to set the gradient direction over een geselecteerd object om de richting van het verloop in te stellen to select an artboard | om een tekengebied te selecteren | - on the canvas to create one op het canvas om er een te maken + on the canvas to create one ( op het canvas om er een te maken ( + selects several, or draws one inside an artboard) selecteert er meerdere of maakt er een binnen een tekengebied) a stroke to add a width point | op een lijn om een breedtepunt toe te voegen | to change one side | om één kant te wijzigen | to select more points | om meer punten te selecteren | @@ -1323,6 +1338,7 @@ lighting Light Licht Leading Regelafstand No Break Niet afbreken Proportional Metrics Proportionele metingen + Character Direction Tekenrichting Digits Cijfers Arabic Arabisch Hindi Hindi @@ -1390,6 +1406,7 @@ lighting Light Licht {count} more rules: Export writes every one. {count} regels meer: Export schrijft ze allemaal. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} naamloze objecten weggelaten: geef ze een naam, of zet Naamloze objecten aan in het paneelmenu. All Categories Alle categorieën + Repeat Objects Herhalingsobjecten Save… Opslaan… Selection Only Alleen selectie {title} (selection) {title} (selectie) @@ -1423,6 +1440,7 @@ lighting Light Licht Edit the selected point's colour in the Color panel Bewerk de kleur van het geselecteerde punt in het Kleur-paneel Edit the stop Stoppunt bewerken Eyedropper: click the art to sample the stop's colour Pipet: klik op de illustratie om de kleur van het stoppunt over te nemen + Eyedropper: click the art to sample the key color Pipet: klik op de illustratie om de sleutelkleur over te nemen Gradient across stroke Verloop over de lijn Gradient along stroke Verloop langs de lijn Gradient Fill: click to apply, drag onto art Verloopvulling: klik om toe te passen, sleep naar de illustratie @@ -1586,6 +1604,9 @@ lighting Light Licht Left Indent Links inspringen Left-to-Right Paragraph Direction Alinearichting van links naar rechts Line-end Punctuation Half Width Leestekens op regeleinde op halve breedte + Insert Kashidas Kashida's invoegen + Long Lang + Stylistic Stilistisch Mojikumi Set Mojikumi-set Kinsoku Set Kinsoku-set Hard Hard @@ -1609,6 +1630,13 @@ lighting Light Licht Repeat Pathfinder Pathfinder herhalen Repeat {name} {name} herhalen Trap… Overbrugging… + Trap Overbrugging + Height/Width Hoogte/breedte + Tint Reduction Tintvermindering + Traps with Process Color Overbruggingen met proceskleuren + Reverse Traps Overbruggingen omkeren + Precision Precisie + Remove Redundant Points Overbodige punten verwijderen Bottom in Front Onderkant vooraan Brick by Column Baksteen per kolom Brick by Row Baksteen per rij @@ -1735,8 +1763,8 @@ lighting Light Licht {kind}, {model} {kind}, {model} No symbols in this document Geen symbolen in dit document Select art and click New Symbol to make one. Selecteer artwork en klik op Nieuw symbool om er een te maken. - Symbol Libraries (on the roadmap) Symboolbibliotheken (op de roadmap) Align On: Uitlijnen op: + Bottom-Justified Tab Onder uitgelijnde tabstop Center-Justified Tab Midden uitgelijnde tabstop Above Text Boven tekst Position Panel Above Text Paneel boven tekst plaatsen @@ -1748,6 +1776,7 @@ lighting Light Licht No text selected Geen tekst geselecteerd Repeat Tab Tabstop herhalen Right-Justified Tab Rechts uitgelijnde tabstop + Top-Justified Tab Boven uitgelijnde tabstop Select a text object to set its tab stops. Selecteer een tekstobject om de tabstops in te stellen. Select text Selecteer tekst Snap to Unit Uitlijnen op eenheid @@ -1863,6 +1892,13 @@ lighting Light Licht Tints and Shades Tinten en schakeringen Hue Shift Tintverschuiving Key Color: Sleutelkleur: + Tips Tips + Colorization Tips Tips voor inkleuring + The samples show this brush on a blue stroke. De voorbeelden tonen dit penseel op een blauwe contour. + The art keeps its own colors. De illustratie behoudt haar eigen kleuren. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Zwart wordt de contourkleur en wit blijft wit. Andere kleuren worden tinten van de contourkleur, en hoe lichter een kleur is, hoe lichter de tint is. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Zwart blijft zwart en wit blijft wit. Andere kleuren worden schakeringen van de contourkleur als ze donker zijn en tinten ervan als ze licht zijn. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. De sleutelkleur wordt de contourkleur. De andere kleuren draaien even ver over de kleurencirkel als de sleutelkleur en behouden hun verzadiging en helderheid. Size: Grootte: Calligraphic Brush Options Opties voor kalligrafisch penseel Bristle Brush Options Opties voor borstelpenseel @@ -2175,11 +2211,11 @@ lighting Light Licht Fonts whose licence doesn't allow embedding are not copied. Lettertypen waarvan de licentie insluiten niet toestaat, worden niet gekopieerd. Package collects a saved document with its linked files and fonts. Verpakken verzamelt een opgeslagen document met zijn gekoppelde bestanden en lettertypen. Package created Pakket gemaakt - Packaged {n} file(s) in {folder}{note} {n} bestand(en) verpakt in {folder}{note} + Packaged {n} file(s) in {folder} {note} {n} bestand(en) verpakt in {folder} {note} Packaged {n} file(s){note} {n} bestand(en) verpakt{note} Relink Linked Files to the Document Gekoppelde bestanden opnieuw koppelen aan het document Save the document first? Wil je het document eerst opslaan? - {count} file(s) in {folder}{note}. Show the package folder? {count} bestand(en) in {folder}{note}. De pakketmap openen? + {count} file(s) in {folder} {note}. Show the package folder? {count} bestand(en) in {folder} {note}. De pakketmap openen? {count} linked file(s) not found {count} gekoppelde bestand(en) niet gevonden fonts not copied: {fonts} niet gekopieerde lettertypen: {fonts} Built-in presets are read-only: New… starts an editable copy. Ingebouwde voorinstellingen zijn alleen-lezen: Nieuw… start een bewerkbare kopie. @@ -2226,6 +2262,11 @@ lighting Light Licht File Dimensions Bestandsafmetingen Transforms Transformaties Background Color: Achtergrondkleur: + Show Center Mark Middenmarkering tonen + Show Cross Hairs Dradenkruis tonen + Show Video Safe Areas Veilige videogebieden tonen + Video Ruler Pixel Aspect Ratio: Pixelverhouding van videolinialen: + Hide Video Rulers Videolinialen verbergen Client-side (.html) Client-side (.html) Color Reduction: Kleurreductie: Lossless (lossy WebP isn't available yet) Verliesvrij (WebP met verlies is nog niet beschikbaar) @@ -2679,12 +2720,21 @@ lighting Light Licht Darken Donkerder Exclusion Uitsluiting Hard Light Hard licht + Hard Mix Hard mengen Lighten Lichter Luminosity Luminositeit Overlay Bedekken Soft Light Zacht licht + Soft Mix Zachte menging Crosshatch Kruisarcering Fine Dots Fijne stippen + + Simple Eenvoudig + Light Rough Licht ruw + Dark Rough Donker ruw + Wide Sharp Breed scherp + Wide Blurry Breed wazig + Sparkle Fonkel Medium Dots Middelgrote stippen Grainy Dots Korrelige stippen Coarse Dots Grove stippen @@ -2756,6 +2806,14 @@ theme Appearance Vormgeving Edit Symbol Symbool bewerken Expand Brush Strokes Kwaststreken omzetten New Brush… Nieuwe kwast… + New Brush Nieuwe kwast + 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 New Symbol Nieuw symbool New Symbol… Nieuw symbool… Place Symbol Instance Symboolexemplaar plaatsen @@ -3216,6 +3274,7 @@ theme Appearance Vormgeving Scale Corners Hoeken schalen Scale Cursor Proportional to UI Cursor proportioneel schalen met UI Scale Strokes & Effects Streken & effecten schalen + Scale Text Attributes Tekstkenmerken schalen Scratch Disks Scratchschijven Secondary Secundair Select Same Tint % Zelfde tint % selecteren @@ -3231,6 +3290,7 @@ theme Appearance Vormgeving Size/Leading Tekengrootte/Regelafstand Snapping Tolerance Snapping-tolerantie Spacing Guides Hulplijnen voor tussenruimte + Invoke Distance Guides Afstandshulplijnen oproepen Spanish Spaans Subdivisions Onderverdelingen Templates Folder Sjablonenmap @@ -3316,6 +3376,7 @@ theme Appearance Vormgeving Delete Swatch Kleurstaal verwijderen Duplicate Swatch Kleurstaal dupliceren Merge Swatches Kleurstalen samenvoegen + Move Brush Kwast verplaatsen Move Swatch Kleurstaal verplaatsen New Color Group Nieuwe kleurgroep New Swatch Nieuw kleurstaal @@ -3533,6 +3594,8 @@ effect Regular Regelmatig Put back in the dock Terugzetten in het dock All Scripts Alle scripts Any Kind Elke soort + Font Technology Lettertypetechnologie +font-technology Variable Variabel Decorative Decoratief Favorites Favorieten Latin Latijns @@ -3797,6 +3860,7 @@ imageTrace Outline Contour @msg Couldn't copy to the system clipboard: {e} Kon niet naar het systeemklembord kopiëren: {e} @msg Couldn't open {name}: {e} Kon {name} niet openen: {e} @msg Couldn't place {name}: the document it was dropped on was closed Kon {name} niet plaatsen: het document waarop het werd neergezet, is gesloten +@msg Couldn't place {name}: the document it was placed in was closed Kon {name} niet plaatsen: het document waarin het werd geplaatst, is gesloten @msg Couldn't read {name}: {e} Kon {name} niet lezen: {e} @msg Couldn't save recovery data: {e} Kon herstelgegevens niet opslaan: {e} @msg Create Outlines: select text objects Contouren maken: selecteer tekstobjecten @@ -3833,8 +3897,9 @@ imageTrace Outline Contour @msg Opened {url} {url} geopend @msg Outline Stroke: no stroked paths selected Lijn omzetten in contouren: geen paden met een lijn geselecteerd @msg PDF settings are not an object PDF-instellingen zijn geen object +@msg PDF security settings are not an object PDF-beveiligingsinstellingen zijn geen object @msg PDF writer error: {0} PDF-schrijffout: {0} -@msg Packaged {n} file(s) in {folder}{note} {n} bestand(en) verpakt in {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} {n} bestand(en) verpakt in {folder} {note} @msg Packaged {n} file(s){note} {n} bestand(en) verpakt{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Voorvertoning in browser heeft de desktop-app nodig (het web downloadt in plaats daarvan bestanden) @msg Release Guides: no guides Hulplijnen vrijgeven: geen hulplijnen @@ -3899,6 +3964,25 @@ imageTrace Outline Contour @msg a PostScript error ({name} in `{op}`: {at}) een PostScript-fout ({name} in `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} een PostScript-fout ({name} in `{op}`: {at}), in {procs} @msg a PostScript error ({name}) een PostScript-fout ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) een PostScript-fout ({name} in `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} een PostScript-fout ({name} in `{op}`; {meaning}), in {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) een PostScript-fout ({name} in `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} een PostScript-fout ({name} in `{op}`: {at}; {meaning}), in {procs} +@msg a PostScript error ({name}; {meaning}) een PostScript-fout ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin het programma beëindigt een woordenboek dat het niet heeft begonnen +@msg the program opens a file, which the reader can't do het programma opent een bestand, wat de PostScript-lezer niet kan +@msg the program restores a saved state that was already restored het programma herstelt een opgeslagen toestand die al is hersteld +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none gegevens die het programma leest zijn beschadigd of afgebroken, of het programma vraagt een positie in een bestand dat er geen heeft +@msg the program goes past one of the reader's limits het programma overschrijdt een van de limieten van de PostScript-lezer +@msg the path has no point to continue from het pad heeft geen punt om vanaf verder te gaan +@msg a value is outside the range the operator takes een waarde ligt buiten het bereik dat de operator aanneemt +@msg the operator needs more values than the stack holds de operator heeft meer waarden nodig dan de stapel bevat +@msg part of the program isn't valid PostScript text een deel van het programma is geen geldige PostScript-tekst +@msg a value has the wrong type for the operator een waarde heeft het verkeerde type voor de operator +@msg the program runs, deletes or renames a file, which the reader can't do het programma voert een bestand uit, verwijdert het of wijzigt de naam ervan, wat de PostScript-lezer niet kan +@msg the program asks for a resource that isn't defined het programma vraagt om een resource die niet is gedefinieerd +@msg a calculation has no result, as in a division by zero een berekening heeft geen uitkomst, zoals bij een deling door nul +@msg the operator looks for a mark on the stack and finds none de operator zoekt een markering op de stapel en vindt er geen @msg a bitmap is empty een bitmap is leeg @msg a bitmap is too large to read een bitmap is te groot om te lezen @msg a colour index is 0 to {_1}, not {n} een kleurindex is 0 tot {_1}, niet {n} @@ -3929,6 +4013,7 @@ imageTrace Outline Contour @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` as moet "vertical", "horizontal" of een hoek in graden zijn, niet `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" achtergrond {other}: transparant, wit, zwart of een kleur zoals "#ff8800" @msg bad color {v} onjuiste kleur {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio moet een getal van 0,1 tot 10 zijn @msg bad colour `{hex}` (#rrggbb) onjuiste kleur `{hex}` (#rrggbb) @msg bad page tree onjuiste paginaboom @msg bad shortcut onjuiste sneltoets @@ -3972,7 +4057,7 @@ imageTrace Outline Contour @msg give horizontal or vertical geef horizontaal of verticaal @msg give the path `id` and the width point `index` geef het `id` van het pad en de `index` van het breedtepunt @msg guides are locked de hulplijnen zijn vergrendeld -@msg image '{key}' has no pixels and was left out afbeelding '{key}' heeft geen pixels en is weggelaten +@msg an image has no pixels and was left out een afbeelding zonder pixels is weggelaten @msg image data `{_1}` is missing afbeeldingsgegevens `{_1}` ontbreken @msg image would be too large; lower the resolution de afbeelding zou te groot worden; verlaag de resolutie @msg index must be a number or first, previous, next or last index moet een getal zijn of first, previous, next of last @@ -3996,7 +4081,7 @@ imageTrace Outline Contour @msg missing `name` `name` ontbreekt @msg missing `tool` `tool` ontbreekt @msg missing index index ontbreekt -@msg multiple windows land with M11.5 meerdere vensters komen binnen met M11.5 +@msg New Window isn't available yet: a document opens in one window Nieuw venster is nog niet beschikbaar: een document opent in één venster @msg name the file geef het bestand een naam @msg no CSS to copy: select objects with CSS of their own geen CSS om te kopiëren: selecteer objecten met eigen CSS @msg no Live Paint edge near that point geen Live Paint-rand bij dat punt @@ -4015,6 +4100,7 @@ imageTrace Outline Contour @msg no bounds geen grenzen @msg no brush named `{name}` geen penseel met de naam `{name}` @msg no brush selected geen penseel geselecteerd +@msg brushes move only within their type penselen kunnen alleen binnen hun eigen type worden verplaatst @msg no catalog geen catalogus @msg no changes since the last save geen wijzigingen sinds de laatste keer opslaan @msg no clock to keep a heartbeat by geen klok om een hartslag bij te houden @@ -4118,6 +4204,7 @@ imageTrace Outline Contour @msg path is already closed pad is al gesloten @msg paths contain invalid coordinates paden bevatten ongeldige coördinaten @msg paths: give at least one file paden: geef minstens één bestand +@msg replace: the object to replace was changed or deleted vervangen: het te vervangen object is gewijzigd of verwijderd @msg pattern missing patroon ontbreekt @msg patterns nested too deeply are filled with mid-grey patronen die te diep genest zijn, worden gevuld met middelgrijs @msg percent must be above 0 and at most 6400, not {p} procent moet boven 0 en maximaal 6400 zijn, niet {p} @@ -4302,6 +4389,8 @@ imageTrace Outline Contour @msg Saving Bezig met opslaan @msg Saving {_1} Bezig met opslaan van {_1} @msg Exporting {_1} Bezig met exporteren van {_1} +@msg Packaging {_1} Bezig met verpakken van {_1} +@msg Placing {_1} Bezig met plaatsen van {_1} @msg Saving recovery data for {_1} Bezig met opslaan van herstelgegevens voor {_1} @msg Discarded {_1} recovered document(s) {_1} hersteld(e) document(en) verworpen @msg Restored {_1} recovered document(s): save them to keep them {_1} hersteld(e) document(en) teruggezet: sla ze op om ze te behouden @@ -4384,7 +4473,9 @@ imageTrace Outline Contour @msg automation filesystem access is not granted: read authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de leesbevoegdheid ontbreekt: {path} @msg automation filesystem access is not granted: write authority is absent: {path} toegang tot het bestandssysteem voor automatisering is niet verleend: de schrijfbevoegdheid ontbreekt: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringspad afgewezen: buiten de leesroot {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} automatiseringspad afgewezen: buiten de leesroots {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} automatiseringspad afgewezen: buiten de schrijfroot {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} automatiseringspad afgewezen: buiten de schrijfroots {roots}: {path} @msg automation path rejected: path is empty automatiseringspad afgewezen: het pad is leeg @msg automation path rejected: a link that leads nowhere: {path} automatiseringspad afgewezen: een koppeling die nergens heen leidt: {path} @msg automation path rejected: cannot resolve {path}: {e} automatiseringspad afgewezen: kan {path} niet oplossen: {e} @@ -4407,6 +4498,12 @@ imageTrace Outline Contour @msg before must name a panel before moet een paneel benoemen @msg docking action contains an unknown panel or an excessive split path dokactie bevat een onbekend paneel of een overmatig splitspad @msg group must be true or false group moet true of false zijn +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} paneel zweeft; vouw de groep samen met window.panel.float {panel, collapsed} +@msg panel is not in a dock group paneel staat niet in een dockgroep +@msg only a dock group above or below others collapses, not one beside another alleen een dockgroep boven of onder andere vouwt samen, niet een ernaast +@msg icon must be true or false icon moet true of false zijn +@msg icon only docks a panel in the icon column, with no anchor icon dockt een paneel alleen in de pictogramkolom, zonder anchor +@msg column docks a set as a dock column, with no anchor or icon column dockt een set als dockkolom, zonder anchor of icon @msg move requires a destination panel move vereist een doelpaneel @msg move requires an existing destination panel move vereist een bestaand doelpaneel @msg onto must name a floating panel onto moet een zwevend paneel benoemen @@ -4466,6 +4563,7 @@ imageTrace Outline Contour Logo 1000×1000 Logo 1000×1000 Business Card 3.5 × 2 in Visitekaartje 3.5 × 2 in Business Card 85 × 55 mm Visitekaartje 85 × 55 mm + Business Card 91 × 55 mm Visitekaartje 91 × 55 mm Letterhead Letter Briefhoofd Letter Letterhead A4 Briefhoofd A4 Envelope DL Envelop DL @@ -4793,6 +4891,9 @@ mark-style Japanese Japans Softness Zachtheid Ambient Light Omgevingslicht Light behind the object Licht achter het object + 3D Lighting Preset Voorinstelling 3D-belichting + Standard Standaard + Diffuse Diffuus # 3D light management. Lights Lichtbronnen Add Light Lichtbron toevoegen @@ -4837,6 +4938,7 @@ mark-style Japanese Japans Painting Schilderen Printing and Proofing Afdrukken en proefdrukken Tracing Tracering + Touch Touch Typography Typografie VectorCraft VectorCraft Web Web @@ -4903,6 +5005,7 @@ mark-style Japanese Japans @msg Measured anchor no longer exists Gemeten ankerpunt bestaat niet meer @msg Measured segment no longer exists Gemeten segment bestaat niet meer @msg Non-finite dimension Niet-eindige maatlijn +@msg Scale Text Attributes: selected type has no scale to bake Tekstkenmerken schalen: de geselecteerde tekst heeft geen schaling om toe te passen @msg Scale must be drawing:real, for example 1:10 Schaal moet tekening:werkelijk zijn, bijvoorbeeld 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Schaalverhouding moet tussen 1:1000000 en 1000000:1 liggen @msg Second measured segment no longer exists Tweede gemeten segment bestaat niet meer @@ -4929,6 +5032,7 @@ mark-style Japanese Japans Make Image Dynamic Afbeelding dynamisch maken Import Data Sets… Gegevenssets importeren… Export Data Sets… Gegevenssets exporteren… + Load Variable Library… Variabelenbibliotheek laden… Make Graph Dynamic Grafiek dynamisch maken Batch… Batch… Source Folder: Bronmap: @@ -5073,9 +5177,24 @@ swatch Bright Violet Felviolet @msg select the art to export first selecteer eerst de illustratie die je wilt exporteren @msg no saved selection recalled geen opgeslagen selectie opgeroepen Merge only with selection Alleen samenvoegen met selectie + Consider Open Filled Paths as Closed Open gevulde paden als gesloten beschouwen + In Merge Mode, Clicking Stroke Splits the Path In samenvoegmodus splitst klikken op de contour het pad + Pick Color From: Kleur kiezen uit: + Artwork Artwork + Cursor Swatch Preview Staalvoorbeeld bij de cursor + Selection: Selectie: + Straight Line Rechte lijn + Highlight: Markering: + Stroke When Editable Contour indien bewerkbaar Sketch Schets Halftone Pattern… Rasterpatroon… Halftone Pattern Rasterpatroon + Torn Edges… Gescheurde randen… + Torn Edges Gescheurde randen + Conté Crayon… Conté-krijt… + Conté Crayon Conté-krijt + Foreground Level Voorgrondniveau + Background Level Achtergrondniveau Pattern Type Patroontype Dot Punt Export Type: Exporttype: @@ -5104,6 +5223,8 @@ swatch Bright Violet Felviolet Gap Detection Openingen detecteren Gap Options Openingsopties Gap Preview Color: Voorbeeldkleur openingen: + Show Live Paint Gaps Live Paint-openingen tonen + Hide Live Paint Gaps Live Paint-openingen verbergen Graph Column Grafiekkolom JavaScript Files JavaScript-bestanden JavaScript: JavaScript: @@ -5124,12 +5245,24 @@ swatch Bright Violet Felviolet Lock Variables Variabelen vergrendelen Unlock Variables Variabelen ontgrendelen Change to Artboard Rulers Wijzigen in tekenbordlinialen + Colored Pencil… Gekleurd potlood… + Colored Pencil Gekleurd potlood Cutout… Knipsel… + Dry Brush… Droog penseel… Neon Glow… Neongloed… + Palette Knife… Paletmes… Poster Edges… Posterranden… + Rough Pastels… Ruwe pastels… Cutout Knipsel + Dry Brush Droog penseel Neon Glow Neongloed + Paint Daubs… Verfstipjes… + Paint Daubs Verfstipjes + Palette Knife Paletmes Poster Edges Posterranden + Rough Pastels Ruwe pastels + Underpainting… Onderlaag… + Underpainting Onderlaag Apply Data Set Gegevensset toepassen Cancel Pattern Editing Patroon bewerken annuleren Copy Appearance Uiterlijk kopiëren @@ -5207,6 +5340,12 @@ swatch Bright Violet Felviolet Typing Typen Persistent Permanent Library Panel Options Opties voor bibliotheekpaneel + Snapping Uitlijnen + Snap to Tangent Uitlijnen op raaklijn + Snap to Perpendicular Uitlijnen op loodlijn +@msg unknown snap option `{key}` (use {options}) onbekende uitlijnoptie `{key}` (gebruik {options}) + Gap Length: Lengte van opening: + Locate Library… Bibliotheek lokaliseren… @msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Opslaan schrijft {_1}, niet {_2}: gebruik file.saveAsTemplate om het te schrijven @msg `{key}` must be a #rrggbb color `{key}` moet een #rrggbb-kleur zijn @@ -5230,14 +5369,17 @@ swatch Bright Violet Felviolet @msg the image's pixels can't be read de pixels van de afbeelding kunnen niet worden gelezen @msg the spine has no segments de as heeft geen segmenten @msg variables are locked variabelen zijn vergrendeld +@msg variables that aren't text were left out: {kinds} variabelen die geen tekst zijn, zijn weggelaten: {kinds} @msg x and y must be finite x en y moeten eindig zijn @msg Persistent libraries not found: {files} Permanente bibliotheken niet gevonden: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` is geen bibliotheekpaneel (zie window.swatchLibrary) @msg library panel `{given}` isn't open bibliotheekpaneel `{given}` is niet geopend +@msg library panel `{given}` isn't missing its library de bibliotheek van bibliotheekpaneel `{given}` ontbreekt niet @msg too many library panels were opened in this session er zijn in deze sessie te veel bibliotheekpanelen geopend @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. De afbeelding is {_1} × {_2} pixels, meer dan {LARGE_TRACE_SIDE} aan één zijde: ze wordt getraceerd, maar langzamer. Een voorinstelling met minder detail of een kleinere kopie van de afbeelding traceert sneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). De afbeelding is getraceerd op {_1} × {_2} pixels, verkleind van {_3} × {_4} (maximaal {_5} megapixels). Artboard {n} of {total} Tekengebied {n} van {total} + File {n} of {total} Bestand {n} van {total} Encoding and writing the file… Bestand coderen en schrijven… @plural {n} more export waiting|{n} more exports waiting Nog {n} export in de wachtrij|Nog {n} exports in de wachtrij Continue in Background Doorgaan op achtergrond @@ -5253,6 +5395,14 @@ swatch Bright Violet Felviolet Full Justify Volledig uitvullen Minimum must not exceed Desired, nor Desired Maximum. Minimum mag niet groter zijn dan Gewenst, en Gewenst niet groter dan Maximum. Justification Uitvullen + Add filters from the list above Voeg filters toe uit de lijst hierboven + Add to Stack Toevoegen aan stapel + Applied Filters Toegepaste filters + Categories Categorieën + Choose a filter Kies een filter + Effect Gallery Effectgalerij + Filters Filters + Select artwork to apply effects Selecteer grafiek om effecten toe te passen # Select by Color (select.color) Select by Color Selecteren op kleur Smudge Stick… Uitsmeerstift… @@ -5262,6 +5412,7 @@ swatch Bright Violet Felviolet Watercolor… Aquarel… Watercolor Aquarel Brush Size Penseelgrootte + Texture Coverage Textuurdekking Definition Definitie Brush Detail Penseeldetail Shadow Intensity Schaduwintensiteit @@ -5293,3 +5444,90 @@ swatch Bright Violet Felviolet Plaster Gips Note Paper… Notitiepapier… Note Paper Notitiepapier + Water Paper… Nat papier… + Water Paper Nat papier + Ruby… Ruby… + Ruby Ruby + Geometric Guides Geometrische hulplijnen + Ease In Langzaam beginnen + Ease Out Langzaam eindigen + Ease In Out Langzaam beginnen en eindigen + Ease Out In Langzaam eindigen en beginnen + Ramp: Sterkte: + Shift: Verschuiving: + Fresco… Fresco… + Fresco Fresco + Effect Gallery… Effectgalerij… + Effect Gallery Dialog Dialoogvenster Effectgalerij + Arc Segment Tool Options Opties boogsegmentgereedschap + Base Along: Basis langs: + Closed Gesloten + Convex Concaaf + Fill Arc Boog vullen + Length X-Axis: Lengte X-as: + Length Y-Axis: Lengte Y-as: + Slope: Helling: +axis X Axis X-as +axis Y Axis Y-as +@msg Packaged {n} file(s) in {folder} {n} bestand(en) verpakt in {folder} +@msg Packaged {n} file(s) {note} {n} bestand(en) verpakt {note} +@msg Packaged {n} file(s) {n} bestand(en) verpakt + Packaged {n} file(s) {n} bestand(en) verpakt + Packaged {n} file(s) in {folder} {n} bestand(en) verpakt in {folder} + Packaged {n} file(s) {note} {n} bestand(en) verpakt {note} + {count} file(s) in {folder}. Show the package folder? {count} bestand(en) in {folder}. De pakketmap openen? + Clockwise Met de klok mee + Dither Dither + Fine noise hides banding in the gradient fill Fijne ruis verbergt banden in de verloopvulling +@msg `dither` applies to gradient fills (stroke: false) `dither` geldt voor verloopvullingen (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` geldt voor verloopvullingen (paint.setFill) +@msg `dither` must be true or false `dither` moet true of false zijn +@msg `method` must be classic or perceptual `method` moet classic of perceptual zijn +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? „{name}” installeren, dat om een groot rekenbudget vraagt? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Elke run kan tot {minutes} minuten duren en {times} keer zoveel werk doen als andere plug-ins mogen. Hij draait op de achtergrond en je kunt hem annuleren. + It may take minutes, so it runs in the background, where you can cancel it. Dit kan minuten duren, dus het draait op de achtergrond, waar je het kunt annuleren. + The plug-in is computing… De plug-in is aan het rekenen… + Inspect Plug-in Plug-in inspecteren + Cancel Plug-in Run Plug-in-run annuleren +# Actions panel: action sets (#3712). + New Set… Nieuwe set… + New Set Nieuwe set + Set Options… Setopties… + Set Options Setopties + Create New Set Nieuwe set maken + Stop Global Edit Globale bewerking stoppen + Global Edit Options Opties voor globale bewerking + Match Overeenkomst + On Artboards Op tekengebieden + All Artboards Alle tekengebieden + Include Objects On Canvas Objecten op het canvas opnemen + Result(s): {count} Resultaat/resultaten: {count} + Add to Symbols Aan symbolen toevoegen + Basic Shapes Basisvormen + Open Symbol Library Symboolbibliotheek openen + Save Symbol Library Symboolbibliotheek opslaan + Save Symbol Library… Symboolbibliotheek opslaan… + Save Symbols… Symbolen opslaan… + Selected Symbols Only Alleen geselecteerde symbolen + Symbol Libraries Menu Menu symboolbibliotheken + Symbol Library Symboolbibliotheek + User Symbol Library 1 Symboolbibliotheek van gebruiker 1 + User Symbol Library 2 Symboolbibliotheek van gebruiker 2 + User Symbol Library 3 Symboolbibliotheek van gebruiker 3 + User Symbol Library 4 Symboolbibliotheek van gebruiker 4 + User Symbol Library 5 Symboolbibliotheek van gebruiker 5 + User Symbol Library 6 Symboolbibliotheek van gebruiker 6 + User Symbol Library 7 Symboolbibliotheek van gebruiker 7 + User Symbol Library 8 Symboolbibliotheek van gebruiker 8 + User Symbol Library 9 Symboolbibliotheek van gebruiker 9 + User Symbol Library 10 Symboolbibliotheek van gebruiker 10 + {w} × {h} pt {w} × {h} pt +@msg Trap: select two or more objects filled with solid colours Overbrugging: selecteer twee of meer objecten met een egale vulkleur +@msg Trap: no edges of different colours meet among the selected objects Overbrugging: tussen de geselecteerde objecten raken geen randen van verschillende kleuren elkaar +@msg thickness must be from 0.01 to 5000 pt thickness moet tussen 0,01 en 5000 pt liggen +@msg heightWidth must be from 1 to 1000 % heightWidth moet tussen 1 en 1000 % liggen +@msg tintReduction must be from 0 to 100 % tintReduction moet tussen 0 en 100 % liggen + Rectangular Grid Tool Options Opties rechthoekig-rastergereedschap + Polar Grid Tool Options Opties polair-rastergereedschap + Fill Line Lijn vullen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 262acf329..b60324fd1 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -17,7 +17,7 @@ # # Plurals: one (1), few (2–4, 22–24, … but not 12–14), many (0, 5–21, 25–31, …). # -# Coverage: every menu string, tl! literal, plural, status and error message (enforced in tests: +# Coverage: every menu string, tl! literal, plural, status and error message (`untranslated_report` lists gaps: # `complete_menus` and `COMPLETE_MESSAGES`). Left in English on purpose (`MENU_KEEP_AS_IS` in the # tests): the product name, language names, OpenType, the built-in workspace names and the # bracketed perspective grid preset names. Keep placeholders, ellipses and the leading/trailing @@ -38,8 +38,14 @@ Medium Light Średnio jasny Light Jasny Quit VectorCraft Zakończ VectorCraft +# Artwork drag between document tabs. + Drop Artwork Upuść kompozycję +@msg {key} must be [x, y] with finite coordinates {key} musi mieć postać [x, y] ze skończonymi współrzędnymi +@msg ids must contain 1–10000 object ids ids musi zawierać od 1 do 10000 identyfikatorów obiektów + File Plik New… Nowy… + New Without Dialog Nowy bez okna dialogowego New from Template… Nowy z szablonu… Open… Otwórz… Open Recent Files Otwórz ostatnie pliki @@ -229,6 +235,8 @@ Large Gaps Duże przerwy Gap Preview Color: Kolor podglądu przerw: Close Gaps With Paths Zamknij przerwy ścieżkami + Show Live Paint Gaps Pokaż przerwy aktywnego malowania + Hide Live Paint Gaps Ukryj przerwy aktywnego malowania Select Fills Zaznaczaj wypełnienia Select Strokes Zaznaczaj obrysy Image Trace Obrys obrazu @@ -275,6 +283,13 @@ Glyphs Glify Insert Special Character Wstaw znak specjalny Bullet Punktor + Bullets Punktory + Bullets and Numbering Punktory i numeracja + Apply Bullets Zastosuj punktory + Filled Dot Pełna kropka + Dash Myślnik + Hollow Circle Pusty okrąg + Filled Square Pełny kwadrat Copyright Symbol Symbol praw autorskich Ellipsis Wielokropek Paragraph Symbol Symbol akapitu @@ -717,7 +732,7 @@ Units Jednostki Guides & Grid Linie pomocnicze i siatka Hyphenation Dzielenie wyrazów - Performance & Storage Wydajność i pamięć + Plug-ins & Scratch Disks Wtyczki i dyski robocze User Interface Interfejs użytkownika Performance Wydajność File Handling Obsługa plików @@ -767,6 +782,7 @@ Activate Panel Aktywuj panel Change Panel Layout Zmień układ paneli Close Panel Zamknij panel + Collapse Panel Group Zwiń grupę paneli Move Panel Przesuń panel Group with Grupuj z Resize panels Zmień rozmiar paneli @@ -1064,7 +1080,8 @@ lighting Light Światło to sample a color aby pobrać próbkę koloru across a selected object to set the gradient direction przez zaznaczony obiekt, aby ustawić kierunek gradientu to select an artboard | aby zaznaczyć obszar kompozycji | - on the canvas to create one na płótnie, aby go utworzyć + on the canvas to create one ( na płótnie, aby go utworzyć ( + selects several, or draws one inside an artboard) zaznacza kilka lub tworzy nowy wewnątrz obszaru kompozycji) a stroke to add a width point | obrys, aby dodać punkt szerokości | to change one side | aby zmienić jedną stronę | to select more points | aby zaznaczyć więcej punktów | @@ -1303,6 +1320,7 @@ lighting Light Światło Leading Interlinia No Break Bez podziału Proportional Metrics Metryka proporcjonalna + Character Direction Kierunek znaków Digits Cyfry Arabic Arabskie Hindi Hindi @@ -1371,6 +1389,7 @@ lighting Light Światło {count} more rules: Export writes every one. Więcej reguł: {count}. Eksport zapisze wszystkie. {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. Pominięto obiekty bez nazwy: {count}. Nadaj im nazwy lub włącz Obiekty bez nazwy w menu panelu. All Categories Wszystkie kategorie + Repeat Objects Obiekty powtórzenia Save… Zapisz… Selection Only Tylko zaznaczenie {title} (selection) {title} (zaznaczenie) @@ -1404,6 +1423,7 @@ lighting Light Światło Edit the selected point's colour in the Color panel Edytuj kolor zaznaczonego punktu w panelu Kolor Edit the stop Edytuj punkt koloru Eyedropper: click the art to sample the stop's colour Kroplomierz: kliknij kompozycję, aby pobrać kolor punktu + Eyedropper: click the art to sample the key color Kroplomierz: kliknij kompozycję, aby pobrać kolor kluczowy Gradient across stroke Gradient w poprzek obrysu Gradient along stroke Gradient wzdłuż obrysu Gradient Fill: click to apply, drag onto art Wypełnienie gradientem: kliknij, aby zastosować, przeciągnij na kompozycję @@ -1438,7 +1458,7 @@ lighting Light Światło Clear History Wyczyść historię Set History Limit… Ustaw limit historii… Open a document to see its history. Otwórz dokument, aby zobaczyć jego historię. -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Zastosuj zestaw danych Area Type Tekst akapitowy Cancel Pattern Editing Anuluj edycję wzorka @@ -1673,6 +1693,9 @@ lighting Light Światło Left Indent Wcięcie z lewej Left-to-Right Paragraph Direction Kierunek akapitu od lewej do prawej Line-end Punctuation Half Width Interpunkcja na końcu wiersza o połowie szerokości + Insert Kashidas Wstaw kaszidy + Long Długi + Stylistic Stylistyczny Mojikumi Set Zestaw mojikumi Kinsoku Set Zestaw kinsoku Hard Ścisły @@ -1696,6 +1719,13 @@ lighting Light Światło Repeat Pathfinder Powtórz odnajdywanie ścieżek Repeat {name} Powtórz {name} Trap… Zalewka… + Trap Zalewka + Height/Width Wysokość/szerokość + Tint Reduction Redukcja tinty + Traps with Process Color Zalewki kolorami procesowymi + Reverse Traps Odwrócone zalewki + Precision Dokładność + Remove Redundant Points Usuń zbędne punkty Bottom in Front Dolny na wierzchu Brick by Column Cegły według kolumn Brick by Row Cegły według wierszy @@ -1821,8 +1851,8 @@ lighting Light Światło {kind}, {model} {kind}, {model} No symbols in this document Brak symboli w tym dokumencie Select art and click New Symbol to make one. Zaznacz kompozycję i kliknij Nowy symbol, aby go utworzyć. - Symbol Libraries (on the roadmap) Biblioteki symboli (w planach) Align On: Wyrównaj do: + Bottom-Justified Tab Tabulator dolny Center-Justified Tab Tabulator środkowy Above Text Nad tekstem Position Panel Above Text Umieść panel nad tekstem @@ -1834,6 +1864,7 @@ lighting Light Światło No text selected Nie zaznaczono tekstu Repeat Tab Powtórz tabulator Right-Justified Tab Tabulator prawy + Top-Justified Tab Tabulator górny Select a text object to set its tab stops. Zaznacz obiekt tekstowy, aby ustawić jego tabulatory. Select text Zaznacz tekst Snap to Unit Przyciągaj do jednostek @@ -1948,6 +1979,13 @@ lighting Light Światło Tints and Shades Tinty i odcienie Hue Shift Przesunięcie barwy Key Color: Kolor kluczowy: + Tips Wskazówki + Colorization Tips Wskazówki dotyczące koloryzacji + The samples show this brush on a blue stroke. Próbki pokazują ten pędzel na niebieskim obrysie. + The art keeps its own colors. Kompozycja zachowuje własne kolory. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Czarny staje się kolorem obrysu, a biały pozostaje biały. Pozostałe kolory stają się tintami koloru obrysu, a im jaśniejszy jest kolor, tym jaśniejsza jest jego tinta. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Czarny pozostaje czarny, a biały pozostaje biały. Pozostałe kolory stają się ciemnymi odcieniami koloru obrysu, gdy są ciemne, i jego tintami, gdy są jasne. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Kolor kluczowy staje się kolorem obrysu. Pozostałe kolory przesuwają się po kole barw o tyle samo co kolor kluczowy i zachowują nasycenie oraz jasność. Size: Rozmiar: Calligraphic Brush Options Opcje pędzla kaligraficznego Bristle Brush Options Opcje pędzla włosiankowego @@ -2173,6 +2211,15 @@ lighting Light Światło Alt key toggles to Smooth Tool Klawisz Alt przełącza na narzędzie Wygładzanie Keep selected Zachowaj zaznaczenie Merge only with selection Scalaj tylko z zaznaczeniem + Consider Open Filled Paths as Closed Traktuj otwarte wypełnione ścieżki jako zamknięte + In Merge Mode, Clicking Stroke Splits the Path W trybie scalania kliknięcie obrysu dzieli ścieżkę + Pick Color From: Źródło koloru: + Artwork Grafika + Cursor Swatch Preview Podgląd próbek przy kursorze + Selection: Zaznaczanie: + Straight Line Linia prosta + Highlight: Podświetlenie: + Stroke When Editable Obrys, gdy edytowalny The document has no colour swatches. Dokument nie zawiera próbek kolorów. Style Name: Nazwa stylu: Crop To Kadruj do @@ -2264,11 +2311,11 @@ lighting Light Światło Fonts whose licence doesn't allow embedding are not copied. Czcionki, których licencja nie zezwala na osadzanie, nie są kopiowane. Package collects a saved document with its linked files and fonts. Pakowanie zbiera zapisany dokument wraz z połączonymi plikami i czcionkami. Package created Utworzono pakiet - Packaged {n} file(s) in {folder}{note} Spakowane pliki: {n} w {folder}{note} + Packaged {n} file(s) in {folder} {note} Spakowane pliki: {n} w {folder}{note} Packaged {n} file(s){note} Spakowane pliki: {n}{note} Relink Linked Files to the Document Połącz ponownie połączone pliki z dokumentem Save the document first? Najpierw zapisać dokument? - {count} file(s) in {folder}{note}. Show the package folder? Pliki: {count} w {folder}{note}. Pokazać folder pakietu? + {count} file(s) in {folder} {note}. Show the package folder? Pliki: {count} w {folder}{note}. Pokazać folder pakietu? {count} linked file(s) not found Nie znaleziono połączonych plików: {count} fonts not copied: {fonts} nieskopiowane czcionki: {fonts} Built-in presets are read-only: New… starts an editable copy. Wbudowane ustawienia są tylko do odczytu: Nowe… tworzy edytowalną kopię. @@ -2315,6 +2362,11 @@ lighting Light Światło File Dimensions Wymiary pliku Transforms Przekształcenia Background Color: Kolor tła: + Show Center Mark Pokaż znacznik środka + Show Cross Hairs Pokaż krzyż nitkowy + Show Video Safe Areas Pokaż bezpieczne obszary wideo + Video Ruler Pixel Aspect Ratio: Proporcje piksela linijek wideo: + Hide Video Rulers Ukryj linijki wideo Client-side (.html) Po stronie klienta (.html) Color Reduction: Redukcja kolorów: Lossless (lossy WebP isn't available yet) Bezstratny (stratny WebP nie jest jeszcze dostępny) @@ -2761,12 +2813,21 @@ lighting Light Światło Darken Ciemniej Exclusion Wykluczenie Hard Light Ostre światło + Hard Mix Mieszanie twarde Lighten Jaśniej Luminosity Jasność Overlay Nakładka Soft Light Łagodne światło + Soft Mix Miękkie mieszanie Crosshatch Kreskowanie krzyżowe Fine Dots Drobne kropki + + Simple Prosty + Light Rough Jasny szorstki + Dark Rough Ciemny szorstki + Wide Sharp Szeroki ostry + Wide Blurry Szeroki rozmyty + Sparkle Iskra Medium Dots Średnie kropki Grainy Dots Ziarniste kropki Coarse Dots Grube kropki @@ -2844,6 +2905,14 @@ theme Appearance Wygląd Edit Symbol Edytuj symbol Expand Brush Strokes Rozszerz pociągnięcia pędzla New Brush… Nowy pędzel… + New Brush Nowy pędzel + Calligraphic Brush Pędzel kaligraficzny + Scatter Brush Pędzel rozproszony + Art Brush Pędzel artystyczny + Bristle Brush Pędzel włosiankowy + Pattern Brush Pędzel wzorka + Select art to make a scatter, art or pattern brush. Zaznacz kompozycję, aby utworzyć pędzel rozproszony, artystyczny lub wzorka. + Brush Art Kompozycja pędzla New Symbol Nowy symbol New Symbol… Nowy symbol… Place Symbol Instance Umieść wystąpienie symbolu @@ -3304,6 +3373,7 @@ theme Appearance Wygląd Scale Corners Skaluj narożniki Scale Cursor Proportional to UI Skaluj kursor proporcjonalnie do interfejsu Scale Strokes & Effects Skaluj obrysy i efekty + Scale Text Attributes Skaluj atrybuty tekstu Scratch Disks Dyski robocze Secondary Dodatkowy Select Same Tint % Zaznaczaj ten sam % tinty @@ -3319,6 +3389,7 @@ theme Appearance Wygląd Size/Leading Rozmiar/interlinia Snapping Tolerance Tolerancja przyciągania Spacing Guides Linie odstępów + Invoke Distance Guides Wywołuj linie odległości Spanish Hiszpański Subdivisions Podpodziały Templates Folder Folder szablonów @@ -3400,6 +3471,7 @@ theme Appearance Wygląd Delete Swatch Usuń próbkę Duplicate Swatch Powiel próbkę Merge Swatches Scal próbki + Move Brush Przesuń pędzel Move Swatch Przesuń próbkę New Color Group Nowa grupa kolorów New Swatch Nowa próbka @@ -3602,6 +3674,8 @@ effect Regular Normalny Put back in the dock Odłóż z powrotem do doku All Scripts Wszystkie skrypty Any Kind Dowolny rodzaj + Font Technology Technologia czcionek +font-technology Variable Zmienne Decorative Ozdobne Favorites Ulubione Latin Łacińskie @@ -3861,6 +3935,7 @@ imageTrace Outline Kontur @msg Couldn't copy to the system clipboard: {e} Nie udało się skopiować do schowka systemowego: {e} @msg Couldn't open {name}: {e} Nie udało się otworzyć {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Nie udało się umieścić {name}: dokument, na który go upuszczono, został zamknięty +@msg Couldn't place {name}: the document it was placed in was closed Nie udało się umieścić {name}: dokument, w którym był umieszczany, został zamknięty @msg Couldn't read {name}: {e} Nie udało się odczytać {name}: {e} @msg Couldn't save recovery data: {e} Nie udało się zapisać danych odzyskiwania: {e} @msg Create Outlines: select text objects Utwórz kontury: zaznacz obiekty tekstowe @@ -3897,8 +3972,9 @@ imageTrace Outline Kontur @msg Opened {url} Otwarto {url} @msg Outline Stroke: no stroked paths selected Obrys konturu: nie zaznaczono ścieżek z obrysem @msg PDF settings are not an object Ustawienia PDF nie są obiektem +@msg PDF security settings are not an object Ustawienia zabezpieczeń PDF nie są obiektem @msg PDF writer error: {0} Błąd zapisu PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} Spakowane pliki: {n} w {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Spakowane pliki: {n} w {folder}{note} @msg Packaged {n} file(s){note} Spakowane pliki: {n}{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Podgląd w przeglądarce wymaga aplikacji desktopowej (wersja webowa zamiast tego pobiera pliki) @msg Release Guides: no guides Uwolnij linie pomocnicze: brak linii pomocniczych @@ -3967,6 +4043,25 @@ imageTrace Outline Kontur @msg a PostScript error ({name} in `{op}`: {at}) błąd PostScript ({name} w `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} błąd PostScript ({name} w `{op}`: {at}), w {procs} @msg a PostScript error ({name}) błąd PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) błąd PostScript ({name} w `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} błąd PostScript ({name} w `{op}`; {meaning}), w {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) błąd PostScript ({name} w `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} błąd PostScript ({name} w `{op}`: {at}; {meaning}), w {procs} +@msg a PostScript error ({name}; {meaning}) błąd PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin program kończy słownik, którego nie rozpoczął +@msg the program opens a file, which the reader can't do program otwiera plik, czego czytnik PostScript nie potrafi +@msg the program restores a saved state that was already restored program przywraca zapisany stan, który został już przywrócony +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none dane czytane przez program są uszkodzone lub urwane albo program pyta o pozycję w pliku, który jej nie ma +@msg the program goes past one of the reader's limits program przekracza jeden z limitów czytnika PostScript +@msg the path has no point to continue from ścieżka nie ma punktu, od którego można kontynuować +@msg a value is outside the range the operator takes wartość jest poza zakresem przyjmowanym przez operator +@msg the operator needs more values than the stack holds operator potrzebuje więcej wartości, niż jest na stosie +@msg part of the program isn't valid PostScript text część programu nie jest poprawnym tekstem PostScript +@msg a value has the wrong type for the operator wartość ma niewłaściwy typ dla operatora +@msg the program runs, deletes or renames a file, which the reader can't do program uruchamia, usuwa lub zmienia nazwę pliku, czego czytnik PostScript nie potrafi +@msg the program asks for a resource that isn't defined program żąda zasobu, który nie jest zdefiniowany +@msg a calculation has no result, as in a division by zero obliczenie nie ma wyniku, jak przy dzieleniu przez zero +@msg the operator looks for a mark on the stack and finds none operator szuka znacznika na stosie i go nie znajduje @msg a bitmap is empty bitmapa jest pusta @msg a bitmap is too large to read bitmapa jest zbyt duża, aby ją odczytać @msg a colour index is 0 to {_1}, not {n} indeks koloru musi mieścić się w zakresie od 0 do {_1}, nie {n} @@ -3999,6 +4094,7 @@ imageTrace Outline Kontur @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` oś musi mieć wartość "vertical", "horizontal" lub kąt w stopniach, nie `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" tło {other}: transparent, white, black lub kolor, np. "#ff8800" @msg bad color {v} nieprawidłowy kolor {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio musi być liczbą od 0,1 do 10 @msg the Color Picker is not open Próbnik kolorów nie jest otwarty @msg the Simplify slider is not open Suwak Uprość nie jest otwarty @msg the screen color sampler is already open narzędzie do pobierania koloru z ekranu jest już otwarte @@ -4049,7 +4145,7 @@ imageTrace Outline Kontur @msg give the path `id` and the width point `index` podaj `id` ścieżki i `index` punktu szerokości @msg guides are locked linie pomocnicze są zablokowane @msg handle must be in or out handle musi mieć wartość in lub out -@msg image '{key}' has no pixels and was left out obraz '{key}' nie ma pikseli i został pominięty +@msg an image has no pixels and was left out pominięto obraz bez pikseli @msg image data `{_1}` is missing brak danych obrazu `{_1}` @msg image would be too large; lower the resolution obraz byłby zbyt duży; zmniejsz rozdzielczość @msg index must be a number or first, previous, next or last indeks musi być liczbą lub first, previous, next albo last @@ -4076,7 +4172,7 @@ imageTrace Outline Kontur @msg missing index brak indeksu @msg missing number `{key}` brak liczby `{key}` @msg missing integer `{key}` brak liczby całkowitej `{key}` -@msg multiple windows land with M11.5 wiele okien pojawi się w M11.5 +@msg New Window isn't available yet: a document opens in one window Nowe okno nie jest jeszcze dostępne: dokument otwiera się w jednym oknie @msg name the file nadaj nazwę plikowi @msg no CSS to copy: select objects with CSS of their own brak CSS do skopiowania: zaznacz obiekty z własnym CSS @msg no Live Paint edge near that point brak krawędzi aktywnego malowania w pobliżu tego punktu @@ -4094,7 +4190,9 @@ imageTrace Outline Kontur @msg no blend options brak opcji przejścia @msg no bounds brak granic @msg no brush named `{name}` brak pędzla o nazwie `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} nazwa pędzla włosiankowego może mieć najwyżej {max} znaków (liczba znaków w `{name}`: {count}) @msg no brush selected nie zaznaczono pędzla +@msg brushes move only within their type pędzle można przesuwać tylko w obrębie ich typu @msg no catalog brak katalogu @msg no changes since the last save brak zmian od ostatniego zapisu @msg no clock to keep a heartbeat by brak zegara do podtrzymywania sygnału aktywności @@ -4113,6 +4211,7 @@ imageTrace Outline Kontur @msg no file system here brak systemu plików @msg no file writer brak modułu zapisu plików @msg no files to place brak plików do umieszczenia +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` nie jest dokumentem VectorCraft, plikiem PDF ani plikiem .ai zgodnym z PDF @msg no format to export to brak formatu eksportu @msg no free recovery area brak wolnego obszaru odzyskiwania @msg no gradient stop or freeform point is selected nie zaznaczono punktu koloru gradientu ani dowolnego punktu @@ -4148,6 +4247,7 @@ imageTrace Outline Kontur @msg no such action brak takiej operacji @msg no such anchor brak takiego punktu @msg no such artboard brak takiego obszaru kompozycji +@msg the artboard is locked obszar kompozycji jest zablokowany @msg no such document brak takiego dokumentu @msg no such layer or object brak takiej warstwy lub obiektu @msg no such object {0} brak obiektu {0} @@ -4205,6 +4305,7 @@ imageTrace Outline Kontur @msg path is already closed ścieżka jest już zamknięta @msg paths contain invalid coordinates ścieżki zawierają nieprawidłowe współrzędne @msg paths: give at least one file paths: podaj co najmniej jeden plik +@msg replace: the object to replace was changed or deleted replace: obiekt do zastąpienia został zmieniony lub usunięty @msg pattern missing brak wzorka @msg patterns nested too deeply are filled with mid-grey zbyt głęboko zagnieżdżone wzorki są wypełniane średnią szarością @msg percent must be above 0 and at most 6400, not {p} procent musi być większy od 0 i nie większy niż 6400, nie {p} @@ -4240,6 +4341,10 @@ imageTrace Outline Kontur @msg select a repeat zaznacz powtórzenie @msg select an Intertwine group zaznacz grupę przeplotu @msg select an Objects on Path group zaznacz grupę obiektów na ścieżce +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects do ścieżki wewnątrz ścieżki złożonej, przejścia, koperty, powtórzenia lub grupy przeplotu nie można dołączyć obiektów +@msg a clipping path can't take objects do ścieżki przycinającej nie można dołączyć obiektów +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path obiektów wewnątrz przejścia, koperty, powtórzenia lub grupy przeplotu nie można dołączyć do ścieżki +@msg a clipping path can't be attached to a path ścieżki przycinającej nie można dołączyć do ścieżki @msg select a stroke with a variable width zaznacz obrys o zmiennej szerokości @msg select a symbol instance zaznacz wystąpienie symbolu @msg select an Image Trace object zaznacz obiekt obrysu obrazu @@ -4360,6 +4465,7 @@ imageTrace Outline Kontur @msg unknown workspace (one of {_1}) nieznany obszar roboczy (jeden z: {_1}) @msg unsupported: {0} nieobsługiwane: {0} @msg variables are locked zmienne są zablokowane +@msg variables that aren't text were left out: {kinds} pominięto zmienne, które nie są tekstowe: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files plików w wersji {0} nie można kompresować: skompresowane pliki otwierają tylko wersje {COMPRESSED_SINCE} i nowsze @msg width and height are at least 1 pixel szerokość i wysokość muszą wynosić co najmniej 1 piksel @msg workspace name can't be empty nazwa obszaru roboczego nie może być pusta @@ -4371,6 +4477,7 @@ imageTrace Outline Kontur @msg {_1} font families available dostępne rodziny czcionek: {_1} @msg {_1} is a live effect (Effect › Plug-ins) {_1} to efekt na żywo (Efekt › Wtyczki) @msg {_1} is an exporter (File › Export › Export As…) {_1} to wtyczka eksportu (Plik › Eksportuj › Eksportuj jako…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` ma {size} bajtów (limit {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} jest już używany przez {_2} @msg {_1} linked file(s) can't be read here: their previews show połączonych plików nie można tu odczytać ({_1}): wyświetlane są ich podglądy @msg {_1} linked image(s) changed on disk: Update Links shows the new versions połączone obrazy zmienione na dysku: {_1} — Aktualizuj łącza pokaże nowe wersje @@ -4399,6 +4506,8 @@ imageTrace Outline Kontur @msg Saving Zapisywanie @msg Saving {_1} Zapisywanie {_1} @msg Exporting {_1} Eksportowanie {_1} +@msg Packaging {_1} Pakowanie {_1} +@msg Placing {_1} Umieszczanie {_1} @msg Saving recovery data for {_1} Zapisywanie danych odzyskiwania dla {_1} @msg Discarded {_1} recovered document(s) Odrzucone odzyskane dokumenty: {_1} @msg Restored {_1} recovered document(s): save them to keep them Przywrócone odzyskane dokumenty: {_1} — zapisz je, aby je zachować @@ -4483,7 +4592,9 @@ imageTrace Outline Kontur @msg automation filesystem access is not granted: read authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do odczytu: {path} @msg automation filesystem access is not granted: write authority is absent: {path} nie przyznano dostępu automatyzacji do systemu plików: brak uprawnień do zapisu: {path} @msg automation path rejected: outside the read root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym odczytu {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi odczytu {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} odrzucono ścieżkę automatyzacji: poza katalogiem głównym zapisu {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} odrzucono ścieżkę automatyzacji: poza katalogami głównymi zapisu {roots}: {path} @msg automation path rejected: path is empty odrzucono ścieżkę automatyzacji: ścieżka jest pusta @msg automation path rejected: a link that leads nowhere: {path} odrzucono ścieżkę automatyzacji: łącze prowadzi donikąd: {path} @msg automation path rejected: cannot resolve {path}: {e} odrzucono ścieżkę automatyzacji: nie można rozwiązać {path}: {e} @@ -4506,6 +4617,12 @@ imageTrace Outline Kontur @msg before must name a panel before musi wskazywać panel @msg docking action contains an unknown panel or an excessive split path operacja dokowania zawiera nieznany panel lub zbyt długą ścieżkę podziału @msg group must be true or false group musi mieć wartość true lub false +@msg icon must be true or false icon musi mieć wartość true lub false +@msg icon only docks a panel in the icon column, with no anchor icon tylko dokuje panel w kolumnie ikon, bez anchor +@msg column docks a set as a dock column, with no anchor or icon column dokuje zestaw jako kolumnę doku, bez anchor ani icon +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel jest pływający; zwiń jego grupę za pomocą window.panel.float {panel, collapsed} +@msg panel is not in a dock group panel nie należy do żadnej grupy doku +@msg only a dock group above or below others collapses, not one beside another zwinąć można tylko grupę doku nad lub pod innymi, a nie obok innej @msg move requires a destination panel przeniesienie wymaga panelu docelowego @msg move requires an existing destination panel przeniesienie wymaga istniejącego panelu docelowego @msg onto must name a floating panel onto musi wskazywać panel pływający @@ -4565,6 +4682,7 @@ imageTrace Outline Kontur Logo 1000×1000 Logo 1000×1000 Business Card 3.5 × 2 in Wizytówka 3,5 × 2 cale Business Card 85 × 55 mm Wizytówka 85 × 55 mm + Business Card 91 × 55 mm Wizytówka 91 × 55 mm Letterhead Letter Papier firmowy Letter Letterhead A4 Papier firmowy A4 Envelope DL Koperta DL @@ -4819,12 +4937,24 @@ imageTrace Outline Kontur DWG (use DXF) DWG (użyj DXF) Artistic Artystyczne Film Grain… Ziarno filmu… + Colored Pencil… Kolorowa kredka… + Colored Pencil Kolorowa kredka Cutout… Wycinanka… Cutout Wycinanka + Dry Brush… Suchy pędzel… + Dry Brush Suchy pędzel Neon Glow… Neonowa poświata… + Palette Knife… Szpachel… Neon Glow Neonowa poświata + Paint Daubs… Plamy farby… + Paint Daubs Plamy farby + Palette Knife Szpachel Poster Edges… Krawędzie plakatu… + Rough Pastels… Suche pastele… Poster Edges Krawędzie plakatu + Rough Pastels Suche pastele + Underpainting… Podmalówka… + Underpainting Podmalówka Levels Poziomy Edge Simplicity Prostota krawędzi Edge Fidelity Wierność krawędzi @@ -4938,6 +5068,9 @@ mark-style Japanese Japońskie Softness Miękkość Ambient Light Światło otoczenia Light behind the object Światło za obiektem + 3D Lighting Preset Ustawienie oświetlenia 3D + Standard Standardowe + Diffuse Rozproszone # 3D light management. Lights Światła Add Light Dodaj światło @@ -4995,6 +5128,7 @@ mark-style Japanese Japońskie @msg Measured anchor no longer exists Mierzony punkt kontrolny już nie istnieje @msg Measured segment no longer exists Mierzony odcinek już nie istnieje @msg Non-finite dimension Wymiar nie jest liczbą skończoną +@msg Scale Text Attributes: selected type has no scale to bake Skaluj atrybuty tekstu: wybrany tekst nie ma skali do zastosowania @msg Scale must be drawing:real, for example 1:10 Skala musi mieć postać rysunek:rzeczywistość, na przykład 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Współczynnik skali musi mieścić się między 1:1000000 a 1000000:1 @msg Second measured segment no longer exists Drugi mierzony odcinek już nie istnieje @@ -5013,6 +5147,7 @@ mark-style Japanese Japońskie Make Image Dynamic Uczyń obraz dynamicznym Import Data Sets… Importuj zestawy danych… Export Data Sets… Eksportuj zestawy danych… + Load Variable Library… Wczytaj bibliotekę zmiennych… Make Graph Dynamic Uczyń wykres dynamicznym Batch… Przetwarzanie wsadowe… Source Folder: Folder źródłowy: @@ -5036,6 +5171,12 @@ mark-style Japanese Japońskie Sketch Szkic Halftone Pattern… Wzór rastra… Halftone Pattern Wzór rastra + Torn Edges… Postrzępione krawędzie… + Torn Edges Postrzępione krawędzie + Conté Crayon… Kredka Conté… + Conté Crayon Kredka Conté + Foreground Level Poziom pierwszego planu + Background Level Poziom tła Pattern Type Typ wzoru Dot Kropka # User Interface › Color Scheme @@ -5054,6 +5195,7 @@ mark-style Japanese Japońskie Watercolor… Akwarela… Watercolor Akwarela Brush Size Rozmiar pędzla + Texture Coverage Pokrycie teksturą Definition Wyrazistość Brush Detail Szczegółowość pędzla Shadow Intensity Intensywność cieni @@ -5082,13 +5224,21 @@ mark-style Japanese Japońskie @msg the image's pixels can't be read nie można odczytać pikseli obrazu Persistent Trwały Library Panel Options Opcje panelu biblioteki + Locate Library… Znajdź bibliotekę… @msg Persistent libraries not found: {files} Nie znaleziono trwałych bibliotek: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` nie jest panelem biblioteki (zobacz window.swatchLibrary) @msg library panel `{given}` isn't open panel biblioteki `{given}` nie jest otwarty +@msg library panel `{given}` isn't missing its library panelowi biblioteki `{given}` nie brakuje biblioteki @msg too many library panels were opened in this session w tej sesji otwarto zbyt wiele paneli bibliotek @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Obraz ma {_1} × {_2} pikseli, ponad {LARGE_TRACE_SIDE} na jednym boku: zostanie obrysowany, ale wolniej. Ustawienie wstępne o niższej wierności lub mniejsza kopia obrazu obrysuje się szybciej. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Obraz obrysowano w rozdzielczości {_1} × {_2} pikseli, po zmniejszeniu z {_3} × {_4} (najwyżej {_5} megapikseli). + Snapping Przyciąganie + Snap to Tangent Przyciągaj do stycznych + Snap to Perpendicular Przyciągaj do prostopadłych +@msg unknown snap option `{key}` (use {options}) nieznana opcja przyciągania `{key}` (użyj: {options}) + Gap Length: Długość przerwy: Artboard {n} of {total} Obszar kompozycji {n} z {total} + File {n} of {total} Plik {n} z {total} Encoding and writing the file… Kodowanie i zapisywanie pliku… @plural {n} more export waiting|{n} more exports waiting Czeka jeszcze {n} eksport|Czekają jeszcze {n} eksporty|Czeka jeszcze {n} eksportów Continue in Background Kontynuuj w tle @@ -5104,6 +5254,14 @@ mark-style Japanese Japońskie Full Justify Pełne justowanie Minimum must not exceed Desired, nor Desired Maximum. Minimum nie może przekraczać wartości optymalnej, a wartość optymalna maksimum. Justification Justowanie + Add filters from the list above Dodaj filtry z listy powyżej + Add to Stack Dodaj do stosu + Applied Filters Zastosowane filtry + Categories Kategorie + Choose a filter Wybierz filtr + Effect Gallery Galeria efektów + Filters Filtry + Select artwork to apply effects Wybierz grafikę, aby zastosować efekty @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them zmienne i zestawy danych pliku nie zostały wczytane (zmienne: {variables}, zestawy danych: {data_sets}): panel Zmienne ich nie wyświetla # Select by Color (select.color) Select by Color Zaznacz według koloru @@ -5125,3 +5283,105 @@ mark-style Japanese Japońskie Plaster Gips Note Paper… Papier listowy… Note Paper Papier listowy + Water Paper… Mokry papier… + Water Paper Mokry papier + Ruby… Ruby… + Ruby Ruby + Geometric Guides Geometryczne linie pomocnicze + Ease In Łagodny początek + Ease Out Łagodny koniec + Ease In Out Łagodny początek i koniec + Ease Out In Łagodny koniec i początek + Ramp: Siła: + Shift: Przesunięcie: + Fresco… Fresk… + Fresco Fresk + Effect Gallery… Galeria efektów… + Effect Gallery Dialog Okno dialogowe Galeria efektów + Arc Segment Tool Options Opcje narzędzia Segment łuku + Base Along: Podstawa wzdłuż: + Closed Zamknięty + Convex Wypukła + Fill Arc Wypełnij łuk + Length X-Axis: Długość osi X: + Length Y-Axis: Długość osi Y: + Slope: Nachylenie: +axis X Axis Oś X +axis Y Axis Oś Y +@msg Packaged {n} file(s) in {folder} Spakowane pliki: {n} w {folder} +@msg Packaged {n} file(s) {note} Spakowane pliki: {n} {note} +@msg Packaged {n} file(s) Spakowane pliki: {n} + Packaged {n} file(s) Spakowane pliki: {n} + Packaged {n} file(s) in {folder} Spakowane pliki: {n} w {folder} + Packaged {n} file(s) {note} Spakowane pliki: {n} {note} + {count} file(s) in {folder}. Show the package folder? Pliki: {count} w {folder}. Pokazać folder pakietu? + Clockwise Zgodnie z ruchem wskazówek zegara + Dither Rozpraszanie + Fine noise hides banding in the gradient fill Drobny szum ukrywa pasy w wypełnieniu gradientem +@msg `dither` applies to gradient fills (stroke: false) `dither` dotyczy wypełnień gradientem (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` dotyczy wypełnień gradientem (paint.setFill) +@msg `dither` must be true or false `dither` musi mieć wartość true lub false +@msg `method` must be classic or perceptual `method` musi mieć wartość classic lub perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Zainstalować „{name}”, wtyczkę, która prosi o duży budżet obliczeń? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Każde uruchomienie może trwać do {minutes} minut i wykonać {times} razy więcej pracy, niż mogą inne wtyczki. Działa w tle i można je anulować. + It may take minutes, so it runs in the background, where you can cancel it. Może to potrwać kilka minut, dlatego działa w tle, gdzie można to anulować. + The plug-in is computing… Wtyczka wykonuje obliczenia… + Inspect Plug-in Sprawdź wtyczkę + Cancel Plug-in Run Anuluj działanie wtyczki +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} zakończył działanie, ale w międzyczasie zmienił się dokument lub wtyczka: uruchom ją ponownie +@msg Running “{name}”… Uruchamianie „{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy` +@msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi +# Actions panel: action sets (#3712). + New Set… Nowy zestaw… + New Set Nowy zestaw + Set Options… Opcje zestawu… + Set Options Opcje zestawu + Create New Set Utwórz nowy zestaw + Stop Global Edit Zakończ edycję globalną + Global Edit Options Opcje edycji globalnej + Match Dopasowanie + On Artboards Na obszarach kompozycji + All Artboards Wszystkie obszary kompozycji + Include Objects On Canvas Uwzględnij obiekty na płótnie + Result(s): {count} Wyniki: {count} +@msg select one object zaznacz jeden obiekt +@msg Global Edit works on paths, compound paths and symbol instances Edycja globalna działa na ścieżkach, ścieżkach złożonych i wystąpieniach symboli +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nie znaleziono podobnych obiektów (obrócone i odbite kopie nie są dopasowywane); zmień opcje edycji globalnej i spróbuj ponownie +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all lub zakres, np. 1, 3-5, a nie {v} + Add to Symbols Dodaj do symboli + Basic Shapes Kształty podstawowe + Open Symbol Library Otwórz bibliotekę symboli + Save Symbol Library Zapisz bibliotekę symboli + Save Symbol Library… Zapisz bibliotekę symboli… + Save Symbols… Zapisz symbole… + Selected Symbols Only Tylko zaznaczone symbole + Symbol Libraries Menu Menu bibliotek symboli + Symbol Library Biblioteka symboli + User Symbol Library 1 Biblioteka symboli użytkownika 1 + User Symbol Library 2 Biblioteka symboli użytkownika 2 + User Symbol Library 3 Biblioteka symboli użytkownika 3 + User Symbol Library 4 Biblioteka symboli użytkownika 4 + User Symbol Library 5 Biblioteka symboli użytkownika 5 + User Symbol Library 6 Biblioteka symboli użytkownika 6 + User Symbol Library 7 Biblioteka symboli użytkownika 7 + User Symbol Library 8 Biblioteka symboli użytkownika 8 + User Symbol Library 9 Biblioteka symboli użytkownika 9 + User Symbol Library 10 Biblioteka symboli użytkownika 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` nie ma symboli do umieszczenia +@msg missing `library` (see symbol.libraries) brak `library` (zobacz symbol.libraries) +@msg no symbol `{n}` brak symbolu `{n}` +@msg no symbol `{n}` in `{_1}` brak symbolu `{n}` w `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) brak biblioteki symboli `{key}` (zobacz symbol.libraries) +@msg the document has no symbols to save dokument nie ma symboli do zapisania +@msg Trap: select two or more objects filled with solid colours Zalewka: zaznacz co najmniej dwa obiekty wypełnione kolorem jednolitym +@msg Trap: no edges of different colours meet among the selected objects Zalewka: wśród zaznaczonych obiektów nie stykają się krawędzie różnych kolorów +@msg thickness must be from 0.01 to 5000 pt thickness musi wynosić od 0,01 do 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth musi wynosić od 1 do 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction musi wynosić od 0 do 100 % + Rectangular Grid Tool Options Opcje narzędzia Siatka prostokątna + Polar Grid Tool Options Opcje narzędzia Siatka biegunowa + Fill Line Wypełnij odcinek diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 4aace8720..945e43bc3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -19,7 +19,7 @@ # commands is kept. # # Coverage: the whole interface (every menu string and every `tl!` literal, `complete_menus`, -# tested) and the status and error messages (`@msg` rows, `COMPLETE_MESSAGES`, tested). Left in +# `untranslated_report` lists gaps) and the status and error messages (`@msg` rows, `COMPLETE_MESSAGES`, `untranslated_report` lists gaps). Left in # English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language names, OpenType, # the built-in workspace names and the bracketed perspective grid preset names. # @@ -40,7 +40,8 @@ from center | do centro | keeps its proportions) mantém suas proporções) near a selected pin to rotate | perto de um pino selecionado para girar | - on the canvas to create one na tela para criar um + on the canvas to create one ( na tela para criar um ( + selects several, or draws one inside an artboard) seleciona várias ou cria uma dentro de uma mesa de trabalho) over paths to roughen their outlines | sobre caminhos para enrugar seus contornos | over paths, and hold still to keep going | sobre caminhos, e mantenha parado para continuar | the art to add a pin | a arte para adicionar um pino | @@ -263,6 +264,11 @@ Average… Média… BLANK DOCUMENT PRESETS PREDEFINIDOS DE DOCUMENTO EM BRANCO Background Color: Cor do Plano de Fundo: + Show Center Mark Mostrar Marca Central + Show Cross Hairs Mostrar Linhas Cruzadas + Show Video Safe Areas Mostrar Áreas Seguras de Vídeo + Video Ruler Pixel Aspect Ratio: Proporção de Pixel das Réguas de Vídeo: + Hide Video Rulers Ocultar Réguas de Vídeo Background Contents Conteúdo do Plano de Fundo Background Contents: Conteúdo do Plano de Fundo: Background: Plano de Fundo: @@ -316,6 +322,13 @@ Built-in presets don't change: New… starts an editable copy. Predefinidos integrados não mudam: Novo… inicia uma cópia editável. Bulge… Estufar… Bullet Marcador + Bullets Marcadores + Bullets and Numbering Marcadores e Numeração + Apply Bullets Aplicar Marcadores + Filled Dot Ponto Preenchido + Dash Travessão + Hollow Circle Círculo Vazado + Filled Square Quadrado Preenchido Butt Cap Extremidade Reta Byte Order: Ordem dos Bytes: CMYK CMYK @@ -907,6 +920,7 @@ Export CSS Exportar CSS Export CSS… Exportar CSS… Export Data Sets… Exportar Conjuntos de Dados… + Load Variable Library… Carregar Biblioteca de Variáveis… Export DXF Exportar DXF Export Document Exportar Documento Export EPS Exportar EPS @@ -937,6 +951,7 @@ Eyedropper Picks Up: O Conta-gotas Capta: Eyedropper Tool Ferramenta Conta-gotas Eyedropper: click the art to sample the stop's colour Conta-gotas: clique na arte para colher a cor da parada + Eyedropper: click the art to sample the key color Conta-gotas: clique na arte para colher a cor-chave Feather… Penugem… Feet Pés Feet & Inches Pés e Polegadas @@ -944,6 +959,11 @@ Fidelity: Fidelidade: Figure Space Espaço de Figura Figure: Figura: +# Artwork drag between document tabs. + Drop Artwork Soltar arte +@msg {key} must be [x, y] with finite coordinates {key} deve ser [x, y] com coordenadas finitas +@msg ids must contain 1–10000 object ids ids deve conter de 1 a 10000 IDs de objetos + File Arquivo File Format: Formato do Arquivo: File Formats Formatos de Arquivo @@ -966,6 +986,15 @@ Alt key toggles to Smooth Tool A tecla Alt alterna para a ferramenta Suavizar Keep selected Manter selecionado Merge only with selection Mesclar somente com a seleção + Consider Open Filled Paths as Closed Tratar caminhos abertos preenchidos como fechados + In Merge Mode, Clicking Stroke Splits the Path No modo de mesclagem, clicar no traçado divide o caminho + Pick Color From: Origem da cor: + Artwork Ilustração + Cursor Swatch Preview Visualização de amostras junto ao cursor + Selection: Seleção: + Straight Line Linha reta + Highlight: Destaque: + Stroke When Editable Traçado quando editável Fill with Placeholder Text Preencher com Texto de Placeholder Insert Inline Symbol Inserir Símbolo no Texto Fill: Preenchimento: @@ -1057,6 +1086,8 @@ Large Gaps Lacunas Grandes Gap Preview Color: Cor de Visualização das Lacunas: Close Gaps With Paths Fechar Lacunas com Caminhos + Show Live Paint Gaps Mostrar Lacunas da Pintura ao Vivo + Hide Live Paint Gaps Ocultar Lacunas da Pintura ao Vivo Select Fills Selecionar Preenchimentos Select Strokes Selecionar Traçados Gaussian Blur… Desfoque Gaussiano… @@ -1127,6 +1158,7 @@ Hand-Drawn Desenhado à Mão Handles Alças Hard Light Luz Forte + Hard Mix Mistura definida Harmony Rules: Regras de Harmonia: Harmony Sets Conjuntos de Harmonia Height Altura @@ -1169,7 +1201,7 @@ Clear History Limpar Histórico Set History Limit… Definir Limite do Histórico… History States Estados do Histórico -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Aplicar Conjunto de Dados Cancel Pattern Editing Cancelar Edição de Padrão Copy Appearance Copiar Aparência @@ -1562,6 +1594,7 @@ Move Slices Mover Fatias Move Spine Point Mover Ponto de Coluna Reshape Spine Segment Remodelar Segmento da Coluna + Move Brush Mover Pincel Move Swatch Mover Amostra Move Up Mover para Cima Move in Perspective Mover em Perspectiva @@ -1581,6 +1614,14 @@ New Artboard Nova Mesa de Trabalho New Bristle Brush Novo Pincel de Cerdas New Brush… Novo Pincel… + New Brush Novo Pincel + Calligraphic Brush Pincel Caligráfico + Scatter Brush Pincel de Dispersão + Art Brush Pincel de Arte + Bristle Brush Pincel de Cerdas + Pattern Brush Pincel de Padrão + Select art to make a scatter, art or pattern brush. Selecione arte para criar um pincel de dispersão, de arte ou de padrão. + Brush Art Arte do Pincel New Calligraphic Brush Novo Pincel Caligráfico New Character Style Novo Estilo de Caractere New Color Group Novo Grupo de Cores @@ -1604,6 +1645,7 @@ New Symbol… Novo Símbolo… New View… Nova Visualização… New Window Nova Janela + New Without Dialog Novo sem Diálogo New Workspace Novo Espaço de Trabalho New Workspace… Novo Espaço de Trabalho… New file Novo arquivo @@ -1845,7 +1887,7 @@ Percent: Porcentagem: Perceptual Scales Escalas Perceptuais Performance Desempenho - Performance & Storage Desempenho e Armazenamento + Plug-ins & Scratch Disks Plug-ins & Discos de Cópia Permissions Permissões Permissions password: Senha de permissões: Perspective Perspectiva @@ -1953,6 +1995,7 @@ Proof Setup Configuração de Prova Properties Propriedades Proportional Metrics Métrica Proporcional + Character Direction Direção de Caracteres Digits Dígitos Arabic Arábicos Hindi Hindi @@ -2117,6 +2160,7 @@ Reorder Reordenar Reorder Appearance Item Reordenar Item de Aparência Repeat Repetir + Repeat Objects Objetos de Repetição Repeat Pathfinder Repetir Localizador de Caminhos Repeat Tab Repetir Parada de Tabulação Repeat {name} Repetir {name} @@ -2291,6 +2335,7 @@ Scale Cursor Proportional to UI Dimensionar Cursor Proporcionalmente à Interface Scale Lineweights Dimensionar Espessuras de Linha Scale Strokes & Effects Dimensionar Traçados e Efeitos + Scale Text Attributes Dimensionar Atributos de Texto Scale Tool Ferramenta Escala Scale to Fit Artboard Dimensionar para Ajustar à Mesa de Trabalho Scale: Escala: @@ -2517,6 +2562,7 @@ Snap to Unit Ajustar à Unidade Snapping Tolerance Tolerância de Ajuste Soft Light Luz Suave + Soft Mix Mistura suave Solid Sólido Sort by Kind Ordenar por Tipo Sort by Name Ordenar por Nome @@ -2525,6 +2571,7 @@ Space Before Paragraph Espaço Antes do Parágrafo Spaces Espaços Spacing Guides Guias de Espaçamento + Invoke Distance Guides Invocar Guias de Distância Spacing: Espaçamento: Spanish Espanhol Specify: Especificar: @@ -2596,7 +2643,6 @@ Switch workspace Trocar espaço de trabalho Symbol Instance Instância de Símbolo Symbol Libraries Bibliotecas de Símbolos - Symbol Libraries (on the roadmap) Bibliotecas de Símbolos (no roadmap) Symbol Screener Tool Ferramenta Peneira de Símbolos Symbol Scruncher Tool Ferramenta Amontoar Símbolos Symbol Shifter Tool Ferramenta Deslocador de Símbolos @@ -2699,6 +2745,13 @@ Transparent Background Fundo Transparente Transverse (the page a quarter turn on the paper) Transversal (a página um quarto de volta no papel) Trap… Armadilha… + Trap Armadilha + Height/Width Altura/largura + Tint Reduction Redução de matiz + Traps with Process Color Armadilhas com cores de escala + Reverse Traps Inverter armadilhas + Precision Precisão + Remove Redundant Points Remover pontos redundantes Trim Aparar Trim View Modo de Aparar Trim mark weight: Peso da marca de corte: @@ -2904,7 +2957,7 @@ the preset o predefinido tile {number} bloco {number} to para - {count} file(s) in {folder}{note}. Show the package folder? {count} arquivo(s) em {folder}{note}. Mostrar a pasta do pacote? + {count} file(s) in {folder} {note}. Show the package folder? {count} arquivo(s) em {folder}{note}. Mostrar a pasta do pacote? {count} linked file(s) not found {count} arquivo(s) vinculado(s) não encontrado(s) fonts not copied: {fonts} fontes não copiadas: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} imagem(ns) vinculada(s) alterada(s) desde que este documento foi salvo. Mostrar as novas versões? @@ -3030,6 +3083,7 @@ imageTrace Outline Contorno Activate Panel Ativar Painel Change Panel Layout Alterar Layout dos Painéis Close Panel Fechar Painel + Collapse Panel Group Recolher Grupo de Painéis Move Panel Mover Painel Group with Agrupar com Resize panels Redimensionar painéis @@ -3070,6 +3124,8 @@ lighting Light Luz All Scripts Todos os scripts Anchors: Âncoras: Any Kind Qualquer tipo + Font Technology Tecnologia da Fonte +font-technology Variable Variáveis Apply Aplicar Ellipse Properties: Propriedades da elipse: Invert Pie Inverter fatia @@ -3133,6 +3189,9 @@ lighting Light Luz Lock Others Bloquear outros Merge Selected Mesclar selecionados Model Modelo + Insert Kashidas Inserir Kashidas + Long Longo + Stylistic Estilístico Mojikumi Set Conjunto Mojikumi Kinsoku Set Conjunto Kinsoku Hard Duro @@ -3172,6 +3231,13 @@ lighting Light Luz Tints and Shades Matizes e sombras Hue Shift Deslocamento de matiz Key Color: Cor-chave: + Tips Dicas + Colorization Tips Dicas de colorização + The samples show this brush on a blue stroke. As amostras mostram este pincel em um traçado azul. + The art keeps its own colors. A arte mantém as próprias cores. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. O preto passa a ser a cor do traçado e o branco continua branco. As outras cores passam a ser matizes da cor do traçado, e quanto mais clara é uma cor, mais claro é o seu matiz. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. O preto continua preto e o branco continua branco. As outras cores passam a ser sombras da cor do traçado quando são escuras e matizes dela quando são claras. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. A cor-chave passa a ser a cor do traçado. As outras cores giram na roda de cores tanto quanto a cor-chave e mantêm a saturação e o brilho. Shape: Forma: New Artboard Options… Opções de nova mesa de trabalho… New Layer Options… Opções de nova camada… @@ -3326,6 +3392,7 @@ axis Both Ambos @msg Couldn't copy to the system clipboard: {e} Não foi possível copiar para a área de transferência do sistema: {e} @msg Couldn't open {name}: {e} Não foi possível abrir {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Não foi possível inserir {name}: o documento em que foi solto foi fechado +@msg Couldn't place {name}: the document it was placed in was closed Não foi possível inserir {name}: o documento em que estava sendo inserido foi fechado @msg Couldn't read {name}: {e} Não foi possível ler {name}: {e} @msg Couldn't save recovery data: {e} Não foi possível salvar os dados de recuperação: {e} @msg Create Outlines: select text objects Criar contornos: selecione objetos de texto @@ -3362,13 +3429,14 @@ axis Both Ambos @msg Opened {url} {url} aberto @msg Outline Stroke: no stroked paths selected Contornar traço: nenhum traçado com traço selecionado @msg PDF settings are not an object as configurações de PDF não são um objeto +@msg PDF security settings are not an object as configurações de segurança de PDF não são um objeto @msg PDF writer error: {0} Erro do gravador de PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} {n} arquivo(s) empacotado(s) em {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} {n} arquivo(s) empacotado(s) em {folder}{note} @msg Packaged {n} file(s){note} {n} arquivo(s) empacotado(s){note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Visualizar no navegador exige o aplicativo para desktop (na web, os arquivos são baixados) @msg Release Guides: no guides Liberar guias: não há guias @msg Release: no layer to put the rectangle in Liberar: não há camada para colocar o retângulo -@msg Replace Spine: select a blend and a path Substituir eixo: selecione uma mesclagem e um traçado +@msg Replace Spine: select a blend and a path Substituir coluna: selecione uma mesclagem e um traçado @msg select two or more objects to distribute selecione dois ou mais objetos para distribuir @msg select paths or anchor points selecione traçados ou pontos de ancoragem @msg SVG parse error: {0} Erro de análise do SVG: {0} @@ -3431,6 +3499,25 @@ axis Both Ambos @msg a PostScript error ({name} in `{op}`: {at}) um erro de PostScript ({name} em `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} um erro de PostScript ({name} em `{op}`: {at}), em {procs} @msg a PostScript error ({name}) um erro de PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) um erro de PostScript ({name} em `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} um erro de PostScript ({name} em `{op}`; {meaning}), em {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) um erro de PostScript ({name} em `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} um erro de PostScript ({name} em `{op}`: {at}; {meaning}), em {procs} +@msg a PostScript error ({name}; {meaning}) um erro de PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin o programa encerra um dicionário que não iniciou +@msg the program opens a file, which the reader can't do o programa abre um arquivo, o que o leitor de PostScript não consegue fazer +@msg the program restores a saved state that was already restored o programa restaura um estado salvo que já foi restaurado +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none os dados que o programa lê estão danificados ou cortados, ou o programa pede uma posição em um arquivo que não tem posição +@msg the program goes past one of the reader's limits o programa ultrapassa um dos limites do leitor de PostScript +@msg the path has no point to continue from o traçado não tem um ponto a partir do qual continuar +@msg a value is outside the range the operator takes um valor está fora do intervalo aceito pelo operador +@msg the operator needs more values than the stack holds o operador precisa de mais valores do que a pilha contém +@msg part of the program isn't valid PostScript text parte do programa não é texto PostScript válido +@msg a value has the wrong type for the operator um valor tem o tipo errado para o operador +@msg the program runs, deletes or renames a file, which the reader can't do o programa executa, exclui ou renomeia um arquivo, o que o leitor de PostScript não consegue fazer +@msg the program asks for a resource that isn't defined o programa pede um recurso que não está definido +@msg a calculation has no result, as in a division by zero um cálculo não tem resultado, como em uma divisão por zero +@msg the operator looks for a mark on the stack and finds none o operador procura uma marca na pilha e não encontra nenhuma @msg a bitmap is empty um bitmap está vazio @msg a bitmap is too large to read um bitmap é grande demais para ser lido @msg a colour index is 0 to {_1}, not {n} um índice de cor vai de 0 a {_1}, não {n} @@ -3462,6 +3549,7 @@ axis Both Ambos @msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard nesta escala o desenho tem {w} × {h} pt, mais do que a tela comporta: escolha uma escala menor ou ajuste-o à prancheta @msg background {other}: transparent, white, black or a colour such as "#ff8800" plano de fundo {other}: transparent, white, black ou uma cor como "#ff8800" @msg bad color {v} cor inválida {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio deve ser um número de 0,1 a 10 @msg bad colour `{hex}` (#rrggbb) cor inválida `{hex}` (#rrggbb) @msg bad page tree árvore de páginas inválida @msg bad shortcut atalho inválido @@ -3507,7 +3595,7 @@ axis Both Ambos @msg give the path `id` and the width point `index` informe o `id` do traçado e o `index` do ponto de largura @msg guides are locked as guias estão bloqueadas @msg handle must be in or out handle deve ser in ou out -@msg image '{key}' has no pixels and was left out a imagem '{key}' não tem pixels e foi omitida +@msg an image has no pixels and was left out uma imagem sem pixels foi omitida @msg image data `{_1}` is missing os dados da imagem `{_1}` estão ausentes @msg image would be too large; lower the resolution a imagem ficaria grande demais; reduza a resolução @msg index must be a number or first, previous, next or last index deve ser um número ou first, previous, next ou last @@ -3534,7 +3622,7 @@ axis Both Ambos @msg missing index falta o índice @msg missing number `{key}` falta o número `{key}` @msg missing integer `{key}` falta o número inteiro `{key}` -@msg multiple windows land with M11.5 várias janelas chegam com o M11.5 +@msg New Window isn't available yet: a document opens in one window Nova Janela ainda não está disponível: um documento abre em uma única janela @msg name the file dê um nome ao arquivo @msg no CSS to copy: select objects with CSS of their own não há CSS para copiar: selecione objetos que tenham CSS próprio @msg no Live Paint edge near that point nenhuma aresta de Pintura dinâmica perto desse ponto @@ -3552,7 +3640,9 @@ axis Both Ambos @msg no blend options sem opções de mesclagem @msg no bounds sem limites @msg no brush named `{name}` nenhum pincel chamado `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} o nome de um pincel de cerdas pode ter no máximo {max} caracteres: `{name}` tem {count} @msg no brush selected nenhum pincel selecionado +@msg brushes move only within their type os pincéis só podem ser movidos dentro do seu tipo @msg no catalog sem catálogo @msg no changes since the last save nenhuma alteração desde o último salvamento @msg no clock to keep a heartbeat by sem relógio para manter o sinal de atividade @@ -3571,6 +3661,7 @@ axis Both Ambos @msg no file system here nenhum sistema de arquivos aqui @msg no file writer sem gravador de arquivos @msg no files to place nenhum arquivo para inserir +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` não é um documento do VectorCraft, um PDF nem um arquivo .ai compatível com PDF @msg no format to export to nenhum formato para exportar @msg no free recovery area nenhuma área de recuperação livre @msg no gradient stop or freeform point is selected nenhuma parada de gradiente ou ponto de forma livre selecionado @@ -3606,6 +3697,7 @@ axis Both Ambos @msg no such action ação inexistente @msg no such anchor âncora inexistente @msg no such artboard prancheta inexistente +@msg the artboard is locked a prancheta está bloqueada @msg no such document documento inexistente @msg no such layer or object camada ou objeto inexistente @msg no such object {0} objeto inexistente {0} @@ -3660,6 +3752,7 @@ axis Both Ambos @msg path is already closed o traçado já está fechado @msg paths contain invalid coordinates os traçados contêm coordenadas inválidas @msg paths: give at least one file paths: informe pelo menos um arquivo +@msg replace: the object to replace was changed or deleted replace: o objeto a ser substituído foi alterado ou excluído @msg pattern missing padrão ausente @msg patterns nested too deeply are filled with mid-grey padrões aninhados com profundidade excessiva são preenchidos com cinza médio @msg percent must be above 0 and at most 6400, not {p} a porcentagem deve ser maior que 0 e no máximo 6400, não {p} @@ -3695,6 +3788,10 @@ axis Both Ambos @msg select a repeat selecione uma repetição @msg select an Intertwine group selecione um grupo entrelaçado @msg select an Objects on Path group selecione um grupo de objetos no caminho +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects um caminho dentro de um caminho composto, mesclagem, envelope, repetição ou grupo entrelaçado não pode receber objetos +@msg a clipping path can't take objects um caminho de recorte não pode receber objetos +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objetos dentro de uma mesclagem, envelope, repetição ou grupo entrelaçado não podem ser anexados a um caminho +@msg a clipping path can't be attached to a path um caminho de recorte não pode ser anexado a um caminho @msg select a stroke with a variable width selecione um traço com largura variável @msg select a symbol instance selecione uma instância de símbolo @msg select an Image Trace object selecione um objeto de Vetorização de imagem @@ -3814,6 +3911,7 @@ axis Both Ambos @msg unknown workspace (one of {_1}) espaço de trabalho desconhecido (um de {_1}) @msg unsupported: {0} não suportado: {0} @msg variables are locked as variáveis estão bloqueadas +@msg variables that aren't text were left out: {kinds} as variáveis que não são de texto foram omitidas: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files arquivos da versão {0} não podem ser compactados: somente a versão {COMPRESSED_SINCE} e posteriores abrem arquivos compactados @msg width and height are at least 1 pixel largura e altura têm pelo menos 1 pixel @msg workspace name can't be empty o nome do espaço de trabalho não pode estar vazio @@ -3825,6 +3923,7 @@ axis Both Ambos @msg {_1} font families available {_1} famílias de fontes disponíveis @msg {_1} is a live effect (Effect › Plug-ins) {_1} é um efeito dinâmico (Efeito › Plug-ins) @msg {_1} is an exporter (File › Export › Export As…) {_1} é um plug-in de exportação (Arquivo › Exportar › Exportar Como…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` tem {size} bytes (limite {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} já é usado por {_2} @msg {_1} linked file(s) can't be read here: their previews show {_1} arquivo(s) vinculado(s) não podem ser lidos aqui: suas visualizações são exibidas @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} imagem(ns) vinculada(s) mudou(aram) no disco: Atualizar links mostra as novas versões @@ -3852,6 +3951,8 @@ axis Both Ambos @msg Saving Salvando @msg Saving {_1} Salvando {_1} @msg Exporting {_1} Exportando {_1} +@msg Packaging {_1} Empacotando {_1} +@msg Placing {_1} Inserindo {_1} @msg Saving recovery data for {_1} Salvando dados de recuperação de {_1} @msg Discarded {_1} recovered document(s) {_1} documento(s) recuperado(s) descartado(s) @msg Restored {_1} recovered document(s): save them to keep them {_1} documento(s) recuperado(s) restaurado(s): salve-os para mantê-los @@ -3932,7 +4033,9 @@ axis Both Ambos @msg automation filesystem access is not granted: read authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de leitura: {path} @msg automation filesystem access is not granted: write authority is absent: {path} o acesso da automação ao sistema de arquivos não foi concedido: falta autorização de gravação: {path} @msg automation path rejected: outside the read root {root}: {path} caminho da automação rejeitado: fora da raiz de leitura {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} caminho da automação rejeitado: fora das raízes de leitura {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} caminho da automação rejeitado: fora da raiz de gravação {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} caminho da automação rejeitado: fora das raízes de gravação {roots}: {path} @msg automation path rejected: path is empty caminho da automação rejeitado: o caminho está vazio @msg automation path rejected: a link that leads nowhere: {path} caminho da automação rejeitado: um link que não leva a lugar nenhum: {path} @msg automation path rejected: cannot resolve {path}: {e} caminho da automação rejeitado: não foi possível resolver {path}: {e} @@ -3958,6 +4061,12 @@ axis Both Ambos @msg before must name a panel before deve nomear um painel @msg docking action contains an unknown panel or an excessive split path a ação de encaixe contém um painel desconhecido ou um caminho de divisão excessivo @msg group must be true or false group deve ser true ou false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} o painel está flutuando; recolha o grupo com window.panel.float {panel, collapsed} +@msg panel is not in a dock group o painel não está em um grupo do encaixe +@msg only a dock group above or below others collapses, not one beside another só um grupo do encaixe acima ou abaixo de outros é recolhido, não um ao lado +@msg icon must be true or false icon deve ser true ou false +@msg icon only docks a panel in the icon column, with no anchor icon apenas encaixa um painel na coluna de ícones, sem anchor +@msg column docks a set as a dock column, with no anchor or icon column encaixa um conjunto como coluna do encaixe, sem anchor nem icon @msg move requires a destination panel mover exige um painel de destino @msg move requires an existing destination panel mover exige um painel de destino existente @msg onto must name a floating panel onto deve nomear um painel flutuante @@ -3977,12 +4086,24 @@ axis Both Ambos Reset Ruler Origin Redefinir origem das réguas Artistic Artístico Film Grain… Granulação de filme… + Colored Pencil… Lápis de cor… + Colored Pencil Lápis de cor Cutout… Recorte… Cutout Recorte + Dry Brush… Pincel seco… + Dry Brush Pincel seco Neon Glow… Brilho neon… + Palette Knife… Espátula… Neon Glow Brilho neon + Paint Daubs… Manchas de tinta… + Paint Daubs Manchas de tinta + Palette Knife Espátula Poster Edges… Bordas de pôster… + Rough Pastels… Pastéis ásperos… Poster Edges Bordas de pôster + Rough Pastels Pastéis ásperos + Underpainting… Subpintura… + Underpainting Subpintura Levels Níveis Edge Simplicity Simplicidade das bordas Edge Fidelity Fidelidade das bordas @@ -4020,6 +4141,9 @@ axis Both Ambos Softness Suavidade Ambient Light Luz ambiente Light behind the object Luz atrás do objeto + 3D Lighting Preset Predefinição de iluminação 3D + Standard Padrão + Diffuse Difusa # 3D light management. Lights Luzes Add Light Adicionar luz @@ -4042,6 +4166,13 @@ axis Both Ambos Channel 4 Canal 4 Cell Size Tamanho da Célula Fine Dots Pontos Finos + + Simple Simples + Light Rough Áspero claro + Dark Rough Áspero escuro + Wide Sharp Largo nítido + Wide Blurry Largo borrado + Sparkle Brilho Medium Dots Pontos Médios Grainy Dots Pontos Granulados Coarse Dots Pontos Grossos @@ -4082,6 +4213,7 @@ axis Both Ambos Black and White Logo Logotipo em Preto e Branco Bottom Inferior Bottom in Front Inferior na Frente + Bottom-Justified Tab Parada de Tabulação Inferior Bounds Limites Bounds (Fill Proportionally) Limites (Preencher Proporcionalmente) Bounds (Fit Proportionally) Limites (Ajustar Proporcionalmente) @@ -4335,7 +4467,7 @@ axis Both Ambos Output Black Preto de Saída Output White Branco de Saída Output › Mode Saída › Modo - Packaged {n} file(s) in {folder}{note} {n} arquivo(s) empacotado(s) em {folder}{note} + Packaged {n} file(s) in {folder} {note} {n} arquivo(s) empacotado(s) em {folder}{note} Packaged {n} file(s){note} {n} arquivo(s) empacotado(s){note} Page information Informações da página Paragraph Style Estilo de Parágrafo @@ -4465,6 +4597,7 @@ axis Both Ambos Toolbar: Reset Barra de Ferramentas: Redefinir Top Superior Top in Front Superior na Frente + Top-Justified Tab Parada de Tabulação Superior Transforms Transformações Transparent (Default) Transparente (Padrão) Trim marks Marcas de corte @@ -4652,6 +4785,7 @@ effect Regular Regular Logo 1000×1000 Logotipo 1000×1000 Business Card 3.5 × 2 in Cartão de Visita 3.5 × 2 pol Business Card 85 × 55 mm Cartão de Visita 85 × 55 mm + Business Card 91 × 55 mm Cartão de Visita 91 × 55 mm Letterhead Letter Papel Timbrado Carta Letterhead A4 Papel Timbrado A4 Envelope DL Envelope DL @@ -5068,6 +5202,7 @@ mark-style Japanese Japonesas @msg Measured anchor no longer exists O ponto de ancoragem medido não existe mais @msg Measured segment no longer exists O segmento medido não existe mais @msg Non-finite dimension Cota não finita +@msg Scale Text Attributes: selected type has no scale to bake Dimensionar Atributos de Texto: o texto selecionado não tem escala para aplicar @msg Scale must be drawing:real, for example 1:10 A escala deve ser desenho:real, por exemplo 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 A proporção da escala deve estar entre 1:1000000 e 1000000:1 @msg Second measured segment no longer exists O segundo segmento medido não existe mais @@ -5103,6 +5238,12 @@ mark-style Japanese Japonesas Sketch Esboço Halftone Pattern… Padrão de meio-tom… Halftone Pattern Padrão de meio-tom + Torn Edges… Bordas rasgadas… + Torn Edges Bordas rasgadas + Conté Crayon… Lápis Conté… + Conté Crayon Lápis Conté + Foreground Level Nível do primeiro plano + Background Level Nível do plano de fundo Pattern Type Tipo de padrão Dot Ponto # User Interface › Color Scheme @@ -5119,6 +5260,7 @@ mark-style Japanese Japonesas Watercolor… Aquarela… Watercolor Aquarela Brush Size Tamanho do pincel + Texture Coverage Cobertura da textura Definition Definição Brush Detail Detalhe do pincel Shadow Intensity Intensidade da sombra @@ -5147,13 +5289,21 @@ mark-style Japanese Japonesas @msg the image's pixels can't be read não é possível ler os pixels da imagem Persistent Persistente Library Panel Options Opções do Painel de Biblioteca + Locate Library… Localizar Biblioteca… @msg Persistent libraries not found: {files} Bibliotecas persistentes não encontradas: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` não é um painel de biblioteca (consulte window.swatchLibrary) @msg library panel `{given}` isn't open o painel de biblioteca `{given}` não está aberto +@msg library panel `{given}` isn't missing its library a biblioteca do painel de biblioteca `{given}` não está faltando @msg too many library panels were opened in this session foram abertos painéis de biblioteca demais nesta sessão @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. A imagem tem {_1} × {_2} pixels, mais de {LARGE_TRACE_SIDE} em um lado: ela é traçada, mas mais devagar. Uma predefinição de menor fidelidade ou uma cópia menor da imagem traça mais rápido. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). A imagem foi traçada em {_1} × {_2} pixels, reduzida de {_3} × {_4} (no máximo {_5} megapixels). + Snapping Ajuste + Snap to Tangent Ajustar à Tangente + Snap to Perpendicular Ajustar à Perpendicular +@msg unknown snap option `{key}` (use {options}) opção de ajuste desconhecida `{key}` (use {options}) + Gap Length: Comprimento da Lacuna: Artboard {n} of {total} Mesa de Trabalho {n} de {total} + File {n} of {total} Arquivo {n} de {total} Encoding and writing the file… Codificando e gravando o arquivo… @plural {n} more export waiting|{n} more exports waiting Mais {n} exportação aguardando|Mais {n} exportações aguardando Continue in Background Continuar em Segundo Plano @@ -5169,6 +5319,14 @@ mark-style Japanese Japonesas Full Justify Justificação Completa Minimum must not exceed Desired, nor Desired Maximum. O mínimo não pode passar do desejado, nem o desejado do máximo. Justification Justificação + Add filters from the list above Adicione filtros da lista acima + Add to Stack Adicionar à Pilha + Applied Filters Filtros Aplicados + Categories Categorias + Choose a filter Escolha um filtro + Effect Gallery Galeria de Efeitos + Filters Filtros + Select artwork to apply effects Selecione a arte para aplicar efeitos @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them as variáveis e os conjuntos de dados do arquivo não foram lidos (variáveis: {variables}, conjuntos de dados: {data_sets}): o painel Variáveis não os lista # Select by Color (select.color) Select by Color Selecionar por Cor @@ -5190,3 +5348,105 @@ mark-style Japanese Japonesas Plaster Gesso Note Paper… Papel de carta… Note Paper Papel de carta + Water Paper… Papel molhado… + Water Paper Papel molhado + Ruby… Ruby… + Ruby Ruby + Geometric Guides Guias geométricas + Ease In Entrada suave + Ease Out Saída suave + Ease In Out Entrada e saída suaves + Ease Out In Saída e entrada suaves + Ramp: Intensidade: + Shift: Deslocamento: + Fresco… Afresco… + Fresco Afresco + Effect Gallery… Galeria de Efeitos… + Effect Gallery Dialog Caixa de diálogo Galeria de Efeitos + Arc Segment Tool Options Opções da Ferramenta Segmento de Arco + Base Along: Base ao longo de: + Closed Fechado + Convex Convexo + Fill Arc Preencher arco + Length X-Axis: Comprimento eixo X: + Length Y-Axis: Comprimento eixo Y: + Slope: Inclinação: +axis X Axis Eixo X +axis Y Axis Eixo Y +@msg Packaged {n} file(s) in {folder} {n} arquivo(s) empacotado(s) em {folder} +@msg Packaged {n} file(s) {note} {n} arquivo(s) empacotado(s) {note} +@msg Packaged {n} file(s) {n} arquivo(s) empacotado(s) + Packaged {n} file(s) {n} arquivo(s) empacotado(s) + Packaged {n} file(s) in {folder} {n} arquivo(s) empacotado(s) em {folder} + Packaged {n} file(s) {note} {n} arquivo(s) empacotado(s) {note} + {count} file(s) in {folder}. Show the package folder? {count} arquivo(s) em {folder}. Mostrar a pasta do pacote? + Clockwise Sentido horário + Dither Dither + Fine noise hides banding in the gradient fill Um ruído fino esconde as faixas do preenchimento em gradiente +@msg `dither` applies to gradient fills (stroke: false) `dither` se aplica a preenchimentos em gradiente (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` se aplica a preenchimentos em gradiente (paint.setFill) +@msg `dither` must be true or false `dither` deve ser true ou false +@msg `method` must be classic or perceptual `method` deve ser classic ou perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Instalar “{name}”, que pede um orçamento de processamento grande? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Cada execução pode levar até {minutes} minutos e fazer {times} vezes o trabalho permitido a outros plug-ins. Ela é executada em segundo plano, e você pode cancelá-la. + It may take minutes, so it runs in the background, where you can cancel it. Pode levar minutos, então é executado em segundo plano, onde você pode cancelá-lo. + The plug-in is computing… O plug-in está processando… + Inspect Plug-in Inspecionar Plug-in + Cancel Plug-in Run Cancelar Execução do Plug-in +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} terminou, mas o documento ou o plug-in mudou nesse meio-tempo: execute-o novamente +@msg Running “{name}”… Executando “{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy` +@msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar +# Actions panel: action sets (#3712). + New Set… Novo Conjunto… + New Set Novo Conjunto + Set Options… Opções do Conjunto… + Set Options Opções do Conjunto + Create New Set Criar Novo Conjunto + Stop Global Edit Parar Edição Global + Global Edit Options Opções de Edição Global + Match Corresponder + On Artboards Nas Mesas de Trabalho + All Artboards Todas as Mesas de Trabalho + Include Objects On Canvas Incluir Objetos na Tela + Result(s): {count} Resultado(s): {count} +@msg select one object selecione um objeto +@msg Global Edit works on paths, compound paths and symbol instances A edição global funciona com demarcadores, demarcadores compostos e instâncias de símbolos +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nenhum objeto semelhante encontrado (cópias giradas ou refletidas não são correspondidas); altere as opções de edição global e tente novamente +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ou um intervalo como 1, 3-5, não {v} + Add to Symbols Adicionar aos Símbolos + Basic Shapes Formas Básicas + Open Symbol Library Abrir Biblioteca de Símbolos + Save Symbol Library Salvar Biblioteca de Símbolos + Save Symbol Library… Salvar Biblioteca de Símbolos… + Save Symbols… Salvar Símbolos… + Selected Symbols Only Somente Símbolos Selecionados + Symbol Libraries Menu Menu de Bibliotecas de Símbolos + Symbol Library Biblioteca de Símbolos + User Symbol Library 1 Biblioteca de Símbolos do Usuário 1 + User Symbol Library 2 Biblioteca de Símbolos do Usuário 2 + User Symbol Library 3 Biblioteca de Símbolos do Usuário 3 + User Symbol Library 4 Biblioteca de Símbolos do Usuário 4 + User Symbol Library 5 Biblioteca de Símbolos do Usuário 5 + User Symbol Library 6 Biblioteca de Símbolos do Usuário 6 + User Symbol Library 7 Biblioteca de Símbolos do Usuário 7 + User Symbol Library 8 Biblioteca de Símbolos do Usuário 8 + User Symbol Library 9 Biblioteca de Símbolos do Usuário 9 + User Symbol Library 10 Biblioteca de Símbolos do Usuário 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` não tem símbolos para inserir +@msg missing `library` (see symbol.libraries) falta `library` (consulte symbol.libraries) +@msg no symbol `{n}` nenhum símbolo `{n}` +@msg no symbol `{n}` in `{_1}` nenhum símbolo `{n}` em `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) nenhuma biblioteca de símbolos `{key}` (consulte symbol.libraries) +@msg the document has no symbols to save o documento não tem símbolos para salvar +@msg Trap: select two or more objects filled with solid colours Armadilha: selecione dois ou mais objetos preenchidos com cores sólidas +@msg Trap: no edges of different colours meet among the selected objects Armadilha: nenhuma borda de cores diferentes se encontra entre os objetos selecionados +@msg thickness must be from 0.01 to 5000 pt thickness deve estar entre 0,01 e 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth deve estar entre 1 e 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction deve estar entre 0 e 100 % + Rectangular Grid Tool Options Opções da Ferramenta Grade Retangular + Polar Grid Tool Options Opções da Ferramenta Grade Polar + Fill Line Preencher linha diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b3d28f064..2d9f41824 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7,7 +7,7 @@ # Перо, Прямое выделение). Shortcut keys keep their key-cap names (Shift, Alt, Ctrl, Esc). # # Coverage: the whole interface — every menu string and every `tl!` literal (`complete_menus`, -# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language +# `untranslated_report` lists gaps). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language # names, OpenType, the built-in workspace names and the bracketed perspective grid preset names. # Photoshop-style raster effects (Effect menu, their dialogs). Glowing Edges… Светящиеся края… @@ -35,6 +35,13 @@ Channel 4 Канал 4 Cell Size Размер ячейки Fine Dots Мелкие точки + + Simple Простой + Light Rough Светлая грубость + Dark Rough Тёмная грубость + Wide Sharp Широкий резкий + Wide Blurry Широкий размытый + Sparkle Блеск Medium Dots Средние точки Grainy Dots Зернистые точки Coarse Dots Крупные точки @@ -185,6 +192,8 @@ Anti-aliased Artwork Сглаженный рисунок Anti-aliasing: Сглаживание: Any Kind Любой тип + Font Technology Технология шрифта +font-technology Variable Вариативные Appearance Оформление Appearance Attribute Атрибут оформления Appearance Item Элемент оформления @@ -268,6 +277,11 @@ Average… Усреднить… Axis: Ось: Background Color: Цвет фона: + Show Center Mark Показать центральную метку + Show Cross Hairs Показать перекрестие + Show Video Safe Areas Показать безопасные зоны видео + Video Ruler Pixel Aspect Ratio: Соотношение сторон пикселя видео-линеек: + Hide Video Rulers Скрыть видео-линейки Background Contents Содержимое фона Background Contents: Содержимое фона: Background: Фон: @@ -305,6 +319,7 @@ Blur Размытие Bottom Внизу Bottom in Front Нижний спереди + Bottom-Justified Tab Табуляция с выравниванием по нижнему краю Bottom: Внизу: Bounds Границы Bounds (Fill Proportionally) Границы (заполнять пропорционально) @@ -335,6 +350,13 @@ Built-in presets don't change: New… starts an editable copy. Встроенные наборы не изменяются: «Создать…» создаёт редактируемую копию. Bulge… Выпуклость… Bullet Маркер + Bullets Маркеры + Bullets and Numbering Маркеры и нумерация + Apply Bullets Применить маркеры + Filled Dot Закрашенная точка + Dash Тире + Hollow Circle Пустой круг + Filled Square Закрашенный квадрат Butt Cap Стыковое окончание Byte Order: Порядок байтов: Calligraphic Каллиграфическая @@ -1050,6 +1072,7 @@ Eyedropper Picks Up: Пипетка берёт: Eyedropper Tool Инструмент «Пипетка» Eyedropper: click the art to sample the stop's colour Пипетка: щёлкните рисунок, чтобы взять цвет точки перехода + Eyedropper: click the art to sample the key color Пипетка: щёлкните рисунок, чтобы взять ключевой цвет Favorites Избранное Feather… Растушёвка… Feet Футы @@ -1058,6 +1081,11 @@ Fidelity: Точность: Figure Space Цифровая шпация Figure: Цифры: +# Artwork drag between document tabs. + Drop Artwork Поместить графику +@msg {key} must be [x, y] with finite coordinates {key} должен быть [x, y] с конечными координатами +@msg ids must contain 1–10000 object ids ids должен содержать от 1 до 10000 идентификаторов объектов + File Файл File Dimensions Размеры файла File Format: Формат файла: @@ -1079,6 +1107,16 @@ Alt key toggles to Smooth Tool Клавиша Alt переключает на инструмент «Сглаживание» Keep selected Оставлять выбранным Merge only with selection Объединять только с выделенным + Consider Open Filled Paths as Closed Считать открытые контуры с заливкой замкнутыми + In Merge Mode, Clicking Stroke Splits the Path В режиме объединения щелчок по обводке разделяет контур + Pick Color From: Источник цвета: + Artwork Графика + Cursor Swatch Preview Образцы у указателя + Selection: Выделение: + Freeform Произвольно + Straight Line Прямая линия + Highlight: Подсветка: + Stroke When Editable Редактируемая обводка Fill Color Цвет заливки Fill New Type Objects With Placeholder Text Заполнять новые текстовые объекты текстом-заполнителем Fill Rule Правило заливки @@ -1207,6 +1245,8 @@ file-filter Templates Шаблоны Large Gaps Большие зазоры Gap Preview Color: Цвет просмотра зазоров: Close Gaps With Paths Закрыть зазоры контурами + Show Live Paint Gaps Показать зазоры быстрой заливки + Hide Live Paint Gaps Скрыть зазоры быстрой заливки Select Fills Выделять заливки Select Strokes Выделять обводки Gaussian Blur… Размытие по Гауссу… @@ -1313,6 +1353,7 @@ file-filter Templates Шаблоны Hand-Drawn Нарисованный от руки Handles Маркеры Hard Light Жёсткий свет + Hard Mix Жёсткое наложение Harmony Rules: Правила гармонии: Harmony Sets Наборы гармонии Height Высота @@ -1362,7 +1403,7 @@ file-filter Templates Шаблоны Clear History Очистить историю Set History Limit… Задать предел истории… History States Состояния истории -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Применить набор данных Area Type Текст в области Bloat Раздувание @@ -1851,6 +1892,9 @@ file-filter Templates Шаблоны Modified: Изменено: Modify Изменить Mojikumi Mojikumi + Insert Kashidas Вставлять кашиды + Long Длинные + Stylistic Стилистические Mojikumi Set Набор mojikumi Kinsoku Set Набор kinsoku Hard Строгий @@ -1885,6 +1929,7 @@ file-filter Templates Шаблоны Move Slices Переместить фрагменты Move Spine Point Переместить точку остова Reshape Spine Segment Изменить форму сегмента остова + Move Brush Переместить кисть Move Swatch Переместить образец Move Up Переместить вверх Move… Переместить… @@ -1940,6 +1985,13 @@ file-filter Templates Шаблоны Tints and Shades Оттенки и тени Hue Shift Сдвиг тона Key Color: Ключевой цвет: + Tips Советы + Colorization Tips Советы по перекрашиванию + The samples show this brush on a blue stroke. Образцы показывают эту кисть на синей обводке. + The art keeps its own colors. Рисунок сохраняет собственные цвета. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Чёрный становится цветом обводки, а белый остаётся белым. Остальные цвета становятся оттенками цвета обводки, и чем светлее цвет, тем светлее его оттенок. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Чёрный остаётся чёрным, а белый остаётся белым. Остальные цвета становятся тенями цвета обводки, если они тёмные, и его оттенками, если они светлые. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Ключевой цвет становится цветом обводки. Остальные цвета сдвигаются по цветовому кругу на столько же, на сколько ключевой цвет, и сохраняют насыщенность и яркость. Roundness: Округлость: Fixed Фиксированный Pressure Нажим @@ -1960,6 +2012,14 @@ file-filter Templates Шаблоны Round Fan Круглая веерная Flat Fan Плоская веерная New Brush… Новая кисть… + New Brush Новая кисть + Calligraphic Brush Каллиграфическая кисть + Scatter Brush Кисть-распыление + Art Brush Художественная кисть + Bristle Brush Щетинная кисть + Pattern Brush Узорная кисть + Select art to make a scatter, art or pattern brush. Выделите рисунок, чтобы создать кисть-распыление, художественную или узорную кисть. + Brush Art Рисунок кисти New Calligraphic Brush Новая каллиграфическая кисть New Character Style Новый стиль символа New Color Group Новая цветовая группа @@ -1990,6 +2050,7 @@ file-filter Templates Шаблоны New View Новый вид New View… Новый вид… New Window Новое окно + New Without Dialog Создать без диалогового окна New Workspace Новая рабочая среда New Workspace… Новая рабочая среда… New {kind} Style… Новый стиль {kind}… @@ -2196,7 +2257,7 @@ file-filter Templates Шаблоны Package Упаковать Package collects a saved document with its linked files and fonts. Упаковка собирает сохранённый документ с его связанными файлами и шрифтами. Package created Пакет создан - Packaged {n} file(s) in {folder}{note} Упаковано файлов: {n} в {folder}{note} + Packaged {n} file(s) in {folder} {note} Упаковано файлов: {n} в {folder}{note} Packaged {n} file(s){note} Упаковано файлов: {n}{note} Package… Упаковать… Page Страница @@ -2259,7 +2320,7 @@ file-filter Templates Шаблоны Performance Производительность diagnostics render отрисовка diagnostics ui интерфейс - Performance & Storage Производительность и хранение + Plug-ins & Scratch Disks Модули & Рабочие диски Permissions Разрешения Permissions password: Пароль разрешений: Perspective Перспектива @@ -2381,6 +2442,7 @@ mark-style Japanese Японский Proof Setup Настройка цветопробы Properties Свойства Proportional Metrics Пропорциональные метрики + Character Direction Направление символов Digits Цифры Arabic Арабские Hindi Хинди @@ -2575,6 +2637,7 @@ mark-style Japanese Японский Reorder Изменить порядок Reorder Appearance Item Изменить порядок элемента оформления Repeat Повторить + Repeat Objects Объекты повтора Repeat Options Параметры повтора Repeat Pathfinder Повторить обработку контуров Repeat Tab Повторить табуляцию @@ -2786,6 +2849,7 @@ mark-style Japanese Японский Scale Cursor Proportional to UI Масштабировать курсор пропорционально интерфейсу Scale Lineweights Масштабировать толщины линий Scale Strokes & Effects Масштабировать обводки и эффекты + Scale Text Attributes Масштабировать атрибуты текста Scale to Fit Artboard Масштабировать по монтажной области Scale Tool Инструмент «Масштаб» Scale: Масштаб: @@ -3058,6 +3122,7 @@ mark-style Japanese Японский Snapping Tolerance Допуск привязки Social Square Post 1080×1080 Квадратный пост для соцсетей 1080×1080 Soft Light Мягкий свет + Soft Mix Мягкое смешение Solid Сплошной Sort by Kind Сортировать по типу Sort by Name Сортировать по имени @@ -3068,6 +3133,7 @@ mark-style Japanese Японский Space Before Paragraph Интервал перед абзацем Spaces Пробелы Spacing Guides Направляющие интервалов + Invoke Distance Guides Вызывать направляющие расстояний Spacing: Интервал: Spanish Испанский Specified Distance Заданное расстояние @@ -3205,7 +3271,6 @@ mark-style Japanese Японский Show all open documents Показать все открытые документы Symbol Instance Экземпляр символа Symbol Libraries Библиотеки символов - Symbol Libraries (on the roadmap) Библиотеки символов (в плане развития) Symbol Screener Tool Инструмент «Прозрачность символов» Symbol Scruncher Tool Инструмент «Сжатие символов» Symbol Shifter Tool Инструмент «Сдвиг символов» @@ -3298,6 +3363,7 @@ mark-style Japanese Японский Tools Инструменты Top Вверху Top in Front Верхний спереди + Top-Justified Tab Табуляция с выравниванием по верхнему краю Top: Вверху: Total Width: Общая ширина: Touch Type Настройка текста @@ -3333,6 +3399,13 @@ mark-style Japanese Японский Transparent Background Прозрачный фон Transverse (the page a quarter turn on the paper) Поперечная (страница повёрнута на четверть оборота на листе) Trap… Треппинг… + Trap Треппинг + Height/Width Высота/ширина + Tint Reduction Снижение оттенка + Traps with Process Color Треппинг триадными цветами + Reverse Traps Обратный треппинг + Precision Точность + Remove Redundant Points Удалить лишние точки Trim Подрезать Trim mark weight: Толщина меток подрезки: Trim marks Метки подрезки @@ -3564,7 +3637,7 @@ mark-style Japanese Японский Zoom Tool Инструмент «Масштаб» Zoom with Mouse Wheel Масштабировать колесом мыши Scrub Numeric Fields by Dragging Изменять числовые поля перетаскиванием - {count} file(s) in {folder}{note}. Show the package folder? Файлов: {count} в {folder}{note}. Показать папку пакета? + {count} file(s) in {folder} {note}. Show the package folder? Файлов: {count} в {folder}{note}. Показать папку пакета? {count} linked file(s) not found Не найдено связанных файлов: {count} fonts not copied: {fonts} шрифты не скопированы: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? Связанных изображений ({count}) изменились с момента сохранения этого документа. Показать новые версии? @@ -3637,7 +3710,8 @@ imageTrace Outline Контур to sample a color для взятия образца цвета across a selected object to set the gradient direction по выделенному объекту для задания направления градиента to select an artboard | для выбора монтажной области | - on the canvas to create one на холсте для создания одной + on the canvas to create one ( на холсте для создания одной ( + selects several, or draws one inside an artboard) выбирает несколько или создаёт новую внутри монтажной области) a stroke to add a width point | обводку для добавления точки ширины | to change one side | для изменения одной стороны | to select more points | для выделения большего числа точек | @@ -3733,7 +3807,7 @@ axis Both Оба # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. @msg no library `{id}` нет библиотеки `{id}` @msg select some type first сначала выделите текст @@ -3771,6 +3845,7 @@ axis Both Оба @msg Couldn't copy to the system clipboard: {e} Не удалось скопировать в системный буфер обмена: {e} @msg Couldn't open {name}: {e} Не удалось открыть {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Не удалось поместить {name}: документ, в который его перетащили, был закрыт +@msg Couldn't place {name}: the document it was placed in was closed Не удалось поместить {name}: документ, в который его помещали, был закрыт @msg Couldn't read {name}: {e} Не удалось прочитать {name}: {e} @msg Couldn't save recovery data: {e} Не удалось сохранить данные восстановления: {e} @msg Create Outlines: select text objects Создание контуров: выделите текстовые объекты @@ -3807,8 +3882,9 @@ axis Both Оба @msg Opened {url} Открыто {url} @msg Outline Stroke: no stroked paths selected Обводка контура: не выделены контуры с обводкой @msg PDF settings are not an object параметры PDF не являются объектом +@msg PDF security settings are not an object параметры безопасности PDF не являются объектом @msg PDF writer error: {0} ошибка записи PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} Упаковано файлов: {n} в {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Упаковано файлов: {n} в {folder}{note} @msg Packaged {n} file(s){note} Упаковано файлов: {n}{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Просмотр в браузере требует настольного приложения (web-версия скачивает файлы) @msg Release Guides: no guides Освобождение направляющих: нет направляющих @@ -3878,6 +3954,25 @@ axis Both Оба @msg a PostScript error ({name} in `{op}`: {at}) ошибка PostScript ({name} в `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} ошибка PostScript ({name} в `{op}`: {at}), в {procs} @msg a PostScript error ({name}) ошибка PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) ошибка PostScript ({name} в `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} ошибка PostScript ({name} в `{op}`; {meaning}), в {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) ошибка PostScript ({name} в `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} ошибка PostScript ({name} в `{op}`: {at}; {meaning}), в {procs} +@msg a PostScript error ({name}; {meaning}) ошибка PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin программа закрывает словарь, который она не открывала +@msg the program opens a file, which the reader can't do программа открывает файл, а читатель PostScript этого не умеет +@msg the program restores a saved state that was already restored программа восстанавливает сохранённое состояние, которое уже восстановлено +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none данные, которые читает программа, повреждены или обрываются, или программа запрашивает позицию в файле, у которого её нет +@msg the program goes past one of the reader's limits программа выходит за один из пределов читателя PostScript +@msg the path has no point to continue from у контура нет точки, от которой можно продолжить +@msg a value is outside the range the operator takes значение вне диапазона, который принимает оператор +@msg the operator needs more values than the stack holds оператору нужно больше значений, чем есть в стеке +@msg part of the program isn't valid PostScript text часть программы не является допустимым текстом PostScript +@msg a value has the wrong type for the operator значение имеет неверный тип для оператора +@msg the program runs, deletes or renames a file, which the reader can't do программа запускает, удаляет или переименовывает файл, а читатель PostScript этого не умеет +@msg the program asks for a resource that isn't defined программа запрашивает ресурс, который не определён +@msg a calculation has no result, as in a division by zero у вычисления нет результата, как при делении на ноль +@msg the operator looks for a mark on the stack and finds none оператор ищет метку в стеке и не находит её @msg a bitmap is empty растровое изображение пусто @msg a bitmap is too large to read растровое изображение слишком велико для чтения @msg a colour index is 0 to {_1}, not {n} индекс цвета от 0 до {_1}, а не {n} @@ -3910,6 +4005,7 @@ axis Both Оба @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis должно быть "vertical", "horizontal" или углом в градусах, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black или цвет, например "#ff8800" @msg bad color {v} недопустимый цвет {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio должно быть числом от 0,1 до 10 @msg the Color Picker is not open палитра цветов не открыта @msg the Simplify slider is not open ползунок «Упростить» не открыт @msg the screen color sampler is already open средство выбора цвета с экрана уже открыто @@ -3960,7 +4056,7 @@ axis Both Оба @msg give the path `id` and the width point `index` укажите `id` контура и `index` точки ширины @msg guides are locked направляющие заблокированы @msg handle must be in or out handle должен быть in или out -@msg image '{key}' has no pixels and was left out изображение '{key}' не имеет пикселей и было пропущено +@msg an image has no pixels and was left out изображение без пикселей пропущено @msg image data `{_1}` is missing данные изображения `{_1}` отсутствуют @msg image would be too large; lower the resolution изображение было бы слишком большим; уменьшите разрешение @msg index must be a number or first, previous, next or last index должен быть числом или first, previous, next, last @@ -3987,7 +4083,7 @@ axis Both Оба @msg missing index отсутствует index @msg missing number `{key}` отсутствует число `{key}` @msg missing integer `{key}` отсутствует целое число `{key}` -@msg multiple windows land with M11.5 несколько окон появятся в M11.5 +@msg New Window isn't available yet: a document opens in one window Новое окно пока недоступно: документ открывается в одном окне @msg name the file дайте файлу имя @msg no CSS to copy: select objects with CSS of their own нет CSS для копирования: выделите объекты с собственным CSS @msg no Live Paint edge near that point рядом с этой точкой нет границы быстрой заливки @@ -4005,7 +4101,9 @@ axis Both Оба @msg no blend options нет параметров перехода @msg no bounds нет границ @msg no brush named `{name}` нет кисти с именем `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} наибольшая длина имени щетинной кисти (символов): {max}; в `{name}` символов: {count} @msg no brush selected кисть не выбрана +@msg brushes move only within their type кисти можно перемещать только в пределах их типа @msg no catalog нет каталога @msg no changes since the last save нет изменений с момента последнего сохранения @msg no clock to keep a heartbeat by нет часов для поддержания сигнала @@ -4024,6 +4122,7 @@ axis Both Оба @msg no file system here здесь нет файловой системы @msg no file writer нет средства записи файлов @msg no files to place нет файлов для размещения +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` не является документом VectorCraft, PDF или файлом .ai, совместимым с PDF @msg no format to export to нет формата для экспорта @msg no free recovery area нет свободной области восстановления @msg no gradient stop or freeform point is selected не выделена ни точка перехода градиента, ни произвольная точка @@ -4059,6 +4158,7 @@ axis Both Оба @msg no such action такого действия нет @msg no such anchor такой опорной точки нет @msg no such artboard такой монтажной области нет +@msg the artboard is locked монтажная область заблокирована @msg no such document такого документа нет @msg no such layer or object такого слоя или объекта нет @msg no such object {0} объект {0} не существует @@ -4115,6 +4215,7 @@ axis Both Оба @msg path is already closed контур уже замкнут @msg paths contain invalid coordinates контуры содержат недопустимые координаты @msg paths: give at least one file paths: укажите как минимум один файл +@msg replace: the object to replace was changed or deleted replace: заменяемый объект был изменён или удалён @msg pattern missing узор отсутствует @msg patterns nested too deeply are filled with mid-grey слишком глубоко вложенные узоры заполняются серым @msg percent must be above 0 and at most 6400, not {p} процент должен быть больше 0 и не более 6400, а не {p} @@ -4150,6 +4251,10 @@ axis Both Оба @msg select a repeat выделите повтор @msg select an Intertwine group выделите группу переплетения @msg select an Objects on Path group выделите группу объектов на контуре +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects к контуру внутри составного контура, перехода, оболочки, повтора или группы переплетения нельзя прикрепить объекты +@msg a clipping path can't take objects к контуру обрезки нельзя прикрепить объекты +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path объекты внутри перехода, оболочки, повтора или группы переплетения нельзя прикрепить к контуру +@msg a clipping path can't be attached to a path контур обрезки нельзя прикрепить к контуру @msg select a stroke with a variable width выделите обводку с переменной шириной @msg select a symbol instance выделите экземпляр символа @msg select an Image Trace object выделите объект трассировки изображения @@ -4269,6 +4374,7 @@ axis Both Оба @msg unknown workspace (one of {_1}) неизвестная рабочая среда (одна из: {_1}) @msg unsupported: {0} не поддерживается: {0} @msg variables are locked переменные заблокированы +@msg variables that aren't text were left out: {kinds} переменные, которые не являются текстовыми, пропущены: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файлы версии {0} не могут быть сжаты: только версия {COMPRESSED_SINCE} и новее открывают сжатые файлы @msg width and height are at least 1 pixel ширина и высота должны быть не менее 1 пикселя @msg workspace name can't be empty имя рабочей среды не может быть пустым @@ -4280,6 +4386,7 @@ axis Both Оба @msg {_1} font families available доступно семейств шрифтов: {_1} @msg {_1} is a live effect (Effect › Plug-ins) {_1} — динамический эффект (Эффект › Модули) @msg {_1} is an exporter (File › Export › Export As…) {_1} — модуль экспорта (Файл › Экспорт › Экспортировать как…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` занимает {size} байт (предел {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} уже используется {_2} @msg {_1} linked file(s) can't be read here: their previews show здесь невозможно прочитать связанных файлов: {_1}: отображаются их предварительные просмотры @msg {_1} linked image(s) changed on disk: Update Links shows the new versions связанных изображений ({_1}) изменились на диске: «Обновить связи» покажет новые версии @@ -4309,6 +4416,8 @@ axis Both Оба @msg Saving Сохранение @msg Saving {_1} Сохранение {_1} @msg Exporting {_1} Экспорт {_1} +@msg Packaging {_1} Упаковка {_1} +@msg Placing {_1} Размещение {_1} @msg Saving recovery data for {_1} Сохранение данных восстановления для {_1} @msg Discarded {_1} recovered document(s) Отменено восстановленных документов: {_1} @msg Restored {_1} recovered document(s): save them to keep them Восстановлено документов: {_1}: сохраните их, чтобы оставить @@ -4417,7 +4526,9 @@ axis Both Оба @msg automation filesystem access is not granted: read authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на чтение: {path} @msg automation filesystem access is not granted: write authority is absent: {path} у автоматизации нет доступа к файловой системе: нет права на запись: {path} @msg automation path rejected: outside the read root {root}: {path} путь автоматизации отклонён: вне корневой папки для чтения {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для чтения {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} путь автоматизации отклонён: вне корневой папки для записи {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} путь автоматизации отклонён: вне корневых папок для записи {roots}: {path} @msg automation path rejected: path is empty путь автоматизации отклонён: путь пуст @msg automation path rejected: a link that leads nowhere: {path} путь автоматизации отклонён: ссылка, которая никуда не ведёт: {path} @msg automation path rejected: cannot resolve {path}: {e} путь автоматизации отклонён: не удаётся определить {path}: {e} @@ -4523,6 +4634,7 @@ axis Both Оба Activate Panel Активировать панель Change Panel Layout Изменить расположение панелей Close Panel Закрыть панель + Collapse Panel Group Свернуть группу панелей Move Panel Переместить панель Group with Сгруппировать с Resize panels Изменить размер панелей @@ -4533,6 +4645,12 @@ axis Both Оба @msg before must name a panel before должен указывать панель @msg docking action contains an unknown panel or an excessive split path Действие прикрепления содержит неизвестную панель или слишком длинный путь разделений @msg group must be true or false group должен иметь значение true или false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панель плавающая; сверните её группу через window.panel.float {panel, collapsed} +@msg panel is not in a dock group панель не находится в группе дока +@msg only a dock group above or below others collapses, not one beside another свернуть можно только группу дока над или под другими, а не рядом с ними +@msg icon must be true or false icon должен быть true или false +@msg icon only docks a panel in the icon column, with no anchor icon только закрепляет панель в столбце значков, без anchor +@msg column docks a set as a dock column, with no anchor or icon column закрепляет набор как столбец дока, без anchor и icon @msg move requires a destination panel Для перемещения требуется целевая панель @msg move requires an existing destination panel Для перемещения требуется существующая целевая панель @msg onto must name a floating panel onto должен указывать плавающую панель @@ -4611,12 +4729,24 @@ lighting Light Свет Reset Ruler Origin Сбросить начало координат линеек Artistic Художественные Film Grain… Зернистость плёнки… + Colored Pencil… Цветной карандаш… + Colored Pencil Цветной карандаш Cutout… Аппликация… Cutout Аппликация + Dry Brush… Сухая кисть… + Dry Brush Сухая кисть Neon Glow… Неоновое свечение… + Palette Knife… Нож для палитры… Neon Glow Неоновое свечение + Paint Daubs… Мазки кисти… + Paint Daubs Мазки кисти + Palette Knife Нож для палитры Poster Edges… Края плаката… + Rough Pastels… Грубые пастели… Poster Edges Края плаката + Rough Pastels Грубые пастели + Underpainting… Подмалёвка… + Underpainting Подмалёвка Levels Уровни Edge Simplicity Простота краёв Edge Fidelity Точность краёв @@ -4706,6 +4836,9 @@ lighting Light Свет Softness Мягкость Ambient Light Окружающий свет Light behind the object Свет позади объекта + 3D Lighting Preset Предустановка 3D освещения + Standard Стандартное + Diffuse Рассеянное # 3D light management. Lights Источники света Add Light Добавить свет @@ -4752,6 +4885,7 @@ lighting Light Свет @msg Measured anchor no longer exists Измеряемая опорная точка больше не существует @msg Measured segment no longer exists Измеряемый сегмент больше не существует @msg Non-finite dimension Размер не является конечным числом +@msg Scale Text Attributes: selected type has no scale to bake Масштабировать атрибуты текста: у выбранного текста нет масштаба для применения @msg Scale must be drawing:real, for example 1:10 Масштаб должен быть в виде чертёж:реальность, например 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Соотношение масштаба должно быть от 1:1000000 до 1000000:1 @msg Second measured segment no longer exists Второй измеряемый сегмент больше не существует @@ -4770,6 +4904,7 @@ lighting Light Свет Make Image Dynamic Сделать изображение динамическим Import Data Sets… Импортировать наборы данных… Export Data Sets… Экспортировать наборы данных… + Load Variable Library… Загрузить библиотеку переменных… Make Graph Dynamic Сделать диаграмму динамической Batch… Пакетная обработка… Source Folder: Исходная папка: @@ -4793,6 +4928,12 @@ lighting Light Свет Sketch Эскиз Halftone Pattern… Полутоновый узор… Halftone Pattern Полутоновый узор + Torn Edges… Рваные края… + Torn Edges Рваные края + Conté Crayon… Мелок Конте… + Conté Crayon Мелок Конте + Foreground Level Уровень переднего плана + Background Level Уровень фона Pattern Type Тип узора Dot Точка # User Interface › Color Scheme @@ -4811,6 +4952,7 @@ lighting Light Свет Watercolor… Акварель… Watercolor Акварель Brush Size Размер кисти + Texture Coverage Покрытие текстурой Definition Чёткость Brush Detail Детализация кисти Shadow Intensity Интенсивность теней @@ -4839,13 +4981,21 @@ lighting Light Свет @msg the image's pixels can't be read не удаётся прочитать пиксели изображения Persistent Постоянная Library Panel Options Параметры панели библиотеки + Locate Library… Найти библиотеку… @msg Persistent libraries not found: {files} Постоянные библиотеки не найдены: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` — не панель библиотеки (см. window.swatchLibrary) @msg library panel `{given}` isn't open панель библиотеки `{given}` не открыта +@msg library panel `{given}` isn't missing its library библиотека панели библиотеки `{given}` не отсутствует @msg too many library panels were opened in this session в этом сеансе открыто слишком много панелей библиотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Изображение {_1} × {_2} пикселей, больше {LARGE_TRACE_SIDE} по одной стороне: трассировка выполняется, но медленнее. Стиль с меньшей точностью или уменьшенная копия изображения трассируются быстрее. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Изображение трассировано в размере {_1} × {_2} пикселей, уменьшенное с {_3} × {_4} (не более {_5} мегапикселей). + Snapping Привязка + Snap to Tangent Привязать к касательной + Snap to Perpendicular Привязать к перпендикуляру +@msg unknown snap option `{key}` (use {options}) неизвестный параметр привязки `{key}` (допустимо: {options}) + Gap Length: Длина зазора: Artboard {n} of {total} Монтажная область {n} из {total} + File {n} of {total} Файл {n} из {total} Encoding and writing the file… Кодирование и запись файла… @plural {n} more export waiting|{n} more exports waiting Ещё {n} экспорт в очереди|Ещё {n} экспорта в очереди|Ещё {n} экспортов в очереди Continue in Background Продолжить в фоне @@ -4862,6 +5012,14 @@ lighting Light Свет Minimum must not exceed Desired, nor Desired Maximum. Минимум не может превышать желательное значение, а желательное — максимум. Minimum Минимум Justification Выключка + Add filters from the list above Добавьте фильтры из списка выше + Add to Stack Добавить в стек + Applied Filters Применённые фильтры + Categories Категории + Choose a filter Выберите фильтр + Effect Gallery Галерея эффектов + Filters Фильтры + Select artwork to apply effects Выберите графику, чтобы применить эффекты @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them переменные и наборы данных файла не прочитаны (переменных: {variables}, наборов данных: {data_sets}): панель «Переменные» их не показывает # Select by Color (select.color) Select by Color Выделить по цвету @@ -4883,3 +5041,107 @@ lighting Light Свет Plaster Гипс Note Paper… Почтовая бумага… Note Paper Почтовая бумага + Water Paper… Мокрая бумага… + Water Paper Мокрая бумага + Ruby… Руби… + Ruby Руби + Geometric Guides Геометрические направляющие + Ease In Плавное начало + Ease Out Плавное окончание + Ease In Out Плавное начало и окончание + Ease Out In Плавное окончание и начало + Ramp: Сила: + Shift: Сдвиг: + Fresco… Фреска… + Fresco Фреска + Effect Gallery… Галерея эффектов… + Effect Gallery Dialog Диалог «Галерея эффектов» + Arc Segment Tool Options Параметры инструмента «Дуговой сегмент» + Base Along: Основание вдоль: + Closed Замкнутый + Concave Вогнутый + Convex Выпуклый + Fill Arc Заливка дуги + Length X-Axis: Длина по оси X: + Length Y-Axis: Длина по оси Y: + Slope: Уклон: +axis X Axis Ось X +axis Y Axis Ось Y +@msg Packaged {n} file(s) in {folder} Упаковано файлов: {n} в {folder} +@msg Packaged {n} file(s) {note} Упаковано файлов: {n} {note} +@msg Packaged {n} file(s) Упаковано файлов: {n} + Packaged {n} file(s) Упаковано файлов: {n} + Packaged {n} file(s) in {folder} Упаковано файлов: {n} в {folder} + Packaged {n} file(s) {note} Упаковано файлов: {n} {note} + {count} file(s) in {folder}. Show the package folder? Файлов: {count} в {folder}. Показать папку пакета? + Clockwise По часовой стрелке + Dither Дизеринг + Fine noise hides banding in the gradient fill Мелкий шум скрывает полосы в градиентной заливке +@msg `dither` applies to gradient fills (stroke: false) `dither` применяется к градиентным заливкам (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` применяется к градиентным заливкам (paint.setFill) +@msg `dither` must be true or false `dither` должно быть true или false + Perceptual Перцепционный +@msg `method` must be classic or perceptual `method` должен быть classic или perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Установить «{name}», которому нужен большой вычислительный бюджет? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Каждый запуск может длиться до {minutes} минут и выполнять в {times} раз больше работы, чем разрешено другим модулям. Он работает в фоне, и его можно отменить. + It may take minutes, so it runs in the background, where you can cancel it. Это может занять несколько минут, поэтому модуль работает в фоне, где его можно отменить. + The plug-in is computing… Модуль выполняет вычисления… + Inspect Plug-in Проверить модуль + Cancel Plug-in Run Отменить запуск модуля +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} завершился, но документ или модуль за это время изменился: запустите его снова +@msg Running “{name}”… Выполняется «{name}»… +@msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy` +@msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится +# Actions panel: action sets (#3712). + New Set… Новый набор… + New Set Новый набор + Set Options… Параметры набора… + Set Options Параметры набора + Create New Set Создать новый набор + Stop Global Edit Завершить глобальное редактирование + Global Edit Options Параметры глобального редактирования + Match Совпадение + On Artboards На монтажных областях + All Artboards Все монтажные области + Include Objects On Canvas Включать объекты на холсте + Result(s): {count} Результаты: {count} +@msg select one object выберите один объект +@msg Global Edit works on paths, compound paths and symbol instances Глобальное редактирование работает с контурами, составными контурами и экземплярами символов +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again похожие объекты не найдены (повёрнутые или отражённые копии не учитываются); измените параметры глобального редактирования и повторите попытку +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all или диапазон, например 1, 3-5, а не {v} + Add to Symbols Добавить в символы + Basic Shapes Базовые фигуры + Open Symbol Library Открыть библиотеку символов + Save Symbol Library Сохранить библиотеку символов + Save Symbol Library… Сохранить библиотеку символов… + Save Symbols… Сохранить символы… + Selected Symbols Only Только выбранные символы + Symbol Libraries Menu Меню «Библиотеки символов» + Symbol Library Библиотека символов + User Symbol Library 1 Пользовательская библиотека символов 1 + User Symbol Library 2 Пользовательская библиотека символов 2 + User Symbol Library 3 Пользовательская библиотека символов 3 + User Symbol Library 4 Пользовательская библиотека символов 4 + User Symbol Library 5 Пользовательская библиотека символов 5 + User Symbol Library 6 Пользовательская библиотека символов 6 + User Symbol Library 7 Пользовательская библиотека символов 7 + User Symbol Library 8 Пользовательская библиотека символов 8 + User Symbol Library 9 Пользовательская библиотека символов 9 + User Symbol Library 10 Пользовательская библиотека символов 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place В `{_1}` нет символов для размещения +@msg missing `library` (see symbol.libraries) не указан `library` (см. symbol.libraries) +@msg no symbol `{n}` нет символа `{n}` +@msg no symbol `{n}` in `{_1}` нет символа `{n}` в `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) нет библиотеки символов `{key}` (см. symbol.libraries) +@msg the document has no symbols to save в документе нет символов для сохранения +@msg Trap: select two or more objects filled with solid colours Треппинг: выберите два или более объекта со сплошной заливкой +@msg Trap: no edges of different colours meet among the selected objects Треппинг: среди выбранных объектов нет соприкасающихся краёв разных цветов +@msg thickness must be from 0.01 to 5000 pt thickness должно быть от 0,01 до 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth должно быть от 1 до 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction должно быть от 0 до 100 % + Rectangular Grid Tool Options Параметры инструмента «Прямоугольная сетка» + Polar Grid Tool Options Параметры инструмента «Полярная сетка» + Fill Line Заливка отрезка diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index b2d18c09b..b8c197520 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -34,6 +34,13 @@ Channel 4 Kanal 4 Cell Size Cellstorlek Fine Dots Fina punkter + + Simple Enkel + Light Rough Lätt grov + Dark Rough Mörk grov + Wide Sharp Bred skarp + Wide Blurry Bred suddig + Sparkle Glittra Medium Dots Medelstora punkter Grainy Dots Korniga punkter Coarse Dots Grova punkter @@ -175,6 +182,8 @@ Anti-aliased Artwork Kantutjämnad grafik Anti-aliasing: Kantutjämning: Any Kind Alla typer + Font Technology Teckensnittsteknik +font-technology Variable Variabla Appearance Utseende Appearance Attribute Utseendeattribut Appearance Item Utseendeobjekt @@ -259,6 +268,11 @@ Average… Medelvärde… Axis: Axel: Background Color: Bakgrundsfärg: + Show Center Mark Visa mittmarkering + Show Cross Hairs Visa hårkors + Show Video Safe Areas Visa videosäkra områden + Video Ruler Pixel Aspect Ratio: Pixelproportion för videolinjaler: + Hide Video Rulers Dölj videolinjaler Background Contents Bakgrundsinnehåll Background Contents: Bakgrundsinnehåll: Background: Bakgrund: @@ -296,6 +310,7 @@ Blur Oskärpa Bottom Nederkant Bottom in Front Nederkant framför + Bottom-Justified Tab Bottenjusterad tabb Bottom: Nederkant: Bounds Gränser Bounds (Fill Proportionally) Gränser (fyll proportionellt) @@ -326,6 +341,13 @@ Built-in presets don't change: New… starts an editable copy. Inbyggda förinställningar ändras inte: Ny… skapar en redigerbar kopia. Bulge… Utbuktning… Bullet Punkt + Bullets Punkter + Bullets and Numbering Punkter och numrering + Apply Bullets Använd punkter + Filled Dot Fylld punkt + Dash Tankstreck + Hollow Circle Tom cirkel + Filled Square Fylld kvadrat Butt Cap Rak ände Byte Order: Byteordning: Calligraphic Kalligrafisk @@ -1034,6 +1056,7 @@ Eyedropper Picks Up: Pipetten hämtar: Eyedropper Tool Pipettverktyg Eyedropper: click the art to sample the stop's colour Pipett: klicka på grafiken för att hämta stoppets färg + Eyedropper: click the art to sample the key color Pipett: klicka på grafiken för att hämta nyckelfärgen Favorites Favoriter Feather… Fjädring… Feet Fot @@ -1042,6 +1065,11 @@ Fidelity: Återgivning: Figure Space Siffermellanrum Figure: Figur: +# Artwork drag between document tabs. + Drop Artwork Släpp grafik +@msg {key} must be [x, y] with finite coordinates {key} måste vara [x, y] med ändliga koordinater +@msg ids must contain 1–10000 object ids ids måste innehålla 1–10000 objekt-id:n + File Fil File Dimensions Fildimensioner File Format: Filformat: @@ -1177,6 +1205,8 @@ Large Gaps Stora mellanrum Gap Preview Color: Förhandsgranskningsfärg för mellanrum: Close Gaps With Paths Stäng mellanrum med banor + Show Live Paint Gaps Visa mellanrum i direktmålning + Hide Live Paint Gaps Dölj mellanrum i direktmålning Select Fills Markera fyllningar Select Strokes Markera konturer Gaussian Blur… Gaussisk oskärpa… @@ -1283,6 +1313,7 @@ Hand-Drawn Handritad Handles Handtag Hard Light Hårt ljus + Hard Mix Hård blandning Harmony Rules: Harmoniregler: Harmony Sets Harmoniuppsättningar Height Höjd @@ -1331,7 +1362,7 @@ Clear History Rensa historik Set History Limit… Ange historikgräns… History States Historiktillstånd -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Använd datauppsättning Cancel Pattern Editing Avbryt mönsterredigering Copy Appearance Kopiera utseende @@ -1786,6 +1817,9 @@ Modified: Ändrad: Modify Ändra Mojikumi Mojikumi + Insert Kashidas Infoga kashidor + Long Lång + Stylistic Stilistisk Mojikumi Set Mojikumi-uppsättning Kinsoku Set Kinsoku-uppsättning Hard Hård @@ -1820,6 +1854,7 @@ Move Slices Flytta skivor Move Spine Point Flytta ryggradspunkt Reshape Spine Segment Omforma ryggradssegment + Move Brush Flytta pensel Move Swatch Flytta färgruta Move Up Flytta upp Move… Flytta… @@ -1875,6 +1910,13 @@ Tints and Shades Ljusare och mörkare toner Hue Shift Nyansförskjutning Key Color: Nyckelfärg: + Tips Tips + Colorization Tips Tips om färgläggning + The samples show this brush on a blue stroke. Exemplen visar den här penseln på en blå kontur. + The art keeps its own colors. Grafiken behåller sina egna färger. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Svart blir konturfärgen och vitt förblir vitt. Andra färger blir ljusare toner av konturfärgen, och ju ljusare en färg är, desto ljusare är dess ton. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Svart förblir svart och vitt förblir vitt. Andra färger blir mörkare toner av konturfärgen när de är mörka och ljusare toner av den när de är ljusa. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Nyckelfärgen blir konturfärgen. De andra färgerna flyttas lika långt runt färghjulet som nyckelfärgen och behåller sin mättnad och ljusstyrka. Roundness: Rundhet: Fixed Fast Pressure Tryck @@ -1895,6 +1937,14 @@ Round Fan Rund solfjäder Flat Fan Platt solfjäder New Brush… Ny pensel… + New Brush Ny pensel + Calligraphic Brush Kalligrafisk pensel + Scatter Brush Spridningspensel + Art Brush Konstpensel + Bristle Brush Borstpensel + Pattern Brush Mönsterpensel + Select art to make a scatter, art or pattern brush. Markera grafik för att skapa en spridnings-, konst- eller mönsterpensel. + Brush Art Penselgrafik New Calligraphic Brush Ny kalligrafisk pensel New Character Style Ny teckenstil New Color Group Ny färggrupp @@ -1925,6 +1975,7 @@ New View Ny vy New View… Ny vy… New Window Nytt fönster + New Without Dialog Ny utan dialogruta New Workspace Ny arbetsyta New Workspace… Ny arbetsyta… New {kind} Style… Ny {kind}stil… @@ -2131,7 +2182,7 @@ Package Paketera Package collects a saved document with its linked files and fonts. Paketera samlar ett sparat dokument med dess länkade filer och typsnitt. Package created Paketet har skapats - Packaged {n} file(s) in {folder}{note} Paketerade {n} fil(er) i {folder}{note} + Packaged {n} file(s) in {folder} {note} Paketerade {n} fil(er) i {folder}{note} Packaged {n} file(s){note} Paketerade {n} fil(er){note} Package… Paketera… Page Sida @@ -2190,7 +2241,7 @@ Percent: Procent: Perceptual Scales Perceptuella skalor Performance Prestanda - Performance & Storage Prestanda och lagring + Plug-ins & Scratch Disks Insticksprogram & Arbetsdiskar Permissions Behörigheter Permissions password: Lösenord för behörigheter: Perspective Perspektiv @@ -2310,6 +2361,7 @@ Proof Setup Provtrycksinställningar Properties Egenskaper Proportional Metrics Proportionella mått + Character Direction Teckenriktning Digits Siffror Arabic Arabiska Hindi Hindi @@ -2503,6 +2555,7 @@ Reorder Ändra ordning Reorder Appearance Item Ändra ordning på utseendeobjekt Repeat Upprepa + Repeat Objects Upprepningsobjekt Repeat Options Upprepningsalternativ Repeat Pathfinder Upprepa banöverlappning Repeat Tab Upprepa tabb @@ -2711,6 +2764,7 @@ Scale Cursor Proportional to UI Skala pekaren proportionellt mot gränssnittet Scale Lineweights Skala linjebredder Scale Strokes & Effects Skala konturer och effekter + Scale Text Attributes Skala textattribut Scale to Fit Artboard Skala för att passa ritytan Scale Tool Skalningsverktyg Scale: Skala: @@ -2970,6 +3024,7 @@ Snapping Tolerance Fästtolerans Social Square Post 1080×1080 Kvadratiskt inlägg för sociala medier 1080×1080 Soft Light Mjukt ljus + Soft Mix Mjuk blandning Solid Enfärgad Sort by Kind Sortera efter typ Sort by Name Sortera efter namn @@ -2980,6 +3035,7 @@ Space Before Paragraph Avstånd före stycke Spaces Blanksteg Spacing Guides Avståndsstödlinjer + Invoke Distance Guides Anropa mätstödlinjer för avstånd Spacing: Avstånd: Spanish Spanska Specified Distance Angivet avstånd @@ -3117,7 +3173,6 @@ Show all open documents Visa alla öppna dokument Symbol Instance Symbolinstans Symbol Libraries Symbolbibliotek - Symbol Libraries (on the roadmap) Symbolbibliotek (planeras) Symbol Screener Tool Verktyg för symbolopacitet Symbol Scruncher Tool Verktyg för att dra ihop symboler Symbol Shifter Tool Verktyg för att flytta symboler @@ -3210,6 +3265,7 @@ Tools Verktyg Top Överkant Top in Front Ovansidan framför + Top-Justified Tab Toppjusterad tabb Top: Överkant: Total Width: Total bredd: Touch Type Tool Beröringstextverktyg @@ -3244,6 +3300,13 @@ Transparent Background Transparent bakgrund Transverse (the page a quarter turn on the paper) Tvärställd (sidan vriden ett kvarts varv på pappret) Trap… Överfyllning… + Trap Överfyllning + Height/Width Höjd/bredd + Tint Reduction Tonreducering + Traps with Process Color Överfyllningar med processfärger + Reverse Traps Omvända överfyllningar + Precision Precision + Remove Redundant Points Ta bort överflödiga punkter Trim Trimma Trim mark weight: Tjocklek på skärmärken: Trim marks Skärmärken @@ -3475,7 +3538,7 @@ Zoom Tool Zoomverktyg Zoom with Mouse Wheel Zooma med mushjulet Scrub Numeric Fields by Dragging Ändra numeriska fält genom att dra - {count} file(s) in {folder}{note}. Show the package folder? {count} fil(er) i {folder}{note}. Visa paketmappen? + {count} file(s) in {folder} {note}. Show the package folder? {count} fil(er) i {folder}{note}. Visa paketmappen? {count} linked file(s) not found {count} länkad(e) fil(er) hittades inte fonts not copied: {fonts} typsnitt som inte kopierats: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? {count} länkad(e) bild(er) har ändrats sedan dokumentet sparades. Visa de nya versionerna? @@ -3547,7 +3610,8 @@ imageTrace Outline Disposition to sample a color för att hämta en färg across a selected object to set the gradient direction över ett markerat objekt för att ange gradientens riktning to select an artboard | för att markera en rityta | - on the canvas to create one på arbetsytan för att skapa en + on the canvas to create one ( på arbetsytan för att skapa en ( + selects several, or draws one inside an artboard) markerar flera eller skapar en ny i en rityta) a stroke to add a width point | en kontur för att lägga till en breddpunkt | to change one side | för att ändra en sida | to select more points | för att markera fler punkter | @@ -3665,6 +3729,7 @@ axis Both Båda @msg Couldn't copy to the system clipboard: {e} Kunde inte kopiera till systemets urklipp: {e} @msg Couldn't open {name}: {e} Kunde inte öppna {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Kunde inte placera {name}: dokumentet det släpptes på har stängts +@msg Couldn't place {name}: the document it was placed in was closed Kunde inte placera {name}: dokumentet det placerades i har stängts @msg Couldn't read {name}: {e} Kunde inte läsa {name}: {e} @msg Couldn't save recovery data: {e} Kunde inte spara återställningsdata: {e} @msg Create Outlines: select text objects Skapa konturer: markera textobjekt @@ -3701,8 +3766,9 @@ axis Both Båda @msg Opened {url} Öppnade {url} @msg Outline Stroke: no stroked paths selected Kontur till bana: inga banor med kontur markerade @msg PDF settings are not an object PDF-inställningarna är inte ett objekt +@msg PDF security settings are not an object PDF-säkerhetsinställningarna är inte ett objekt @msg PDF writer error: {0} fel i PDF-skrivaren: {0} -@msg Packaged {n} file(s) in {folder}{note} Paketerade {n} fil(er) i {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Paketerade {n} fil(er) i {folder}{note} @msg Packaged {n} file(s){note} Paketerade {n} fil(er){note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Förhandsgranska i webbläsare kräver skrivbordsappen (webbversionen hämtar filer i stället) @msg Release Guides: no guides Släpp stödlinjer: inga stödlinjer @@ -3771,6 +3837,25 @@ axis Both Båda @msg a PostScript error ({name} in `{op}`: {at}) ett PostScript-fel ({name} i `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} ett PostScript-fel ({name} i `{op}`: {at}), i {procs} @msg a PostScript error ({name}) ett PostScript-fel ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) ett PostScript-fel ({name} i `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} ett PostScript-fel ({name} i `{op}`; {meaning}), i {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) ett PostScript-fel ({name} i `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} ett PostScript-fel ({name} i `{op}`: {at}; {meaning}), i {procs} +@msg a PostScript error ({name}; {meaning}) ett PostScript-fel ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin programmet avslutar en ordlista som det inte har påbörjat +@msg the program opens a file, which the reader can't do programmet öppnar en fil, vilket PostScript-läsaren inte kan +@msg the program restores a saved state that was already restored programmet återställer ett sparat läge som redan har återställts +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none data som programmet läser är skadade eller avkortade, eller så frågar programmet efter en position i en fil som inte har någon +@msg the program goes past one of the reader's limits programmet överskrider en av PostScript-läsarens gränser +@msg the path has no point to continue from banan har ingen punkt att fortsätta från +@msg a value is outside the range the operator takes ett värde ligger utanför intervallet som operatorn tar emot +@msg the operator needs more values than the stack holds operatorn behöver fler värden än stacken innehåller +@msg part of the program isn't valid PostScript text en del av programmet är inte giltig PostScript-text +@msg a value has the wrong type for the operator ett värde har fel typ för operatorn +@msg the program runs, deletes or renames a file, which the reader can't do programmet kör, tar bort eller byter namn på en fil, vilket PostScript-läsaren inte kan +@msg the program asks for a resource that isn't defined programmet begär en resurs som inte är definierad +@msg a calculation has no result, as in a division by zero en beräkning har inget resultat, som vid division med noll +@msg the operator looks for a mark on the stack and finds none operatorn letar efter en markering på stacken och hittar ingen @msg a bitmap is empty en bitmapp är tom @msg a bitmap is too large to read en bitmapp är för stor för att läsas @msg a colour index is 0 to {_1}, not {n} ett färgindex är 0 till {_1}, inte {n} @@ -3803,6 +3888,7 @@ axis Both Båda @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis måste vara "vertical", "horizontal" eller en vinkel i grader, inte `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black eller en färg som "#ff8800" @msg bad color {v} ogiltig färg {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio måste vara ett tal från 0,1 till 10 @msg bad colour `{hex}` (#rrggbb) ogiltig färg `{hex}` (#rrggbb) @msg bad page tree ogiltigt sidträd @msg bad shortcut ogiltigt kortkommando @@ -3849,7 +3935,7 @@ axis Both Båda @msg give the path `id` and the width point `index` ange banans `id` och breddpunktens `index` @msg guides are locked stödlinjerna är låsta @msg handle must be in or out handle måste vara in eller out -@msg image '{key}' has no pixels and was left out bilden '{key}' har inga pixlar och utelämnades +@msg an image has no pixels and was left out en bild utan pixlar utelämnades @msg image data `{_1}` is missing bilddata `{_1}` saknas @msg image would be too large; lower the resolution bilden skulle bli för stor; sänk upplösningen @msg index must be a number or first, previous, next or last index måste vara ett tal eller first, previous, next eller last @@ -3876,7 +3962,7 @@ axis Both Båda @msg missing index index saknas @msg missing number `{key}` talet `{key}` saknas @msg missing integer `{key}` heltalet `{key}` saknas -@msg multiple windows land with M11.5 flera fönster kommer med M11.5 +@msg New Window isn't available yet: a document opens in one window Nytt fönster är inte tillgängligt än: ett dokument öppnas i ett fönster @msg name the file namnge filen @msg no CSS to copy: select objects with CSS of their own ingen CSS att kopiera: markera objekt som har egen CSS @msg no Live Paint edge near that point ingen Live Paint-kant nära den punkten @@ -3894,7 +3980,9 @@ axis Both Båda @msg no blend options inga blandningsalternativ @msg no bounds inga gränser @msg no brush named `{name}` ingen pensel med namnet `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} namnet på en borstpensel får ha högst {max} tecken: `{name}` har {count} @msg no brush selected ingen pensel är markerad +@msg brushes move only within their type penslar kan bara flyttas inom sin egen typ @msg no catalog ingen katalog @msg no changes since the last save inga ändringar sedan senaste sparningen @msg no clock to keep a heartbeat by ingen klocka att styra hjärtslagen efter @@ -3913,6 +4001,7 @@ axis Both Båda @msg no file system here inget filsystem här @msg no file writer ingen filskrivare @msg no files to place inga filer att placera +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` är inte ett VectorCraft-dokument, en PDF eller en PDF-kompatibel .ai-fil @msg no format to export to inget format att exportera till @msg no free recovery area inget ledigt återställningsområde @msg no gradient stop or freeform point is selected inget övertoningsstopp eller fri punkt är markerad @@ -3948,6 +4037,7 @@ axis Both Båda @msg no such action åtgärden finns inte @msg no such anchor ankaret finns inte @msg no such artboard ritytan finns inte +@msg the artboard is locked ritytan är låst @msg no such document dokumentet finns inte @msg no such layer or object lagret eller objektet finns inte @msg no such object {0} objektet {0} finns inte @@ -4004,6 +4094,7 @@ axis Both Båda @msg path is already closed banan är redan sluten @msg paths contain invalid coordinates banorna innehåller ogiltiga koordinater @msg paths: give at least one file paths: ange minst en fil +@msg replace: the object to replace was changed or deleted replace: objektet som skulle ersättas har ändrats eller tagits bort @msg pattern missing mönster saknas @msg patterns nested too deeply are filled with mid-grey mönster som är kapslade för djupt fylls med mellangrått @msg percent must be above 0 and at most 6400, not {p} procent måste vara över 0 och högst 6400, inte {p} @@ -4156,6 +4247,7 @@ axis Both Båda @msg unknown workspace (one of {_1}) okänd arbetsyta (en av {_1}) @msg unsupported: {0} stöds inte: {0} @msg variables are locked variablerna är låsta +@msg variables that aren't text were left out: {kinds} variabler som inte är text utelämnades: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files filer i version {0} kan inte komprimeras: endast version {COMPRESSED_SINCE} och senare kan öppna komprimerade filer @msg width and height are at least 1 pixel bredd och höjd är minst 1 pixel @msg workspace name can't be empty arbetsytans namn får inte vara tomt @@ -4167,6 +4259,7 @@ axis Both Båda @msg {_1} font families available {_1} teckensnittsfamiljer tillgängliga @msg {_1} is a live effect (Effect › Plug-ins) {_1} är en live-effekt (Effekt › Insticksprogram) @msg {_1} is an exporter (File › Export › Export As…) {_1} är ett exportinsticksprogram (Fil › Exportera › Exportera som…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` är {size} byte (gräns {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} används redan av {_2} @msg {_1} linked file(s) can't be read here: their previews show {_1} länkade fil(er) kan inte läsas här: deras förhandsgranskningar visas @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} länkade bild(er) har ändrats på disken: Uppdatera länkar visar de nya versionerna @@ -4196,6 +4289,8 @@ axis Both Båda @msg Saving Sparar @msg Saving {_1} Sparar {_1} @msg Exporting {_1} Exporterar {_1} +@msg Packaging {_1} Paketerar {_1} +@msg Placing {_1} Placerar {_1} @msg Saving recovery data for {_1} Sparar återställningsdata för {_1} @msg Discarded {_1} recovered document(s) Kastade {_1} återställda dokument @msg Restored {_1} recovered document(s): save them to keep them Återställde {_1} återskapade dokument: spara dem för att behålla dem @@ -4314,7 +4409,9 @@ axis Both Båda @msg automation filesystem access is not granted: read authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: läsbehörighet saknas: {path} @msg automation filesystem access is not granted: write authority is absent: {path} automatiseringen har inte fått åtkomst till filsystemet: skrivbehörighet saknas: {path} @msg automation path rejected: outside the read root {root}: {path} automatiseringssökvägen avvisades: utanför läsroten {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} automatiseringssökvägen avvisades: utanför läsrötterna {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} automatiseringssökvägen avvisades: utanför skrivroten {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} automatiseringssökvägen avvisades: utanför skrivrötterna {roots}: {path} @msg automation path rejected: path is empty automatiseringssökvägen avvisades: sökvägen är tom @msg automation path rejected: a link that leads nowhere: {path} automatiseringssökvägen avvisades: en länk som inte leder någonstans: {path} @msg automation path rejected: cannot resolve {path}: {e} automatiseringssökvägen avvisades: kan inte lösa upp {path}: {e} @@ -4420,6 +4517,7 @@ effect Soft Mjuk Activate Panel Aktivera panel Change Panel Layout Ändra panellayout Close Panel Stäng panel + Collapse Panel Group Komprimera panelgrupp Move Panel Flytta panel Group with Gruppera med Resize panels Ändra storlek på paneler @@ -4430,6 +4528,12 @@ effect Soft Mjuk @msg before must name a panel before måste ange en panel @msg docking action contains an unknown panel or an excessive split path dockningsåtgärden innehåller en okänd panel eller en alltför djup delningssökväg @msg group must be true or false group måste vara true eller false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panelen flyter; komprimera dess grupp med window.panel.float {panel, collapsed} +@msg panel is not in a dock group panelen ligger inte i en dockningsgrupp +@msg only a dock group above or below others collapses, not one beside another bara en dockningsgrupp ovanför eller under andra komprimeras, inte en bredvid en annan +@msg icon must be true or false icon måste vara true eller false +@msg icon only docks a panel in the icon column, with no anchor icon dockar bara en panel i ikonkolumnen, utan anchor +@msg column docks a set as a dock column, with no anchor or icon column dockar en uppsättning som en dockningskolumn, utan anchor eller icon @msg move requires a destination panel flyttning kräver en målpanel @msg move requires an existing destination panel flyttning kräver en befintlig målpanel @msg onto must name a floating panel onto måste ange en flytande panel @@ -4504,12 +4608,24 @@ lighting Light Ljus Reset Ruler Origin Återställ linjalens origo Artistic Konstnärligt Film Grain… Filmkorn… + Colored Pencil… Färgpenna… + Colored Pencil Färgpenna Cutout… Urklipp… Cutout Urklipp + Dry Brush… Torr pensel… + Dry Brush Torr pensel Neon Glow… Neonsken… + Palette Knife… Palettkniv… Neon Glow Neonsken + Paint Daubs… Färgklickar… + Paint Daubs Färgklickar + Palette Knife Palettkniv Poster Edges… Affischkanter… + Rough Pastels… Grova pasteller… Poster Edges Affischkanter + Rough Pastels Grova pasteller + Underpainting… Undermålning… + Underpainting Undermålning Levels Nivåer Edge Simplicity Kantförenkling Edge Fidelity Kanttrohet @@ -4638,6 +4754,7 @@ lighting Light Ljus Brown Brun Business Card 3.5 × 2 in Visitkort 3,5 × 2 in Business Card 85 × 55 mm Visitkort 85 × 55 mm + Business Card 91 × 55 mm Visitkort 91 × 55 mm CRLF (Windows) CRLF (Windows) Cap Height Versalhöjd Chevron Chevron @@ -5017,6 +5134,9 @@ theme Appearance Utseende Softness Mjukhet Ambient Light Omgivande ljus Light behind the object Ljus bakom objektet + 3D Lighting Preset Förinställning för 3D-belysning + Standard Standard + Diffuse Diffust # 3D light management. Lights Ljuskällor Add Light Lägg till ljus @@ -5043,6 +5163,7 @@ theme Appearance Utseende Drawing : real size — 1:10 labels 1 unit on the page as 10. Ritning : verklig storlek — 1:10 visar 1 enhet på sidan som 10. Editable Redigerbar Export Data Sets… Exportera datauppsättningar… + Load Variable Library… Läs in variabelbibliotek… Filter Filter Filter Layers Panel Rows Filtrera rader i panelen Lager Flatten Platta till @@ -5059,6 +5180,15 @@ theme Appearance Utseende Isolate Selected Object Isolera markerat objekt Keep selected Behåll markerad Merge only with selection Sammanfoga endast med markeringen + Consider Open Filled Paths as Closed Behandla öppna fyllda banor som slutna + In Merge Mode, Clicking Stroke Splits the Path I sammanfogningsläge delar ett klick på konturen banan + Pick Color From: Hämta färg från: + Artwork Bildmaterial + Cursor Swatch Preview Förhandsvisning av färgprover vid pekaren + Selection: Markering: + Straight Line Rak linje + Highlight: Framhävning: + Stroke When Editable Kontur när den kan redigeras Legacy (version 7) Äldre (version 7) Legacy .ai Options Alternativ för äldre .ai Linked Files Länkade filer @@ -5104,6 +5234,7 @@ theme Appearance Utseende @msg Measured anchor no longer exists Den mätta ankarpunkten finns inte längre @msg Measured segment no longer exists Det mätta segmentet finns inte längre @msg Non-finite dimension Måttet är inte ett ändligt tal +@msg Scale Text Attributes: selected type has no scale to bake Skala textattribut: det valda texten har ingen skalning att tillämpa @msg Scale must be drawing:real, for example 1:10 Skalan måste anges som ritning:verklighet, till exempel 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Skalförhållandet måste ligga mellan 1:1000000 och 1000000:1 @msg Second measured segment no longer exists Det andra mätta segmentet finns inte längre @@ -5117,6 +5248,10 @@ theme Appearance Utseende @msg nothing in data set {name} was applied: no object is bound to a variable it has a value for inget i datauppsättningen {name} användes: inget objekt är bundet till en variabel som den har ett värde för @msg select an Intertwine group markera en sammanflätningsgrupp @msg select an Objects on Path group markera en grupp med objekt på bana +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects en bana inuti en sammansatt bana, en blandning, ett kuvert, en upprepning eller en sammanflätningsgrupp kan inte ta emot objekt +@msg a clipping path can't take objects en klippbana kan inte ta emot objekt +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path objekt inuti en blandning, ett kuvert, en upprepning eller en sammanflätningsgrupp kan inte fästas på en bana +@msg a clipping path can't be attached to a path en klippbana kan inte fästas på en bana @msg span must be "object" or "selection", got {v} span måste vara "object" eller "selection", fick {v} @msg span: "selection" places a `gradient`, not a swatch span: "selection" placerar en `gradient`, inte en färgruta @msg span: "selection" places a gradient: give `gradient` span: "selection" placerar en övertoning: ange `gradient` @@ -5140,6 +5275,12 @@ theme Appearance Utseende Sketch Skiss Halftone Pattern… Rastermönster… Halftone Pattern Rastermönster + Torn Edges… Riviga kanter… + Torn Edges Riviga kanter + Conté Crayon… Contékrita… + Conté Crayon Contékrita + Foreground Level Förgrundsnivå + Background Level Bakgrundsnivå Pattern Type Mönstertyp Dot Punkt # User Interface › Color Scheme @@ -5158,6 +5299,7 @@ theme Appearance Utseende Watercolor… Akvarell… Watercolor Akvarell Brush Size Penselstorlek + Texture Coverage Texturtäckning Definition Skärpa Brush Detail Penseldetalj Shadow Intensity Skuggintensitet @@ -5186,13 +5328,21 @@ theme Appearance Utseende @msg the image's pixels can't be read bildens pixlar kan inte läsas Persistent Beständig Library Panel Options Alternativ för bibliotekspanelen + Locate Library… Hitta bibliotek… @msg Persistent libraries not found: {files} Beständiga bibliotek hittades inte: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` är inte en bibliotekspanel (se window.swatchLibrary) @msg library panel `{given}` isn't open bibliotekspanelen `{given}` är inte öppen +@msg library panel `{given}` isn't missing its library bibliotekspanelen `{given}` saknar inte sitt bibliotek @msg too many library panels were opened in this session för många bibliotekspaneler har öppnats i den här sessionen @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Bilden är {_1} × {_2} pixlar, mer än {LARGE_TRACE_SIDE} på en sida: den vektoriseras, men långsammare. En förinställning med lägre återgivning eller en mindre kopia av bilden går snabbare. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Bilden vektoriserades vid {_1} × {_2} pixlar, nedskalad från {_3} × {_4} (högst {_5} megapixlar). + Snapping Fästning + Snap to Tangent Fäst mot tangent + Snap to Perpendicular Fäst mot vinkelrät +@msg unknown snap option `{key}` (use {options}) okänt fästalternativ `{key}` (använd {options}) + Gap Length: Lucklängd: Artboard {n} of {total} Rityta {n} av {total} + File {n} of {total} Fil {n} av {total} Encoding and writing the file… Kodar och skriver filen… @plural {n} more export waiting|{n} more exports waiting {n} export till väntar|{n} exporter till väntar Continue in Background Fortsätt i bakgrunden @@ -5208,6 +5358,14 @@ theme Appearance Utseende Full Justify Fullständig marginaljustering Minimum must not exceed Desired, nor Desired Maximum. Minimum får inte överstiga Önskat, och Önskat inte Maximum. Justification Justering + Add filters from the list above Lägg till filter från listan ovan + Add to Stack Lägg till i stacken + Applied Filters Tillämpade filter + Categories Kategorier + Choose a filter Välj ett filter + Effect Gallery Effektgalleri + Filters Filter + Select artwork to apply effects Välj grafik för att tillämpa effekter @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them filens variabler och datauppsättningar lästes inte (variabler: {variables}, datauppsättningar: {data_sets}): panelen Variabler visar dem inte # Select by Color (select.color) Select by Color Markera efter färg @@ -5229,3 +5387,105 @@ theme Appearance Utseende Plaster Gips Note Paper… Anteckningspapper… Note Paper Anteckningspapper + Water Paper… Vått papper… + Water Paper Vått papper + Ruby… Ruby… + Ruby Ruby + Geometric Guides Geometriska stödlinjer + Ease In Långsam start + Ease Out Långsamt slut + Ease In Out Långsam start och slut + Ease Out In Långsamt slut och start + Ramp: Styrka: + Shift: Förskjutning: + Fresco… Fresk… + Fresco Fresk + Effect Gallery… Effektgalleri… + Effect Gallery Dialog Dialogrutan Effektgalleri + Arc Segment Tool Options Alternativ för bågsegmentsverktyget + Base Along: Bas längs: + Closed Stängd + Convex Konvex + Fill Arc Fyll båge + Length X-Axis: Längd X-axel: + Length Y-Axis: Längd Y-axel: + Slope: Lutning: +axis X Axis X-axel +axis Y Axis Y-axel +@msg Packaged {n} file(s) in {folder} Paketerade {n} fil(er) i {folder} +@msg Packaged {n} file(s) {note} Paketerade {n} fil(er) {note} +@msg Packaged {n} file(s) Paketerade {n} fil(er) + Packaged {n} file(s) Paketerade {n} fil(er) + Packaged {n} file(s) in {folder} Paketerade {n} fil(er) i {folder} + Packaged {n} file(s) {note} Paketerade {n} fil(er) {note} + {count} file(s) in {folder}. Show the package folder? {count} fil(er) i {folder}. Visa paketmappen? + Clockwise Medurs + Dither Dithering + Fine noise hides banding in the gradient fill Fint brus döljer bandningen i övertoningsfyllningen +@msg `dither` applies to gradient fills (stroke: false) `dither` gäller övertoningsfyllningar (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` gäller övertoningsfyllningar (paint.setFill) +@msg `dither` must be true or false `dither` måste vara true eller false +@msg `method` must be classic or perceptual `method` måste vara classic eller perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Installera ”{name}”, som begär en stor beräkningsbudget? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Varje körning kan ta upp till {minutes} minuter och göra {times} gånger så mycket arbete som andra insticksprogram får. Den körs i bakgrunden och du kan avbryta den. + It may take minutes, so it runs in the background, where you can cancel it. Det kan ta flera minuter, så det körs i bakgrunden, där du kan avbryta det. + The plug-in is computing… Insticksprogrammet räknar… + Inspect Plug-in Granska insticksprogram + Cancel Plug-in Run Avbryt körning av insticksprogram +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} är klar, men dokumentet eller insticksprogrammet ändrades under tiden: kör det igen +@msg Running “{name}”… Kör ”{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy` +@msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt +# Actions panel: action sets (#3712). + New Set… Ny uppsättning… + New Set Ny uppsättning + Set Options… Alternativ för uppsättning… + Set Options Alternativ för uppsättning + Create New Set Skapa ny uppsättning + Stop Global Edit Avsluta global redigering + Global Edit Options Alternativ för global redigering + Match Matcha + On Artboards På ritytor + All Artboards Alla ritytor + Include Objects On Canvas Ta med objekt på arbetsytan + Result(s): {count} Resultat: {count} +@msg select one object markera ett objekt +@msg Global Edit works on paths, compound paths and symbol instances Global redigering fungerar på banor, sammansatta banor och symbolinstanser +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again inga liknande objekt hittades (roterade eller speglade kopior matchas inte); ändra alternativen för global redigering och försök igen +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all eller ett intervall som 1, 3-5, inte {v} + Add to Symbols Lägg till i symboler + Basic Shapes Grundformer + Open Symbol Library Öppna symbolbibliotek + Save Symbol Library Spara symbolbibliotek + Save Symbol Library… Spara symbolbibliotek… + Save Symbols… Spara symboler… + Selected Symbols Only Endast markerade symboler + Symbol Libraries Menu Meny för symbolbibliotek + Symbol Library Symbolbibliotek + User Symbol Library 1 Användarbibliotek för symboler 1 + User Symbol Library 2 Användarbibliotek för symboler 2 + User Symbol Library 3 Användarbibliotek för symboler 3 + User Symbol Library 4 Användarbibliotek för symboler 4 + User Symbol Library 5 Användarbibliotek för symboler 5 + User Symbol Library 6 Användarbibliotek för symboler 6 + User Symbol Library 7 Användarbibliotek för symboler 7 + User Symbol Library 8 Användarbibliotek för symboler 8 + User Symbol Library 9 Användarbibliotek för symboler 9 + User Symbol Library 10 Användarbibliotek för symboler 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` har inga symboler att placera +@msg missing `library` (see symbol.libraries) `library` saknas (se symbol.libraries) +@msg no symbol `{n}` ingen symbol `{n}` +@msg no symbol `{n}` in `{_1}` ingen symbol `{n}` i `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) inget symbolbibliotek `{key}` (se symbol.libraries) +@msg the document has no symbols to save dokumentet har inga symboler att spara +@msg Trap: select two or more objects filled with solid colours Överfyllning: markera två eller fler objekt fyllda med enfärgade färger +@msg Trap: no edges of different colours meet among the selected objects Överfyllning: inga kanter i olika färger möts bland de markerade objekten +@msg thickness must be from 0.01 to 5000 pt thickness måste vara från 0,01 till 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth måste vara från 1 till 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction måste vara från 0 till 100 % + Rectangular Grid Tool Options Alternativ för verktyget för rektangulärt rutnät + Polar Grid Tool Options Alternativ för verktyget för polärt rutnät + Fill Line Fyll linje diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index e6171cc24..9a444f72d 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -59,6 +59,53 @@ fn tags_map_to_languages() { assert_eq!(lang_from_tag("_"), None); } +/// Italian, Brazilian Portuguese, Finnish, Turkish and Polish resolve from their locale tags and +/// preferences, and each language names itself in its own language. +#[test] +fn languages_resolve_from_their_tags_and_name_themselves() { + let lang = |code: &str| Lang::from_code(code).expect("registered"); + for (code, tags) in [ + ("it", &["it", "it_IT.UTF-8", "it-CH", "it_SM"][..]), + ("pt-br", &["pt-br", "pt_BR.UTF-8", "pt-BR", "pt_BR"]), + ("fi", &["fi", "fi-FI", "fi_FI.UTF-8", "FI-fi"]), + ("tr", &["tr", "tr-TR", "tr_TR.UTF-8", "TR-tr", "tr-CY"]), + ("pl", &["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"]), + ] { + for tag in tags { + assert_eq!(lang_from_tag(tag), Some(lang(code)), "{tag}"); + } + } + // Filipino (`fil`) and Swedish in Finland (`sv-FI`) don't pick Finnish. + assert_ne!(lang_from_tag("fil"), Some(lang("fi"))); + assert_ne!(lang_from_tag("sv-FI"), Some(lang("fi"))); + for (pref, code) in [("vi", "vi"), ("VI", "vi"), ("fi", "fi"), ("FI", "fi"), ("tr", "tr"), ("TR", "tr"), ("pt-br", "pt-br"), ("pt-BR", "pt-br")] { + assert_eq!(Lang::from_pref(pref), lang(code), "{pref}"); + } + for (code, name) in [("vi", "Tiếng Việt"), ("fi", "Suomi"), ("tr", "Türkçe"), ("pt-br", "Português (Brasil)")] { + assert_eq!(lang(code).name(), name); + } +} + +/// Each language counts with its own plural rule: the form a count picks (`one`, then `few`, then +/// `other`/`many`). +#[test] +fn each_language_counts_with_its_plural_rule() { + const COUNTS: [u64; 8] = [0, 1, 2, 3, 5, 11, 21, 22]; + for (codes, forms) in [ + (&["de", "es", "it", "pt-br", "fi"][..], [1, 0, 1, 1, 1, 1, 1, 1]), + (&["fr"], [0, 0, 1, 1, 1, 1, 1, 1]), + (&["ja", "ka", "hu", "ko", "vi", "tr"], [0; 8]), + (&["ru", "uk"], [2, 0, 1, 1, 2, 2, 0, 1]), + (&["cs"], [2, 0, 1, 1, 2, 2, 2, 2]), + (&["pl"], [2, 0, 1, 1, 2, 2, 2, 1]), + ] { + for code in codes { + let rule = Lang::from_code(code).expect("registered").0.plural; + assert_eq!(COUNTS.map(rule), forms, "{code}"); + } + } +} + #[test] fn candidates_walk_from_specific_to_general() { assert_eq!(candidates("pt_BR.UTF-8"), ["pt-br", "pt"]); @@ -115,11 +162,12 @@ fn a_language_set_by_another_test_leaves_this_one_in_english() { #[test] fn lookups_fall_back_to_english() { assert_eq!(tr(Lang::EN, "Layers"), "Layers"); - assert_eq!(tr(ZH(), "Layers"), "圖層"); + let layers = ZH().catalog().plain("Layers"); + assert_eq!(tr(ZH(), "Layers"), layers.unwrap_or("Layers")); assert_eq!(tr(ZH(), "no such label"), "no such label"); - assert_eq!(tr_id(ZH(), "no.such.id", "Layers"), "圖層"); - assert_eq!(tr_ctx(ZH(), "no such context", "Layers"), "圖層"); - assert!(has(ZH(), "Layers")); + assert_eq!(tr_id(ZH(), "no.such.id", "Layers"), tr(ZH(), "Layers")); + assert_eq!(tr_ctx(ZH(), "no such context", "Layers"), tr(ZH(), "Layers")); + assert_eq!(has(ZH(), "Layers"), layers.is_some()); assert!(!has(Lang::EN, "Layers")); } @@ -147,8 +195,9 @@ fn plurals_and_placeholders() { assert_eq!(trn(Lang::EN, 1, "{n} item", "{n} items"), "1 item"); assert_eq!(trn(Lang::EN, 0, "{n} item", "{n} items"), "0 items"); assert_eq!(trn(Lang::EN, 7, "{n} item", "{n} items"), "7 items"); - assert_eq!(trn(ZH(), 1, "{n} item", "{n} items"), "1 個項目"); - assert_eq!(trn(ZH(), 7, "{n} item", "{n} items"), "7 個項目"); + // A catalog row's form takes the count; without a row the English form does. + let zh_items = ZH().catalog().plural("{n} item", "{n} items", 0).unwrap_or("{n} items"); + assert_eq!(trn(ZH(), 7, "{n} item", "{n} items"), zh_items.replace("{n}", "7")); assert_eq!(fmt("{b} before {a}", &[("a", "x"), ("b", "y"), ("c", "z")]), "y before x"); assert_eq!(fmt("{missing}", &[]), "{missing}"); assert_eq!(placeholders("a {x} b {y} {"), ["x", "y"]); @@ -209,7 +258,8 @@ fn partial_catalogs_only_translate_strings_the_ui_shows() { } /// The engine learns the language the UI is drawn in each frame: new type takes the Japanese -/// defaults while it is Japanese (#432). The frames stay in English. +/// defaults while it is Japanese (#432). A Japanese frame draws in Japanese on this test's thread +/// only: the tests running beside it on other threads still draw in English. #[test] fn the_engine_follows_the_interface_language() { let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); @@ -220,14 +270,19 @@ fn the_engine_follows_the_interface_language() { frame(&mut app); assert_eq!(app.session.ui_language.as_deref(), Some("en"), "tests resolve `auto` to English"); assert!(!app.session.japanese_interface()); - // Each frame hands the engine the language it draws in, whatever the engine had. - app.session.ui_language = Some("ja".into()); + app.session.prefs.interface_language = "ja".into(); + frame(&mut app); + assert_eq!(app.session.ui_language.as_deref(), Some("ja")); assert!(app.session.japanese_interface()); + assert_eq!(current().code(), "ja"); + // A test running beside this one on another thread still draws in English. + assert_eq!(std::thread::spawn(|| (current(), t("Layers"))).join().unwrap(), (Lang::EN, "Layers")); + // With the preference back on `auto`, the next frame hands the engine `en` again. + app.session.prefs.interface_language = "auto".into(); frame(&mut app); assert_eq!(app.session.ui_language.as_deref(), Some("en")); - // The language a frame would draw in follows the preference. - app.session.prefs.interface_language = "ja".into(); - assert_eq!(app.ui_language().code(), "ja"); + assert!(!app.session.japanese_interface()); + assert_eq!(current(), Lang::EN); } /// `app.language` sets the `interfaceLanguage` preference, which is what persists; the checked @@ -275,6 +330,7 @@ fn is_cjk(c: char) -> bool { } /// Georgian letters (Mkhedruli, Mtavruli, Asomtavruli): only craft-fonts' Noto Sans Georgian has them. +#[cfg(feature = "interface-fonts")] fn is_georgian(c: char) -> bool { matches!(c as u32, 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F) } @@ -284,7 +340,9 @@ fn is_georgian(c: char) -> bool { /// the craft-fonts build input, which is optional: without it they are left to the installed /// fonts, and with it the Japanese catalog is checked against BIZ UDPGothic (the Traditional /// Chinese one waits for a Traditional Chinese UI font in craft-fonts). Georgian letters are the -/// same: checked against Noto Sans Georgian when craft-fonts has it. +/// same: checked against Noto Sans Georgian when craft-fonts has it. It needs the interface's own +/// faces (`interface-fonts`): egui's built-in fonts lack some of the catalogs' symbols. +#[cfg(feature = "interface-fonts")] #[test] fn bundled_fonts_cover_the_catalogs() { let japanese_ui_font = vectorcraft_text::CRAFT_FONTS.iter().any(|f| f.is_japanese()); @@ -423,61 +481,6 @@ fn no_translated_word_mixes_latin_cyrillic_and_greek_letters() { assert!(bad.is_empty(), "words that mix scripts:\n{}", bad.join("\n")); } -/// Every menu string (top-level titles, submenu names, item labels, section headers, UI and -/// engine command labels and menu paths) has an entry in each language that claims complete menus. -#[test] -fn complete_languages_translate_every_menu_string() { - let strings = crate::menus::menu_strings(); - assert!(strings.len() > 500, "menu scan found only {} strings", strings.len()); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let cat = l.catalog(); - // Languages that keep the product, workspace and perspective preset names in English. - let kept = |s: &str| KEEPS_MENU_NAMES.contains(&l.code) && MENU_KEEP_AS_IS.contains(&s); - let missing: Vec<_> = strings.iter().filter(|s| !kept(s) && cat.plain(s).is_none()).collect(); - assert!(missing.is_empty(), "{}: {} untranslated menu strings: {missing:#?}", l.code, missing.len()); - } -} - -/// Every `tl!("literal")` in the shell has an entry in each language that claims complete menus -/// (so a new label can't ship untranslated by accident). Literals that are deliberately shown as -/// they are (names, units) are listed in `KEEP_AS_IS`. -#[test] -fn every_tl_literal_is_translated() { - const KEEP_AS_IS: &[&str] = &[]; - let literals = tl_literals(); - assert!(literals.len() > 300, "scan found only {} literals", literals.len()); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let cat = l.catalog(); - let missing: Vec<_> = literals.iter().filter(|s| !KEEP_AS_IS.contains(&s.as_str()) && cat.plain(s).is_none()).collect(); - assert!(missing.is_empty(), "{}: {} untranslated tl! strings: {missing:#?}", l.code, missing.len()); - } -} - -/// Every undo step name the engine, the tools and the shell record ([`undo_labels`]), every -/// effect's name without its ellipsis, and the Liquify tools' step names (`{} Tool`), has a row in -/// each language marked `complete_menus`: its plain row, or the row of the same name with an -/// ellipsis (`panels::history::step_name`). Every template a step name is made from with -/// `Session::edit_named` has a plain row in each, which `panels::history::entry_name` fills with the -/// name. No other step name is built with `format!`. -#[test] -fn every_undo_label_is_translated() { - let scan = undo_scan(); - let mut labels = scan.labels; - assert!(labels.len() > 300, "scan found only {} undo labels", labels.len()); - labels.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.trim_end_matches('…').to_string())); - let liquify = ["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"]; - labels.extend(liquify.iter().filter_map(|k| vectorcraft_tools::distort::liquify::LiquifyKind::parse(k)).map(|k| format!("{} Tool", k.label()))); - assert_eq!(scan.formatted.iter().collect::>(), ["{} Tool"], "step names built with format! (make them with Session::edit_named)"); - assert!(scan.templates.len() >= 3, "scan found only the templates {:?}", scan.templates); - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let lang = Lang::from_code(l.code).expect("registered"); - let missing: Vec<_> = labels.iter().filter(|s| crate::panels::history::step_name(lang, s).is_none()).collect(); - assert!(missing.is_empty(), "{}: {} untranslated undo labels: {missing:#?}", l.code, missing.len()); - let missing: Vec<_> = scan.templates.iter().filter(|t| l.catalog().plain(t).is_none()).collect(); - assert!(missing.is_empty(), "{}: {} untranslated undo label templates: {missing:#?}", l.code, missing.len()); - } -} - /// Every `tl!("…")` literal in this crate's sources (test modules aside). pub fn tl_literals() -> std::collections::BTreeSet { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); @@ -846,17 +849,22 @@ fn string_literals(e: &str, keep: impl Fn(&str, &str) -> bool) -> Vec { out } -/// `VECTORCRAFT_I18N_DUMP= cargo test -p vectorcraft-ui-egui dump_source_strings` writes every -/// English source string a complete catalog has to cover, one per line, for translators. +/// For translators, `cargo test -p vectorcraft-ui-egui dump_source_strings` writes every English +/// interface string to `VECTORCRAFT_I18N_DUMP=`, and every status and error message as a +/// catalog template to `VECTORCRAFT_I18N_DUMP_MESSAGES=`, one per line. #[test] fn dump_source_strings() { - let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP") else { return }; - let mut all = crate::menus::menu_strings(); - all.extend(tl_literals()); - all.extend(undo_labels()); - all.extend(undo_scan().templates); - let text: String = all.iter().map(|s| format!("{}\n", s.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t"))).collect(); - std::fs::write(path, text).expect("write dump"); + if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP") { + let mut all = crate::menus::menu_strings(); + all.extend(tl_literals()); + all.extend(undo_labels()); + all.extend(undo_scan().templates); + let text: String = all.iter().map(|s| format!("{}\n", s.replace('\\', "\\\\").replace('\n', "\\n").replace('\t', "\\t"))).collect(); + std::fs::write(path, text).expect("write dump"); + } + if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_MESSAGES") { + std::fs::write(path, message_literals().iter().map(|m| format!("{m}\n")).collect::()).expect("write dump"); + } } fn cs() -> Lang { @@ -864,7 +872,7 @@ fn cs() -> Lang { } /// Languages whose catalogs leave [`MENU_KEEP_AS_IS`] in English. -const KEEPS_MENU_NAMES: [&str; 17] = ["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "fi"]; +const KEEPS_MENU_NAMES: [&str; 18] = ["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "fi", "tr"]; /// Menu labels the menu-complete catalogs (Czech, German, Spanish, Italian, Japanese, Brazilian Portuguese) show as they are: the product name, a format name, the built-in workspace /// names and the perspective grid presets (names, shown untranslated wherever else they appear). @@ -879,6 +887,7 @@ const MENU_KEEP_AS_IS: &[&str] = &[ "Painting", "Printing and Proofing", "Tracing", + "Touch", "Typography", "Web", "[1P-Normal View]", @@ -931,9 +940,11 @@ fn toggled_labels() -> Vec { "view.textThreads", "type.hiddenCharacters", "view.gradientAnnotator", + "view.livePaintGaps", "view.artboards", "view.template", "view.rulers", + "view.videoRulers", "view.boundingBox", "view.transparencyGrid", "view.guides", @@ -957,448 +968,188 @@ fn toggled_labels() -> Vec { labels } -/// Czech, German, Spanish, Italian, Japanese and Brazilian Portuguese cover every menu label, the Show/Hide pairs -/// and the canvas context menu included (panels and dialogs not yet). +/// Prints, per language, the strings its catalog has no row for: menu strings (titles, submenus, +/// items, headers, command labels and menu paths), `tl!` literals, undo step names and their +/// templates, the menu labels with the Show/Hide pairs and the context menu, render-site labels, +/// status and error messages, PostScript error meanings and default swatch names. The interface +/// shows a missing string in English, so a gap never fails this test; its assertions catch a scan +/// that stopped finding strings. `cargo xtask ci` and `cargo xtask i18n-report` run it with +/// `--nocapture`. #[test] -fn menu_catalogs_translate_every_menu_label() { +fn untranslated_report() { + use std::collections::{BTreeMap, BTreeSet}; + // For each kind of string, the languages checked and what each one lacks. + let mut gaps: Vec<(&str, BTreeMap<&str, BTreeSet>)> = Vec::new(); + let mut add = |code: &'static str, what: &'static str, missing: BTreeSet| { + let at = gaps.iter().position(|(w, _)| *w == what).unwrap_or_else(|| { + gaps.push((what, BTreeMap::new())); + gaps.len() - 1 + }); + gaps[at].1.insert(code, missing); + }; + + let strings = crate::menus::menu_strings(); + assert!(strings.len() > 500, "menu scan found only {} strings", strings.len()); + let literals = tl_literals(); + assert!(literals.len() > 300, "scan found only {} tl! literals", literals.len()); + // Undo step names: the recorded names, every effect's name without its ellipsis, and the + // Liquify tools' step names (`{} Tool`). A name's row is its plain row or the row of the same + // name with an ellipsis (`panels::history::step_name`); a template made with + // `Session::edit_named` needs a plain row, which `panels::history::entry_name` fills. + let scan = undo_scan(); + let mut undo = scan.labels; + assert!(undo.len() > 300, "scan found only {} undo labels", undo.len()); + undo.extend(vectorcraft_effects::effect_catalog().iter().map(|e| e.label.trim_end_matches('…').to_string())); + let liquify = ["warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"]; + undo.extend(liquify.iter().filter_map(|k| vectorcraft_tools::distort::liquify::LiquifyKind::parse(k)).map(|k| format!("{} Tool", k.label()))); + assert_eq!(scan.formatted.iter().collect::>(), ["{} Tool"], "step names built with format! (make them with Session::edit_named)"); + assert!(scan.templates.len() >= 3, "scan found only the templates {:?}", scan.templates); + let messages = message_literals(); + assert!(messages.len() > 300, "scan found only {} messages", messages.len()); + let (swatches, groups) = vectorcraft_color::default_swatches(vectorcraft_color::cms::Model::Rgb); + // The greys' `K=` values read the same in any language. + let swatch_names: BTreeSet = swatches + .iter() + .map(|s| s.name.clone()) + .chain(groups.iter().flat_map(|g| std::iter::once(g.name.clone()).chain(g.swatches.iter().map(|s| s.name.clone())))) + .chain([vectorcraft_color::swatch::REGISTRATION.to_string()]) + .filter(|n| !n.starts_with("K=")) + .collect(); + + for l in LANGUAGES.iter().filter(|l| l.complete_menus) { + let cat = l.catalog(); + let lang = Lang::from_code(l.code).expect("registered"); + // Languages that keep the product, workspace and perspective preset names in English. + let kept = |s: &str| KEEPS_MENU_NAMES.contains(&l.code) && MENU_KEEP_AS_IS.contains(&s); + add(l.code, "menu strings", strings.iter().filter(|s| !kept(s) && cat.plain(s).is_none()).cloned().collect::>()); + add(l.code, "tl! strings", literals.iter().filter(|s| cat.plain(s).is_none()).cloned().collect::>()); + add(l.code, "undo labels", undo.iter().filter(|s| crate::panels::history::step_name(lang, s).is_none()).cloned().collect::>()); + add(l.code, "undo label templates", scan.templates.iter().filter(|t| cat.plain(t).is_none()).cloned().collect::>()); + let (entries, _) = parse_entries(l.source); + let rows: BTreeSet<(&str, &str)> = entries.iter().map(|(ctx, src, _)| (ctx.as_str(), src.as_str())).collect(); + let render_site = RENDER_SITE_ROWS.iter().filter(|row| !rows.contains(row)).map(|(ctx, src)| format!("{ctx}: {src}")); + add(l.code, "render-site labels", render_site.collect()); + add(l.code, "swatch names", swatch_names.iter().filter(|n| cat.contextual("swatch", n).is_none()).cloned().collect::>()); + } + + // The menu labels as the menus show them, with what menu strings don't hold: the labels + // toggling items switch to, the canvas context menu, the macOS menu bar's own labels and its + // Settings submenu's Preferences pages. let labels = menu_labels(); - // What the menus show (recent files and fonts, user presets and libraries show their names). - let shown = crate::menus::menu_strings(); let language_name = |l: &str| Lang::all().any(|lang| lang.name() == l); // Font names and sizes, and installed plug-ins' own names, are not interface text. let interface = |(label, id, p): &(&str, &str, serde_json::Value)| { *id != "text.setStyle" && !crate::menus::lists_plugin(id, p) - && shown.contains(*label) + && strings.contains(*label) && !MENU_KEEP_AS_IS.contains(label) && !language_name(label) }; - let mut all: Vec = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect(); - all.extend(toggled_labels()); - all.extend(crate::menus::CONTEXT_LABELS.iter().map(|l| l.to_string())); - // The macOS menu bar's own labels, and its Settings submenu's Preferences pages. - all.extend(crate::native_menu::MAC_LABELS.iter().map(|l| l.to_string())); - all.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|l| l.to_string())); - all.sort(); - all.dedup(); + let mut shown: BTreeSet = labels.iter().filter(|l| interface(l)).map(|(l, ..)| l.to_string()).collect(); + shown.extend(toggled_labels()); + shown.extend(crate::menus::CONTEXT_LABELS.iter().map(|l| l.to_string())); + shown.extend(crate::native_menu::MAC_LABELS.iter().map(|l| l.to_string())); + shown.extend(vectorcraft_engine::cmd::prefscmds::PREF_CATEGORIES.iter().map(|l| l.to_string())); for code in KEEPS_MENU_NAMES { let lang = Lang::from_code(code).expect("registered"); - let missing: Vec<&String> = all.iter().filter(|l| !has(lang, l)).collect(); - assert!(missing.is_empty(), "{code}: untranslated menu labels: {missing:?}"); - for keep in MENU_KEEP_AS_IS { - assert!(labels.iter().any(|(l, ..)| l == keep), "`{keep}` isn't a menu label"); - assert!(!has(lang, keep), "{code}: `{keep}` is both kept and translated"); + // Labels the menu strings above already listed aren't listed twice. + let missing = shown.iter().filter(|l| !has(lang, l) && (!lang.complete_menus() || !strings.contains(*l))); + add(code, "menu labels", missing.cloned().collect::>()); + } + + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let (entries, _) = parse_entries(lang.0.source); + let rows: BTreeSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect(); + add(code, "messages", messages.iter().filter(|m| !rows.contains(m.as_str())).cloned().collect::>()); + let meanings = vectorcraft_eps::POSTSCRIPT_ERRORS.iter().filter(|(_, m)| message(lang, m) == *m).map(|(_, m)| m.to_string()); + add(code, "PostScript error meanings", meanings.collect()); + } + + // A string that every language checked lacks is listed once, under "every language". + let mut by_language: BTreeMap<&str, Vec<(&str, Vec<&String>)>> = BTreeMap::new(); + let mut everywhere = vec![]; + for (what, langs) in &gaps { + let first = langs.values().next(); + let common: BTreeSet<&String> = first.into_iter().flatten().filter(|m| langs.values().all(|set| set.contains(*m))).collect(); + if !common.is_empty() { + everywhere.push((*what, langs.len(), common.iter().copied().collect::>())); + } + for (code, missing) in langs { + let rest: Vec<&String> = missing.iter().filter(|m| !common.contains(m)).collect(); + if !rest.is_empty() { + by_language.entry(code).or_default().push((*what, rest)); + } } } - assert_eq!(tr(cs(), "File"), "Soubor"); - assert_eq!(tr(Lang::from_code("ja").expect("ja"), "File"), "ファイル"); - // Italian: Italy, Switzerland and San Marino share the one catalog. - for tag in ["it", "it_IT.UTF-8", "it-CH", "it_SM"] { - assert_eq!(lang_from_tag(tag), Lang::from_code("it"), "{tag}"); + let total: usize = gaps.iter().flat_map(|(_, langs)| langs.values()).map(BTreeSet::len).sum(); + let mut out = format!("Untranslated strings, shown in English until a catalog row is added: {total} missing rows\n"); + let list = |out: &mut String, what: &str, missing: &[&String]| { + *out += &format!(" {what} ({}):\n", missing.len()); + for m in missing { + *out += &format!(" {}\n", m.replace('\n', "\\n")); + } + }; + if !everywhere.is_empty() { + out += "\nevery language:\n"; + for (what, langs, missing) in &everywhere { + list(&mut out, &format!("{what}, all {langs} languages checked"), missing); + } } - assert_eq!(tr(Lang::from_code("pt-br").expect("pt-br"), "File"), "Arquivo"); - assert_eq!(tr(es(), "File"), "Archivo"); - assert_eq!(tr(de(), "File"), "Datei"); - assert_eq!(tr(it(), "Edit"), "Modifica"); + for (code, kinds) in &by_language { + out += &format!("\n{code}:\n"); + for (what, missing) in kinds { + list(&mut out, what, missing); + } + } + println!("{out}"); +} + +/// The names [`MENU_KEEP_AS_IS`] lists are menu labels, and no catalog that keeps them in English +/// translates them. +#[test] +fn kept_menu_names_are_menu_labels_left_in_english() { + let labels = menu_labels(); + for keep in MENU_KEEP_AS_IS { + assert!(labels.iter().any(|(l, ..)| l == keep), "`{keep}` isn't a menu label"); + for code in KEEPS_MENU_NAMES { + assert!(!has(Lang::from_code(code).expect("registered"), keep), "{code}: `{keep}` is both kept and translated"); + } + } +} + +/// Georgian interface text has no capitals: no row uses Mtavruli. +#[test] +fn georgian_rows_have_no_mtavruli() { + let (entries, _) = parse_entries(ka().0.source); + let caps: Vec<_> = entries.iter().filter(|(_, _, tr)| tr.chars().any(|c| matches!(c, '\u{1C90}'..='\u{1CBF}'))).collect(); + assert!(caps.is_empty(), "Mtavruli in ka.tsv: {caps:?}"); } fn de() -> Lang { Lang::from_code("de").expect("de registered") } -fn es() -> Lang { - Lang::from_code("es").expect("es registered") -} - -fn fr() -> Lang { - Lang::from_code("fr").expect("fr registered") -} - -fn it() -> Lang { - Lang::from_code("it").expect("it registered") -} - -fn ru() -> Lang { - Lang::from_code("ru").expect("ru registered") -} - fn ka() -> Lang { Lang::from_code("ka").expect("ka registered") } -/// Georgian uses verbal nouns for commands, genitive + "ხელსაწყო" for tools, one count-neutral -/// plural form (nouns stay singular after a number), and translates the reason inside a message. -#[test] -fn georgian_reads_as_georgian() { - for (en, want) in [ - ("Artboard Tool", "სამუშაო დაფის ხელსაწყო"), - ("Swatches", "ფერის ნიმუშები"), - ("Pathfinder", "ტრაექტორიების ოპერაციები"), - ("Stroke", "კონტური"), - ("Fill", "შევსება"), - ("Direct Selection Tool", "პირდაპირი მონიშვნის ხელსაწყო"), - ("Save As…", "შენახვა როგორც…"), - ("Undo", "დაბრუნება"), - ("File", "ფაილი"), - ] { - assert_eq!(tr(ka(), en), want); - } - for n in [0, 1, 2, 5, 21] { - assert_eq!(trn(ka(), n, "{n} Layer", "{n} Layers"), format!("შრე: {n}")); - } - assert_eq!(tr_ctx(ka(), "axis", "Both"), "ორივე"); - assert_eq!(tr_id(ka(), "pathfinder.add", "Add"), "გაერთიანება"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(ka(), nested), "ბრძანება `object.group` ახლა ხელმისაწვდომი არ არის: არაფერია მონიშნული"); - // No Mtavruli: Georgian UI text has no capitals. - let (entries, _) = parse_entries(ka().0.source); - let caps: Vec<_> = entries.iter().filter(|(_, _, tr)| tr.chars().any(|c| matches!(c, '\u{1C90}'..='\u{1CBF}'))).collect(); - assert!(caps.is_empty(), "Mtavruli in ka.tsv: {caps:?}"); -} - fn hu() -> Lang { Lang::from_code("hu").expect("hu registered") } -/// Hungarian uses its vector-illustration vocabulary, drops the word "tool" from tool names, keeps -/// a count in the singular (one plural form), gives dialog buttons a verb while the File menu's -/// Close keeps its noun through an `@id` row, and translates the reason inside a message too. -#[test] -fn hungarian_reads_as_hungarian() { - for (en, want) in [ - ("Artboard Tool", "Rajztábla"), - ("Swatches", "Színminták"), - ("Pathfinder", "Útvonalkezelő"), - ("Stroke", "Körvonal"), - ("Fill", "Kitöltés"), - ("Direct Selection Tool", "Közvetlen kijelölés"), - ("Save As…", "Mentés másként…"), - ("Undo", "Visszavonás"), - ("Close", "Bezár"), - ] { - assert_eq!(tr(hu(), en), want); - } - assert_eq!(tr_id(hu(), "file.close", "Close"), "Bezárás"); - assert_eq!(tr_id(hu(), "pathfinder.add", "Add"), "Egyesítés"); - for n in [0, 1, 3, 21] { - assert_eq!(trn(hu(), n, "{n} Layer", "{n} Layers"), format!("{n} réteg")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(hu(), nested), "a parancs jelenleg nem érhető el: `object.group`: nincs kijelölés"); -} - fn ko() -> Lang { Lang::from_code("ko").expect("ko registered") } -/// Korean uses the vector-illustration vocabulary its users know, has no plural forms, and -/// translates the reason inside a message too. -#[test] -fn korean_reads_as_korean() { - for (en, want) in [ - ("Artboard Tool", "아트보드 도구"), - ("Swatches", "견본"), - ("Pathfinder", "패스파인더"), - ("Stroke", "선"), - ("Fill", "칠"), - ("Direct Selection Tool", "직접 선택 도구"), - ("Save As…", "다른 이름으로 저장…"), - ("Undo", "실행 취소"), - ("Close", "닫기"), - ] { - assert_eq!(tr(ko(), en), want); - } - for n in [0, 1, 3, 21] { - assert_eq!(trn(ko(), n, "{n} Layer", "{n} Layers"), format!("레이어 {n}개")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(ko(), nested), "명령 `object.group`은(는) 지금 사용할 수 없습니다: 선택된 항목이 없습니다"); -} - fn vi() -> Lang { Lang::from_code("vi").expect("vi registered") } -/// Vietnamese has one plural form and covers the complete interface and messages. -#[test] -fn vietnamese_reads_as_vietnamese() { - assert_eq!(vi().name(), "Tiếng Việt"); - assert!(vi().complete_menus()); - assert_eq!(Lang::from_pref("vi"), vi()); - assert_eq!(Lang::from_pref("VI"), vi()); - for (en, want) in [ - ("Artboard Tool", "Công cụ Bảng vẽ"), - ("Swatches", "Mẫu màu"), - ("Stroke", "Nét"), - ("Fill", "Tô"), - ("Direct Selection Tool", "Công cụ Chọn trực tiếp"), - ("Save As…", "Lưu thành…"), - ("Undo", "Hoàn tác"), - ("Close", "Đóng"), - ] { - assert_eq!(tr(vi(), en), want); - } - for n in [0, 1, 3, 21] { - assert_eq!(trn(vi(), n, "{n} Layer", "{n} Layers"), format!("{n} lớp")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(vi(), nested), "lệnh `object.group` hiện không khả dụng: chưa chọn gì"); -} - -fn fi() -> Lang { - Lang::from_code("fi").expect("fi registered") -} - -/// Finnish has one/other plurals (partitive after numbers other than 1) and covers the complete -/// interface and messages. -#[test] -fn finnish_reads_as_finnish() { - assert_eq!(fi().name(), "Suomi"); - assert!(fi().complete_menus()); - assert_eq!(Lang::from_pref("fi"), fi()); - assert_eq!(Lang::from_pref("FI"), fi()); - for tag in ["fi", "fi-FI", "fi_FI.UTF-8", "FI-fi"] { - assert_eq!(lang_from_tag(tag), Some(fi()), "{tag}"); - } - // Filipino (`fil`) and Swedish in Finland (`sv-FI`) don't pick Finnish. - assert_ne!(lang_from_tag("fil"), Some(fi())); - assert_ne!(lang_from_tag("sv-FI"), Some(fi())); - for (en, want) in [ - ("Artboard Tool", "Piirtoalustatyökalu"), - ("Swatches", "Värimallit"), - ("Stroke", "Viiva"), - ("Fill", "Täyttö"), - ("Direct Selection Tool", "Suoravalintatyökalu"), - ("Save As…", "Tallenna nimellä…"), - ("Undo", "Kumoa"), - ("Close", "Sulje"), - ] { - assert_eq!(tr(fi(), en), want); - } - for (n, layer) in [(0, "tasoa"), (1, "taso"), (3, "tasoa"), (21, "tasoa")] { - assert_eq!(trn(fi(), n, "{n} Layer", "{n} Layers"), format!("{n} {layer}")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(fi(), nested), "komento `object.group` ei ole nyt käytettävissä: mitään ei ole valittu"); -} - -/// Ambiguous English terms keep their intended Vietnamese meaning across UI contexts. -#[test] -fn vietnamese_domain_terms_are_consistent() { - for (en, want) in [ - ("All Scripts", "Tất cả hệ chữ"), - ("Pointillize", "Hiệu ứng chấm"), - ("Object Highlighting", "Đánh dấu đối tượng"), - ("Color Theme", "Bộ màu"), - ("Color Themes", "Bộ màu"), - ("Asset Export", "Xuất asset"), - ("Envelope Distort", "Biến dạng Envelope"), - ("Move Spine Point", "Di chuyển điểm trục"), - ("Dashed Line", "Đường nét đứt"), - ("Eyedropper Tool", "Công cụ Ống hút màu"), - ("Character Alignment", "Căn chỉnh ký tự"), - ] { - assert_eq!(tr(vi(), en), want, "{en}"); - } - assert_eq!(tr(vi(), "Appearance"), "Diện mạo"); - assert_eq!(tr_ctx(vi(), "theme", "Appearance"), "Giao diện"); - assert_eq!(message(vi(), "no saved themes"), "chưa có bộ màu nào được lưu"); - assert_eq!(message(vi(), r#"choice must be "merge" or "add", not `foo`"#), r#"lựa chọn phải là "merge" hoặc "add", không phải `foo`"#); - assert_eq!(message(vi(), "select a blend"), "hãy chọn một đối tượng hòa trộn"); - assert_eq!(message(vi(), "select a repeat"), "hãy chọn một đối tượng lặp"); - assert_eq!(message(vi(), "Replace Spine: select a blend and a path"), "Thay thế trục: chọn một đối tượng hòa trộn và một đường dẫn"); -} - fn uk() -> Lang { Lang::from_code("uk").expect("uk registered") } -fn pt_br() -> Lang { - Lang::from_code("pt-br").expect("pt-br registered") -} - -/// Brazilian Portuguese covers the complete interface and messages, with two plural forms like -/// English (zero takes the plural). -#[test] -fn brazilian_portuguese_reads_as_portuguese() { - assert_eq!(pt_br().name(), "Português (Brasil)"); - assert!(pt_br().complete_menus()); - assert_eq!(Lang::from_pref("pt-br"), pt_br()); - assert_eq!(Lang::from_pref("pt-BR"), pt_br()); - for tag in ["pt-br", "pt_BR.UTF-8", "pt-BR", "pt_BR"] { - assert_eq!(lang_from_tag(tag), Some(pt_br()), "{tag}"); - } - for (en, want) in [ - ("Artboard Tool", "Ferramenta Mesa de Trabalho"), - ("Swatches", "Amostras"), - ("Pathfinder", "Localizador de Caminhos"), - ("Stroke", "Traçado"), - ("Fill", "Preenchimento"), - ("Direct Selection Tool", "Ferramenta Seleção Direta"), - ("Save As…", "Salvar Como…"), - ("Undo", "Desfazer"), - ("Close", "Fechar"), - ] { - assert_eq!(tr(pt_br(), en), want, "{en}"); - } - assert_eq!(trn(pt_br(), 1, "{n} Layer", "{n} Layers"), "1 Camada"); - assert_eq!(trn(pt_br(), 0, "{n} Layer", "{n} Layers"), "0 Camadas"); - assert_eq!(trn(pt_br(), 3, "{n} Layer", "{n} Layers"), "3 Camadas"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(pt_br(), nested), "o comando `object.group` não está disponível agora: nada selecionado"); -} - -/// Spanish uses the vector-illustration vocabulary its users know, has two plural forms like -/// English, and reads the same in the menus and in the panels. -#[test] -fn spanish_reads_as_spanish() { - for (en, want) in [ - ("Artboard Tool", "Herramienta Mesa de trabajo"), - ("Swatches", "Muestras"), - ("Pathfinder", "Buscatrazos"), - ("Stroke", "Trazo"), - ("Fill", "Relleno"), - ("Direct Selection Tool", "Herramienta Selección directa"), - ("Save As…", "Guardar como…"), - ("Undo", "Deshacer"), - ] { - assert_eq!(tr(es(), en), want); - } - assert_eq!(trn(es(), 1, "{n} Layer", "{n} Layers"), "1 capa"); - assert_eq!(trn(es(), 0, "{n} Layer", "{n} Layers"), "0 capas"); - assert_eq!(trn(es(), 3, "{n} Layer", "{n} Layers"), "3 capas"); -} - -/// German uses the vector-illustration vocabulary its users know, has two plural forms like -/// English (zero takes the plural), translates the reason inside a message too, and tells the -/// interface theme ("Erscheinungsbild") from the Appearance panel ("Aussehen"). -#[test] -fn german_reads_as_german() { - for (en, want) in [ - ("Artboard Tool", "Zeichenflächen-Werkzeug"), - ("Swatches", "Farbfelder"), - ("Pathfinder", "Pathfinder"), - ("Stroke", "Kontur"), - ("Fill", "Fläche"), - ("Direct Selection Tool", "Direktauswahl-Werkzeug"), - ("Save As…", "Speichern unter…"), - ("Undo", "Rückgängig"), - ] { - assert_eq!(tr(de(), en), want); - } - assert_eq!(trn(de(), 1, "{n} Layer", "{n} Layers"), "1 Ebene"); - assert_eq!(trn(de(), 0, "{n} Layer", "{n} Layers"), "0 Ebenen"); - assert_eq!(trn(de(), 3, "{n} Layer", "{n} Layers"), "3 Ebenen"); - assert_eq!(tr_ctx(de(), "axis", "Both"), "Beide"); - assert_eq!(tr(de(), "Appearance"), "Aussehen"); - assert_eq!(tr_ctx(de(), "theme", "Appearance"), "Erscheinungsbild"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(de(), nested), "der Befehl `object.group` ist derzeit nicht verfügbar: nichts ausgewählt"); - // Text styles are "…format" in German: the kind is joined to it ("Neues Zeichenformat erstellen"). - assert_eq!(fmt(tr(de(), "Create New {kind} Style"), &[("kind", tr(de(), "Character"))]), "Neues Zeichenformat erstellen"); - assert_eq!(fmt(tr(de(), "Show {kind} Brushes"), &[("kind", tr(de(), "Calligraphic"))]), "Kalligrafiepinsel einblenden"); -} - -/// French uses the vector-illustration vocabulary its users know, puts zero in the singular (« 0 -/// calque »), and reads the same in the menus and in the panels. -#[test] -fn french_reads_as_french() { - for (en, want) in [ - ("Artboard Tool", "Outil Plan de travail"), - ("Swatches", "Nuancier"), - ("Pathfinder", "Pathfinder"), - ("Stroke", "Contour"), - ("Fill", "Fond"), - ("Direct Selection Tool", "Outil Sélection directe"), - ("Save As…", "Enregistrer sous…"), - ("Undo", "Annuler"), - ] { - assert_eq!(tr(fr(), en), want); - } - assert_eq!(trn(fr(), 0, "{n} Layer", "{n} Layers"), "0 calque"); - assert_eq!(trn(fr(), 1, "{n} Layer", "{n} Layers"), "1 calque"); - assert_eq!(trn(fr(), 2, "{n} Layer", "{n} Layers"), "2 calques"); -} - -/// Italian uses the vector-illustration vocabulary its users know, has two plural forms like -/// English (zero takes the plural), and reads the same in the menus and in the panels. -#[test] -fn italian_reads_as_italian() { - for (en, want) in [ - ("Artboard Tool", "Strumento Tavola da disegno"), - ("Swatches", "Campioni"), - ("Pathfinder", "Elaborazione tracciati"), - ("Stroke", "Traccia"), - ("Fill", "Riempimento"), - ("Direct Selection Tool", "Strumento Selezione diretta"), - ("Save As…", "Salva con nome…"), - ("Undo", "Annulla"), - ] { - assert_eq!(tr(it(), en), want); - } - assert_eq!(trn(it(), 1, "{n} Layer", "{n} Layers"), "1 livello"); - assert_eq!(trn(it(), 0, "{n} Layer", "{n} Layers"), "0 livelli"); - assert_eq!(trn(it(), 3, "{n} Layer", "{n} Layers"), "3 livelli"); -} - -/// Russian uses the vector-illustration vocabulary its users know, has the three plural forms -/// (one/few/many), and reads the same in the menus and in the panels. -#[test] -fn russian_reads_as_russian() { - for (en, want) in [ - ("Artboard Tool", "Инструмент «Монтажная область»"), - ("Swatches", "Образцы"), - ("Pathfinder", "Обработка контуров"), - ("Stroke", "Обводка"), - ("Fill", "Заливка"), - ("Direct Selection Tool", "Инструмент «Прямое выделение»"), - ("Save As…", "Сохранить как…"), - ("Undo", "Отменить"), - ] { - assert_eq!(tr(ru(), en), want); - } - assert_eq!(trn(ru(), 1, "{n} Layer", "{n} Layers"), "1 слой"); - assert_eq!(trn(ru(), 2, "{n} Layer", "{n} Layers"), "2 слоя"); - assert_eq!(trn(ru(), 5, "{n} Layer", "{n} Layers"), "5 слоёв"); - assert_eq!(trn(ru(), 11, "{n} Layer", "{n} Layers"), "11 слоёв"); - assert_eq!(trn(ru(), 21, "{n} Layer", "{n} Layers"), "21 слой"); - assert_eq!(trn(ru(), 22, "{n} Layer", "{n} Layers"), "22 слоя"); -} - -#[test] -fn ukrainian_reads_as_ukrainian() { - for (en, want) in [ - ("Artboard Tool", "Інструмент «Монтажна область»"), - ("Swatches", "Зразки"), - ("Pathfinder", "Обробка контурів"), - ("Stroke", "Обведення"), - ("Fill", "Заливка"), - ("Direct Selection Tool", "Інструмент «Пряме виділення»"), - ("Save As…", "Зберегти як…"), - ("Undo", "Скасувати"), - ] { - assert_eq!(tr(uk(), en), want); - } - assert_eq!(tr_ctx(uk(), "axis", "Both"), "Обидві"); - for (n, word) in [ - (0, "шарів"), - (1, "шар"), - (2, "шари"), - (5, "шарів"), - (11, "шарів"), - (21, "шар"), - (22, "шари"), - (111, "шарів"), - (112, "шарів"), - (121, "шар"), - (124, "шари"), - ] { - assert_eq!(trn(uk(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}")); - } - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(uk(), nested), "команда `object.group` зараз недоступна: нічого не виділено"); -} - /// Every Ukrainian plural has all three forms; exercise different endings through real lookups. #[test] fn ukrainian_plurals_have_three_forms() { @@ -1469,34 +1220,6 @@ fn czech_plurals_have_three_forms() { assert!(count >= 13, "Czech plural rows missing"); } -/// Czech uses the vector-illustration vocabulary its users know, has three plural forms -/// (one/few/other), and translates the reason inside a message too. -#[test] -fn czech_reads_as_czech() { - for (en, want) in [ - ("Artboard Tool", "Nástroj Pracovní plocha"), - ("Swatches", "Vzorník"), - ("Pathfinder", "Operace s cestami"), - ("Stroke", "Tah"), - ("Fill", "Výplň"), - ("Direct Selection Tool", "Nástroj Přímý výběr"), - ("Save As…", "Uložit jako…"), - ("Undo", "Zpět"), - ] { - assert_eq!(tr(cs(), en), want); - } - for (n, word) in [(0, "vrstev"), (1, "vrstva"), (2, "vrstvy"), (4, "vrstvy"), (5, "vrstev"), (11, "vrstev")] { - assert_eq!(trn(cs(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}")); - } - assert_eq!(tr_ctx(cs(), "axis", "Both"), "Obě"); - // "Add" is Unite in Effect › Pathfinder (an `@id` row for the effect id); "Přidat" elsewhere. - assert_eq!(tr(cs(), "Add"), "Přidat"); - assert_eq!(tr_id(cs(), "pathfinder.add", "Add"), "Sjednotit"); - assert_eq!(tr_id(cs(), "pathfinder.intersect", "Intersect"), "Protnout"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(cs(), nested), "příkaz `object.group` nyní není k dispozici: nic není vybráno"); -} - #[test] fn russian_plurals_have_three_forms() { let forms: Vec = [0, 1, 2, 4, 5, 11, 21, 22, 25, 111, 121].into_iter().map(plural_east_slavic).collect(); @@ -1598,79 +1321,80 @@ fn messages_translate_by_template_and_their_parts() { assert_eq!(message(Lang::EN, nested), nested); } -/// Each language that translates status and error messages ([`COMPLETE_MESSAGES`]) has an `@msg` -/// row for every message in the sources that reach the status bar, so a new one can't ship -/// untranslated by accident (`VECTORCRAFT_I18N_DUMP_MESSAGES=` lists them). #[test] -fn complete_languages_translate_every_message() { - let messages = message_literals(); - assert!(messages.len() > 300, "scan found only {} messages", messages.len()); - if let Ok(path) = std::env::var("VECTORCRAFT_I18N_DUMP_MESSAGES") { - std::fs::write(path, messages.iter().map(|m| format!("{m}\n")).collect::()).expect("write dump"); - } - for code in COMPLETE_MESSAGES { - let lang = Lang::from_code(code).expect("registered"); - let (entries, _) = parse_entries(lang.0.source); - let rows: std::collections::HashSet<&str> = entries.iter().filter(|(ctx, _, _)| ctx == "@msg").map(|(_, src, _)| src.as_str()).collect(); - let missing: Vec<_> = messages.iter().filter(|m| !rows.contains(m.as_str())).collect(); - assert!(missing.is_empty(), "{code}: {} untranslated messages: {missing:#?}", missing.len()); - } +fn package_status_message_templates_parse() { + let c = Catalog::parse(concat!( + "@msg\tPackaged {n} file(s) in {folder}\tpackaged plain\n", + "@msg\tPackaged {n} file(s) in {folder} {note}\tpackaged noted\n", + "@msg\t{count} file(s) in {folder}. Show the package folder?\tdetail plain\n", + "@msg\t{count} file(s) in {folder} {note}. Show the package folder?\tdetail noted\n", + )); + assert!(c.message("Packaged 1 file(s) in /tmp/foo").is_some()); + assert!(c.message("Packaged 1 file(s) in /tmp/foo (1 linked file(s) not found)").is_some()); + assert!(c.message("2 file(s) in /tmp/foo. Show the package folder?").is_some()); + assert!(c.message("2 file(s) in /tmp/foo (1 linked file(s) not found). Show the package folder?").is_some()); + assert!(Catalog::parse("@msg\tPackaged {n} file(s) in {folder}{note}\tbad\n").message("Packaged 1 file(s) in /a b").is_none()); } -/// The parameter errors of Align, Distribute Spacing, Reflect and Shear, whose text the scan above -/// doesn't read, are translated whole in each language of [`COMPLETE_MESSAGES`]. +/// The scan reads the message of an error made with one of the engine's [`ERROR_HELPERS`] or with +/// an error enum's variant: a literal or a `format!` string, on the call's line or wrapped over +/// several lines, in the same template form as the catalogs' rows. It does not read a call whose +/// name ends in a helper's name (`is_bad(`), a method call (`.err(`), `std::io::Error::new` or one +/// of [`NAME_VARIANTS`]; without the helpers, it reads only the two variants in this source. #[test] -fn align_reflect_and_shear_param_errors_are_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - for x in [0, 50] { - s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap(); +fn message_scan_reads_error_helpers_and_variants() { + let code = r#" +fn width(p: &Value, cmd: &str, key: &str) -> Result { + let w = p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?; + if w < 0.0 { + return Err(bad(cmd, "the width can't be negative")); } - s.execute("select.all", &json!({})).unwrap(); - for (cmd, p) in [ - ("object.align", json!({})), - ("object.align", json!({"horizontal": "middle"})), - ("object.align", json!({"vertical": "centre"})), - ("object.align", json!({"horizontal": "left", "to": "page"})), - ("object.align", json!({"horizontal": "left", "bounds": "visual"})), - ("object.distributeSpacing", json!({"axis": "diagonal"})), - ("object.distributeSpacing", json!({"spacing": "5"})), - ("object.reflect", json!({"axis": "diagonal"})), - ("object.shear", json!({"axis": "diagonal"})), + let e = bad( + "object.example", + format!( + "`{}` must be between {lo:.1} and {hi}, not {w}", + key + ), + ); + let _ = bad(&format!("{cmd}, {key}"), "a comma in the first argument"); + let _ = is_bad(cmd, "not an error helper"); + let _ = r.err("not an error helper either"); + let _ = err(format!("can't open `{name}`: damaged")); + let _ = PsError::Limit(format!("the {what} is nested too deep")); + let _ = Error::Manifest("the manifest has no name".into()); + let _ = std::io::Error::new(kind, "not an error variant"); + let _ = PsError::Ps("not an error message", op); + let _ = EngineError::UnknownCommand("not.an.error.message".into()); + Err(error("Select only dimensions")) +} +"#; + let engine: Vec<_> = ERROR_HELPERS.iter().filter(|(c, _, _)| *c == "engine").map(|&(_, call, before)| (call, before)).collect(); + let found = source_messages(code, &engine); + for want in [ + "`{key}` must be a number", + "the width can't be negative", + "`{_1}` must be between {lo} and {hi}, not {w}", + "a comma in the first argument", + "can't open `{name}`: damaged", + "the {what} is nested too deep", + "the manifest has no name", + "Select only dimensions", ] { - let e = s.execute(cmd, &p).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{cmd} {p}: {e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()), "{code}: {shown}"); - } - } -} - -/// The warning that a data set with no value for a bound variable gives, which the status bar -/// shows and the scan above doesn't read, is translated in each language of [`COMPLETE_MESSAGES`], -/// with the data set's name kept. -#[test] -fn the_data_set_applied_nothing_warning_is_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - s.execute("dataset.import", &json!({"csv": "name,Name\nRow 7,Ann\n"})).unwrap(); - let v = s.execute("dataset.select", &json!({"name": "Row 7"})).unwrap(); - let warning = v["warning"].as_str().unwrap(); - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), warning); - assert!(shown.contains("Row 7") && !shown.contains("was applied"), "{code}: {shown}"); + assert!(found.iter().any(|m| m == want), "{want} not in {found:#?}"); } + assert!(!found.iter().any(|m| m.contains("not an error") || m.contains("not.an.error")), "{found:#?}"); + let elsewhere = source_messages(code, &[]); + assert!(elsewhere.len() == 2 && elsewhere.iter().all(|m| m.contains("nested") || m.contains("manifest")), "{elsewhere:#?}"); + // A message read this way matches its row: `{key}` keeps its name. + let c = Catalog::parse("@msg\t`{key}` must be a number\t`{key}` ok\n"); + assert_eq!(c.message("`width` must be a number").map(|m| m.1), Some(vec![("key", "width")])); } /// The parameter errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add /// Spine Point and Delete Spine Point, which Direct Selection, the Pen and the Anchor Point tool -/// send), whose text the scan above doesn't read, are translated whole in each language of -/// [`COMPLETE_MESSAGES`] and in Bulgarian. +/// send) say what is wrong. #[test] -fn blend_spine_param_errors_are_translated() { +fn blend_spine_param_errors_say_what_is_wrong() { use serde_json::json; use vectorcraft_engine::{EngineError, Session}; let mut s = Session::new(); @@ -1695,7 +1419,6 @@ fn blend_spine_param_errors_are_translated() { let other = rectangle(&mut s, 0, 600); let b = blend(&mut s, 0); s.execute("select.set", &json!({"ids": [b]})).unwrap(); - let mut errors = Vec::new(); for (cmd, p, want) in [ ("object.blend.spine.moveAnchor", json!({}), "missing number `anchor`"), ("object.blend.spine.moveAnchor", json!({"anchor": -1, "x": 0, "y": 0}), "index must be ≥ 0"), @@ -1711,142 +1434,46 @@ fn blend_spine_param_errors_are_translated() { ] { let e = s.execute(cmd, &p).unwrap_err(); assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == want), "{cmd} {p}: {e}"); - errors.push(e); } s.execute("select.none", &json!({})).unwrap(); let e = s.execute("object.blend.spine.moveAnchor", &json!({"anchor": 0, "x": 0, "y": 0})).unwrap_err(); assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == "select a blend or give `id`"), "{e}"); - errors.push(e); - // No command leaves a blend with a pinned spine of one point, so this one is built here. - errors.push(EngineError::BadParams { cmd: "object.blend.spine.addAnchor".into(), msg: "the spine has no segments".into() }); - let mut untranslated = Vec::new(); - for e in &errors { - let EngineError::BadParams { msg, .. } = e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES.iter().chain(&["bg"]) { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - if shown.contains(msg.as_str()) { - untranslated.push(format!("{code}: {shown}")); +} + +/// The status bar's line for an EPS file whose PostScript stops on an error, opened partly or +/// refused, and the refusal's dialog keep the error's name as PostScript spells it and its meaning, +/// which each language shows as its catalog translates the meaning. +#[test] +fn postscript_errors_keep_their_name_and_meaning() { + let meaning = "a value has the wrong type for the operator"; + for (body, opens) in [("0 0 10 10 rectfill 1 (a) add", true), ("1 (a) add", false)] { + let bytes = format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\n%%EOF\n").into_bytes(); + let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.services.read = Some(Box::new(move |_: &str| Ok(bytes.clone()))); + assert_eq!(crate::io::open_reporting(&mut app, "/in/a.eps").is_ok(), opens, "{}", app.ui.status); + let mut shown_texts = vec![app.ui.status.clone()]; + if !opens { + shown_texts.push(app.ui.dialog.take().expect("a dialog says why").str("detail")); + } + for text in shown_texts { + assert!(text.contains(&format!("(typecheck in `add`; {meaning})")), "{text}"); + for lang in Lang::all() { + let shown = message(lang, &text); + let meaning_shown = shown.contains(&message(lang, meaning)) || shown.contains(meaning); + let ok = shown.contains("typecheck") && shown.contains("`add`") && meaning_shown; + assert!(ok, "{}: {shown}", lang.code()); } } } - assert!(untranslated.is_empty(), "untranslated spine errors:\n{}", untranslated.join("\n")); -} - -/// The parameter error of Save as Template for a `.ait` with `aiVersion` 7 is translated whole in -/// each language of [`COMPLETE_MESSAGES`]. -#[test] -fn legacy_ait_param_error_is_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let e = s.execute("file.saveAsTemplate", &json!({"format": "ait", "aiVersion": "7"})).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()), "{code}: {shown}"); - } -} - -/// The errors for a parameter key a command doesn't take (#1442), whose text the scan above -/// doesn't read, are translated whole in each language of [`COMPLETE_MESSAGES`]. -#[test] -fn unknown_parameter_errors_are_translated() { - use serde_json::json; - for (cmd, p) in [("file.new", json!({"widht": 300})), ("select.all", json!({"zzz": 1})), ("shape.line", json!({"colour": "red"}))] { - let e = vectorcraft_engine::check_params(cmd, &p).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{cmd} {p}: {e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains("unknown parameter") && shown.contains(cmd), "{code}: {shown} ({msg})"); - } - } -} - -/// The error for an effect `color` the effect can't draw, whose text the scan above doesn't read, -/// is translated whole in each language of [`COMPLETE_MESSAGES`] and in Bulgarian. -#[test] -fn effect_color_param_error_is_translated() { - use serde_json::json; - let p = json!({"effect": "stylize.dropShadow", "params": {"color": "not-a-color"}}); - let e = vectorcraft_engine::check_params("effect.apply", &p).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES.iter().chain(&["bg"]) { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()) && shown.contains("`color`") && shown.contains("effect.apply"), "{code}: {shown}"); - } -} - -/// The error for a Levels or Curves `channel` the effect doesn't have, whose text the scan above -/// doesn't read, is translated whole in each language of [`COMPLETE_MESSAGES`] and in Bulgarian. -#[test] -fn effect_channel_param_error_is_translated() { - use serde_json::json; - let p = json!({"effect": "adjust.levels", "params": {"channel": "alpha"}}); - let e = vectorcraft_engine::check_params("effect.apply", &p).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES.iter().chain(&["bg"]) { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()) && shown.contains("`channel`") && shown.contains("effect.apply"), "{code}: {shown}"); - } -} - -/// The notes of an EPS or `.ai` whose layers weren't read, which the importer pushes as warnings -/// (the scan above doesn't read them), are translated whole, reason included, as the status bar -/// shows them after opening (#2035). -#[test] -fn unread_layers_notes_are_translated() { - for note in [ - "the file's layers weren't read (its editing data is for a 600 × 400 pt page, not the file's 612 × 792 pt page): it comes in as its page, in one layer", - "the file's layers weren't read from its editing data (its editing data is for a 100 × 100 pt page, the file's 1000 × 1000 pt page at 1:10): they come from its PDF part", - "the file's layers weren't read from its editing data (its editing data is damaged): they come from its PDF part", - "the file's layers weren't read from its editing data (its zlib compression is damaged): they come from its PDF part", - "the file's layers weren't read from its editing data (its Zstandard compression is damaged): they come from its PDF part", - "the file's layers weren't read from its editing data (it decompresses to more than 256 MB): they come from its PDF part", - "the file's variables and data sets weren't read (variables: 2, data sets: 3): the Variables panel doesn't list them", - ] { - let status = format!("Opened with 1 note(s): {note}"); - for code in COMPLETE_MESSAGES.iter().chain(&["pt-br"]) { - let shown = message(Lang::from_code(code).unwrap(), &status); - assert!( - !shown.contains("weren't read") - && !shown.contains("its editing data") - && !shown.contains("is damaged") - && !shown.contains("decompresses") - && !shown.contains("Opened with"), - "{code}: {shown}" - ); - } - } -} - -/// The parameter error of `select.key` for an id that isn't a selected top-level object, whose -/// text [`message_literals`] doesn't read, is translated whole in each language of -/// [`COMPLETE_MESSAGES`]. -#[test] -fn select_key_param_error_is_translated() { - use serde_json::json; - let mut s = vectorcraft_engine::Session::new(); - s.execute("file.new", &json!({})).unwrap(); - let rectangle = |s: &mut vectorcraft_engine::Session, x: u64| { - s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone() - }; - let [a, b, unselected] = [0, 50, 100].map(|x| rectangle(&mut s, x)); - s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); - let e = s.execute("select.key", &json!({"id": unselected})).unwrap_err(); - let vectorcraft_engine::EngineError::BadParams { msg, .. } = &e else { panic!("{e}") }; - for code in COMPLETE_MESSAGES { - let shown = message(Lang::from_code(code).unwrap(), &e.to_string()); - assert!(!shown.contains(msg.as_str()), "{code}: {shown}"); - } } /// The errors of the parameter checks that many commands share (a number given as text, a missing /// number or index, a fraction for a whole number, `ids` that aren't a list of object ids, an -/// object id that is negative or names no object, a date that isn't Unix seconds) are translated -/// whole by their own rows in each language of [`COMPLETE_MESSAGES`], in Bulgarian, and in Danish, -/// Indonesian and Dutch. The value in the message, such as a quoted `"60 mm"`, is shown as given. +/// object id that is negative or names no object, a date that isn't Unix seconds) fill their +/// templates, and each language shows the value in the message, such as a quoted `"60 mm"`, as +/// given: in its row's translation, or in English where it has none. #[test] -fn shared_param_check_errors_are_translated() { +fn shared_param_check_errors_show_their_values_as_given() { use serde_json::json; use vectorcraft_engine::EngineError; let mut s = vectorcraft_engine::Session::new(); @@ -1867,26 +1494,23 @@ fn shared_param_check_errors_are_translated() { ("edit.clear", json!({"ids": [99999]}), "no such object id {value}", &[("value", "99999")]), ("file.new", json!({"created": "yesterday"}), "{key} must be Unix seconds (an integer) or null", &[("key", "created")]), ]; - let mut untranslated = Vec::new(); for (cmd, p, template, values) in cases { let e = s.execute(cmd, &p).unwrap_err(); assert!(matches!(&e, EngineError::BadParams { msg, .. } if *msg == fmt(template, values)), "{cmd} {p}: {e}"); - for code in COMPLETE_MESSAGES.iter().chain(&["bg", "da", "id", "nl"]) { - let lang = Lang::from_code(code).unwrap(); + for lang in Lang::all() { let (entries, _) = parse_entries(lang.0.source); let row = entries.iter().find(|(ctx, src, _)| ctx == "@msg" && src == template); let shown = message(lang, &e.to_string()); - if shown.contains("invalid parameters") || !row.is_some_and(|(_, _, tr)| shown.contains(&fmt(tr, values))) { - untranslated.push(format!("{code}: {shown}")); - } + let as_given = shown.contains(&fmt(template, values)) || row.is_some_and(|(_, _, tr)| shown.contains(&fmt(tr, values))); + assert!(as_given, "{}: {shown}", lang.code()); } } - assert!(untranslated.is_empty(), "untranslated parameter check errors:\n{}", untranslated.join("\n")); } -/// Languages whose catalogs cover every status and error message. +/// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists +/// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = - &["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "zh-hant", "fi"]; + &["cs", "de", "el", "es", "fr", "hu", "it", "ja", "ka", "ko", "pl", "pt-br", "ru", "sv", "uk", "vi", "zh-hans", "zh-hant", "fi", "tr"]; /// Crates whose error and status messages reach the status bar. const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", "pdf", "eps", "text", "plugins", "metafile", "cad", "trace"]; @@ -1894,6 +1518,17 @@ const MESSAGE_CRATES: &[&str] = &["ui-egui", "engine", "doc", "format", "svg", " /// Where a message literal starts: a status, an error value, a `thiserror` message. const MESSAGE_MARKERS: &[&str] = &[".status(", "ui.status = ", "status = ", "Other(", "Err(", "ok_or(", "ok_or_else(|| ", "#[error("]; +/// Helpers that make an error from a message, by crate, with the number of arguments before the +/// message: the engine's `bad(cmd, msg)` (`EngineError::BadParams`), `err(msg)` (`cmd/fileio`) +/// and `error(msg)` (`cmd/dimension.rs`), and the PDF writer's `bad(why)` (`forms.rs`) and +/// `failed(why)` (`encrypt.rs`, `linearize.rs`). +const ERROR_HELPERS: &[(&str, &str, usize)] = + &[("engine", "bad(", 1), ("engine", "err(", 0), ("engine", "error(", 0), ("pdf", "bad(", 0), ("pdf", "failed(", 0)]; + +/// Error variants whose first argument is a name, not a message: a command id +/// (`EngineError::UnknownCommand`) and a PostScript error name (`PsError::Ps`). +const NAME_VARIANTS: &[&str] = &["UnknownCommand", "Ps"]; + /// Messages that are signals, not text for people. const NOT_MESSAGES: &[&str] = &["quit", "cancelled"]; @@ -1915,24 +1550,78 @@ pub fn message_literals() -> std::collections::BTreeSet { // Control replies are English protocol data, never status-bar messages // (see i18n's module contract). Engine messages are scanned in their own crates. let text = std::fs::read_to_string(&path).unwrap_or_default(); - let code = without_test_module(&text); - for marker in MESSAGE_MARKERS { - for (at, _) in code.match_indices(marker) { - let rest = code[at + marker.len()..].trim_start(); - let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start); - let Some(lit) = rest.strip_prefix('"').and_then(string_literal) else { continue }; - let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); - if has_word && !NOT_MESSAGES.contains(&lit.as_str()) { - out.insert(as_template(&lit)); - } - } - } + let helpers: Vec<_> = + ERROR_HELPERS.iter().filter(|(c, _, _)| path.starts_with(crates.join(c))).map(|&(_, call, before)| (call, before)).collect(); + out.extend(source_messages(without_test_module(&text), &helpers)); } } } out } +/// The message literals in `code`, as catalog templates (see [`message_literals`]): the literal +/// after each of [`MESSAGE_MARKERS`], the first argument of an error enum's variant +/// (`PsError::Limit(…)`, `Error::Manifest(…)`; not [`NAME_VARIANTS`]), +/// and the message argument of each call in `helpers` (a call and the number of arguments before +/// its message), a literal or a `format!` string. +fn source_messages(code: &str, helpers: &[(&str, usize)]) -> Vec { + let after_markers = MESSAGE_MARKERS.iter().flat_map(|marker| code.match_indices(marker).map(|(at, _)| &code[at + marker.len()..])); + let in_variants = code.match_indices("Error::").filter_map(|(at, m)| { + let rest = &code[at + m.len()..]; + let (variant, args) = rest.split_at(rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len())); + let named = variant.starts_with(|c: char| c.is_ascii_uppercase()) && !NAME_VARIANTS.contains(&variant); + if named { args.strip_prefix('(') } else { None } + }); + let in_helpers = helpers.iter().flat_map(|&(call, before)| { + code.match_indices(call) + .filter(|&(at, _)| !code[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_' || c == '.')) + .filter_map(move |(at, _)| skip_args(&code[at + call.len()..], before)) + }); + let mut out = vec![]; + for rest in after_markers.chain(in_variants).chain(in_helpers) { + let rest = rest.trim_start(); + let rest = rest.strip_prefix("format!(").map_or(rest, str::trim_start); + let Some(lit) = rest.strip_prefix('"').and_then(string_literal) else { continue }; + let has_word = lit.as_bytes().windows(3).any(|w| w.iter().all(u8::is_ascii_lowercase)); + if has_word && !NOT_MESSAGES.contains(&lit.as_str()) { + out.push(as_template(&lit)); + } + } + out +} + +/// The arguments of a call whose opening parenthesis was just read, past the first `n` of them; +/// `None` when `n` is 1 or more and the call has `n` arguments or fewer. +fn skip_args(args: &str, n: usize) -> Option<&str> { + if n == 0 { + return Some(args); + } + let (mut left, mut depth, mut chars) = (n, 0usize, args.char_indices()); + while let Some((at, c)) = chars.next() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth = depth.checked_sub(1)?, + '"' => loop { + match chars.next()?.1 { + '\\' => { + chars.next()?; + } + '"' => break, + _ => {} + } + }, + ',' if depth == 0 => { + left -= 1; + if left == 0 { + return Some(&args[at + 1..]); + } + } + _ => {} + } + } + None +} + /// Source up to its inline test module (`#[cfg(test)] mod tests {`); declarations of test files /// (`#[cfg(test)] mod tests_x;`) don't end it. fn without_test_module(text: &str) -> &str { @@ -1986,59 +1675,29 @@ fn as_template(s: &str) -> String { out } -/// Render-site labels keep English fallback, placeholders and Czech grammar independent of -/// the plain language-name key. No global language changes: other UI tests may run alongside. +/// Render-site labels looked up in a context fall back to the plain row and then to English, and +/// their templates fill in English as in any language. #[test] -fn czech_render_site_follow_up() { - for (source, expected) in [ - ("All readable files", "Všechny čitelné soubory"), - ("All placeable files", "Všechny soubory pro umístění"), - ("SVG Compressed", "Komprimované SVG"), - ("PDF-compatible .ai", ".ai kompatibilní s PDF"), - ("VectorCraft Template", "Šablona VectorCraft"), - ] { - assert_eq!(tr_ctx(cs(), "file-filter", source), expected); - assert_eq!(tr_ctx(Lang::EN, "file-filter", source), source); +fn render_site_labels_fall_back_to_english() { + for (_, source) in RENDER_SITE_ROWS.iter().filter(|(ctx, _)| *ctx == "file-filter") { + assert_eq!(tr_ctx(Lang::EN, "file-filter", source), *source); } for source in ["SVG", "PNG", "VectorCraft", "unregistered filter"] { - assert_eq!(tr_ctx(cs(), "file-filter", source), source); + assert_eq!(tr_ctx(cs(), "file-filter", source), tr(cs(), source)); } - assert_eq!(tr_ctx(cs(), "diagnostics", "render"), "vykreslení"); - assert_eq!(tr_ctx(cs(), "diagnostics", "ui"), "rozhraní"); - assert_eq!(fmt(tr(cs(), "Preset {number}"), &[("number", "12")]), "Přednastavení 12"); assert_eq!(fmt(tr(Lang::EN, "Preset {number}"), &[("number", "12")]), "Preset 12"); - for (kind, expected) in [("Linear", "Lineární přechod"), ("Radial", "Kruhový přechod"), ("Freeform", "Volný přechod")] { - assert_eq!(fmt(tr(cs(), "{kind} Gradient"), &[("kind", tr_ctx(cs(), "gradient-kind", kind))]), expected); + for kind in ["Linear", "Radial", "Freeform"] { assert_eq!(fmt(tr(Lang::EN, "{kind} Gradient"), &[("kind", tr_ctx(Lang::EN, "gradient-kind", kind))]), format!("{kind} Gradient")); } - assert_eq!(tr(cs(), "Japanese"), "Japonština"); - assert_eq!(tr_ctx(cs(), "mark-style", "Japanese"), "Japonské"); assert_eq!(tr_ctx(Lang::EN, "mark-style", "Japanese"), "Japanese"); - assert_eq!(tr(cs(), "PRs"), "PR"); - for (n, expected) in [(0, "0 commitů"), (1, "1 commit"), (2, "2 commity"), (4, "4 commity"), (5, "5 commitů")] { - assert_eq!(trn(cs(), n, "{n} commit", "{n} commits"), expected); - assert_eq!(trn(cs(), n, "{n} PR", "{n} PRs"), format!("{n} PR")); + for n in [0, 1, 2, 5] { + let commits = if n == 1 { "commit" } else { "commits" }; + assert_eq!(trn(Lang::EN, n, "{n} commit", "{n} commits"), format!("{n} {commits}")); } } -/// The labels in [`RENDER_SITE_ROWS`], which the UI looks up through a variable or a context that -/// the literal scans above don't read, have a row in every complete catalog. -#[test] -fn complete_catalogs_translate_render_site_labels() { - for l in LANGUAGES.iter().filter(|l| l.complete_menus) { - let (entries, _) = parse_entries(l.source); - let rows: std::collections::HashSet<(&str, &str)> = entries.iter().map(|(ctx, src, _)| (ctx.as_str(), src.as_str())).collect(); - let missing: Vec<_> = RENDER_SITE_ROWS.iter().filter(|row| !rows.contains(row)).collect(); - assert!(missing.is_empty(), "{}: {} missing rows: {missing:#?}", l.code, missing.len()); - } - // German's plain "Light" row is Hell (bright), and its plain "Regular" row stays in English. - assert_eq!(tr_ctx(de(), "lighting", "Light"), "Licht"); - assert_eq!(tr_ctx(de(), "effect", "Regular"), "Regelmäßig"); - assert_eq!(tr_ctx(Lang::from_code("ja").unwrap(), "mark-style", "Japanese"), "日本式"); -} - -/// (context, English source) of the labels [`complete_catalogs_translate_render_site_labels`] -/// requires: the file dialogs' filter names, the status bar's timings, New Document's preset +/// (context, English source) of the labels [`untranslated_report`] lists for a language without +/// their rows: the file dialogs' filter names, the status bar's timings, New Document's preset /// name, the contributor tooltips, the gradient kind in a swatch's description, the printer mark /// styles and the label of the 3D gizmo's light. const RENDER_SITE_ROWS: &[(&str, &str)] = &[ @@ -2090,47 +1749,3 @@ fn polish_plurals_have_three_forms() { } assert!(count >= 13, "Polish plural rows missing"); } - -/// Polish uses its vector-illustration vocabulary (obszar kompozycji, ścieżka, obrys…), resolves -/// every `pl-*` locale, counts with one/few/many and translates the reason inside a message too. -#[test] -fn polish_reads_as_polish() { - for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl"] { - assert_eq!(lang_from_tag(tag), Some(pl()), "{tag}"); - } - for (en, want) in [ - ("File", "Plik"), - ("Artboard Tool", "Narzędzie Obszar kompozycji"), - ("Swatches", "Próbki"), - ("Pathfinder", "Odnajdywanie ścieżek"), - ("Stroke", "Obrys"), - ("Fill", "Wypełnienie"), - ("Workspace", "Obszar roboczy"), - ] { - assert_eq!(tr(pl(), en), want); - } - for (n, word) in [(1, "warstwa"), (3, "warstwy"), (5, "warstw"), (22, "warstwy"), (21, "warstw")] { - assert_eq!(trn(pl(), n, "{n} Layer", "{n} Layers"), format!("{n} {word}")); - } - assert_eq!(tr_ctx(pl(), "axis", "Both"), "Obie"); - assert_eq!(tr_id(pl(), "pathfinder.add", "Add"), "Połącz"); - let nested = "command `object.group` is not available right now: nothing selected"; - assert_eq!(message(pl(), nested), "polecenie `object.group` jest teraz niedostępne: nic nie zaznaczono"); -} - -/// Japanese names every default swatch and colour group the Swatches panel shows (the greys' -/// `K=` values read the same in any language); a name the user gave a swatch has no row. -#[test] -fn japanese_names_the_default_swatches() { - let ja = Lang::from_code("ja").unwrap().catalog(); - let (swatches, groups) = vectorcraft_color::default_swatches(vectorcraft_color::cms::Model::Rgb); - let names = swatches - .iter() - .map(|s| s.name.clone()) - .chain(groups.iter().flat_map(|g| std::iter::once(g.name.clone()).chain(g.swatches.iter().map(|s| s.name.clone())))) - .chain([vectorcraft_color::swatch::REGISTRATION.to_string()]); - let missing: Vec = names.filter(|n| !n.starts_with("K=") && ja.contextual("swatch", n).is_none()).collect(); - assert!(missing.is_empty(), "{missing:?}"); - assert_eq!(ja.contextual("swatch", "White"), Some("ホワイト")); - assert_eq!(ja.contextual("swatch", "My Red"), None); -} diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv new file mode 100644 index 000000000..33874a03a --- /dev/null +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -0,0 +1,5498 @@ +# Turkish (tr) catalog. Columns, separated by a TAB: context English source Turkish translation +# +# context empty for a plain UI string; other contexts (@id, @plural …) as in the other catalogs +# escapes \n \t \\ inside a column. {placeholders} appear in both columns and may be reordered; +# a trailing "…" (dialog commands) is kept. +# lines starting with # and blank lines are ignored. +# +# Written from VectorCraft's English strings, using ordinary Turkish software vocabulary. No other catalog +# and no proprietary localisation was translated from or copied. Labels use sentence case, commands +# are imperatives; product, format and key-cap names keep their spelling. + + Glowing Edges… Parlayan Kenarlar… + Glowing Edges Parlayan Kenarlar + Edge Width Kenar Genişliği + Edge Brightness Kenar Parlaklığı + Smoothness Pürüzsüzlük + Radial Blur… Radyal Bulanıklık… + Smart Blur… Akıllı Bulanıklaştırma… + Sharpen Keskinleştir + Unsharp Mask… Keskinliği Azaltma Maskesi… + Pixelate Pikselleştir + Color Halftone… Renk Noktalı Resim… + Crystallize… Kristalleş… + Mezzotint… Mezotint… + Pointillize… Noktalaştır… + Color Halftone Renk Noktalı Resim + Crystallize Kristalleş + Mezzotint Mezotint + Pointillize Noktalaştır + Max. Radius Maks. Yarıçap + Channel 1 Kanal 1 + Channel 2 Kanal 2 + Channel 3 Kanal 3 + Channel 4 Kanal 4 + Cell Size Hücre Boyutu + Fine Dots İnce Noktalar + Medium Dots Orta Noktalar + Grainy Dots Grenli Noktalar + Coarse Dots Kaba Noktalar + Short Lines Kısa Çizgiler + Medium Lines Orta Çizgiler + Long Lines Uzun Çizgiler + Short Strokes Kısa Vuruşlar + Medium Strokes Orta Vuruşlar + Long Strokes Uzun Vuruşlar + Radial Blur Radyal Bulanıklık + Smart Blur Akıllı Bulanıklaştırma + Unsharp Mask Keskinliği Azaltma Maskesi + Gaussian Blur Gauss Bulanıklığı + Method Yöntem + Quality Kalite + Radius Yarıçap + Spin Döndürme + Draft Taslak + Good İyi + Best En iyi + Mode Mod + Blur Mode Bulanıklaştırma Modu + Edge Only Yalnızca Kenar + Overlay Edge Kaplama Kenarı + — missing - eksik + to search every command her komutu aramak için + (closest) (en yakın) + + Add Scale + Ölçek Ekle + 0.2 (quality) to 100 (speed) 0,2 (kalite) ila 100 (hız) + 1 Color Job… 1 Renk İşi… + 1 {unit} on the artboard stands for {real} {unit} in the scene. Çalışma yüzeyindeki 1 {unit}, sahnedeki {real} {unit} anlamına gelir. + 2 Color Job… 2 Renkli İş… + 3 Color Job… 3 Renkli İş… + 30° Angles 30° Açılar + 3D and Materials 3D ve Malzemeler + 3D Ribbon 3D Şerit + 45° Angles 45° Açılar + 60° Angles 60° Açılar + 90° & 45° & 30° Angles 90° & 45° & 30° Açılar + 90° & 45° Angles 90° ve 45° Açılar + 90° Angles 90° Açılar + < 1 sec < 1 saniye + + [Default] is protected: New… starts an editable copy. [Varsayılan] korumalıdır: Yeni… düzenlenebilir bir kopya başlatır. + [Unnamed] [İsimsiz] + A File… Bir Dosya… + A lower value follows the stroke closely; a higher one gives fewer points and smoother curves. Daha düşük bir değer konturu yakından takip eder; daha yüksek olanı daha az nokta ve daha yumuşak eğriler verir. + A new preset starting from the selected one Seçilenden başlayarak yeni bir ön ayar + A saved copy of the selected preset Seçilen ön ayarın kaydedilmiş bir kopyası + About VectorCraft VectorCraft Hakkında + above yukarıda + Absolute Position Mutlak Konum + Accurate Doğru + Abutting bitişik + Action {n} Eylem {n} + Actions Eylemler + Activate Document Belgeyi Etkinleştir + Active Perspective Plane Aktif Perspektif Düzlemi + Actual Size Gerçek Boyut + Add Ekle + Add Anchor (Pen) Çapa Ekle (Kalem) + Add Anchor Point Bağlantı Noktası Ekle + Add Anchor Point Tool Bağlantı Noktası Ekleme Aracı + Add Anchor Points Bağlantı Noktaları Ekle + Add Effect Efekt Ekle + Add Freeform Line Serbest Biçimli Çizgi Ekle + Add Freeform Point Serbest Biçim Noktası Ekle + Add Guide Kılavuz Ekle + Add Mesh Line Mesh Hattı Ekle + Add New Yeni Ekle + Add New Effect Yeni Efekt Ekle + Add New Fill Yeni Dolgu Ekle + Add New Stroke Yeni Kontur Ekle + Add Selected Artwork (Alt: as one asset) Seçilen Resmi Ekle (Alt: tek varlık olarak) + Add Selected Artwork as Multiple Assets Seçilen Sanat Eserini Birden Çok Öğe Olarak Ekle + Add Selected Artwork as One Asset Seçilen Sanat Eserini Tek Varlık Olarak Ekle + Add Spine Point Omurga Noktası Ekle + Add Swatches Renk Örnekleri Ekle + Add to Graphic Styles Grafik Stillerine Ekle + Add to Profiles Profillere Ekle + Add to Profiles… Profillere Ekle… + Add to Selection Seçime Ekle + Add to Swatches Renk Örneklerine Ekle + Add Used Colors Kullanılan Renkleri Ekle + Additional Plug-ins Folder Ek Eklentiler Klasörü + Additional Fonts Folder Ek Yazı Tipleri Klasörü + Adjust Adjoining Width Points Bitişik Genişlik Noktalarını Ayarlayın + Adjust Color Balance… Renk Dengesini Ayarlayın… + Adjust Colors Renkleri Ayarla + Adjust Options: Seçenekleri Ayarlayın: + Advanced Gelişmiş + Advanced Options Gelişmiş Seçenekler + Align Hizala + Align center Ortaya hizala + Align left Sola hizala + Align Objects: Nesneleri Hizala: + Align On: Hizalama Açık: + Align right Sağa hizala + Align Stroke to Center Konturu Merkeze Hizala + Align Stroke to Inside Konturu İçeri Hizala + Align Stroke to Outside Konturu Dışarıya Hizala + Align Stroke: Konturu Hizala: + Align to Artboard Çalışma Yüzeyine Hizala + Align to Glyph Bounds Glif Sınırlarına Hizala + Align to Key Object Anahtar Nesneye Hizala + Align to Page Sayfaya Hizala + Align to Path Yola Hizala + Align to Selection Seçime Hizala + Align To: Şuna Hizala: + Align: Hizala: + Alignment Guides Hizalama Kılavuzları + Alignment: Hizalama: + All Hepsi + All Artwork Above Yukarıdaki Tüm Sanat Eserleri + All Caps Tamamı Büyük Harf + All Categories Tüm Kategoriler + All colours are within the CMYK gamut Tüm renkler CMYK gamındadır + All on Active Artboard Hepsi Aktif Çalışma Yüzeyinde + All on Same Layers Hepsi Aynı Katmanlarda + All printer's marks Tüm yazıcı işaretleri + All Scripts Tüm Komut Dosyaları + All Text Objects Tüm Metin Nesneleri + All the art Tüm sanat + All Tools Tüm Araçlar + Alt Alternatif + Alt: Alternatif: + Alter Paths for Appearance Görünüm İçin Yolları Değiştirin + Amount Tutar + Amount: Tutar: + Anchor Point Bağlantı Noktası + Anchor Point Tool Bağlantı Noktası Aracı + Anchor Points Bağlantı Noktaları + Anchor Points, Handle, and Bounding Box Display Bağlantı Noktaları, Tutamaç ve Sınırlandırma Kutusu Görünümü + Anchor/Path Labels Bağlantı/Yol Etiketleri + Anchors: Çapalar: + Angle Açı + Angle: Açı: + Angles Açılar + Animated Zoom Animasyonlu Yakınlaştırma + Anti-alias Kenar yumuşatma + Anti-Alias Kenar Yumuşatma + Anti-alias Rasters Kenar Yumuşatma Rasterleri + Anti-aliased Artwork Kenar Yumuşatılmış Sanat Çalışması + Anti-aliasing: Kenar yumuşatma: + Any Kind Her Türlü + Font Technology Yazı Tipi Teknolojisi +font-technology Variable Değişken + Appearance Görünüm + Appearance Attribute Görünüm Özniteliği + Appearance Item Görünüm Öğesi + Appearance of Black Siyahın Görünümü + Appearance Panel Görünüm Paneli + Appearance panel Görünüm paneli + Applies when the linked file is relinked or updated. Bağlantılı dosya yeniden bağlandığında veya güncellendiğinde geçerlidir. + Applies the next time VectorCraft starts. VectorCraft'ın bir sonraki başlatılışında uygulanır. + Apply Uygula + Apply Brush Fırça Uygula + Apply Character Style Karakter Stilini Uygula + Apply Effect Efekt Uygula + Apply Graphic Style Grafik Stili Uygula + Apply Last Color Son Rengi Uygula + Apply Last Effect Son Efekti Uygula + Apply Last Gradient Son Degradeyi Uygula + Apply Paragraph Style Paragraf Stilini Uygula + Apply SVG Filter… SVG Filtresini Uygula… + Apply to All Tümüne Uygula + Apply to All ({count} more missing) Tümüne Uygula ({count} daha eksik) + Arc Yay + Arc Lower… Ark Alt… + Arc Tool Yay Aracı + Arc Upper… Ark Üst… + Arch… Kemer… + Arc… Yay… + Area / Path Type Alan / Yol Türü + Area Graph Tool Alan Grafiği Aracı + Area Text Objects Alan Metin Nesneleri + Area Type Options Alan Tipi Seçenekleri + Area Type Options… Alan Tipi Seçenekleri… + Area Type Tool Alan Metni Aracı + Around Object Nesnenin Etrafında + Arrange Düzenle + Arrange: Bring to Front Düzenle: Öne Getir + Arrowheads: Ok uçları: + Art Sanat + Art using a deleted global swatch keeps its colour. Silinen bir global renk örneğini kullanan sanat eseri rengini korur. + Artboard Options Çalışma Yüzeyi Seçenekleri + Artboard Options… Çalışma Yüzeyi Seçenekleri… + Artboard Tool Çalışma Yüzeyi Aracı + Artboard {number} Çalışma yüzeyi {number} + Artboard: Çalışma yüzeyi: + Artboards Çalışma Yüzeyleri + ArtCraft Website ArtCraft Web Sitesi + ArtCraft website ArtCraft web sitesi + Artwork Colors Sanat Eseri Renkleri + As Multiple Assets Çoklu Varlıklar Olarak + As Single Asset Tek Varlık Olarak + Ascender Yükselen + Ask When Modified Değiştirildiğinde Sor + Aspect Ratio En Boy Oranı + Asset Export Varlık Dışa Aktarma + Asset Export Settings Varlık Dışa Aktarma Ayarları + Assets Varlıklar + Assign Ata + Assign Profile… Profil Ata… + Assign: Ata: + Attach to Active Plane Etkin Düzlem'e Ekle + Attributes Nitelikler + Author Title: Yazarın Adı: + Author: Yazar: + Auto Otomatik + Auto Color Otomatik Renk + Automatic Otomatik + Auto Size New Area Type Otomatik Boyutlandırma Yeni Alan Türü + Auto Spell Check Otomatik Yazım Denetimi + Auto-Collapse Iconic Panels İkonik Panelleri Otomatik Daralt + Ellipse Properties: Elips Özellikleri: + Invert Pie Pastayı Ters Çevir + Side Length: Yan Uzunluk: + Make Sides Equal Tarafları Eşitleyin + Pie End Angle: Pasta Sonu Açısı: + Pie Start Angle: Pasta Başlangıç Açısı: + Show Tool Group Labels Araç Grubu Etiketlerini Göster + Auto-Rotate (turn the paper to each artboard) Otomatik Döndürme (kağıdı her çalışma yüzeyine çevirin) + Automatic flatness Otomatik düzlük + Automatically Otomatik olarak + Automatically Save Recovery Data Kurtarma Verilerini Otomatik Olarak Kaydet + Average Ortalama + Average… Ortalama… + Axis: Eksen: + Background Color: Arka Plan Rengi: + Show Center Mark Merkez İşaretini Göster + Show Cross Hairs Artı Çizgilerini Göster + Show Video Safe Areas Video Güvenli Alanlarını Göster + Video Ruler Pixel Aspect Ratio: Video Cetveli Piksel En Boy Oranı: + Hide Video Rulers Video Cetvellerini Gizle + Background Contents Arka Plan İçeriği + Background Contents: Arkaplan İçeriği: + Background: Arka plan: + Backspace Geri tuşu + Bar Graph Tool Çubuk Grafik Aracı + Baseline Temel + Baseline Shift Taban Çizgisi Kayması + Basic Latin Temel Latince + Batch Toplu + Begin Recording Kaydı Başlat + Bend: Bükülme: + Bevel Join Eğimli Birleştirme + Bitmap Bit eşlem + Black Siyah + Black and White Siyah ve Beyaz + Black and White Logo Siyah Beyaz Logo + BLANK DOCUMENT PRESETS BOŞ BELGE ÖN AYARLARI + Bleed kanama + Bleed: Kanama: + Bleeds Kanamalar + Blend Karışım + Blend Front to Back Önden Arkaya Karıştırın + Blend Horizontally Yatay Olarak Karıştır + Blend Info Karışım Bilgisi + Blend Options Karışım Seçenekleri + Blend Options… Karışım Seçenekleri… + Blend Tool Karışım Aracı + Blend Vertically Dikey Olarak Karıştır + Blending Mode Karıştırma Modu + Blends and Transparency Karışımlar ve Şeffaflık + Bloat Tool Şişirme Aracı + Blob Brush Kabarcık Fırçası + Blob Brush Tool Kabarcık Fırçası Aracı + Blue Mavi + Blur Bulanıklaştırma + Bottom Alt + Bottom in Front Alt Ön + Bottom: Alt: + Bounds Sınırlar + Bounds (Fill Proportionally) Sınırlar (Orantılı Doldurun) + Bounds (Fit Proportionally) Sınırlar (Orantılı Olarak Sığdır) + Break Link to Graphic Style Grafik Stiliyle Bağlantıyı Kes + Break Link to Symbol Sembol Bağlantısını Kes + Brick by Column Sütun Tuğla + Brick by Row Sıra Sıra Tuğla + Brick Offset: Tuğla Ofseti: + Brightness Parlaklık + Brightness/Contrast… Parlaklık/Kontrast… + Brights Parlaklar + Bring Forward Öne Çıkar + Bring to Front Öne Getir + Bristle kıl + Bristle Brush Strokes Kıl Fırça Darbeleri + Browser Tarayıcı + Brush Affects: Fırça Etkileri: + Brush Definition Fırça Tanımı + Brush Libraries Fırça Kitaplıkları + Brush Libraries (on the roadmap) Fırça Kitaplıkları (yol haritasında) + Brush Options… Fırça Seçenekleri… + Brush Strokes Fırça Darbeleri + Brushes Fırçalar + Built-in Libraries Yerleşik Kütüphaneler + Built-in presets are read-only: New… starts an editable copy. Yerleşik ön ayarlar salt okunurdur: Yeni… düzenlenebilir bir kopya başlatır. + Built-in presets don't change: New makes a copy you can edit. Yerleşik ön ayarlar değişmez: Yeni, düzenleyebileceğiniz bir kopya oluşturur. + Built-in presets don't change: New… starts an editable copy. Yerleşik ön ayarlar değişmez: Yeni… düzenlenebilir bir kopya başlatır. + Bulge… çıkıntı… + Bullet Madde işareti + Bullets Madde İşaretleri + Bullets and Numbering Madde İşaretleri ve Numaralandırma + Apply Bullets Madde İşaretlerini Uygula + Filled Dot Dolu Nokta + Dash Tire + Hollow Circle Boş Daire + Filled Square Dolu Kare + Butt Cap Popo Kapağı + Byte Order: Bayt Sırası: + Calligraphic Kaligrafi + Can't print these settings: see the Summary Bu ayarlar yazdırılamıyor: Özet'e bakın + Cancel İptal + Cancel Key Object Anahtar Nesneyi İptal Et + Canvas Color Kanvas Rengi + Cap Kap + Cap: Kapak: + Cascade Çağlayan + Center Merkez + Center Artwork Merkez Resmi + Center on Artboard Çalışma Yüzeyinde Ortala + Center-Justified Tab Ortaya Yaslanmış Sekme + Centimeters Santimetre + Change Değiştir + Change All Tümünü Değiştir + Change Case Büyük/Küçük Harf Değiştir + Change Screen Mode (F) Ekran Modunu Değiştir (F) + Change to Global Rulers Küresel Yöneticilerde Değişiklik + Change to Right-to-Left Layout Sağdan Sola Düzene Geç + Changed since the last Refresh. Son Yenilemeden bu yana değişti. + Changes allowed: İzin verilen değişiklikler: + Changes made since it was last saved will be lost. Son kaydedilmesinden bu yana yapılan değişiklikler kaybolacak. + Changing these settings changes how raster effects look when exported, flattened or expanded. Bu ayarların değiştirilmesi, dışa aktarıldığında, düzleştirildiğinde veya genişletildiğinde tarama efektlerinin nasıl görüneceğini değiştirir. + Channel Kanal + Character Karakter + Character Alignment Karakter Hizalaması + Character / Paragraph Karakter / Paragraf + Character Rotation Karakter Rotasyonu + Character Set: Karakter Seti: + Character Style Karakter Stili + Character Style Options Karakter Stili Seçenekleri + Character Styles Karakter Stilleri + Check Links Bağlantıları Kontrol Et + Check Script Komut Dosyasını Kontrol Et + Check Script… Komut Dosyasını Kontrol Et… + Copy Report Raporu Kopyala + VectorCraft read this script without running it. VectorCraft bu betiği çalıştırmadan okudu. + Needs nothing beyond plain JavaScript Sade JavaScript'in ötesinde hiçbir şeye ihtiyaç duymaz + Needs the script language's own objects or directives Betik dilinin kendi nesnelerine veya direktiflerine ihtiyaç duyar + Needs the document model Belge modeline ihtiyaç var + Needs files Dosyalara ihtiyaç var + Needs menu commands Menü komutlarına ihtiyaç duyar + Needs dialogs Diyaloglara ihtiyaç var + Needs features only the reference app has Yalnızca referans uygulamanın sahip olduğu özelliklere ihtiyaç duyar + Outside the document Belgenin dışında + Writes, moves or deletes files Dosyaları yazar, taşır veya siler + Opens files in other apps Dosyaları diğer uygulamalarda açar + Opens network connections Ağ bağlantılarını açar + Loads native code Yerel kodu yükler + Sends messages to other apps Diğer uygulamalara mesaj gönderir + Reads or sets environment variables Ortam değişkenlerini okur veya ayarlar + Runs code the checker can't read Denetleyicinin okuyamadığı kodu çalıştırır + Changes the reference app's preferences Referans uygulamasının tercihlerini değiştirir + Menu commands Menü komutları + Names the script builds as it runs: {count} Betiğin çalıştığı sırada oluşturduğu adları belirtir: {count} + Document model Belge modeli + Object types: {names} Nesne türleri: {names} + Directives Direktifler + Includes İçerir + Checked İşaretlendi + Not followed: outside the script's folder Takip edilmedi: betiğin klasörünün dışında + Includes itself Kendini içerir + Not read Okunmadı + Not read: a limit was reached Okunmadı: sınıra ulaşıldı + Compiled or not text Derlenmiş veya metin değil + Problems Sorunlar + String not closed Dize kapatılmadı + Comment not closed Yorum kapatılmadı + Regular expression not closed Normal ifade kapatılmadı + Template literal not closed Şablon değişmez bilgisi kapatılmadı + XML literal not closed XML değişmez değeri kapatılmadı + Bracket never closed Braket asla kapanmadı + Closing bracket with no opening one Açıklığı olmayan kapatma braketi + Syntax newer than ES3 Sözdizimi ES3'ten daha yeni + Unknown directive Bilinmeyen direktif + Not valid UTF-8: unreadable bytes were replaced Geçerli değil UTF-8: okunamayan baytlar değiştirildi + Mixed line endings Karışık satır sonları + Nesting too deep to follow Takip edilemeyecek kadar derine yuvalanmak + line {line} satır {line} + lines {lines} satırlar {lines} + {file}, line {line} {file}, {line} satırı + Compiled script: ask its author for the .jsx file Derlenmiş komut dosyası: yazarından .jsx dosyasını isteyin + Too large to check Kontrol edilemeyecek kadar büyük + Not a script for the reference app Referans uygulaması için bir komut dosyası değil + Some lists were cut short at their limit. Bazı listeler sınırlarına göre kısaltıldı. + Check Spelling… Yazımı Kontrol Et… + Choose a folder Bir klasör seçin + Choose… Seç… + Circle daire + Classic Defaults Klasik Varsayılanlar + Clean Up… Temizleme… + Clear Temizle + Clear All Tabs Tüm Sekmeleri Temizle + Above Text Metnin Üstünde + Position Panel Above Text Paneli Metnin Üstüne Konumlandırın + Clear Appearance Görünümü Temizle + Clear Guides Kılavuzları Temizle + Clear Overrides Geçersiz Kılmaları Temizle + Clear Recent Files Son Dosyaları Temizle + Click Tıklayın + Click an anchor point Bir bağlantı noktasını tıklayın + Click or drag Tıklayın veya sürükleyin + Click the art with the Gradient tool to add or select points. Nokta eklemek veya seçmek için Degrade aracıyla resme tıklayın. + Click the first point İlk noktaya tıklayın + Click the ramp or a type button to apply a gradient. Degrade uygulamak için rampayı veya bir yazma düğmesini tıklayın. + Click to add a tab stop, drag to move it, drag it off the ruler to delete it Sekme durağı eklemek için tıklayın, taşımak için sürükleyin, silmek için cetvelin dışına sürükleyin + Click to choose a swatch, Shift-click to mix a colour Bir renk örneğini seçmek için tıklayın, bir rengi karıştırmak için Shift tuşunu basılı tutarak tıklayın + Click to edit the mask; Alt-click to view only the mask; Shift-click to disable or enable it Maskeyi düzenlemek için tıklayın; Yalnızca maskeyi görüntülemek için Alt tuşunu basılı tutarak tıklayın; Devre dışı bırakmak veya etkinleştirmek için Shift tuşunu basılı tutarak tıklayın + Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one. Her dosyayı %100'e yerleştirmek için tıklayın veya boyutlandırmak için sürükleyin; ok tuşları dosyaları değiştirir, Esc birini atlar. + Click to select, double-click to edit Seçmek için tıklayın, düzenlemek için çift tıklayın + Click to toggle visibility Görünürlüğü değiştirmek için tıklayın + Click to zoom in, Alt-click to zoom out, drag to pan, double-click to fit Yakınlaştırmak için tıklayın, uzaklaştırmak için Alt tuşunu basılı tutarak tıklayın, kaydırmak için sürükleyin, sığdırmak için çift tıklayın + Client-side (.html) İstemci tarafı (.html) + Clip Klip + Clip Complex Regions Karmaşık Bölgeleri Kırp + Clip to Art Sanata Klip + Clip to Artboard Çalışma Yüzeyine Klip + Clip to Bounding Box Sınırlayıcı Kutuya Klip + Clipboard as PDF PDF olarak pano + Clipboard as PNG PNG olarak pano + Clipboard as SVG SVG olarak pano + Clipboard as Text Metin Olarak Pano + Clipboard Formats Pano Formatları + Clipboard Handling Pano Kullanımı + Clipping Mask Kırpma Maskesi + Clipping Masks Kırpma Maskeleri + Close Kapat + Close All Tümünü Kapat + Close Library Kitaplığı Kapat + Close Path Yolu Kapat + Close paths when ends are within: Uçlar şu aralıkta olduğunda yolları kapatın: + Cmd Komut + CMYK CMYK + CMYK Color CMYK Rengi + CMYK colours print with their own values; off, they go through the colour settings too. CMYK renkleri kendi değerleriyle yazdırılır; kapalıysa, renk ayarlarından da geçerler. + Collapse Panels to Icons Panelleri Simgelere Daralt + Collapse to Icons Simgelere Daralt + Collate Harmanla + Collect for Export İhracat için toplayın + 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 (,) Renk (,) + Color Adjustments Renk Ayarlamaları + Color bars Renk çubukları + Color blindness – Deuteranopia-type Renk körlüğü – Döteranopi tipi + Color blindness – Protanopia-type Renk körlüğü – Protanopi tipi + Color blindness – Tritanopia-type Renk körlüğü – Tritanopia tipi + Color Burn Renkli Yanık + Color conversion: Renk dönüşümü: + Color Dodge Renk Soldurma + Color Group Renk Grubu + Color Group: Renk Grubu: + Color Group: {name} Renk Grubu: {name} + Color Guide Renk Kılavuzu + Color Guide Options Renk Kılavuzu Seçenekleri + Color Guide Options… Renk Kılavuzu Seçenekleri… + Color images Renkli görseller + Color Library Renk Kitaplığı + Color Library… Renk Kitaplığı… + Color Management Renk Yönetimi + Color Mixer (Shift-click the chip) Renk Karıştırıcı (Çipe Shift tuşuna basarak tıklayın) + Color Mode Renk Modu + Color Mode: Renk Modu: + Color Model: Renk Modeli: + Color Models Renkli Modeller + Color Picker Renk Seçici + Color Picker… Renk Seçici… + Color Reduction: Renk Azaltma: + Color Settings Renk Ayarları + Color Settings… Renk Ayarları… + Color Space Renk Alanı + Color Swatches Renk Örnekleri + Color Table Renk Tablosu + Color Table ({n} color) Renk Tablosu ({n} renk) + Color Table ({n} colors) Renk Tablosu ({n} renkleri) + Color Theme Renk Teması + Color Themes Renk Temaları + Color Type: Renk Türü: + Color: Renk: + Colorize Renklendir + Colors Renkler + Colors: Renkler: + Column Graph Tool Sütun Grafiği Aracı + Columns: Sütunlar: + Column… Sütun… + Combine Slices Dilimleri Birleştir + Coming soon — tracked in the parity plan Çok yakında — eşlik planında takip ediliyor + Command Click to Select Objects Behind Komut Arkasındaki Nesneleri Seçmek İçin Tıklayın + Command Dialog Komut İletişim Kutusu + Community Topluluk + Compatibility: Uyumluluk: + Compatible Gradient and Gradient Mesh Printing Uyumlu Degrade ve Degrade Mesh Baskı + Complement Tamamlayıcı + Complexity: Karmaşıklık: + Composite colours are converted to it; separations separate with it when it is a CMYK profile. Bileşik renkler buna dönüştürülür; CMYK profili olduğunda ayrımlar onunla ayrılır. + Compound Path Bileşik Yol + Compress (RLE) Sıkıştır (RLE) + Compress text and line art Metni ve çizgi resmini sıkıştır + Compression Sıkıştırma + Compression › Color › Above Ppi Sıkıştırma › Renk › Ppi'nin üstünde + Compression: Sıkıştırma: + Condition identifier: Durum tanımlayıcı: + Conflict {n} of {count} {count}'nin {n} çatışması + Connect Selected End Points Seçilen Uç Noktaları Bağlayın + Consolidate All Windows Tüm Pencereleri Birleştir + Constrain Angle Açıyı Sınırla + Constrain Width and Height Proportions Genişlik ve Yükseklik Oranlarını Sınırla + Construction Guides İnşaat Kılavuzları + Contextual Alternates Bağlamsal Alternatifler + Contextual Task Bar Bağlamsal Görev Çubuğu + Pin Bar Position Pim Çubuğu Konumu + Reset Bar Position Çubuk Konumunu Sıfırla + Contrast Kontrast + Control Kontrol + Convert Dönüştür + Convert All Spot Colors to Process Tüm Spot Renkleri Proses Renklere Dönüştür + Convert All Strokes to Outlines Tüm Konturları Anahatlara Dönüştür + Convert All Text to Outlines Tüm Metni Ana Hatlara Dönüştür + Convert Anchor Point Bağlantı Noktasını Dönüştür + Convert Anchor Points Bağlantı Noktalarını Dönüştür + Convert Color Rengi Dönüştür + Convert Document Color Mode Belge Renk Modunu Dönüştür + Convert Process to Global Süreci Globale Dönüştürün + Convert Selected Anchor Points to Corner Seçilen Bağlantı Noktalarını Köşeye Dönüştür + Convert Selected Anchor Points to Smooth Seçilen Bağlantı Noktalarını Düzgünleştir + Convert text to outlines Metni ana hatlara dönüştürün + Convert To Area Type Alan Tipine Dönüştür + Convert to Artboards Çalışma Yüzeylerine Dönüştür + Convert to CMYK CMYK'ye dönüştür + Convert to Grayscale Gri Tonlamaya Dönüştür + Convert To Outlines Anahatlara Dönüştür + Convert To Point Type Nokta Türüne Dönüştür + Convert to RGB RGB'ye dönüştür + Convert to Shape Şekle Dönüştür + Convert to sRGB sRGB'ye dönüştür + Convert to Straight Lines Düz Çizgilere Dönüştür + Convert: Dönüştür: + Copied the CSS of {rules} object(s) {rules} nesnelerinin CSS'si kopyalandı + Copies: Kopyalar: + Copy Kopyala + Copy (make a transformed copy) Kopyala (dönüştürülmüş bir kopya oluştur) + Copy All Objects Tüm Nesneleri Kopyala + Copy All Styles Tüm Stilleri Kopyala + Copy Color Value (Hex) Renk Değerini Kopyala (Hex) + Copy Fonts Used in the Document Belgede Kullanılan Yazı Tiplerini Kopyala + Copy Links Bağlantıları Kopyala + Copy Selected Style Seçilen Stili Kopyala + Copy to User Defined Kullanıcı Tanımlıya Kopyala + Copy Width Point Genişlik Noktasını Kopyala + Copyright Telif hakkı + Copyright Info URL: Telif Hakkı Bilgisi URL'si: + Copyright Notice: Telif Hakkı Bildirimi: + Copyright Status: Telif Hakkı Durumu: + Copyright Symbol Telif Hakkı Sembolü + Corner Point Angle Threshold: Köşe Noktası Açı Eşiği: + Corner Radius Köşe Yarıçapı + Corner Radius: Köşe Yarıçapı: + Corner: Köşe: + Corners Köşeler + Chamfer Pah + Corners… Köşeler… + Cut Path Yolu Kes + Descender iniş + Flip Çevir + Inverted Round Ters Yuvarlak + Radius: Yarıçap: + Round Yuvarlak + Couldn't copy to the system clipboard: {e} Sistem panosuna kopyalanamadı: {e} + Couldn't paste from the clipboard: {e} Panodan yapıştırılamadı: {e} + Couldn't save recovery data: {e} Kurtarma verileri kaydedilemedi: {e} + Couldn't {} {name}: {e} {} yapılamadı {name}: {e} + Create Oluştur + Create Clipping Mask Kırpma Maskesi Oluştur + Create Document Belge Oluştur + Create from Guides Rehberlerden Oluştur + Create from Selection Seçimden Oluştur + Create From: Şuradan Oluştur: + Create Gradient Mesh… Degrade Kafes Oluştur… + Create Graph Grafik Oluştur + Create New Layer Yeni Katman Oluştur + Create New Sublayer Yeni Alt Katman Oluştur + Create New Swatch… Yeni Renk Örneği Oluştur… + Create New {kind} Style Yeni {kind} Stili Oluşturun + Create Object Mosaic… Nesne Mozaiği Oluştur… + Create Outlines Anahatlar Oluştur + Create Path Yol Oluştur + Create PDF layers from layers and sublayers Katmanlardan ve alt katmanlardan PDF katmanları oluşturun + Create PDF layers from top-level layers Üst düzey katmanlardan PDF katmanları oluşturun + Create PDF-Compatible File PDF Uyumlu Dosya Oluşturun + Create Report Rapor Oluştur + Create Slice Dilim Oluştur + Create Sub-folders Alt Klasörler Oluşturun + Create Text Metin Oluştur + Create Trim Marks Kesim İşaretleri Oluşturun + Created Oluşturuldu + Created: Oluşturuldu: + Crop Kırpma + Crop Image Resmi Kırp + Mask Maske + Crop Marks Kırpma İşaretleri + Crop To Kırpma Hedefi + Crystallize Tool Kristalleştirme Aracı + CSS Properties CSS Özellikleri + Ctrl Ctrl + Currency & Symbols Para Birimi ve Semboller + Current Brush Güncel Fırça + Current Colors ({total}) Mevcut Renkler ({total}) + Current Symbol Mevcut Sembol + Curvature Eğrilik + Curvature Tool Kavis Aracı + Curves… Eğriler… + Custom Özel + Customize… Özelleştir… + Cut Kes + Cut Path at Selected Anchor Points Seçilen Bağlantı Noktalarında Yolu Kes + Cyan Camgöbeği + Dark Karanlık + Darken Koyulaştır + Darkness Karanlık + dash kısa çizgi + Dash Pattern Çizgi Deseni + Dashed Kesikli + Dashed Line Kesikli Çizgi + Data Recovery Veri Kurtarma + Data… Veri… + Dates Tarihler + Decimal-Justified Tab Ondalık Blokla Sekme + Decimal: Ondalık: + Decorative Dekoratif + Default Varsayılan + Default Actions Varsayılan Eylemler + Default Figure Varsayılan Şekil + Default Fill and Stroke Varsayılan Dolgu ve Kontur + Default Fill and Stroke (D) Varsayılan Dolgu ve Kontur (D) + Default Language Varsayılan Dil + Default Printer Varsayılan Yazıcı + Default Swatches Varsayılan Renk Örnekleri + Define Grid… Izgarayı Tanımla… + Define Perspective Grid Perspektif Izgarasını Tanımla + Define Perspective Grid… Perspektif Izgarasını Tanımla… + Delete Sil + Delete All Tümünü Sil + Delete Anchor Point Bağlantı Noktasını Sil + Delete Anchor Point Tool Bağlantı Noktası Silme Aracı + Delete Anchor Points Bağlantı Noktalarını Sil + Delete Artboard Çalışma Yüzeyini Sil + Delete Artboards Çalışma Yüzeylerini Sil + Delete Brush Fırçayı Sil + Delete Character Style Karakter Stilini Sil + Delete Empty Artboards Boş Çalışma Yüzeylerini Sil + Delete Freeform Point Serbest Biçim Noktasını Sil + Delete Graphic Style Grafik Stilini Sil + Delete Graphic Style (Alt-click: without asking) Grafik Stilini Sil (Alt tuşuyla tıklayın: sormadan) + Delete Layer Katmanı Sil + Delete Mesh Point Mesh Noktasını Sil + Delete Paragraph Style Paragraf Stilini Sil + Delete Pattern Deseni Sil + Delete PDF Preset PDF Hazır Ayarını Sil + Delete Perspective Grid Preset Perspektif Izgarası Ön Ayarını Sil + Delete Point Noktayı Sil + Delete Preset Ön Ayarı Sil + Delete Print Preset Yazdırma Ön Ayarını Sil + Delete Profile Profili Sil + Delete Save for Web Preset Web Ön Ayarı için Kaydet'i Sil + Delete Selected Item Seçilen Öğeyi Sil + Delete Selection Seçimi Sil + Delete Slices Dilimleri Sil + Delete Spine Point Omurga Noktasını Sil + Delete Stop Durdurmayı Sil + Delete Swatch Renk Örneğini Sil + Delete Symbol Sembolü Sil + Delete Tab Sekmeyi Sil + 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? + Delete Transparency Flattener Preset Saydamlık Düzleştirici Ön Ayarını Sil + Delete Width Point Genişlik Noktasını Sil + Delete Workspace Çalışma Alanını Sil + Delete {kind} Style {kind} Stilini Sil + Delete “{name}”? “{name}” silinsin mi? + Depth: Derinlik: + Description Açıklama + Description: Açıklama: + Deselect Seçimi kaldır + Design… Tasarım… + Destination: Hedef: + Detail: Detay: + Devices Cihazlar + Dialog Diyalog + Diamond Elmas + Difference Fark + Differences from {preset}: {preset}'den farklar: + Dim Copies to: Karartılmış Kopyalar: + Dim Images to: Görüntüleri Karart: + Dimensions Boyutlar + Direct Selection Tool Doğrudan Seçim Aracı + Direction Handles Yön Kolları + Disable Auto Add/Delete Otomatik Ekleme/Silmeyi Devre Dışı Bırak + Disable Opacity Mask Opaklık Maskesini Devre Dışı Bırak + Discard At + Discard Empty Type Boş Türü At + Discard Recovered Documents Kurtarılan Belgeleri Atın + Discard White Overprint in Output Çıktıdaki Beyaz Üst Baskıyı At + Discarded {} recovered document(s) Kurtarılan {} belge atıldı + Discord Discord + Discretionary Hyphen İsteğe Bağlı Kısa Çizgi + Discretionary Ligatures İsteğe Bağlı Bitişik Harfler + Display All Blacks Accurately Tüm Siyahları Doğru Şekilde Görüntüleyin + Display All Blacks as Rich Black Tüm Siyahları Zengin Siyah Olarak Görüntüle + Display Bitmaps as Anti-aliased Images in Pixel Preview Piksel Önizlemede Bitmap'leri Kenar Yumuşatılmış Görüntüler Olarak Görüntüleme + Display Options Ekran Seçenekleri + Display Print Size at 100% Zoom Baskı Boyutunu %100 Yakınlaştırmada Görüntüle + Distort & Transform Deforme Et ve Dönüştür + Distort Appearance Görünümü Deforme Etme + Distort Linear Gradients Doğrusal Degradeleri Deforme Etme + Distort Pattern Fills Desen Dolgularını Deforme Etme + Distortion Bozulma + Distribute Dağıt + Distribute Objects: Nesneleri Dağıt: + Distribute Spacing Aralığı Dağıt + Distribute Spacing: Aralığı Dağıt: + Dither: Titreme: + Divide Böl + Divide Horizontally Into Yatay Olarak Böl + Divide Objects Below Aşağıdaki Nesneleri Böl + Divide Slices Dilimleri Böl + Divide Slices… Dilimleri Böl… + Divide Vertically Into Dikey Olarak Böl + Do Not Move Hareket Etme + Do you want to save the changes you made to “{name}”? “{name}”de yaptığınız değişiklikleri kaydetmek istiyor musunuz? + Can't open “{name}”. “{name}” açılamıyor. + Can't save “{name}”. “{name}” kaydedilemiyor. + Can't export “{name}”. “{name}” dışa aktarılamıyor. + Document Belge + Document Color Mode Belge Renk Modu + Document Info Belge Bilgisi + Document Ink Options Belge Mürekkebi Seçenekleri + Document JSON JSON'u belgeleyin + Document open password Belge açma şifresi + Document profile Belge profili + Document Raster Effects Settings Belge Raster Efekt Ayarları + Document Raster Effects Settings Dialog Belge Tarama Efektleri Ayarları İletişim Kutusu + Document Raster Effects Settings… Belge Raster Efekt Ayarları… + Use Document Raster Effects Resolution Belge Raster Efektleri Çözünürlüğünü Kullan + Document Setup Belge Kurulumu + Document Setup… Belge Düzeni… + Document Swatches Belge Renk Örnekleri + Document Title: Belge Başlığı: + Documents you create appear here. Oluşturduğunuz belgeler burada görünür. + Don't Save Kaydetme + Don't Show Center Merkezi Gösterme + Done Bitti + Dots Noktalar + Double Click To Isolate Yalıtmak İçin Çift Tıklayın + Double Left Quotation Marks Çift Sol Tırnak İşaretleri + Double Quotes: Çift Tırnak: + Double Right Quotation Marks Çift Sağ Tırnak İşaretleri + Double-click Çift tıklama + Double-click to edit the characters' paint Karakterlerin boyasını düzenlemek için çift tıklayın + Double-click to insert Eklemek için çift tıklayın + Double-click to rename Yeniden adlandırmak için çift tıklayın + Double-click to target the contents İçeriği hedeflemek için çift tıklayın + Download İndir + Download: İndir: + Downloaded {what} {what} indirildi + Downloaded {} file(s) {} dosya indirildi + Drag Sürükle + Drag a handle Bir tutamacı sürükleyin + Drag art here, or select it and click +. Hold Alt for one asset of several objects. Resmi buraya sürükleyin veya seçip + işaretine tıklayın. Birkaç nesneden oluşan bir varlık için Alt tuşunu basılı tutun. + Drag onto art to apply this appearance Bu görünümü uygulamak için resmin üzerine sürükleyin + Drag the art to move it on the paper Resmi kağıt üzerinde taşımak için sürükleyin + Draw Beraberlik + Draw Behind Arkaya Çiz + Draw in Perspective Perspektifte Çiz + Draw Inside İçini Çiz + Draw Normal Normal Çiz + Draw: Beraberlik: + Drawing Mode Çizim Modu + Drop Shadow Alt Gölge + Drop Shadow… Alt Gölge… + Duotone Gradients Çift Tonlu Degradeler + Duplicate Çoğalt + Duplicate and Offset 10 pt Çoğalt ve Ofset 10 pt + Duplicate Artboard Çalışma Yüzeyini Çoğalt + Copy Artboard Çalışma Yüzeyini Kopyala + Cut Artboard Çalışma Yüzeyini Kes + Duplicate Artboards Çalışma Yüzeylerini Çoğalt + Duplicate Brush Yinelenen Fırça + Duplicate Character Style Yinelenen Karakter Stili + Duplicate Effect Yinelenen Efekt + Duplicate Graphic Style Grafik Stilini Çoğalt + Duplicate Item Yinelenen Öğe + Duplicate Layer Katmanı Çoğalt + Duplicate Paragraph Style Yinelenen Paragraf Stili + Duplicate Selected Item Seçilen Öğeyi Çoğalt + Duplicate Selection Seçimi Çoğalt + Duplicate Slice Yinelenen Dilim + Duplicate Swatch Renk Örneğini Çoğalt + Duplicate Symbol Yinelenen Sembol + Duplicate {kind} Style Çoğaltılmış {kind} Stili + Dutch Hollandaca + DXF Import Options DXF İçe Aktarma Seçenekleri + DXF Info DXF Bilgisi + DXF Options DXF Seçenekleri + DXF Options Dialog DXF Seçenekleri İletişim Kutusu + e.g. 1-3, 5 örneğin 1-3, 5 + Each page prints as one image, at the document's raster effects resolution. Her sayfa, belgenin tarama efektleri çözünürlüğünde tek bir görüntü olarak yazdırılır. + Earth Tones Toprak Tonları + East Asian Type Doğu Asya Tipi + Edit Düzenle + Edit Artboards Çalışma Yüzeylerini Düzenle + Edit Clipping Path Kırpma Yolunu Düzenle + Edit Color Group Renk Grubunu Düzenle + Edit Colors Renkleri Düzenle + Edit Contents İçeriği Düzenle + Edit Custom Dictionary… Özel Sözlüğü Düzenle… + Edit Envelope Zarfı Düzenle + Edit Freeform Point Serbest Biçim Noktasını Düzenle + Edit Gradient Degradeyi Düzenle + Edit Opacity Mask Opaklık Maskesini Düzenle + Edit or Apply Color Group Renk Grubunu Düzenle veya Uygula + Edit or Apply Color Group… Renk Grubunu Düzenle veya Uygula… + Edit or Apply Colors Renkleri Düzenleyin veya Uygulayın + Edit Original Orijinali Düzenle + Edit Pattern Deseni Düzenle + Edit PDF Preset PDF Hazır Ayarını Düzenle + Edit Print Preset Yazdırma Ön Ayarını Düzenle + Edit selected paths within: Seçilen yolları şunun içinde düzenleyin: + Edit Selection Seçimi Düzenle + Edit Selection… Seçimi Düzenle… + Edit Symbol Sembolü Düzenle + Edit Text Metni Düzenle + Edit the applied effect, or add another one? Uygulanan efekti düzenlemek veya başka bir efekt eklemek mi istiyorsunuz? + Edit the selected point's colour in the Color panel Seçilen noktanın rengini Renk panelinde düzenleyin + Edit the stop Durağı düzenle + Edit Theme Temayı Düzenle + Edit theme Temayı düzenle + Edit Toolbar Araç Çubuğunu Düzenle + Edit Views Görünümleri Düzenle + Edit Views… Görünümleri Düzenle… + Edit… Düzenle… + Effect Efekt + Effect Options Efekt Seçenekleri + Effective PPI Etkili ÜFE + Effects Efektler + Effect… Etki… + Ellipse elips + Ellipse Tool Elips Aracı + Ellipse… elips… + Ellipsis Üç nokta + Em Box Bottom/Left Em Kutusu Alt/Sol + Em Box Center Em Kutusu Merkezi + Em Box Top/Right Em Kutusu Üst/Sağ + Em Dash Em Dash + Em Space Em Uzay + Embed Göm + Embed Fonts (for other applications) Yazı Tiplerini Göm (diğer uygulamalar için) + Embed Fonts (Glyphs Used) Gömülü Yazı Tipleri (Kullanılan Glifler) + Embed ICC Profile ICC Profilini Yerleştirin + Embed ICC Profiles ICC Profillerini Göm + Embed Image Resim Göm + Embed Image(s) Resim(ler)i Göm + Embed page thumbnails Sayfa küçük resimlerini yerleştirme + Embedded Gömülü + Embedded Image Gömülü Resim + Embedded {n} image(s) Gömülü {n} görselleri + Embedded {n} image(s); {missing} missing: relink them first Gömülü {n} görselleri; {missing} eksik: önce onları yeniden bağlayın + Emulsion: Emülsiyon: + En Dash En Dash + En Space Uzayda + Enable Background Export Arka Plan Dışa Aktarmayı Etkinleştir + Enable Background Save Arka Planda Kaydetmeyi Etkinleştir + Enable Contextual Task Bar Bağlamsal Görev Çubuğunu Etkinleştir + Enable copying of text, images and other content Metin, resim ve diğer içeriklerin kopyalanmasını etkinleştirin + Enable in-menu font previews Menü içi yazı tipi önizlemelerini etkinleştir + Enable Missing Glyph Protection Eksik Glif Korumasını Etkinleştir + Enable Opacity Mask Opaklık Maskesini Etkinleştir + Enable plaintext metadata Düz metin meta verilerini etkinleştir + Enable Rubber Band for Lastik Bandı Etkinleştir + Enable text access for screen readers Ekran okuyucular için metin erişimini etkinleştirin + Enable Touch Gestures Dokunma Hareketlerini Etkinleştir + Enable Touch Workspace Dokunmatik Çalışma Alanını Etkinleştir + Encoding Kodlama + Encoding: Kodlama: + Encryption: {name} (set by Compatibility) Şifreleme: {name} (Uyumluluk tarafından ayarlanır) + End arrowhead Son ok ucu + English English + English: UK İngilizce: İngiltere + English: USA İngilizce: ABD + Enter Girin + Enter Isolation Mode İzolasyon Moduna Girin + Entire Font Yazı Tipinin Tamamı + Envelope Distort Zarf Deformasyonu + Envelope Info Zarf Bilgisi + Envelope Mesh Zarf Mesh + Envelope Options Zarf Seçenekleri + Envelope Options… Zarf Seçenekleri… + EPS Options EPS Seçenekleri + EPS Options Dialog EPS Seçenekleri İletişim Kutusu + Eraser Silgi + Eraser Tool Silgi Aracı + Error Hata + Esc Esc + Every (minutes) Her (dakika) + Every option is at its default. Her seçenek varsayılan değerdedir. + Every option matches {base}. Her seçenek {base} ile eşleşir. + Every-line Composer Her Satır Bestecisi + Exact dash lengths Tam çizgi uzunlukları + Exceptions (comma separated) İstisnalar (virgülle ayrılmış) + Exclude Hariç tut + Exclusion Hariç Tutma + Exit Isolation Mode İzolasyon Modundan Çık + Expand Genişlet + Expand Appearance Görünümü Genişlet + Expand applies to compound shapes (Alt-click a shape mode) — on the roadmap Genişletme, yol haritasındaki bileşik şekillere uygulanır (Alt tuşuyla bir şekil moduna tıklayın) + Expand Brush Strokes Fırça Darbelerini Genişlet + Expand Compound Shape Bileşik Şekli Genişlet + Expand Gradient Mesh Degrade Mesh'i Genişlet + Expand Gradient To Degradeyi Genişlet + Expand Layers Panel Rows Katmanlar Paneli Satırlarını Genişlet + Expand Options Seçenekleri Genişlet + Expand Panels Panelleri Genişlet + Expand Repeat Tekrarı Genişlet + Expand Shape Şekli Genişlet + Expand: Genişlet: + Expand… Genişlet… + Export Dışa Aktar + Export All… Tümünü Dışa Aktar… + Export and Clipboard Transparency Flattener Settings Dışa Aktarma ve Pano Şeffaflığı Düzleştirici Ayarları + Export Artboard Çalışma Yüzeyini Dışa Aktar + A File per Object Nesne Başına Bir Dosya + Example: {name} Örnek: {name} + Export Preset: Dışa Aktarma Ön Ayarı: + Folders: Klasörler: + Insert Ekle + Nothing is selected. Select the art to export, then open this tab again. Hiçbir şey seçilmedi. Dışa aktarılacak resmi seçin ve ardından bu sekmeyi tekrar açın. + One File of the Selection Seçimin Bir Dosyası + Start at: Başlangıç tarihi: + {n} objects selected {n} nesneleri seçildi + Delete Export Preset Dışa Aktarma Ön Ayarını Sil + Export Presets Ön Ayarları Dışa Aktarma + Save Export Preset Dışa Aktarma Ön Ayarını Kaydet + Export Layer Katmanı Dışa Aktar + Export Selection Seçimi Dışa Aktar + All in One Folder Hepsi Bir Klasörde + By Size or Row Boyuta veya Satıra Göre + By Format Formata Göre + By Artboard or Item Çalışma Yüzeyine veya Öğeye Göre + By Item, Then Format Öğeye Göre, Sonra Biçimlendir + Document Name Belge Adı + Artboard or Item Name Çalışma Yüzeyi veya Öğe Adı + Number (####) Sayı (####) + Date (YYYYMMDD) Tarih (YYYYAAGG) + Date (YYYY-MM-DD) Tarih (YYYY-AA-GG) + Time (hhmmss) Zaman (hhmmss) + PDF Print PDF Yazdır + Export As Farklı Dışa Aktar + Export As PNG… PNG Olarak Dışa Aktar… + Export As SVG… SVG Olarak Dışa Aktar… + Export Asset Varlığı Dışa Aktar + Export Assets Varlıkları Dışa Aktar + Export As… Farklı Dışa Aktar… + Export CSS CSS'yi dışa aktar + Export CSS… CSS'yi dışa aktar… + Export Document Belgeyi Dışa Aktar + Export DXF DXF'yi dışa aktar + Export EPS EPS'yi dışa aktar + Export for Screens Ekranlar için Dışa Aktarma + Export for Screens Settings Ekran Ayarları için Dışa Aktarma + Export for Screens… Ekranlar için Dışa Aktarma… + Export Keyboard Shortcuts… Klavye Kısayollarını Dışa Aktar… + Export Option Dışa Aktarma Seçeneği + Export PDF PDF'yi dışa aktar + Export PDF Presets PDF Hazır Ayarlarını Dışa Aktar + Export Perspective Grid Presets Perspektif Izgarası Ön Ayarlarını Dışa Aktarma + Export Print Presets Yazdırma Ön Ayarlarını Dışa Aktar + Export Selected Art Only Yalnızca Seçilen Sanatı Dışa Aktar + Export Selected CSS… Seçilen CSS'yi Dışa Aktar… + Export Selection… Seçimi Dışa Aktar… + Export Settings Ayarları Dışa Aktarma + Export the selected assets (else all of them) Seçilen varlıkları dışa aktarın (aksi takdirde tümü) + Export to: Şuraya aktar: + Export Transparency Flattener Presets Şeffaflık Düzleştirici Ön Ayarlarını Dışa Aktarma + Export {count} Selected Seçilen {count}'yi Dışa Aktar + Export: İhracat: + Exported {count} file(s) to {folder} {count} dosyası/dosyaları {folder}'ye aktarıldı + Exported {path} Dışa aktarılan {path} + Exported {what} Dışa aktarılan {what} + Exported {what} with {} note(s): {first} {what}, {} notla birlikte dışa aktarıldı: {first} + Exporting {} {} dışa aktarılıyor + Export… İhracat… + Extrude & Bevel… Ekstrüzyon ve Eğim… + Eyedropper Damlalık + Sample Color from Screen Ekrandan Örnek Renk + This system has no screen color sampler Bu sistemin ekran rengi örnekleyicisi yok + The active document changed while the screen color sampler was open Ekran rengi örnekleyici açıkken etkin belge değişti + Eyedropper Applies: Damlalık Uygulanır: + Eyedropper Options Damlalık Seçenekleri + Eyedropper Picks Up: Damlalık Alır: + Eyedropper Tool Damlalık Aracı + Eyedropper: click the art to sample the stop's colour Damlalık: durağın rengini örneklemek için resme tıklayın + Favorites Favoriler + Feather… tüy… + Feet Fit + Feet & Inches Fit ve İnç + Fewer Elements Daha az Öğesi + Fidelity: Sadakat: + Figure Space Şekil Alanı + Figure: Şekil: + File Dosya + File Dimensions Dosya Boyutları + File Format: Dosya Formatı: + File Formats Dosya Formatları + File Handling Dosya İşleme + File Info Dosya Bilgileri + File Info Dialog Dosya Bilgileri İletişim Kutusu + File Info… Dosya Bilgileri… + File Name: Dosya Adı: + File › Place… brings in an image, linked or embedded. Dosya › Yer… bağlantılı veya gömülü bir görüntüyü getirir. + Files Dosyalar + Files of a PDF standard can't be password-protected. PDF standardının dosyaları parola korumalı olamaz. + Files of this standard tag their colours with ICC profiles. Bu standardın dosyaları renklerini ICC profilleriyle etiketler. + Fill Dolgu + Fill & Stroke Doldur ve Konturla + Fill (X), double-click for the Color Picker Doldur (X), Renk Seçici için çift tıklayın + Fill and Stroke Doldur ve Konturla + Fill new strokes Yeni konturları doldur + Keep selected Seçili kal + Merge only with selection Yalnızca seçimle birleştir + Fill Color Dolgu Rengi + Fill New Type Objects With Placeholder Text Yeni Yazım Nesnelerini Yer Tutucu Metinle Doldurun + Fill Rule Doldurma Kuralı + Fill with Placeholder Text Yer Tutucu Metinle Doldur + Fill: Doldurun: + Fill: click for swatches, Shift-click for the color mixer Doldur: renk örnekleri için tıklayın, renk karıştırıcı için Shift tuşunu basılı tutarak tıklayın + Find Bul + Find and Replace… Bul ve Değiştir… + Find Font Yazı Tipi Bul + Find Font… Yazı Tipi Bul… + Find Next Sonrakini Bul + Find Objects Nesneleri Bul + First Page İlk Sayfa + First-line Left Indent Birinci Satırda Sol Girinti + Fisheye… Balıkgözü… + Fish… Balık… + Fit All Tümünü Sığdır + Fit All in Window Tümünü Pencereye Sığdır + Fit Artboard in Window Çalışma Yüzeyini Pencereye Sığdır + Fit dashes to corners and ends Çizgileri köşelere ve uçlara sığdır + Fit Headline Başlığı Sığdır + Decrease Font Size Yazı Tipi Boyutunu Azalt + Decrease Font Size by Five Steps Yazı Tipi Boyutunu Beş Adım Azaltın + Increase Font Size Yazı Tipi Boyutunu Artır + Increase Font Size by Five Steps Yazı Tipi Boyutunu Beş Adım Artırın + Reset Tracking and Kerning İzlemeyi ve Karakter Aralığını Sıfırla + Reset Horizontal Scale Yatay Ölçeği Sıfırla + Spacing Aralık + Step Type Adım Türü + Fit in View Görünüme Sığdır + Fit in Window Pencereye Sığdır + Fit on Screen Ekrana Sığdır + Fit to Artwork Bounds Sanat Eseri Sınırlarına Uygun + Fit to Selected Art Seçilen Sanata Sığdır + Flag… Bayrak… + Flare parlama + Flare Tool Mercek Parlaması Aracı + Flare Tool Options Genişletme Aracı Seçenekleri + Halo Hale + Rays Işınlar + Rings Yüzükler + Diameter: Çap: + Brightness: Parlaklık: + Growth: Büyüme: + Fuzziness: Bulanıklık: + Number: Sayı: + Longest: En uzun: + Path: Yol: + Largest: En büyük: + Direction: Yön: + Symbolism Tools Options Sembolizm Araçları Seçenekleri + Symbol Set Density: Sembol Seti Yoğunluğu: + Flat Image Düz Resim + Flat Logo Düz Logo + Flatness: Düzlük: + Flatten Artwork Resmi Düzleştir + Flatten Transparency Şeffaflığı Düzleştir + Flatten Transparency… Şeffaflığı Düzleştir… + Flattener preset: Düzleştirici ön ayarı: + Flattener presets Düzleştirici ön ayarları + Flattener Preview Düzleştirici Önizlemesi + Flip Across Çapraz Çevir + Flip Along Boyunca Çevir + Flip Along Horizontal Axis Yatay Eksen Boyunca Çevir + Flip Along Vertical Axis Dikey Eksen Boyunca Çevir + Flip Horizontal Yatay Çevir + Flip Row Order Satır Sırasını Çevir + Flip Vertical Dikey Çevir + Float in Window Pencerede Yüzer Yap + Focal Fill Odak Dolgusu + Focal Stroke Odak Konturu + Folder Klasör + Folder Name: Klasör Adı: + Font Yazı tipi + Font Family Yazı Tipi Ailesi + Font Family & Style Yazı Tipi Ailesi ve Stili + Font Family, Style & Size Yazı Tipi Ailesi, Stili ve Boyutu + Font List Yazı Tipi Listesi + Font Preview Size Yazı Tipi Önizleme Boyutu + Font Size Yazı Tipi Boyutu + Font: Yazı tipi: + Fonts Yazı tipleri + Fonts in Document Belgedeki Yazı Tipleri + Fonts in Document: {count} Belgedeki Yazı Tipleri: {count} + Fonts whose licence doesn't allow embedding are not copied. Lisansı yerleştirmeye izin vermeyen yazı tipleri kopyalanmaz. + Forced Line Break Zorunlu Satır Sonu + Format Biçim + Format Options Biçim Seçenekleri + Format Settings Biçim Ayarları + Format: Biçim: + Formats: Formatlar: + Fractional Widths Kesirli Genişlikler + Fractions Kesirler + Free Ücretsiz + Free Distort Serbest Deformasyon + Free Distort… Serbest Deformasyon… + Free Transform Tool Serbest Dönüştürme Aracı + Freeform Gradient Serbest Biçimli Degrade + French Fransızca + Frequencies and angles are the output device's to apply. Frekanslar ve açılar çıkış cihazının uygulayacağı değerlerdir. + Frequency Frekans + Full Document Tam Belge + Full Screen Mode Tam Ekran Modu + Full Screen Mode with Menu Bar Menü Çubuğuyla Tam Ekran Modu + Gamma Gama + Gamut Check Gamut Kontrolü + Gamut Warning Gamut Uyarısı + gap boşluk + Gap Options… Boşluk Seçenekleri… + Gaussian Blur… Gauss Bulanıklığı… + General Genel + General › Placement › X Genel › Yerleştirme › X + General › Thumbnails Genel › Küçük Resimler + Generate CSS CSS oluştur + Generate CSS: the whole document's CSS while nothing is selected CSS oluştur: hiçbir şey seçili değilken tüm belgenin CSS'si + German Almanca + Get Preferences Tercihleri Al + Get Text Range Metin Aralığını Al + Global Küresel + Global Brush Dimensions Küresel Fırça Boyutları + Global Process Color Global Proses Rengi + Global Process Color, CMYK Küresel Proses Rengi, CMYK + glyph {id} glif {id} + Glyphs Glifler + Go to Artboard Artboard'a git + Go To Link Bağlantıya Git + 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 (.) Gradyan (.) + Gradient across stroke Kontur boyunca gradyan + Gradient along stroke Kontur boyunca gradyan + Gradient and Mesh Resolution: Gradyan ve Mesh Çözünürlüğü: + Gradient Fill: click to apply, drag onto art Degrade Dolgu: uygulamak için tıklayın, resmin üzerine sürükleyin + Gradient Finishes Gradyan Kaplamalar + Gradient Mesh Degrade Örgü + Gradient swatches Degrade renk örnekleri + Gradient Tool Degrade Aracı + Gradient Vector Gradyan Vektör + Gradient within stroke Kontur içindeki gradyan + Gradients Degradeler + Graph Grafik + Graph Data Grafik Verileri + Graph Type Grafik Türü + Graph Design Grafik Tasarımı + Graph Marker Grafik İşaretleyici + New Design Yeni Tasarım + Delete Design Tasarımı Sil + Paste Design Tasarımı Yapıştır + Select Unused Kullanılmayan'ı seçin + Import Data… Verileri İçe Aktar… + Transpose Transpoze + Switch x/y x/y'yi değiştir + Number of Decimals: Ondalık Sayı Sayısı: + Column Width: Sütun Genişliği: + No Legend Efsane Yok + Standard Legend Standart Açıklama + Legends in Wedges Takozlardaki Efsaneler + Ratio Oran + Even Hatta + Stacked Yığılmış + Graphic Style Grafik Stili + Graphic Style Libraries Grafik Stili Kitaplıkları + Graphic Style Libraries Menu Grafik Stili Kitaplıkları Menüsü + Graphic Style Library Grafik Stili Kitaplığı + Graphic Style Options Grafik Stili Seçenekleri + Graphic Style Options… Grafik Stili Seçenekleri… + Symbol Options Sembol Seçenekleri + Symbol Options… Sembol Seçenekleri… + Graphic Styles Grafik Stilleri + Graphic Styles Options Grafik Stilleri Seçenekleri + Graphics Grafik + Graphics Processor Grafik İşlemcisi + Graphics: {adapter} Grafik: {adapter} + Gravity Yerçekimi + Grays Griler + Grays and Neutrals Griler ve Nötrler + Grayscale Gri tonlamalı + Grayscale images Gri tonlamalı görüntüler + Greek & Cyrillic Yunanca ve Kiril + Green Yeşil + Grid Izgara + Grid Color & Opacity Izgara Rengi ve Opaklığı + Grid Colors: Izgara Renkleri: + Grid Size: Izgara Boyutu: + Gridline every Kılavuz çizgisi her + Gridline every: Kılavuz çizgisi her: + Grids In Back Arkadaki Izgaralar + Group Grup + Group Selection Tool Grup Seçim Aracı + Guides Kılavuzlar + Guides & Grid Kılavuzlar ve Izgara + H Spacing: H Aralığı: + H: H: + Hair Space Saç Alanı + Hand Tool El Aracı + Hand-Drawn elle çizilmiş + Handles Kollar + Hard Light Sert Işık + Harmony Rules: Uyum Kuralları: + Harmony Sets Armoni Setleri + Height Yükseklik + Height: Yükseklik: + Help Yardım + Help › Search Commands… Yardım › Arama Komutları… + Hex by Column Sütunlara göre Hex + Hex by Row Satır bazında Hex + Hide Gizle + Hide Artboards Çalışma Yüzeylerini Gizle + Hide Bounding Box Sınırlayıcı Kutuyu Gizle + Hide Code Kodu Gizle + Hide Bar Çubuğu Gizle + More Options Daha Fazla Seçenek + Show Properties Panel Özellikler Panelini Göster + Hide Corner Widget Köşe Widget'ını Gizle + Hide Corner Widget for angles greater than Şu değerden daha büyük açılar için Köşe Widget'ını gizle: + Hide Edges Kenarları Gizle + Hide Gradient Annotator Degrade Açıklayıcıyı Gizle + Hide Grid Izgarayı Gizle + Hide Guides Kılavuzları Gizle + Hide Hidden Characters Gizli Karakterleri Gizle + Hide Note Notu Gizle + Hide Options Seçenekleri Gizle + Hide Others Diğerlerini Gizle + Hide Print Tiling Baskı Döşemeyi Gizle + Hide Rulers Cetvelleri Gizle + Hide Slices Dilimleri Gizle + Hide Template Şablonu Gizle + Hide Text Threads Metin Konularını Gizle + Hide Thumbnail Küçük Resmi Gizle + Hide Thumbnails Küçük Resimleri Gizle + Hide Transparency Grid Şeffaflık Izgarasını Gizle + High Yüksek + High (300 ppi) Yüksek (300 ppi) + High Performance (discrete) Yüksek Performans (ayrık) + Highlight Alternate Glyphs Alternatif Glifleri Vurgula + Highlight anchors on mouse over Fareyle üzerine gelindiğinde bağlantı noktalarını vurgulayın + Highlight Rows Satırları Vurgula + Highlight Variable Değişkeni Vurgula + Highlight Substituted Fonts Değiştirilen Yazı Tiplerini Vurgula + Highlight Substituted Glyphs Değiştirilen Glifleri Vurgula + History Geçmiş + History Options… Geçmiş Seçenekleri… + History States Geçmiş Durumları + Hollow içi boş + Home Ana Sayfa + Horizon Height: Ufuk Yüksekliği: + Horizontal Yatay + Horizontal % Yatay % + Horizontal Align Center Yatay Hizalama Merkezi + Horizontal Align Left Yatay Sola Hizala + Horizontal Align Right Yatay Sağa Hizala + Horizontal Distribute Center Yatay Dağıtım Merkezi + Horizontal Distribute Left Yatay Sola Dağıt + Horizontal Distribute Right Yatay Sağa Dağıt + Horizontal Distribute Space Yatay Dağıtım Alanı + Horizontal Grid: Yatay Izgara: + Horizontal Plane Yatay Düzlem + Horizontal Scale % Yatay Ölçek %'si + Horizontal: Yatay: + How colours outside the press's gamut are separated. Baskı makinesinin gamının dışındaki renklerin nasıl ayrıldığı. + Hue renk tonu + Hue/Saturation… Ton/Doygunluk… + Hyphenate Tireleme + Hyphenation Tireleme + Hyphenation… Tireleme… + Words Longer Than: Şundan Daha Uzun Kelimeler: + After First: İlk Sonra: + Before Last: Sondan Önce: + Hyphen Limit: Kısa Çizgi Sınırı: + Hyphenation Zone: Tireleme Bölgesi: + letters harfler + hyphens kısa çizgiler + Hyphenate Capitalized Words Büyük Harfli Kelimeleri Tirele + ICC Profile ICC Profili + Identify Objects By Nesneleri Şuna Göre Tanımla: + Ignore Yoksay + Ignore Artboards (all the art as one page) Çalışma Yüzeylerini Yoksay (tüm görseller tek sayfa olarak) + Auto Grouping Otomatik Gruplama + Enhanced Geliştirilmiş + Ignore Color Rengi Yoksay + Ignore White Beyazı Yoksay + Palette: Palet: + Limited Sınırlı + Full Tone Tam Ton + Document Library Belge Kitaplığı + Library: Kütüphane: + Image Resim + Image Info Resim Bilgisi + Image Map: Görüntü Haritası: + Image Size Resim Boyutu + Image Trace Görüntü İzleme + Image Tracing Görüntü İzleme + Tracing Presets İzleme Ön Ayarları + Image Trace Presets Görüntü İzleme Ön Ayarları + Image: Resim: + Images: Görseller: + Import Keyboard Shortcuts… Klavye Kısayollarını İçe Aktar… + Import PDF PDF'yi içe aktar + Import PDF Presets PDF Hazır Ayarlarını İçe Aktar + Import Perspective Grid Presets Perspektif Izgarası Ön Ayarlarını İçe Aktar + Import Print Presets Yazdırma Ön Ayarlarını İçe Aktar + Import Transparency Flattener Presets Saydamlık Düzleştirici Ön Ayarlarını İçe Aktar + Imported {what}: {} İçe aktarılan {what}: {} + Import… İthalat… + In Tangent Handles Teğet Tutamaçlarda + In the selected objects Seçilen nesnelerde + Inches İnç + Include Blacks with CMY: CMY ile Siyahları Dahil Et: + Include Bleed Taşmayı Dahil Et + Include Cmy Blacks Cmy Black'leri dahil et + Include CMYK PostScript in RGB Files CMYK PostScript'i RGB Dosyalarına Ekle + Include Document Thumbnails Belge Küçük Resimlerini Ekle + Include Hidden Layers Gizli Katmanları Ekle + Include Linked Files Bağlantılı Dosyaları Ekle + Include Metadata Meta Verileri Ekle + Include non-printing layers Yazdırılmayan katmanları dahil et + Include SVG Code SVG Kodunu Ekle + Include Swatches for Tints Renk Tonları için Renk Örneklerini Ekle + Inflate… Şişir… + Info Bilgi + Ink Mürekkep + Inner Glow… İç Işıma… + Input Black Siyah Giriş + Input White Beyaz Giriş + Insert Break Character Ara Karakteri Ekle + Insert Character Karakter Ekle + Insert Special Character Özel Karakter Ekle + Insert Whitespace Character Boşluk Karakteri Ekle + Inspect Document Belgeyi İncele + Inspect Object Nesneyi İncele + Install Plug-in Eklentiyi Yükle + Install Plug-in… Eklentiyi Yükle… + Installed plug-in {} Yüklü eklenti {} + Intensity: Yoğunluk: + Intent: Niyet: + Interlaced Taramalı + Internal CSS Dahili CSS + Internet Standard RGB (sRGB) İnternet Standardı RGB (sRGB) + Intersect Kesişme + Intertwine iç içe + Arrange Intertwine Area İç İçe Alanı Düzenleme + Inverse Ters + Invert Ters çevir + Invert Colors Renkleri Ters Çevir + Invert Mask Maskeyi Ters Çevir + Isolate İzole et + Isolate Blending İzole Harmanlama + Isolate Group İzolasyon Grubu + Italian İtalyan + Japanese Japonca + Join Katıl + Join Our Discord Discord'umuza Katılın + Join our Discord Discord'umuza katılın + Join the ArtCraft community on Discord (discord.gg/artcraft) Discord'daki ArtCraft topluluğuna katılın (discord.gg/artcraft) + Join the lines of each paragraph Her paragrafın satırlarını birleştirin + Join Tool Birleştirme Aracı + Justification… İki Yana Yaslama… + Justify all lines Tüm satırları iki yana yasla + Justify with last line aligned center Son satıra hizalanmış merkeze yasla + Justify with last line aligned left Son satırı sola hizalanmış şekilde iki yana yasla + Justify with last line aligned right Son satırı sağa hizalayarak yasla + Keep a link to an image file instead of only embedding it Yalnızca gömmek yerine bir görüntü dosyasının bağlantısını koruyun + Keep both sides the same width Her iki tarafı da aynı genişlikte tutun + Keep Formatting Biçimlendirmeye Devam Et + Keep Original Orijinali Koru + Keep Plain Text Düz Metni Koru + Keep the pasted color as a new swatch with a numbered name. Yapıştırılan rengi numaralı bir adla yeni bir renk örneği olarak koruyun. + Keep this row's colors (exclude) Bu satırın renklerini koru (hariç tut) + Keep: Sakla: + Kerning (0 = Auto) Karakter Aralığı (0 = Otomatik) + Keyboard Increment Klavye Artışı + Keyboard Shortcut Conflicts Klavye Kısayolu Çakışmaları + Keyboard Shortcut Set Klavye Kısayol Seti + Keyboard Shortcuts… Klavye Kısayolları… + Import Shortcut Set Kısayol Kümesini İçe Aktar + Import Shortcut Set… Kısayol Kümesini İçe Aktar… + Each entry of the file is matched to a VectorCraft tool, command or panel. Check the matches and answer what is left. Dosyanın her girişi bir VectorCraft aracı, komutu veya paneliyle eşleştirilir. Eşleşmeleri kontrol edin ve kalanları cevaplayın. + Keys Anahtarlar + The file writes keys and modifiers as codes. Check the proposed meanings and answer the codes that are not known yet. Dosya, anahtarları ve değiştiricileri kod olarak yazar. Önerilen anlamları kontrol ederek henüz bilinmeyen kodları cevaplayınız. + Click, then press the key this code stands for (Esc cancels) Tıklayın ve ardından bu kodun temsil ettiği tuşa basın (Esc iptal eder) + Press a key… Bir tuşa basın… + from your earlier imports önceki ithalatlarınızdan + from the format's public description formatın genel açıklamasından + not known yet henüz bilinmiyor + Shortcuts Kısayollar + In the File Dosyada + In VectorCraft VectorCraft'ta + Needs Attention Only Yalnızca Dikkat Gerekir + Ready Hazır + Needs a choice Bir seçime ihtiyacı var + Needs a key Bir anahtara ihtiyacı var + No equivalent Eşdeğeri yok + Tools take a single key (optionally with Shift) Araçlar tek bir tuş alır (isteğe bağlı olarak Shift ile) + A later row takes this shortcut Daha sonraki bir satır bu kısayolu alır + Same item as a later row Daha sonraki bir satırla aynı öğe + Start from VectorCraft Defaults VectorCraft Varsayılanlarından Başlayın + Add to the Current Shortcuts Geçerli Kısayollara Ekle + Remember My Choices Seçimlerimi Hatırla + {ready} of {total} ready {total}'nin {ready}'si hazır + The file is too large to read in full, so only its first part is listed. Dosya tam olarak okunamayacak kadar büyük olduğundan yalnızca ilk kısmı listelenmiştir. + Report copied Rapor kopyalandı + Line {line} {line} Hattı + Line {line}: {name}: {status} Hat {line}: {name}: {status} + Read Shortcut Set File Kısayol Seti Dosyasını Oku + Import Matches Eşleşmeleri İçe Aktar + Remember Import Matches İçe Aktarılan Eşleşmeleri Hatırla + Forget Import Matches İçe Aktarma Eşleşmelerini Unutun +@plural agrees with {n} default shortcut|agrees with {n} default shortcuts {n} varsayılan kısayolla eşleşiyor +@plural {n} part of the file wasn't read|{n} parts of the file weren't read dosyanın {n} bölümü okunamadı + Keywords: Anahtar Kelimeler: + Knife Bıçak + Knockout Group Nakavt Grubu + Lab Laboratuvar + Lab Spot Colors: Laboratuvar Spot Renkleri: + Lab Values Laboratuvar Değerleri + Landscape Manzara + Interface Language Arayüz Dili + Language Dil + Language Options Dil Seçenekleri + Language: Dil: + Large Büyük + Large List View Büyük Liste Görünümü + Large Tabs Büyük Sekmeler + Large Thumbnail View Büyük Küçük Resim Görünümü + Large Thumbnails Büyük Küçük Resimler + Lasso Tool Kement Aracı + Last Effect: {name} Son Etki: {name} + Last Effect: {} Son Etki: {} + Last Effect… Son Etki… + Last Page Son Sayfa + Latin Latince + Thai Tay dili + Latin Extended Latince Genişletilmiş + Latin-1 Supplement Latin-1 Eki + Layer Katman + Layer Names Katman Adları + Layer Options Katman Seçenekleri + Layer Options… Katman Seçenekleri… + Layers Katmanlar + Layers Panel Options Katmanlar Paneli Seçenekleri + Layout: Düzen: + Leader: Lider: + Leading Lider + Left Sol + Left Grid: Sol Izgara: + Left in Front Sol Önde + Left Indent Sol Girinti + Left Plane Sol Düzlem + Left-Justified Tab Sola Yaslanmış Sekme + Left-to-Right Paragraph Direction Soldan Sağa Paragraf Yönü + Left: Sol: + Legacy Macintosh RGB (Gamma 1.8) Eski Macintosh RGB (Gama 1.8) + Legacy vector clipboard Eski vektör panosu + Legacy (version 7) Eski (sürüm 7) + Transparent Images: Şeffaf Görseller: + Flatten Düzleştir + Omit Atla + Legacy .ai Options Eski .ai Seçenekleri + A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. Eski bir .ai dosyasında bir çalışma yüzeyi bulunur: kontrol ettiğiniz her çalışma yüzeyi ayrı bir dosya olarak kaydedilir. + {count} of {total} artboards {total} çalışma yüzeylerinin {count}'si + Check an artboard to save. Kaydedilecek çalışma yüzeyini kontrol edin. + Hidden Objects: Gizli Nesneler: + Hidden Layer Gizli Katman + Make Visible Görünür Yap + Text: Metin: + Editable Düzenlenebilir + PDF-compatible PDF uyumlu + Legacy vector clipboard (no transparency) Eski vektör panosu (saydamlık yok) + Less Daha az + Letter Mektup + Levels… Seviyeler… + Libraries Kütüphaneler + Ligatures are off while the text is tracked. Metin izlenirken bitişik harfler kapalıdır. + Light Işık + Light Blue Açık Mavi + Lighten hafifletmek + Lightness hafiflik + Limit Color Guide to Library Renk Kılavuzunu Kitaplıkla Sınırlandır + Limit colors to swatch library Renkleri renk örneği kitaplığıyla sınırlandırın + Limit colors to swatch library: {name} Renkleri renk örneği kitaplığıyla sınırlayın: {name} + Limit to Library: Kütüphaneyle Sınırlama: + Limit: Sınır: + Line Hat + Line Art and Text Resolution: Çizgi Sanatı ve Metin Çözünürlüğü: + Line Breaks Satır Sonları + Line Color Çizgi Rengi + Line Cut Çizgi Kesim + Line Cut Tool Çizgi Kesme Aracı + Line Endings: Satır Sonları: + Line Graph Tool Çizgi Grafik Aracı + Line Segment Çizgi Segmenti + Line Segment Tool Çizgi Parçası Aracı + Line Segment Tool Options Çizgi Segmenti Aracı Seçenekleri + Line-end Punctuation Half Width Satır Sonu Noktalama İşaretleri Yarım Genişlik + Linear Gradient Doğrusal Degrade + Lines çizgiler + Lines: clicks add points joined by a line Çizgiler: tıklamalar bir çizgiyle birleştirilen noktaları ekler + Link Bağlantı + Link harmony colors Bağlantı uyumu renkleri + Link Info Bağlantı Bilgisi + Link Opacity Mask Bağlantı Opaklığı Maskesi + Link start and end arrowhead scales Bağlantı başlangıç ve bitiş ok ucu ölçekleri + Link the mask Maskeyi bağlayın + Linked Bağlantılı + Linked File Bağlantılı Dosya + Linked File Not Found Bağlantılı Dosya Bulunamadı + Links Bağlantılar + Liquify Sıvılaştır + List Graphic Styles Grafik Stillerini Listeleme + List Keyboard Shortcuts Klavye Kısayollarını Listeleme + List Patterns Desenleri Listeleme + List Plug-ins Eklentileri Listele + List Preferences Tercihleri Listele + List Slices Dilimleri Listeleme + List View Liste Görünümü + List Workspaces Çalışma Alanlarını Listeleme + Live Paint Canlı Boyama + Live Paint Bucket Canlı Boyama Kovası + Live Paint Bucket (Stroke) Canlı Boya Kovası (Strok) + Live Paint Info Canlı Boyama Bilgisi + Live Paint Selection Tool Canlı Boyama Seçim Aracı + Live Shape Properties Canlı Şekil Özellikleri + Load Color Profile… Renk Profilini Yükle… + Load Image into Clipboard Resmi Panoya Yükle + Load Metafile into Clipboard Meta Dosyasını Panoya Yükle + Load PDF into Clipboard PDF'yi Panoya Yükle + Load Place Cursor Yer İmlecini Yükle + Load Profile… Profili Yükle… + Load SVG into Clipboard SVG'yi Panoya yükleyin + Load Text into Clipboard Metni Panoya Yükle + Local Libraries Yerel Kütüphaneler + Locate Object Nesneyi Bul + Location Konum + Location: Konum: + Lock Kilitle + Lock (⌘2) Kilitle (⌘2) + Lock Grid Izgarayı Kilitle + Lock Guides Kilit Kılavuzları + Lock Others Diğerlerini Kilitle + Lock Slices Dilimleri Kilitle + Lock Station Point Kilit İstasyon Noktası + Lossless (lossy WebP isn't available yet) Kayıpsız (kayıplı WebP henüz mevcut değil) + Lossy: Kayıplı: + Low Düşük + lowercase küçük harf + Luminosity parlaklık + LZW Compression LZW Sıkıştırma + Mac Mac + Magenta Macenta + Magic Wand Sihirli Değnek + Magic Wand Options Sihirli Değnek Seçenekleri + Magic Wand Settings Sihirli Değnek Ayarları + Magic Wand Tool Sihirli Değnek Aracı + Maintain Envelope Shape Zarf Şeklini Koruyun + Make Yap + Make a theme on Create and save it. Oluştur'da bir tema oluşturun ve kaydedin. + Make all bleed settings the same Tüm taşma payı ayarlarını aynı yapın + Make and Expand Yap ve Genişlet + Make Clipping Mask Kırpma Maskesi Yap + Make Compound Shape Bileşik Şekil Yap + Make Envelope Zarf Yap + Make Guides Kılavuzlar Oluşturun + Make Mask Maske Yap + Make Opacity Mask Opaklık Maskesi Yap + Make Pixel Perfect Pixel'i Mükemmelleştirin + Make the bleed values differ Taşma değerlerinin farklı olmasını sağlayın + Make with Mesh… Mesh ile Yap… + Make with Top Object Üst Nesneyle Yap + Make with Warp… Warp ile yap… + Make/Release Clipping Mask Kırpma Maskesini Yap/Serbest Bırak + Manage Workspaces Çalışma Alanlarını Yönet + Manage Workspaces… Çalışma Alanlarını Yönet… + Manually Manuel olarak + Mark as trapped Sıkışmış olarak işaretle + Mark Older Files as [Converted] When Opened Eski Dosyaları Açıldığında [Dönüştürülmüş] Olarak İşaretle + Marker… işaretleyici… + Marks İşaretler + Marks and Bleed İşaretler ve Taşma Payı + Marks and Bleeds İşaretler ve Kanamalar + Match User Interface Brightness Kullanıcı Arayüzü Parlaklığını Eşleştir + Light Gray Açık Gri + Medium Gray Orta Gri + Dark Gray Koyu Gri + Materials… Malzemeler… + Matte Mat + Matte: Mat: + Maximize Büyüt + Maximum Maksimum + Maximum Editability Maksimum Düzenlenebilirlik + Measure Tool Ölçüm Aracı + Measurement Labels Ölçü Etiketleri + Media Medya + Medium Orta + Medium (150 ppi) Orta (150 ppi) + Medium Dark Orta Koyu + Medium Light Orta Işık + Medium Thumbnail View Orta Küçük Resim Görünümü + Medium Thumbnails Orta Boy Küçük Resimler + Menu Dialog Menü İletişim Kutusu + Merge Birleştir + Merge colors into one row Renkleri tek satırda birleştirme + Merge Graphic Styles Grafik Stillerini Birleştir + Merge Graphic Styles… Grafik Stillerini Birleştir… + Merge Layers Katmanları Birleştir + Merge Selected Seçilenleri Birleştir + Merge Swatches Renk Örneklerini Birleştir + Mesh Tool Kafes Aracı + Message: Mesaj: + Metadata: Meta veriler: + Metallic Gradients Metalik Degradeler + Meters Metre + Method: Yöntem: + Millimeters Milimetre + Minify Küçült + Minimal Asgari + Minimize Küçült + Minus Back Eksi Geri + Minus Front Eksi Ön + Mirror Ayna + Mirror & Cut Aynalama ve Kesim + Mirror & Cut Tool Ayna ve Kesme Aracı + Missing Eksik + Missing: {path} Eksik: {path} + Miter Join Gönye Birleştirme + Miter Limit Gönye Sınırı + Miter Limit: Gönye Sınırı: + Mixed Appearances Karışık Görünümler + Mixed Objects Karışık Nesneler + Mode: Mod: + Model Modeli + Modified Değiştirildi + Modified since it was read: {path} Okunduktan sonra değiştirildi: {path} + Modified: Değiştirildi: + Modify Değiştir + Mojikumi Mojikumi + Mojikumi Set Mojikumi Seti + Kinsoku Set Kinsoku Seti + Hard Sert + Soft Yumuşak + Monitor RGB RGB'yi izleyin + Mono Mono + Monochrome images Tek renkli görüntüler + Monospaced Tek aralıklı + More Daha Fazla + More Pathfinder options Daha fazla Yol Bulucu seçeneği + More Align options Daha fazla Hizalama seçeneği + More Paragraph options Daha fazla Paragraf seçeneği + More Settings Diğer Ayarlar + Move Taşı + Move All Objects Tüm Nesneleri Taşı + Move Anchors Çapaları Taşı + Move Appearance Görünümü Taşı + Move Artboard Çalışma Yüzeyini Taşı + Move Artboard Up/Down Çalışma Yüzeyini Yukarı/Aşağı Taşı + Move Direction Handle Yön Tutamaçını Taşıma + Move Down Aşağı Taşı + Move Effect Hareket Efekti + Move Envelope Mesh Point Zarf Örgü Noktasını Taşı + Move Graphic Style Grafik Stilini Taşı + Move Guide Taşıma Kılavuzu + Move in Perspective Perspektifte Hareket Et + Move Locked and Hidden Artwork with Artboard Çalışma Yüzeyi ile Kilitli ve Gizli Resimleri Taşıma + Move Mesh Point Mesh Noktasını Taşı + Move Plane Düzlemi Taşı + Move Plane to Match Object Nesneyi Eşleştirecek Düzlemi Taşı + Move Rows Satırları Taşı + Move Slices Dilimleri Taşı + Move Spine Point Omurga Noktasını Taşı + Reshape Spine Segment Omurga Segmentini Yeniden Şekillendirin + Move Swatch Renk Örneğini Taşı + Move Up Yukarı Taşı + Move… Taşı… + Multiply Çarp + My Themes Temalarım + N pt per N pt per + N slices …, evenly spaced N dilim…, eşit aralıklı + Name İsim + Name 40% İsim %40 + Name* @ 66.67 % (RGB/Preview) Ad* @ %66,67 (RGB/Önizleme) + Name* @ 66.67% (RGB/Preview) Ad* @ %66,67 (RGB/Önizleme) + Name: İsim: + Navigate Gezin + Navigator Gezgin + Needs the desktop app Masaüstü uygulaması gerekiyor + Neutral Gradients Nötr Degradeler + New Yeni + New Art Appearance Yeni Sanat Görünümü + New Art Brush from Selection Seçimden Yeni Resim Fırçası + New Art Has Basic Appearance Yeni Sanat Temel Görünüme Sahiptir + New Artboard Yeni Çalışma Yüzeyi + New Bristle Brush Yeni Kıl Fırça + Calligraphic Brush Options Kaligrafi Fırçası Seçenekleri + Bristle Brush Options Kıl Fırça Seçenekleri + Scatter Brush Options Saçılma Fırçası Seçenekleri + Art Brush Options Resim Fırçası Seçenekleri + Pattern Brush Options Desen Fırçası Seçenekleri + Scatter: Dağılım: + Rotation: Rotasyon: + Rotation relative to: Şuna göre rotasyon: + Brush Scale Options Fırça Ölçeği Seçenekleri + Scale Proportionately Orantılı Olarak Ölçeklendirin + Stretch to Fit Stroke Length Kontur Uzunluğuna Sığacak Şekilde Esnetin + Stretch Between Guides Kılavuzlar Arasını Uzat + Start: Başlangıç: + End: Bitiş: + Left to Right Soldan Sağa + Right to Left Sağdan Sola + Top to Bottom Yukarıdan Aşağıya + Bottom to Top Aşağıdan Yukarıya + Flip: Çevir: + Outer Corner Tile Dış Köşe Döşemesi + Side Tile Yan Kiremit + Inner Corner Tile İç Köşe Döşemesi + Start Tile Döşemeyi Başlat + End Tile Son Döşeme + Fit Uygun + Stretch to fit Sığdırmak için uzatın + Add space to fit Sığdırmak için alan ekleyin + Approximate path Yaklaşık yol + Colorization Renklendirme + Tints Renk tonları + Tints and Shades Renk Tonları ve Gölgeler + Hue Shift Ton Değiştirme + Key Color: Anahtar Rengi: + Roundness: Yuvarlaklık: + Fixed Sabit + Pressure Basınç + Shape: Şekil: + Bristle Length: Kıl Uzunluğu: + Bristle Density: Kıl Yoğunluğu: + Bristle Thickness: Kıl Kalınlığı: + Paint Opacity: Boya Opaklığı: + Stiffness: Sertlik: + Round Point Yuvarlak Nokta + Flat Point Düz Nokta + Round Blunt Yuvarlak Küt + Flat Blunt Düz Künt + Round Curve Yuvarlak Eğri + Flat Curve Düz Eğri + Round Angle Yuvarlak Açı + Flat Angle Düz Açı + Round Fan Yuvarlak Fan + Flat Fan Düz Fan + New Brush… Yeni Fırça… + New Calligraphic Brush Yeni Kaligrafi Fırçası + New Character Style Yeni Karakter Stili + New Color Group Yeni Renk Grubu + New Color Group… Yeni Renk Grubu… + New Document Presets Yeni Belge Ön Ayarları + New file Yeni dosya + New from Template Şablondan yeni + New from Template… Şablondan yeni… + New Graphic Style Yeni Grafik Stili + New Graphic Style (Alt-click to name it) Yeni Grafik Stili (Adını vermek için Alt tuşuna basarak tıklayın) + New Graphic Style… Yeni Grafik Stili… + New Layer Options… Yeni Katman Seçenekleri… + New Layer… Yeni Katman… + New Opacity Masks Are Clipping Yeni Opaklık Maskeleri Kırpılıyor + New Opacity Masks Are Inverted Yeni Opaklık Maskeleri Ters Çevrildi + New Paragraph Style Yeni Paragraf Stili + New Pattern Brush from Selection Seçimden Yeni Desen Fırçası + New PDF Preset Yeni PDF Hazır Ayarı + New Print Preset Yeni Yazdırma Ön Ayarı + New Scatter Brush from Selection Seçimden Yeni Saçma Fırçası + New Sublayer… Yeni Alt Katman… + New Swatch Yeni Renk Örneği + New Swatch… Yeni Renk Örneği… + New Symbol Yeni Sembol + New Symbol… Yeni Sembol… + New theme from the current color Mevcut renkten yeni tema + New View Yeni Görünüm + New View… Yeni Görünüm… + New Window Yeni Pencere + New Workspace Yeni Çalışma Alanı + New Workspace… Yeni Çalışma Alanı… + New {kind} Style… Yeni {kind} Stili… + New… Yeni… + Next Sonraki + Next Library Sonraki Kütüphane + Next Object Above Yukarıdaki Sonraki Nesne + Next Object Below Sonraki Nesne Aşağıda + Next Page Sonraki Sayfa + Next page Sonraki sayfa + No actions. Press ● to record. Eylem yok. Kaydetmek için ● tuşuna basın. + No assets Öğe yok + No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel. Henüz öğe yok. Nesne › Dışa Aktarma için Topla ile sanatı toplayın veya Varlık Dışa Aktarma paneline sürükleyin. + No Break Mola Yok + No brushes Fırça yok + No colors to recolor (preserved colors are left out). Yeniden renklendirilecek renk yok (korunan renkler dışarıda bırakılır). + No CSS CSS yok + No document Belge yok + No Document Belge Yok + No document open Hiçbir belge açık değil + No document open. Hiçbir belge açık değil. + No filter plug-ins installed Filtre eklentisi yüklü değil + No fonts are available. Hiçbir yazı tipi mevcut değil. + No gradient swatches Degrade renk örneği yok + No graphic styles Grafik stili yok + No images Resim yok + No matches. Eşleşme yok. + No matching fonts Eşleşen yazı tipi yok + No options Seçenek yok + No printer settings to open here Burada açılacak yazıcı ayarı yok + No saved themes Kaydedilmiş tema yok + No saved views (View → New View…) Kaydedilmiş görünüm yok (Görünüm → Yeni Görünüm…) + No Selection Seçim Yok + No selection Seçim yok + No symbols in this document Bu belgede sembol yok + No text selected Metin seçilmedi + No Thumbnails Küçük Resim Yok + Noise Gürültü + Create: Oluştur: + Fills Dolgular + Strokes Konturlar + Nonbreaking Hyphen Bölünmeyen Kısa Çizgi + Nonbreaking Space Bölünmeyen Alan + None Yok + None (/) Yok (/) + None (keep transparency) Yok (şeffaflığı koruyun) + None. Yok. + Normal Normal + Normal Screen Mode Normal Ekran Modu + Note: Not: + Nothing matches the Show filter. Hiçbir şey Göster filtresiyle eşleşmiyor. + Nothing to print Yazdırılacak bir şey yok + Nudge Dürt + Nudge in Perspective Perspektifte İtme + Number of Artboards: Çalışma Yüzeyi Sayısı: + Number of Colors: Renk Sayısı: + Number of Recent Files to Display Görüntülenecek Son Dosya Sayısı + Number of Recent Fonts Son Yazı Tiplerinin Sayısı + Numbers Without Units Are Points Birimi Olmayan Sayılar Noktadır + Object Nesne + Object Highlighting Nesne Vurgulama + Object IDs: Nesne kimlikleri: + Object Name Nesne Adı + Object Properties Nesne Özellikleri + Object Selection by Path Only Yalnızca Yola Göre Nesne Seçimi + Objects Nesneler + Objects using them keep their look but are no longer linked. Bunları kullanan nesneler görünümlerini korur ancak artık bağlantılı değildir. + of characters used kullanılan karakterlerin sayısı + of {count} {count}'nin + Off Kapalı + Offset Path Ofset Yolu + Offset Path… Ofset Yolu… + Offset X Ofset X + Offset: Ofset: + OK Tamam + On Açık + On Copy Kopyalamada + On Paste Yapıştırıldığında + On Screen Ekranda + One file per artboard: -.{ext} Çalışma yüzeyi başına bir dosya: -.{ext} + One page per artboard. Çalışma yüzeyi başına bir sayfa. + One Point Bir Nokta + One Point Perspective Tek Nokta Perspektifi + One Point Perspective Preset 1 Tek Noktalı Perspektif Ön Ayarı 1 + One Point Perspective Preset 2 Tek Noktalı Perspektif Ön Ayarı 2 + One Point Perspective Preset 3 Tek Noktalı Perspektif Ön Ayarı 3 + One Point Perspective Preset 4 Tek Noktalı Perspektif Ön Ayarı 4 + One Point Perspective Preset 5 Tek Noktalı Perspektif Ön Ayarı 5 + Only Web Colors Yalnızca Web Renkleri + Opacity Opaklık + Opacity & Mask Define Knockout Shape Opaklık ve Maske Tanımlama Nakavt Şekli + Opacity Mask Opaklık Maskesi + Opacity Mask Info Opaklık Maskesi Bilgisi + Opacity Mask Options Opaklık Maskesi Seçenekleri + Opacity: Opaklık: + Opacity: 50% Multiply Opaklık: %50 Çarpma + Opacity: Default Opaklık: Varsayılan + Opacity: {value} Opaklık: {value} + Opaque Opak + Open Açık + Open a document to edit its patterns. Desenlerini düzenlemek için bir belge açın. + Open a document to navigate it. Gezinmek için bir belge açın. + Open a document to preview its flattening. Düzleştirilmesinin önizlemesini görmek için bir belge açın. + Open a document to preview its pages Sayfalarını önizlemek için bir belge açın + Open a document to see its artboards. Çalışma yüzeylerini görmek için bir belge açın. + Open a document to see its graphic styles. Grafik stillerini görmek için bir belge açın. + Open a document to see its history. Geçmişini görmek için bir belge açın. + Open a document to see its swatches. Renk örneklerini görmek için bir belgeyi açın. + Open Document Belgeyi Aç + Open Documents As Tabs Belgeleri Sekme Olarak Açma + Open Graphic Style Library Grafik Stil Kitaplığını Aç + Open Location after Export Dışa Aktarma Sonrası Konumu Aç + Open password: Şifreyi aç: + Open Paths Yolları Aç + Open Recent File 1 Son Dosyayı Aç 1 + Open Recent File 10 Son Dosyayı Aç 10 + Open Recent File 11 Son Dosyayı Aç 11 + Open Recent File 12 Son Dosyayı Aç 12 + Open Recent File 13 Son Dosyayı Aç 13 + Open Recent File 14 Son Dosyayı Aç 14 + Open Recent File 15 Son Dosyayı Aç 15 + Open Recent File 16 Son Dosyayı Aç 16 + Open Recent File 17 Son Dosyayı Aç 17 + Open Recent File 18 Son Dosyayı Aç 18 + Open Recent File 19 Son Dosyayı Aç 19 + Open Recent File 2 Son Dosyayı Aç 2 + Open Recent File 20 Son Dosyayı Aç 20 + Open Recent File 21 Son Dosyayı Aç 21 + Open Recent File 22 Son Dosyayı Aç 22 + Open Recent File 23 Son Dosyayı Aç 23 + Open Recent File 24 Son Dosyayı Aç 24 + Open Recent File 25 Son Dosyayı Aç 25 + Open Recent File 26 Son Dosyayı Aç 26 + Open Recent File 27 Son Dosyayı Aç 27 + Open Recent File 28 Son Dosyayı Aç 28 + Open Recent File 29 Son Dosyayı Aç 29 + Open Recent File 3 Son Dosyayı Aç 3 + Open Recent File 30 Son Dosyayı Aç 30 + Open Recent File 4 Son Dosyayı Aç 4 + Open Recent File 5 Son Dosyayı Aç 5 + Open Recent File 6 Son Dosyayı Aç 6 + Open Recent File 7 Son Dosyayı Aç 7 + Open Recent File 8 Son Dosyayı Aç 8 + Open Recent File 9 Son Dosyayı Aç 9 + Open Recent Files Son Dosyaları Aç + Open Swatch Library Swatch Kitaplığını Aç + Open the URL in the web browser URL'yi web tarayıcısında açın + Opened with {} note(s): {} {} notla açıldı: {} + Open… Aç… + Optical Margin Alignment Optik Kenar Boşluğu Hizalaması + Optimize for fast web view Hızlı web görünümü için optimize edin + Optimized Optimize edilmiş + Options Seçenekler + Options for Selection… Seçim Seçenekleri… + Options: Seçenekler: + Options… Seçenekler… + or more spaces with a tab veya sekmeli daha fazla alan + Order: Sipariş: + Ordinals Sıra Sayıları + Orientation Oryantasyon + Orientation: Oryantasyon: + Original Orijinal + Original: click to restore Orijinal: geri yüklemek için tıklayın + Other Diğer + Other Layers Diğer Katmanlar + Other Libraries Diğer Kütüphaneler + Other Library… Diğer Kütüphane… + Other Script… Diğer Komut Dosyası… + Out of Gamut Warning Gamut Dışı Uyarısı + Out of Web Color Warning Web Dışı Renk Uyarısı + Out Tangent Handles Dışarıya Teğet Tutamaçlar + Outer Glow… Dış Parıltı… + Outline Taslak + Outline Object Anahat Nesnesi + Outline Others Anahat Diğerleri + Outline Stroke Anahat Konturu + Outline Text Anahat Metni + Outlines and Rules Ana Hatlar ve Kurallar + Output Çıkış + Output All Blacks Accurately Tüm Siyahların Doğru Şekilde Çıktısını Alın + Output All Blacks as Rich Black Tüm Siyahların Çıkışını Zengin Siyah Olarak Çıkarın + Output Black Çıkış Siyah + Output condition: Çıkış durumu: + Output Intent Çıkış Amacı + Output intent profile: Çıkış amacı profili: + Output White Çıkış Beyaz + Output › Mode Çıkış › Mod + Output: Çıkış: + Overlap: Örtüşme: + Overlapping Örtüşen + Overlay Kaplama + Overprint Üst Baskı + Overprint and Transparency Flattener Üst Baskı ve Asetat Düzleştirici + Overprint and Transparency Flattener Options Üst Baskı ve Asetat Düzleştirici Seçenekleri + Overprint Black… Üst Baskı Siyah… + Overprint Fill Üst Baskı Dolgusu + Overprint Preview Üst Baskı Önizleme + Overprint Preview (separations) Üst Baskı Önizleme (ayrımlar) + Overprint Stroke Üst Baskı Konturu + Overprint: Üst baskı: + Overprints: Üst baskılar: + Override Character Color Karakter Rengini Geçersiz Kıl + Package Paket + Package collects a saved document with its linked files and fonts. Paket, kayıtlı bir belgeyi bağlantılı dosyaları ve yazı tipleriyle birlikte toplar. + Package created Paket oluşturuldu + Packaged {n} file(s) in {folder} {note} {folder}{note} biçiminde paketlenmiş {n} dosyası/dosyaları + Packaged {n} file(s){note} Paketlenmiş {n} dosyası/dosyaları{note} + Package… Paket… + Page Sayfa + Page information Sayfa bilgisi + Page Isolated Blending Sayfa Yalıtılmış Harmanlama + Page Knockout Group Sayfa Nakavt Grubu + Pages Sayfalar + Paintbrush Boya fırçası + Paintbrush Tool Boya Fırçası Aracı + Panel menu Panel menüsü + Panel Options… Panel Seçenekleri… + Panels Paneller + Panels Except Tools and Control Araçlar ve Kontrol Dışındaki Paneller + Paragraph Paragraf + Paragraph Style Paragraf Stili + Paragraph Style Options Paragraf Stili Seçenekleri + Paragraph Styles Paragraf Stilleri + Paragraph Symbol Paragraf Sembolü + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft'ın bir parçası. MIT VEYA Apache-2.0. Yazı Tipleri: Source Sans 3, Inter, JetBrains Mono (OFL). Simgeler: Lucide (ISC) + VectorCraft. + Password Şifre + Paste Yapıştır + Paste in Back Arkaya Yapıştır + Paste in Front Öne Yapıştır + Paste in Place Yerine Yapıştır + Paste Offset Uzaklığı Yapıştır + Paste on All Artboards Tüm Çalışma Yüzeylerine Yapıştır + Paste Remembers Layers Yapıştır Katmanları Hatırlar + Paste without Formatting Formatlamadan Yapıştır + Pasted Yapıştırıldı + Pastels Pastel + Path Yol + Path Eraser Yol Silici + Path Eraser Tool Yol Silme Aracı + Pathfinder Yol Bulucu + Pathfinder Options… Yol Bulucu Seçenekleri… + Pathfinders: Yol Bulucular: + Paths Yollar + Path Length Yol Uzunluğu + Paths: {paths} Anchors: {anchors} Colors: {colors} Yollar: {paths} Bağlantılar: {anchors} Renkler: {colors} + Pattern Desen + Pattern Editing Desen Düzenleme + Pattern Options Desen Seçenekleri + Pattern options are available in pattern editing mode. Desen seçenekleri desen düzenleme modunda mevcuttur. + PDF PDF'ler + PDF 1.3 files have no transparency: the preset flattens it. PDF 1.3 dosyalarının şeffaflığı yoktur: ön ayar onu düzleştirir. + PDF Info PDF Bilgisi + PDF Preset PDF Ön Ayarı + PDF Preset: PDF Ön Ayarı: + PDF Presets PDF Hazır Ayarları + PDF Presets… PDF Hazır Ayarları… + PDF Settings PDF Ayarları + PDF/A files carry their own output intent. PDF/A dosyaları kendi çıktı amaçlarını taşır. + PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged. PDF/X-1a dosyaları CMYK'dir: her renk hedefe dönüştürülür (boş: çıktı hedefinin CMYK profili), etiketsiz. + Pen Tool Kalem Aracı + Pencil Kalem + Pencil Tool Kurşun Kalem Aracı + per slice dilim başına + Percent: Yüzde: + Perceptual Scales Algısal Ölçekler + Performance Performans + Plug-ins & Scratch Disks Eklentiler ve Geçici Diskler + Permissions İzinler + Permissions password: İzin şifresi: + Perspective Perspektif + Perspective Grid Perspektif Izgarası + Perspective Grid Options Perspektif Izgarası Seçenekleri + Perspective Grid Preset Perspektif Izgarası Ön Ayarı + Perspective Grid Presets Perspektif Izgarası Ön Ayarları + Perspective grid presets Perspektif ızgarası ön ayarları + Perspective Grid Presets… Perspektif Izgarası Ön Ayarları… + Perspective Grid Settings Perspektif Izgarası Ayarları + Perspective Grid Tool Perspektif Izgarası Aracı + Perspective Plane Options… Perspektif Düzlemi Seçenekleri… + Perspective Selection Tool Perspektif Seçim Aracı + Phone 390×844 Telefon 390×844 + Picas Pikalar + Pie Graph Tool Pasta Grafik Aracı + Pixel Preview Piksel Önizlemesi + Pixels Piksel + Place Yer + Place DXF DXF'yi yerleştir + Place onto a new locked, dimmed template layer below the current layer Geçerli katmanın altındaki yeni kilitli, soluk şablon katmanına yerleştirin + Place PDF PDF'yi yerleştir + Place Symbol Instance Sembol Örneğini Yerleştir + Place the pages with the placement again Sayfaları yerleşimle tekrar yerleştirin + Placed File Info Yerleştirilen Dosya Bilgileri + Placement Options Yerleşim Seçenekleri + Placement Options… Yerleşim Seçenekleri… + Placement: Yerleştirme: + Place… Yerleştir… + Plates Plakalar + Platform: Platform: + Play Current Selection Geçerli Seçimi Oynat + Plug-in Info Eklenti Bilgisi + Plug-ins Eklentiler + Plug-in… Eklenti… + Point Sample Nokta Örneği + Point Text Objects Nokta Metin Nesneleri + Points Punto + Points: clicks add free points Noktalar: tıklamalar serbest noktalar ekler + Polar Grid Kutupsal Izgara + Polar Grid Tool Yuvarlak Izgara Aracı + Polygon Çokgen + Polygon Properties: Poligon Özellikleri: + Polygon Tool Çokgen Aracı + Portrait Portre + Power Saving (integrated) Güç Tasarrufu (entegre) + Portuguese Portekizce + Position Pozisyon + Postcard Kartpostal + PostScript: PostScript: + PPI ÜFE + Preferences Tercihler + Preferences… Tercihler… + Prefix: Önek: + Presentation Attributes Sunum Nitelikleri + Presentation Mode Sunum Modu + Preserve Alpha Transparency Alfa Şeffaflığını Koruyun + Preserve Appearance and Overprints Görünümü ve Üst Baskıları Koru + Preserve CMYK Numbers CMYK Numaralarını Koru + Preserve Editing Capabilities Düzenleme Yeteneklerini Koruyun + Preserve editing capabilities Düzenleme yeteneklerini koruyun + Preserve Lightness Hafifliği Koruyun + Preserve Overprints and Spot Colors Üst Baskıları ve Spot Renkleri Koru + Preserve Paths Yolları Koru + Preserve Shape Using: Şunları Kullanarak Şekli Koru: + Preserve Spot Colors Spot Renkleri Koru + Preserve Text Appearance Metin Görünümünü Koru + Preserve Text Editability Metnin Düzenlenebilirliğini Koruyun + Preserve: Koru: + PRESET DETAILS ÖN AYAR DETAYLARI + Preset Name Ön Ayar Adı + Preset name: Ön ayar adı: + Preset {} Ön ayar {} + Preset: Ön ayar: + Presets you save with the Save Preset button appear here. Ön Ayarı Kaydet düğmesiyle kaydettiğiniz ön ayarlar burada görünür. + Presets: Ön ayarlar: + Press Basın + Preview Önizleme + Preview All Layers Tüm Katmanları Önizle + Preview in Browser Tarayıcıda Önizleme + Preview in Browser needs the desktop app (the web downloads files instead) Tarayıcıdaki önizleme için masaüstü uygulaması gerekir (bunun yerine web, dosyaları indirir) + Preview Mode Önizleme Modu + Preview the document as it is now Belgeyi şu anki haliyle önizleyin + Previous Önceki + Previous Library Önceki Kütüphane + Previous Page Önceki Sayfa + Previous page Önceki sayfa + Primary Birincil + Print Yazdır + Print as Bitmap Bitmap olarak yazdır + Print Layers: Katmanları Yazdır: + Print Preset Yazdırma Ön Ayarı + Print Preset: Yazdırma Ön Ayarı: + Print Presets Yazdırma Ön Ayarları + Print presets Yazdırma ön ayarları + Print Presets… Yazdırma Ön Ayarları… + Print Preview Baskı Önizleme + Print Setup Yazdırma Kurulumu + Print Tiling Döşemeyi Yazdır + Print Tiling Pages Döşeme Sayfalarını Yazdır + Print Tiling Tool Baskı Döşeme Aracı + Printer Yazıcı + Printer mark type: Yazıcı işareti türü: + Printer profile: Yazıcı profili: + Printer Setup… Yazıcı Kurulumu… + Printer: Yazıcı: + Printers Yazıcılar + Printing / Exporting Yazdırma / Dışa Aktarma + Printing allowed: İzin verilen yazdırma: + Print… Yazdır… + Process Color Proses Rengi + Profile inclusion: Profilin dahil edilmesi: + Profile: Profil: + Progressive Aşamalı + Projecting Cap Projeksiyon Kapağı + Proof Colors Prova Renkleri + Proof Setup Prova Kurulumu + Properties Özellikler + Proportional Metrics Orantılı Metrikler + Digits Rakamlar + Arabic Arapça + Hindi Hintçe + Farsi Farsça + Proportional Oldstyle Orantılı Eski Stil + Pucker & Bloat… Büzüşme ve Şişkinlik… + Pucker Tool Büzme Aracı + Punctuation Noktalama işaretleri + Punctuation Space Noktalama İşareti Alanı + Puppet Warp Kukla Çözgü + Puppet Warp Pins Kukla Çözgü Pimleri + Puppet Warp Tool Kukla Çarpıtma Aracı + Quality {quality} Kalite {quality} + Quality: Kalite: + Quarter Space Çeyrek Alan + Quick Actions Hızlı Eylemler + Quickly start a new file Hızlı bir şekilde yeni bir dosya başlatın + Quit VectorCraft VectorCraft'tan çıkın + Radar Graph Tool Radar Grafik Aracı + Radial Radyal + Radial Glows Radyal Parıltılar + Radial Gradient Radyal Gradyan + Radius 1 Yarıçap 1 + Radius 2 Yarıçap 2 + Radius1 Yarıçap1 + Radius2 Yarıçap2 + Rainbow Gökkuşağı + Random Rastgele + Randomize Colors Renkleri Rastgeleleştir + Randomly change color order Renk sırasını rastgele değiştir + Randomly change saturation and brightness Doygunluğu ve parlaklığı rastgele değiştirin + Range: Aralık: + Raster Effects Raster Efektleri + Raster File Format: Raster Dosya Formatı: + Raster Sample Size: Raster Örnek Boyutu: + Raster/Vector Balance: Raster/Vektör Dengesi: + Rasterize Rasterleştir + Rasterize Unsupported Art Desteklenmeyen Sanatı Rasterleştir + Rasterize… Rasterleştir… + Rasters Rasterler + Rating: Değerlendirme: + Real World: Gerçek Dünya: + Real-time Drawing and Editing Gerçek Zamanlı Çizim ve Düzenleme + Rearrange All Artboards Tüm Çalışma Yüzeylerini Yeniden Düzenleme + Rearrange All Tümünü Yeniden Düzenle + Layout Order: Yerleşim Sırası: + Change to Left-to-Right Layout Soldan Sağa Düzene Geç + Grid by Row Satırlara Göre Izgara + Grid by Column Sütuna Göre Izgara + Arrange by Row Satıra Göre Düzenleme + Arrange by Column Sütuna göre düzenle + Move Artwork with Artboard Resmi Çalışma Yüzeyiyle Taşıma + Scale Artwork with Artboard Çalışma Yüzeyi ile Resmi Ölçeklendirin + Artboards: {n} Çalışma yüzeyleri: {n} + Rearrange All Artboards… Tüm Çalışma Yüzeylerini Yeniden Düzenleme… + Recall Selection Seçimi Geri Çağır + RECENT SON + Recent Son + Recent Colors Son Renkler + Recent File Son Dosya + Recent Files Son Dosyalar + Recent Font Son Yazı Tipi + Recent Font 1 En Son Yazı Tipi 1 + Recent Font 10 Son Yazı Tipi 10 + Recent Font 11 Son Yazı Tipi 11 + Recent Font 12 Son Yazı Tipi 12 + Recent Font 13 Son Yazı Tipi 13 + Recent Font 14 Son Yazı Tipi 14 + Recent Font 15 Son Yazı Tipi 15 + Recent Font 2 Son Yazı Tipi 2 + Recent Font 3 Son Yazı Tipi 3 + Recent Font 4 Son Yazı Tipi 4 + Recent Font 5 Son Yazı Tipi 5 + Recent Font 6 Son Yazı Tipi 6 + Recent Font 7 Son Yazı Tipi 7 + Recent Font 8 Son Yazı Tipi 8 + Recent Font 9 Son Yazı Tipi 9 + Recent Fonts Son Yazı Tipleri + Recent URLs Son URL'ler + Recolor Yeniden renklendir + Recolor Artwork Resmi Yeniden Renklendir + Recolor Artwork… Resmi Yeniden Renklendir… + Recolor this row Bu satırı yeniden renklendir + Recolor with Preset Ön Ayarla Yeniden Renklendirme + Recover Documents Belgeleri Kurtar + Recovery Data Kurtarma Verileri + Rectangle Dikdörtgen + Rectangle Cut Dikdörtgen Kesim + Rectangle Cut Tool Dikdörtgen Kesme Aracı + Rectangle Properties: Dikdörtgen Özellikleri: + Rectangle Tool Dikdörtgen Aracı + Rectangle: Sunshine Dikdörtgen: Güneş Işığı + Rectangle… Dikdörtgen… + Rectangular Grid Dikdörtgen Izgara + Horizontal Dividers: Yatay Bölücüler: + Vertical Dividers: Dikey Bölücüler: + Rectangular Grid Tool Dikdörtgen Izgara Aracı + Red Kırmızı + Redefine Character Style Karakter Stilini Yeniden Tanımlayın + Redefine Graphic Style Grafik Stilini Yeniden Tanımlayın + Redefine Graphic Style “{name}” “{name}” Grafik Stilini Yeniden Tanımlayın + Redefine Paragraph Style Paragraf Stilini Yeniden Tanımlayın + Redefine Symbol Sembolü Yeniden Tanımla + Redefine {kind} Style {kind} Stilini Yeniden Tanımlayın + Redo Yinele + List Folder Klasörü Listele + Begin Undo Group Geri Alma Grubunu Başlat + End Undo Group Geri Alma Grubunu Sonlandır + Redo {name} {name}'yi yeniden yap + Redo {} {}'i yeniden yap + Reduce Colors Renkleri Azalt + Reduce to Basic Appearance Temel Görünüme Düşürme + Reduction: Azaltma: + Reference Point Referans Noktası + Reflect Yansıt + Reflect Horizontal Yatay Yansıt + Reflect Tool Yansıtma Aracı + Reflect X X'i yansıt + Reflect Y Y'yi yansıt + Reflect… Yansıt… + Refresh Yenile + Refresh Font List Yazı Tipi Listesini Yenile + Missing Fonts Eksik Yazı Tipleri + Missing Fonts Dialog Eksik Yazı Tipleri İletişim Kutusu + These fonts aren't available. Their text shows in another font. Bu yazı tipleri mevcut değil. Metinleri başka bir yazı tipinde gösterilir. + Search Fonts Folder Yazı Tipleri Klasörünü Ara + Find in Folder… Klasörde Bul… + Find Fonts in Folder… Klasördeki Yazı Tiplerini Bul… + Find Font Files Yazı Tipi Dosyalarını Bul + Add Font Files Yazı Tipi Dosyaları Ekle + Add Fonts Yazı Tipleri Ekle + Find Fonts Yazı Tiplerini Bul + Stop Durdur + Searching… Aranıyor… + Not found Bulunamadı + {folders} folder(s) and {files} file(s) searched {folders} klasör(ler)i ve {files} dosya(lar)ı arandı + {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones Diğer uygulamaların ve sistemin klasörleri ve gizli klasörleri gibi {count} klasörleri atlandı + {count} folder(s) or file(s) couldn't be read {count} klasör(ler)i veya dosya(lar) okunamadı + The search stopped at its limit: search a smaller folder. Arama sınırda durdu: daha küçük bir klasörde arama yapın. + Copy only fonts you own or are licensed to install. Yalnızca sahip olduğunuz veya yükleme lisansına sahip olduğunuz yazı tiplerini kopyalayın. + VectorCraft copies the fonts to {folder}. VectorCraft yazı tiplerini {folder}'ye kopyalar. + Registered Trademark Symbol Kayıtlı Ticari Marka Sembolü + Registration marks Kayıt işaretleri + Registry name: Kayıt adı: + Regular Düzenli + Release Sürüm + Release Compound Shape Bileşik Şeklini Serbest Bırakın + Release Guides Sürüm Kılavuzları + Release Opacity Mask Opaklık Maskesini Serbest Bırak + Release Selection Sürüm Seçimi + Release to Layers (Build) Katmanlara Yayınlama (Derleme) + Release to Layers (Sequence) Katmanlara Serbest Bırakma (Sıra) + Release with Perspective Perspektifli Yayın + Relink Yeniden bağla + Relink Linked Files to the Document Bağlantılı Dosyaları Belgeye Yeniden Bağlama + Relink to Folder… Klasöre Yeniden Bağlan… + Relinked {n} image(s) Yeniden bağlanan {n} görselleri + Relinked {n} image(s); not in that folder: {} Yeniden bağlanan {n} görselleri; bu klasörde değil: {} + Relinked {} image(s) {} resim yeniden bağlandı + Relink… Yeniden bağla… + Find Linked Files Bağlantılı Dosyaları Bul + Find Linked Files in Folder… Klasördeki Bağlantılı Dosyaları Bul… + Choose the file to relink each linked file to. Files with the size and contents saved with the link come first. Bağlantılı her dosyanın yeniden bağlanacağı dosyayı seçin. Bağlantıyla kaydedilen boyut ve içeriğe sahip dosyalar ilk sırada gelir. + Same file Aynı dosya + Same size Aynı boyut + Don't Relink Yeniden Bağlama + Copy into the document's folder Belgenin klasörüne kopyalayın + Reload Plug-ins Eklentileri Yeniden Yükle + Remove Kaldır + Remove Anchor Points Bağlantı Noktalarını Kaldır + Remove Asset Varlığı Kaldır + Remove blank lines between paragraphs Paragraflar arasındaki boş satırları kaldırın + Remove Brush Stroke Fırça Darbesini Kaldır + Remove Effect Efekti Kaldır + Remove Guide Kılavuzu Kaldır + Remove Item Öğeyi Kaldır + Remove Plug-in Eklentiyi Kaldır + Remove Selected Assets Seçilen Varlıkları Kaldır + Remove Threading Diş Açmayı Kaldır + Remove “{keyword}” “{keyword}”yi kaldırın + Rename Yeniden adlandır + Rename Asset Varlığı Yeniden Adlandır + Rename Character Style Karakter Stilini Yeniden Adlandır + Rename Graphic Style Grafik Stilini Yeniden Adlandır + Rename Paragraph Style Paragraf Stilini Yeniden Adlandır + Rename Workspace Çalışma Alanını Yeniden Adlandır + Render Threads (-1 = Automatic) İşleme Konuları (-1 = Otomatik) + Rendering intent: Oluşturma amacı: + Reorder Yeniden sırala + Reorder Appearance Item Görünüm Öğesini Yeniden Sırala + Repeat Tekrarla + Repeat Options Tekrarlama Seçenekleri + Repeat Pathfinder Yol Bulucuyu Tekrarla + Repeat Tab Sekmeyi Tekrarla + Repeat {name} {name}'yi tekrarla + Replace Değiştir + Replace Font Yazı Tipini Değiştir + Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Onu başka bir dosyayla değiştirin veya yok sayın: resimleri, belgeyle birlikte kaydedilen önizlemeyi göstermeye devam eder. + Replace Spine Omurgayı Değiştir + Replace Symbol Sembolü Değiştir + Replace With Font Yazı Tipiyle Değiştir + Replaces the saved preset of that name. Bu adın kayıtlı ön ayarını değiştirir. + Replace… Değiştir… + Require a password to open the document Belgeyi açmak için parola gerektir + Reselect Yeniden seç + Reset Sıfırla + Reset Bounding Box Sınırlayıcı Kutuyu Sıfırla + Reset Envelope Mesh Zarf Ağını Sıfırla + Reset Envelope Shape Zarf Şeklini Sıfırla + Reset Essentials Essentials'ı sıfırla + Reset Keyboard Shortcuts Klavye Kısayollarını Sıfırla + Reset OpenType Features OpenType Özelliklerini Sıfırla + Reset Panel Paneli Sıfırla + Reset Preferences Tercihleri Sıfırla + Reset Profiles Profilleri Sıfırla + Reset Rotate View Döndürme Görünümünü Sıfırla + Rotate View to Selection Görünümü Seçime Döndür + Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın + Reset to Default Varsayılana Sıfırla + Reset to White, Black Beyaz, Siyah'a Sıfırla + Reset with Mesh Mesh ile Sıfırla + Reset with Mesh… Mesh ile Sıfırla… + Reset with Warp Warp ile Sıfırla + Reset with Warp… Warp ile Sıfırla… + Reset {name} {name}'yi sıfırla + Reset {} {}'ı sıfırla + Reshape Yeniden şekillendir + Reshape Segment Segmenti Yeniden Şekillendir + Reshape Tool Yeniden Şekillendir Aracı + Reshape Type Area Yazı Alanını Yeniden Şekillendir + Resolution: Çözünürlük: + Responsive duyarlı + Restore Geri yükle + Restore every preference to its default (applied on OK) Her tercihi varsayılana geri yükleyin (Tamam'a uygulanır) + Restore Recovered Documents Kurtarılan Belgeleri Geri Yükle + Restored documents open unsaved: save them to keep them. Geri yüklenen belgeler kaydedilmeden açılır: saklamak için kaydedin. + Restored {} recovered document(s): save them to keep them Geri yüklenen {} kurtarılmış belge: onları saklamak için kaydedin + Restrict printing, editing and other tasks Yazdırmayı, düzenlemeyi ve diğer görevleri kısıtlayın + Reverse Front to Back Önden Arkaya Ters Çevir + Reverse Gradient Ters Degrade + Reverse Order Ters Sıralama + Reverse Path Direction Yol Yönünü Ters Çevir + Reverse Path Direction Off Yol Yönünü Ters Çevir Kapalı + Reverse Path Direction On Yol Yönünü Ters Çevirme Açık + Reverse Spine Ters Omurga + Revert Geri Döndür + Revert to the saved version of “{name}”? “{name}”nin kayıtlı sürümüne dönülsün mü? + Replace “{name}”, the file this document was opened from? Bu belgenin açıldığı dosya olan "{name}" değiştirilsin mi? + Opening it left out what VectorCraft can't read yet ({what}), so replacing it loses that for good. Save under another name to keep it. Bunu açmak, VectorCraft'ın henüz okuyamadığı şeyi ({what}) dışarıda bıraktı, bu nedenle onu değiştirmek onu tamamen kaybeder. Saklamak için başka bir adla kaydedin. + Revolve… Döndür… + RGB Color RGB Rengi + Right Sağ + Right Grid: Sağ Izgara: + Right in Front Sağ Önde + Right Indent Sağ Girinti + Right Plane Sağ Düzlem + Right-Justified Tab Sağa Yaslanmış Sekme + Right-to-Left Paragraph Direction Sağdan Sola Paragraf Yönü + Right: Doğru: + Rise… Yüksel… + Roman Baseline Roma Taban Çizgisi + Roman Hanging Punctuation Roma Asılı Noktalama İşaretleri + Rotate Döndür + Rotate 90° CW 90° CW döndür + Rotate Tool Döndürme Aracı + Rotate View Tool Görünümü Döndür Aracı + Rotate… Döndür… + Rotation Döndürme + Roughen… Pürüzlendirmek… + Round Cap Yuvarlak Başlık + Round Corners… Yuvarlak Köşeler… + Round Join Yuvarlak Katıl + Rounded Yuvarlak + Rounded Rectangle Yuvarlatılmış Dikdörtgen + Rounded Rectangle Tool Yuvarlak Köşeli Dikdörtgen Aracı + Rounded Rectangle… Yuvarlatılmış Dikdörtgen… + Row Size: Satır Boyutu: + Rows: Satırlar: + Rule: Kural: + Rulers Cetveller + Rulers & Grids Cetveller ve Izgaralar + Run Plug-in Eklentiyi Çalıştır + Same Aynı + Same As Source Kaynakla Aynı + Sample Color Örnek Renk + Sans Serif / Gothic Sans Serif / Gotik + Saturate Doygunluk + Saturate… Doygunluk… + Saturation Doygunluk + Save Kaydet + Save a Copy Bir Kopyasını Kaydet + Save a Copy… Bir Kopyasını Kaydet… + Save As Farklı Kaydet + Save as PDF… PDF olarak kaydet… + Save as preset: Ön ayar olarak kaydet: + Save as Template Şablon olarak kaydet + Save as Template… Şablon Olarak Kaydet… + Save As… Farklı Kaydet… + Save changes to {name} Değişiklikleri {name}'ye kaydet + Save color group to Swatch panel Renk grubunu Swatch paneline kaydedin + Save Colors as Swatches Renkleri Renk Örnekleri Olarak Kaydet + Save Document Belgeyi Kaydet + Save Document Info… Belge Bilgilerini Kaydet… + Save each artboard to a separate file Her çalışma yüzeyini ayrı bir dosyaya kaydedin + Save for Office Documents Office Belgeleri için Kaydet + Save for Office Documents… Office Belgeleri için Kaydet… + Save for Web Web için Kaydet + Save for Web (Legacy)… Web için Kaydet (Eski)… + Save for Web Presets Web Ön Ayarları için Kaydet + Save for Web Preview Web Önizlemesi için Kaydet + Save for Web Settings Web Ayarları için Kaydet + Save Graphic Style Library Grafik Stil Kitaplığını Kaydet + Save Graphic Style Library… Grafik Stil Kitaplığını Kaydet… + Save Grid as Preset Izgarayı Ön Ayar Olarak Kaydet + Save Grid as Preset… Izgarayı Ön Ayar Olarak Kaydet… + Save PDF PDF'yi kaydet + Save PDF Dialog PDF İletişim Kutusunu Kaydet + Save PDF Preset PDF Hazır Ayarını Kaydet + Save Perspective Grid Preset Perspektif Izgarası Ön Ayarını Kaydet + Save Preset Ön Ayarı Kaydet + Save Preset… Ön Ayarı Kaydet… + Save Print Preset Yazdırma Ön Ayarını Kaydet + Save Recovery Data Kurtarma Verilerini Kaydet + Save Save for Web Preset Kaydet Web Ön Ayarı için Kaydet + Save selected colors as swatches Seçilen renkleri renk örnekleri olarak kaydet + Save Selection… Seçimi Kaydet… + Update Selection Seçimi Güncelle + Save Swatch Library Swatch Kitaplığını Kaydet + Save Swatch Library… Swatch Kitaplığını Kaydet… + Save the current layout under this name Geçerli düzeni bu adla kaydedin + Save the document first? Önce belge kaydedilsin mi? + Save the selected preset to a file Seçilen ön ayarı bir dosyaya kaydedin + Save the settings as a preset Ayarları ön ayar olarak kaydedin + Save Theme Temayı Kaydet + Save theme to My Themes Temayı Temalarım'a kaydet + Save these settings as a preset Bu ayarları ön ayar olarak kaydedin + Save these settings as a print preset Bu ayarları yazdırma ön ayarı olarak kaydedin + Save to Swatches Renk Örneklerine Kaydet + Save To: Şuraya Kaydet: + Save Transparency Flattener Preset Saydamlık Düzleştirici Ön Ayarını Kaydet + Save Transparency Flattener Preset… Saydamlık Düzleştirici Ön Ayarını Kaydet… + SAVED KAYDEDİLDİ + Saved Kaydedildi + Saved recovery data for {title} {title} için kayıtlı kurtarma verileri + Saved Selection 1 Seçim 1 Kaydedildi + Saved Selection 10 Seçim 10 Kaydedildi + Saved Selection 11 Seçim 11 Kaydedildi + Saved Selection 12 Seçim 12 Kaydedildi + Saved Selection 13 Seçim 13 Kaydedildi + Saved Selection 14 Kaydedilen Seçim 14 + Saved Selection 15 Seçim 15 Kaydedildi + Saved Selection 16 Kaydedilen Seçim 16 + Saved Selection 17 Kaydedilen Seçim 17 + Saved Selection 18 Kaydedilen Seçim 18 + Saved Selection 19 Kaydedilen Seçim 19 + Saved Selection 2 Seçim 2 Kaydedildi + Saved Selection 20 Seçim 20 Kaydedildi + Saved Selection 21 Seçim 21 Kaydedildi + Saved Selection 22 Seçim 22 Kaydedildi + Saved Selection 23 Kaydedilen Seçim 23 + Saved Selection 24 Kaydedilen Seçim 24 + Saved Selection 25 Seçim 25 Kaydedildi + Saved Selection 3 Seçim 3 Kaydedildi + Saved Selection 4 Seçim 4 Kaydedildi + Saved Selection 5 Seçim 5 Kaydedildi + Saved Selection 6 Seçim 6 Kaydedildi + Saved Selection 7 Seçim 7 Kaydedildi + Saved Selection 8 Seçim 8 Kaydedildi + Saved Selection 9 Seçim 9 Kaydedildi + Saved Selections Kaydedilen Seçimler + Saved to User Defined: {} Kullanıcı Tanımlı'ya kaydedildi: {} + Saved View 1 Kaydedilen Görünüm 1 + Saved View 10 Kaydedilen Görünüm 10 + Saved View 2 Kaydedilen Görünüm 2 + Saved View 3 Kaydedilen Görünüm 3 + Saved View 4 Kaydedilen Görünüm 4 + Saved View 5 Kaydedilen Görünüm 5 + Saved View 6 Kaydedilen Görünüm 6 + Saved View 7 Kaydedilen Görünüm 7 + Saved View 8 Kaydedilen Görünüm 8 + Saved View 9 Kaydedilen Görünüm 9 + Saved Views Kayıtlı Görünümler + Saved {n} files beside {path} {path}'nin yanında kaydedilen {n} dosyaları + Saved {path} {path} kaydedildi + Saved {path} with {} note(s): {first} {path}, {} notla kaydedildi: {first} + Saves the current bars, toolbar and panel layout. Geçerli çubukları, araç çubuğunu ve panel düzenini kaydeder. + Save… Kaydet… + Saving Kaydediliyor + Saving Poster.vectorcraft Poster.vectorcraft Kaydediliyor + Saving recovery data for {} {} için kurtarma verileri kaydediliyor + Saving {} {} kaydediliyor + Scale Ölçek + Scale Corners Köşeleri Ölçeklendir + Scale Cursor Proportional to UI İmleci Kullanıcı Arayüzüne Orantılı Olarak Ölçeklendir + Scale Lineweights Çizgi Kalınlıklarını Ölçeklendir + Scale Strokes & Effects Ölçek Konturları ve Efektleri + Scale to Fit Artboard Çalışma Yüzeyine Sığacak Şekilde Ölçeklendir + Scale Tool Ölçek Aracı + Scale: Ölçek: + Scale… Ölçek… + Scaling Ölçeklendirme + Scaling: Ölçekleme: + Scallop Tool Tarak Aracı + Scans: Taramalar: + Scatter Dağılım + Scatter Graph Tool Saçılma Grafiği Aracı + Scissors Tool Makas Aracı + Scratch Disks Kazı Diskleri + Screen Ekran + Screen (72 ppi) Ekran (72 ppi) + Screen Mode Ekran Modu + Screens Ekranlar + Scribble… Karalama… + Script / Brush Senaryo / Fırça + Scripts Komut dosyaları + Search Ara + Search All Tümünü Ara + Filter Filtre + Clear All Tümünü Temizle + By Object Nesneye göre + By Layer Katmana göre + Images Görseller + Groups Gruplar + Linked Files Bağlantılı Dosyalar + Locked Kilitli + Hidden Gizli + Filter Layers Panel Rows Katmanları Filtrele Panel Satırları + Add Light Işık Ekle + Artboard {n} of {total} Çalışma Yüzeyi {n} / {total} + Auto Leading: Otomatik Satır Aralığı: + Cancel Export Dışa Aktarmayı İptal Et + Clear History Geçmişi Temizle + Continue in Background Arka Planda Devam Et + Delete Light Işığı Sil + Desired İstenen + Edit Path Yolu Düzenle + Encoding and writing the file… Dosya kodlanıyor ve yazılıyor… + File {n} of {total} Dosya {n} / {total} + Full Justify Tam Yasla + Glyph Scaling: Glif Ölçekleme: + If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Çizim tabletinin kalemi çizmiyorsa, bir X11 (XWayland) seçeneği belirleyin. + Justification İki Yana Yaslama + Letter Spacing: Harf Boşluğu: + Lights Işıklar + Minimum must not exceed Desired, nor Desired Maximum. Minimum, İstenen'i; İstenen ise Maksimum'u aşmamalıdır. + Select by Color Renge Göre Seç + Set History Limit… Geçmiş Sınırını Ayarla… + Single Word Justification: Tek Sözcük Yaslama: + Step Backward Geriye Doğru Adım + Step Forward İleriye Doğru Adım + Word Spacing: Sözcük Boşluğu: + X11 (XWayland) When a Pen Is Attached Kalem Takıldığında X11 (XWayland) + Apply {name} “{name}” Stilini Uygula + Redefine {name} “{name}” Stilini Yeniden Tanımla +@msg `{key}` must be an array of these names: {_1} "{key}" şu adlardan oluşan bir dizi olmalıdır: {_1} + Search commands and tools Komutları ve araçları arayın + Search commands and tools… Komutları ve araçları arayın… + Search Commands… Arama Komutları… + Secondary İkincil + Section Symbol Bölüm Sembolü + Security Güvenlik + Select Seç + Select a text object Bir metin nesnesi seçin + Select a text object to edit its character attributes. Karakter niteliklerini düzenlemek için bir metin nesnesi seçin. + Select a text object to edit its paragraph attributes. Paragraf niteliklerini düzenlemek için bir metin nesnesi seçin. + Select a text object to set its tab stops. Sekme duraklarını ayarlamak için bir metin nesnesi seçin. + Select All Tümünü Seç + Select All Art on Layer Katmandaki Tüm Resimleri Seç + Select All Assets Tüm Varlıkları Seç + Select All Instances Tüm Örnekleri Seç + Select All Pins Tüm Pinleri Seç + Select All Unused Kullanılmayanların Tümünü Seç + Select an image to see its Link Info. Bağlantı Bilgilerini görmek için bir görsel seçin. + Select an image to trace İzlenecek bir görüntü seçin + Select Anchors Çapaları Seçin + Select Segments Segmentleri Seçin + Select Appearance Item Görünüm Öğesini Seçin + Select Art Sanat Seç + Select art and choose Object › Pattern › Make. Sanatı seçin ve Nesne › Desen › Yap'ı seçin. + Select art and click New Symbol to make one. Resmi seçin ve bir tane oluşturmak için Yeni Sembol'e tıklayın. + Select art and use New Brush to make one. Sanatı seçin ve bir tane oluşturmak için Yeni Fırça'yı kullanın. + Select Freeform Point Serbest Biçim Noktasını Seçin + Select Gradient Stop Degrade Durdurmayı Seçin + Select Guides Kılavuzları Seçin + Select Objects Nesneleri Seç + Select objects to see their CSS, or Generate CSS for the whole document. CSS'lerini görmek için nesneleri seçin veya belgenin tamamı için CSS oluşturun. + Select Same Tint % Aynı Ton Yüzdesini Seç + Select Slices Dilimleri Seç + Select styled art and click New Graphic Style. Stillendirilmiş resmi seçin ve Yeni Grafik Stili'ni tıklayın. + Select text Metni seçin + Select Text by Font Yazı Tipine Göre Metni Seçin + Select text or click into text with the Type tool to insert glyphs Glif eklemek için metni seçin veya Yazım aracıyla metne tıklayın + Select text to set its OpenType features. OpenType özelliklerini ayarlamak için metni seçin. + Select the art (and, on top of it, the mask object) Resmi seçin (ve bunun üzerine maske nesnesini) + Select Tool Aracı Seç + Select: Seçin: + Selected Artwork Seçilen Sanat Eseri + Selected Styles Only Yalnızca Seçilen Stiller + Selected Swatches Seçilen Renk Örnekleri + Selected Swatches Only Yalnızca Seçilen Renk Örnekleri + Selection Seçim + Selection & Anchor Display Seçim ve Bağlantı Ekranı + Selection Only Yalnızca Seçim + Selection Tool Seçim Aracı + Send Backward Geriye Gönder + Send to Back Arkaya Gönder + Send to Current Layer Geçerli Katmana Gönder + Sentence case Cümle durumu + Separate colors into rows Renkleri satırlara ayırın + Separate several keywords with commas. Birkaç anahtar kelimeyi virgülle ayırın. + Separations list the document's inks here: one page per ink that prints. Ayırmalar belgenin mürekkeplerini burada listeler: yazdırılan mürekkep başına bir sayfa. + Separations Preview Ayrımlar Önizlemesi + Serialize Document Belgeyi Serileştir + Serif / Mincho Serif / Minço + Server-side (.map) Sunucu tarafı (.map) + Set 1 Set 1 + Set Anchors Bağlantı Noktalarını Ayarla + Set base color Temel rengi ayarla + Set base color to the current color Temel rengi geçerli renge ayarla + Set Bounds Sınırları Belirle + Set Current Layer Geçerli Katmanı Ayarla + Set Keyboard Shortcut Klavye Kısayolunu Ayarla + Set Mesh Point Color Kafes Noktası Rengini Ayarla + Set Preference Tercihi Ayarla + Set Slice Rectangle Dilim Dikdörtgenini Ayarla + Set Tab Stops Sekme Duraklarını Ayarla + Set Text Metni Ayarla + Set the selected color to the current color Seçilen rengi geçerli renge ayarla + Set Zoom Yakınlaştırmayı Ayarla + Settings… Ayarlar… + Setup… Kurulum… + Shadows and Glows Gölgeler ve Parıltılar + Shape Şekil + Shape Builder Şekil Oluşturucu + Shape Builder Regions Şekil Oluşturucu Bölgeleri + Shape Builder Tool Şekil Oluşturucu Aracı + Shape Effects Şekil Efektleri + Shape Modes: Şekil Modları: + Shape Properties: Şekil Özellikleri: + Shaper Tool Şekillendirici Aracı + Shapes Şekiller + Shear Kesme + Shear Tool Yamultma Aracı + Shear… Kesme… + Shell Lower… Kabuk Alt Kısmı… + Shell Upper… Kabuk Üstü… + Shift Shift + Shift to Color… Renge Geçiş… + Shifts the tints of global and spot colors; other colors stay. Global ve spot renklerin tonlarını değiştirir; diğer renkler kalıyor. + Show Göster + Show Active Plane Widget Aktif Düzlem Widget'ını Göster + Show All Tümünü Göster + Show All Hidden Attributes Tüm Gizli Özellikleri Göster + Show All Layers Tüm Katmanları Göster + Show All Plates Tüm Plakaları Göster + Show all plates Tüm plakaları göster + Show All Swatches Tüm Renk Örneklerini Göster + Show and print from the Lab definition; PDF files carry it as the ink's alternate color. Laboratuvar tanımını gösterin ve yazdırın; PDF dosyaları bunu mürekkebin alternatif rengi olarak taşır. + Show and separate through the working CMYK space, as in older documents. Eski belgelerde olduğu gibi çalışan CMYK alanını gösterin ve ayırın. + Show Artboards Çalışma Yüzeylerini Göster + Show Bounding Box Sınırlayıcı Kutuyu Göster + Show Brush Size Fırça Boyutunu Göster + Show Center Gösteri Merkezi + Show Code Kodu Göster + Show Color Groups Renk Gruplarını Göster + Show Color Swatches Renk Örneklerini Göster + Show Corner Widget Köşe Widget'ını Göster + Show East Asian Options Doğu Asya Seçeneklerini Göster + Show Edges Kenarları Göster + Show Embedded Gömülüleri Göster + Show Find Field Bulma Alanını Göster + Show Font Names in English İngilizce Yazı Tipi Adlarını Göster + Show Gradient Annotator Degrade Açıklayıcıyı Göster + Show Gradient Swatches Degrade Renk Örneklerini Göster + Show Grid Izgarayı Göster + Show Grid (⌘') Izgarayı Göster (⌘') + Show Guides Kılavuzları Göster + Show handles when multiple anchors are selected Birden çok bağlantı seçildiğinde tutamaçları göster + Show Hidden Characters Gizli Karakterleri Göster + Bold Kalın + Italic italik + Show Images in Outline Mode Görüntüleri Anahat Modunda Göster + Show in Folder Klasörde Göster + Show Indic Options Hintçe Seçeneklerini Göster + Show Layers Only Yalnızca Katmanları Göster + Show Link Info Bağlantı Bilgilerini Göster + Show List View Liste Görünümünü Göster + Show Mesh Mesh'i Göster + Show Missing Eksikleri Göster + Show Modified Değiştirilenleri Göster + Show Note Notu Göster + Show Options Seçenekleri Göster + Show Original Path Orijinal Yolu Göster + Color Panel Options Renk Paneli Seçenekleri + Show Package Paketi Göster + Show Panel Paneli Göster + Show Pattern Swatches Desen Örneklerini Göster + Show Perspective Grid Perspektif Izgarasını Göster + Show Pixel Grid (Above 600% Zoom) Piksel Izgarasını Göster (%600 Yakınlaştırmanın Üstünde) + Show Print Tiling Yazdırma Döşemesini Göster + Show Rulers Cetvelleri Göster + Show Rulers (⌘R) Cetvelleri Göster (⌘R) + Show Slice Numbers Dilim Numaralarını Göster + Show Slices Dilimleri Göster + Show Swatch Bounds Renk Örneği Sınırlarını Göster + Show Swatch Kinds Renk Örneği Çeşitlerini Göster + Show Template Şablonu Göster + Show Text Threads Metin Konularını Göster + Show The Home Screen When No Documents Are Open Hiçbir Belge Açık Olmadığında Ana Ekranı Göster + Show Thumbnail Küçük Resmi Göster + Show Thumbnail View Küçük Resim Görünümünü Göster + Show Thumbnails Küçük Resimleri Göster + Show Tile Edge Döşeme Kenarını Göster + Show Tints/Shades Renk Tonlarını/Gölgeleri Göster + Show Tool Tips Araç İpuçlarını Göster + Show Transparency Grid Şeffaflık Izgarasını Göster + Show Video Rulers Video Cetvellerini Göster + Show Vivid/Muted Canlı/Sessiz Göster + Show Warm/Cool Sıcak/Soğuk Göster + Show {kind} Brushes {kind} Fırçalarını Göster + Show/hide plate (Alt-click: only this plate) Plakayı göster/gizle (Alt tuşuyla tıklayın: yalnızca bu plaka) + Show: Göster: + Shown with the optimised preview. Optimize edilmiş önizlemeyle gösterilir. + Side 1: 1. Taraf: + Side 2: 2. Taraf: + Sides: Taraflar: + Simplify Basitleştir + Simplify: Basitleştirin: + Simplify… Basitleştir… + Simulate Colored Paper Renkli Kağıdı Simüle Et + Simulate Paper Kağıdı Simüle Et + Double Column Çift Sütun + Single Left Quotation Mark Tek Sol Tırnak İşareti + Single Quotes: Tek Alıntılar: + Single Right Quotation Mark Tek Sağ Tırnak İşareti + Single-line Composer Tek Satır Oluşturucu + Sixth Space Altıncı Uzay + Size Boyut + Size Tile to Art Sanata Boyut Döşeme + Size/Leading Boyut/Öncülük + Size: Boyut: + Ridges per segment: Segment başına sırtlar: + Size: {size} Tracking: {tracking} Boyut: {size} Takip: {tracking} + Skew Eğik + Skin Tone Ramps Cilt Tonu Rampaları + Skip Blank Artboards Boş Çalışma Yüzeylerini Atla + Sky Gradients Gökyüzü Gradyanları + Slice Dilim + Slice Options Dilim Seçenekleri + Slice Options… Dilim Seçenekleri… + Slice Selection Tool Dilim Seçimi Aracı + Slice Tool Dilim Aracı + Slice Type: Dilim Türü: + Slices Dilimler + slices across, evenly spaced eşit aralıklı dilimler + slices down, evenly spaced aşağı doğru dilimler, eşit aralıklarla + Slices: Dilimler: + Small Küçük + Small Caps Küçük Büyük Harfler + Small Caps (on the roadmap) Küçük Büyük Harfler (yol haritasında) + Small Caps: Küçük Büyük Harfler: + Small List View Küçük Liste Görünümü + Small Thumbnail View Küçük Küçük Resim Görünümü + Small Thumbnails Küçük Küçük Resimler + Smaller ↔ Larger Daha küçük ↔ Daha büyük + Smart Guides Akıllı Kılavuzlar + Smart Guides (View → Smart Guides) Akıllı Kılavuzlar (Görünüm → Akıllı Kılavuzlar) + Smart Guides (⌘U) Akıllı Kılavuzlar (⌘U) + Smart Punctuation… Akıllı Noktalama… + Smooth Pürüzsüz + Corner Köşe + Relative akraba + Absolute Mutlak + Smooth Color Pürüzsüz Renk + Smooth Tool Düzgünleştirme Aracı + Snap Curves To Lines Eğrileri Çizgilere Yasla + View: Görünüm: + Tracing Result İzleme Sonucu + Tracing Result with Outlines Anahatlarla Sonucu İzleme + Outlines Ana hatlar + Outlines with Source Image Kaynak Resimli Ana Hatlar + Source Image Kaynak Resim + Image Trace View Görüntü İzleme Görünümü + Snap Options Yapış Seçenekleri + Snap to Glyph Glif'e Yapış + Snap to Grid Izgaraya Yasla + Snap to Pixel Piksel'e Yapış + Precise Cursors Hassas İmleçler + Snap to Point Noktaya Yasla + Snapping Tolerance Yapışma Toleransı + Social Square Post 1080×1080 Sosyal Meydan Gönderisi 1080×1080 + Soft Light Yumuşak Işık + Solid Katı + Sort by Kind Türe göre sırala + Sort by Name Ada Göre Sırala + Sort by Status Duruma göre sırala + Source code on GitHub GitHub'daki kaynak kodu + Source Sans 3 Kaynak Sans 3 + Space After Paragraph Paragraftan Sonra Boşluk + Space Before Paragraph Paragraftan Önce Boşluk + Spaces Alanlar + Spacing Guides Aralık Kılavuzları + Invoke Distance Guides Mesafe Kılavuzlarını Çağır + Spacing: Aralık: + Spanish İspanyolca + Specified Distance Belirtilen Mesafe + Specified Steps Belirtilen Adımlar + Specify: Belirtiniz: + Spectrum Gradients Spektrum Gradyanları + Spelling Yazım + Spiral Sarmal + Spiral Tool Spiral Aracı + Split Freeform Line Serbest Biçimli Çizgiyi Böl + Split Into Grid Izgaraya Böl + Total: Toplam: + Add Guides Kılavuz Ekle + Split Into Grid… Izgaraya Böl… + Split Path Bölünmüş Yol + spot nokta + Spot Color Nokta Rengi + Spot Colors Nokta Renkleri + Spot Colors… Nokta Renkleri… + Spread: Yayılma: + Square Kare + Squeeze… Sıkıştır… + Stacked Bar Graph Tool Yığınlanmış Çubuk Grafik Aracı + Stacked Column Graph Tool Yığınlanmış Sütun Grafik Aracı + Stair Step Merdiven Basamağı + Standard Ligatures Standart Bitişik Harfler + Standard Vertical Roman Alignment Standart Dikey Roma Hizalaması + Standard: Standart: + Star Yıldız + Star Tool Yıldız Aracı + Start arrowhead Ok ucunu başlat + Start Global Edit Genel Düzenlemeyi Başlat + Status Durum + Steps: Adımlar: + Stop Editing Opacity Mask Opaklık Maskesini Düzenlemeyi Durdur + Stop editing the opacity mask Opaklık maskesini düzenlemeyi durdur + Stop Playing/Recording Oynatmayı/Kaydı Durdur + Stray Points Kaçak Noktalar + Strikethrough Üstü çizili + Stroke Kontur + Stroke (X), double-click for the Color Picker Kontur (X), Renk Seçici için çift tıklayın + Stroke Color Kontur Rengi + Stroke Options Kontur Seçenekleri + Character: Karakter: + Character options Karakter seçenekleri + Stroke options Kontur seçenekleri + Transform options Dönüştürme seçenekleri + Stroke Weight Kontur Kalınlığı + Stroke: Kontur: + Stroke: click for swatches, Shift-click for the color mixer Kontur: renk örnekleri için tıklayın, renk karıştırıcı için Shift tuşunu basılı tutarak tıklayın + Style Stil + Style Attributes Stil Nitelikleri + Style Attributes (Entity References) Stil Nitelikleri (Varlık Referansları) + Style Name: Stil Adı: + Style: Stil: + Styling: Şekillendirme: + Stylize Stilize Et + Subdivisions Alt bölümler + Subscript Abonelik + Subscript (on the roadmap) Abonelik (yol haritasında) + Subscript: Abonelik: + Subset fonts below: Aşağıdaki yazı tiplerinin alt kümesi: + Subtract Çıkar + Suffix son ek + Summary Özet + Superscript Üst simge + Superscript (on the roadmap) Üst simge (yol haritasında) + Superscript: Üst simge: + SVG Filters SVG Filtreleri + SVG Interactivity SVG Etkileşimi + SVG Options SVG Seçenekleri + SVG Profile: SVG Profili: + Swap Fill and Stroke Dolgu ve Konturu Değiştir + Swap Fill and Stroke (Shift+X) Dolgu ve Konturu Değiştir (Shift+X) + Swap start and end arrowheads Başlangıç ve bitiş ok uçlarını değiştirin + Swap the selected object for the file, keeping its place and transform Seçilen nesneyi, yerini koruyarak dosyayla değiştirin ve dönüştürün + Swash çalkalama + Swatch Conflict Renk Örneği Çatışması + Swatch Conflicts Renk Örneği Çatışmaları + Swatch exchange files keep solid colors only. Renk örneği değişim dosyaları yalnızca düz renkleri korur. + Swatch Libraries Renk Örneği Kitaplıkları + Swatch Libraries Menu Swatch Kitaplıkları Menüsü + Swatch Library Renk Örneği Kitaplığı + Swatch Name: Renk Örneği Adı: + Swatch Options Renk Örneği Seçenekleri + Swatch Options… Renk Örneği Seçenekleri… + Swatch Tiles Renk Örneği Fayansları + Swatches Renk Örnekleri + Switch workspace Çalışma alanını değiştir + Add Content İçerik Ekle + Click to apply to the selected type Seçilen türe uygulamak için tıklayın + Create New Library Yeni Kitaplık Oluştur + Create New Library… Yeni Kitaplık Oluştur… + Rename Library… Kitaplığı Yeniden Adlandır… + Delete the library “{name}”? “{name}” kitaplığı silinsin mi? + Drag art or swatches here, or use + to add the selection's colors and text styles. Resmi veya renk örneklerini buraya sürükleyin veya seçimin renklerini ve metin stillerini eklemek için + işaretini kullanın. + Its graphics, colors and text styles are deleted from this machine. Grafikleri, renkleri ve metin stilleri bu makineden silinir. + Keep graphics, colors and text styles here to use them in any document. Libraries are stored on this machine — no account required. Grafikleri, renkleri ve metin stillerini herhangi bir belgede kullanmak için burada tutun. Kitaplıklar bu makinede depolanır; hesap gerekmez. + This library is empty Bu kütüphane boş + Add to Library Kitaplığa Ekle + Delete Library Kitaplığı Sil + Delete Library Item Kitaplık Öğesini Sil + No results Sonuç yok + No items in this library match “{query}”. Bu kitaplıktaki hiçbir öğe “{query}” ile eşleşmiyor. + Right-click an item to move it into this group. Bu gruba taşımak için bir öğeyi sağ tıklayın. + New Group Yeni Grup + New Group… Yeni Grup… + Move to Group Gruba Taşı + No Group Grup Yok + Rename Group… Grubu Yeniden Adlandır… + Delete Group Grubu Sil + Import Library… Kütüphaneyi İçe Aktar… + Export Library… Kütüphaneyi Dışa Aktar… + Import Library Kütüphaneyi İçe Aktar + Export Library Kütüphaneyi Dışa Aktar + New Library Group Yeni Kütüphane Grubu + Rename Library Group Kitaplık Grubunu Yeniden Adlandır + Delete Library Group Kütüphane Grubunu Sil + Move Library Item to Group Kitaplık Öğesini Gruba Taşı + Library Kütüphane + Rename Library Kitaplığı Yeniden Adlandır + Show Library Kitaplığı Göster + Use Library Item Kitaplık Öğesini Kullan + Graphic Grafik + Show all open documents Tüm açık belgeleri göster + Symbol Instance Sembol Örneği + Symbol Libraries Sembol Kitaplıkları + Symbol Screener Tool Sembol Perdeleyici Aracı + Symbol Scruncher Tool Sembol Ezici Aracı + Symbol Shifter Tool Sembol Yer Değiştirme Aracı + Symbol Sizer Tool Sembol Boyutlandırıcı Aracı + Symbol Spinner Tool Sembol Çevirici Aracı + Symbol Sprayer Sembol Püskürtücü + Symbol Sprayer Tool Sembol Spreyi Aracı + Symbol Stainer Tool Sembol Boyayıcı Aracı + Symbol Styler Tool Sembol Stilcisi Aracı + Symbolism Tool Sembolizm Aracı + Symbols Semboller + System Layout Sistem Düzeni + Tab Sekme + Tab Stops Sekme Durakları + Tabs Sekmeler + Tabular Lining Sekmeli Astar + Tabular Oldstyle Tablolu Eski Stil + Target Hedef + Target Contents Hedef İçerikler + Target: Hedef: + Tate-chu-yoko Tate-chu-yoko + Temperature Sıcaklık + Temperature/Tint… Sıcaklık/Renk Tonu… + Template Şablon + Templates Şablonlar + Templates Folder Şablonlar Klasörü + Templates… Şablonlar… + Text Displayed in Cell: Hücrede Görüntülenen Metin: + Text Export Options Metin Dışa Aktarma Seçenekleri + Text Fill Color Metin Dolgu Rengi + Text Import Options Metin İçe Aktarma Seçenekleri + Text Stroke Color Metin Kontur Rengi + Text Wrap Metin Kaydırma + Text Wrap Options Metin Sarma Seçenekleri + Text Wrap Options… Metin Sarma Seçenekleri… + The bounds of the visible art. Görünür sanatın sınırları. + The cell shows the type object's text as HTML. Hücre, yazım nesnesinin metnini HTML olarak gösterir. + The document has no colour swatches. Belgede renk örneği yok. + The edge of the paper the printer can't reach: the art goes inside it, and the preview shows it dashed. Yazıcının kağıdın ulaşamayacağı kenarı: Resim onun içine girer ve önizlemede kesikli çizgi gösterilir. + The page as it prints Sayfa yazdırılırken + the preset ön ayar + The Print Tiling tool placed the pages. Yazdırma Döşeme aracı sayfaları yerleştirdi. + The selected objects have no CSS of their own (see below). Seçilen nesnelerin kendilerine ait CSS'leri yoktur (aşağıya bakın). + The system's settings of this printer Bu yazıcının sistem ayarları + Thin Space İnce Uzay + Third Space Üçüncü Uzay + Third Vanishing Point: Üçüncü Ufuk Noktası: + This document already has a swatch named “{name}” with another color. Bu belgede zaten "{name}" adında ve başka bir renkte bir renk örneği var. + This library is empty. Bu kütüphane boş. + This panel is on the roadmap (see the parity plan). Bu panel yol haritası üzerindedir (eşlik planına bakınız). + Threaded Text Zincirli Metin + Threads Konular + Three Point Üç Nokta + Three Point Perspective Üç Noktalı Perspektif + Three Point Perspective Preset 1 Üç Noktalı Perspektif Ön Ayarı 1 + Three Point Perspective Preset 2 Üç Noktalı Perspektif Ön Ayarı 2 + Three Point Perspective Preset 3 Üç Noktalı Perspektif Ön Ayarı 3 + Three Point Perspective Preset 4 Üç Noktalı Perspektif Ön Ayarı 4 + Three Point Perspective Preset 5 Üç Noktalı Perspektif Ön Ayarı 5 + Threshold Eşik + Thumbnail View Küçük Resim Görünümü + Thumbnails: Küçük resimler: + Tile Fayans + Tile Edge Color Döşeme Kenar Rengi + Tile Edge Color… Döşeme Kenar Rengi… + Tile Full Pages Tam Sayfaları Döşe + Tile Range: Döşeme Aralığı: + Tile Type: Döşeme Türü: + tile {number} kiremit {number} + Tiles are numbered across, then down; leave the range empty for every tile. Fayanslar önce baştan sonra aşağı doğru numaralandırılır; aralığı her döşeme için boş bırakın. + Tint Renk tonu + Tip extends past the end point İpucu bitiş noktasının ötesine uzanıyor + Tip on the end point Bitiş noktasına dair ipucu + Title Case Başlık Durumu + to için + Toggle Fill/Stroke Focus Dolgu/Kontur Odağını Değiştir + Toggle Selection Seçimi Değiştir + Toggle single/double column Tek/çift sütunu değiştir + Tolerance Hoşgörü + Tolerance: Tolerans: + Tool Option Araç Seçeneği + Tool Options… Araç Seçenekleri… + Toolbar: Advanced / Basic Araç Çubuğu: Gelişmiş / Temel + Toolbar: Single/Double Column Araç Çubuğu: Tek/Çift Sütun + Toolbar: Add Tool Araç Çubuğu: Araç Ekle + Toolbar: Remove Tool Araç Çubuğu: Aracı Kaldır + Toolbar: Reset Araç Çubuğu: Sıfırla + Toolbars Araç çubukları + Tools Araçlar + Top Üst + Top in Front Önde Üst + Top: Üst: + Total Width: Toplam Genişlik: + Touch Type Tool Dokunmatik Yazım Aracı + Trace İz + Trace the image with the Default preset Görüntüyü Varsayılan ön ayarla izleyin + Tracking Takip + Trademark Symbol Ticari Marka Sembolü + Transform Dönüştür + Transform Again Yeniden Dönüştür + Transform Both Her İkisini de Dönüştür + Transform Each Her Birini Dönüştür + Transform Each… Her Birini Dönüştür… + Transform in Perspective Perspektifte Dönüşüm + Transform Object Only Yalnızca Nesneyi Dönüştür + Transform Panel Paneli Dönüştür + Transform Pattern Only Yalnızca Deseni Dönüştür + Transform Pattern Tiles Desen Döşemelerini Dönüştür + Transform Patterns Desenleri Dönüştür + Transform Tools Dönüştürme Araçları + Transforms Dönüşümler + Transform… Dönüştür… + Transparency Saydamlık + Transparency Flattener Presets Şeffaflık Düzleştirici Ön Ayarları + Transparency Flattener Presets… Şeffaflık Düzleştirici Ön Ayarları… + Transparency Grid Şeffaflık Izgarası + Transparency Info Şeffaflık Bilgileri + Transparency is flattened with the preset. Type is written as outlines; gradients as bands of colour. Şeffaflık ön ayarla düzleştirilir. Tip ana hatlar halinde yazılır; renk bantları olarak degradeler. + Transparency is flattened with the preset. Type is written as outlines; gradients stay smooth. Şeffaflık ön ayarla düzleştirilir. Tip ana hatlar halinde yazılır; degradeler düzgün kalır. + Transparency Options Şeffaflık Seçenekleri + Transparent Şeffaf + Transparent (Default) Şeffaf (Varsayılan) + Transparent Background Şeffaf Arka Plan + Transverse (the page a quarter turn on the paper) Enine (kağıt üzerinde çeyrek tur dönüş yapan sayfa) + Trap… Tuzak… + Trap Tuzak + Height/Width Yükseklik/Genişlik + Tint Reduction Ton Azaltma + Traps with Process Color Proses Renkli Tuzaklar + Reverse Traps Tuzakları Tersine Çevir + Precision Hassasiyet + Remove Redundant Points Gereksiz Noktaları Kaldır + Trim Kırpma + Trim mark weight: Trim işareti ağırlığı: + Trim marks Kırpma işaretleri + Trim View Görünümü Kırp + True colour needs version 2004 or later: the nearest of 256 colours is written. Gerçek rengin 2004 veya üzeri bir sürüme ihtiyacı vardır: 256 renkten en yakın olanı yazılır. + Turn off Data Recovery for complex documents Karmaşık belgeler için Veri Kurtarma'yı kapatın + Tweak… İnce ayar… + Twirl Rate: Dönme Hızı: + Twirl Tool Burgu Aracı + Twist… Büküm… + Two Point İki Nokta + Two Point Perspective İki Noktalı Perspektif + Two Point Perspective Preset 1 İki Noktalı Perspektif Ön Ayarı 1 + Two Point Perspective Preset 2 İki Noktalı Perspektif Ön Ayarı 2 + Two Point Perspective Preset 3 İki Noktalı Perspektif Ön Ayarı 3 + Two Point Perspective Preset 4 İki Noktalı Perspektif Ön Ayarı 4 + Two Point Perspective Preset 5 İki Noktalı Perspektif Ön Ayarı 5 + Type Yazı + Type becomes outlines, and strokes a CAD line can't draw become filled shapes. Yazı ana hatlara dönüşür ve bir CAD çizgisinin çizemediği konturlar doldurulmuş şekillere dönüşür. + Type Object Selection by Path Only Yalnızca Yola Göre Nesne Seçimini Yazın + Type on a Path Bir Yola Yazın + Type on a Path Options Yol Seçenekleri Üzerine Yazma + Type on a Path Options… Yol Seçenekleri Üzerine Yazma… + Type on a Path Tool Yola Yazma Aracı + Type Orientation Tip Yönü + Type stays text, and every stroke is a line with its lineweight and dashes. Yazı metin olarak kalır ve her kontur, çizgi kalınlığı ve tireleriyle birlikte bir çizgidir. + Type Tool Yazım Aracı + Type: Tür: + Type… Tür… + UI Brightness Kullanıcı Arayüzü Parlaklığı + UI Scaling Kullanıcı Arayüzü Ölçeklendirme + Underline Altı çizili + Undo Geri Al + Undo {name} {name}'yi geri al + Undo {} {} işlemini geri al + Unembed Gömmeyi kaldır + Unembedded to {} {} konumuna kaldırıldı + Unembed… Gömmeyi kaldır… + Ungroup Grubu Çöz + Ungroup Color Group Renk Grubunun Grubunu Çöz + Unicode Unicode + Unicode (UTF-16) Unicode (UTF-16) + Unicode (UTF-8) Unicode (UTF-8) + Uniform Üniforma + Unique Benzersiz + Unite Birleşin + Unite and Simplify Birleşin ve Basitleştirin + Units Birimler + Units: Birimler: + Units: {units} Birimler: {units} + Unlink harmony colors Uyum renklerinin bağlantısını kaldır + Unlink Opacity Mask Opaklık Maskesinin Bağlantısını Kaldır + Unlink the mask Maskenin bağlantısını kaldır + Unlock Kilidi aç + Unlock All Tümünün Kilidini Aç + Unlock All Layers Tüm Katmanların Kilidini Aç + Unlock Grid Izgaranın Kilidini Aç + Unlock Guides Kılavuzların Kilidini Aç + Unnamed Objects Adsız Nesneler + Unprintable margin: Yazdırılamayan kenar boşluğu: + Unshift Kaydırmayı kaldır + Untitled İsimsiz + Update Link Bağlantıyı Güncelle + Update Links Bağlantıları Güncelle + Update Changed Links Değiştirilen Bağlantıları Güncelle + Update Modified Links Değiştirilen Bağlantıları Güncelle + Updated {} image(s) {} resim güncellendi + UPPERCASE BÜYÜK HARF + URL: URL'si: + Use Kullanım + Use Artboards Çalışma Yüzeylerini Kullan + Use Black Point Compensation Siyah Nokta Telafisi Kullan + Use Compression Sıkıştırmayı Kullan + Use document bleed settings Belge taşma payı ayarlarını kullan + Use Even-Odd Fill Rule Çift-Tek Doldurma Kuralını Kullan + Use Japanese Crop Marks Japonca Kırpma İşaretlerini Kullan + Use Low Resolution Proxy for Linked EPS Bağlantılı EPS için Düşük Çözünürlüklü Proxy Kullanın + Use Non-Zero Winding Fill Rule Sıfır Olmayan Sarma Doldurma Kuralını Kullan + Use Precise Cursors Hassas İmleçler Kullanın + Use Pressure Pen Basınç Kalemini Kullan + Use Preview Bounds Önizleme Sınırlarını Kullan + Use Registration Point for Symbol Sembol için Kayıt Noktasını Kullan + Use Square for Previews Önizlemeler için Square'i kullanın + Use Text for Previews Önizlemeler için Metin Kullan + Use the document's swatch: pasted colors take its color. Belgenin renk örneğini kullanın: yapıştırılan renkler kendi rengini alır. + Use the Selected Text's Font Seçilen Metnin Yazı Tipini Kullan + Use Typographer's Quotes Tipografın Sözlerini Kullan + User Defined Kullanıcı Tanımlı + User Defined Libraries Kullanıcı Tanımlı Kütüphaneler + User Graphic Style Library 1 Kullanıcı Grafik Stili Kitaplığı 1 + User Graphic Style Library 10 Kullanıcı Grafik Stili Kitaplığı 10 + User Graphic Style Library 2 Kullanıcı Grafik Stili Kitaplığı 2 + User Graphic Style Library 3 Kullanıcı Grafik Stili Kitaplığı 3 + User Graphic Style Library 4 Kullanıcı Grafik Stili Kitaplığı 4 + User Graphic Style Library 5 Kullanıcı Grafik Stili Kitaplığı 5 + User Graphic Style Library 6 Kullanıcı Grafiği Stil Kitaplığı 6 + User Graphic Style Library 7 Kullanıcı Grafiği Stil Kitaplığı 7 + User Graphic Style Library 8 Kullanıcı Grafik Stili Kitaplığı 8 + User Graphic Style Library 9 Kullanıcı Grafik Stili Kitaplığı 9 + User Interface Kullanıcı Arayüzü + User Library Kullanıcı Kitaplığı + User Preset Kullanıcı Ön Ayarı + User Swatch Library 1 Kullanıcı Swatch Kitaplığı 1 + User Swatch Library 10 Kullanıcı Swatch Kitaplığı 10 + User Swatch Library 2 Kullanıcı Swatch Kitaplığı 2 + User Swatch Library 3 Kullanıcı Swatch Kitaplığı 3 + User Swatch Library 4 Kullanıcı Swatch Kitaplığı 4 + User Swatch Library 5 Kullanıcı Renk Örneği Kitaplığı 5 + User Swatch Library 6 Kullanıcı Swatch Kitaplığı 6 + User Swatch Library 7 Kullanıcı Renk Örneği Kitaplığı 7 + User Swatch Library 8 Kullanıcı Swatch Kitaplığı 8 + User Swatch Library 9 Kullanıcı Swatch Kitaplığı 9 + V Spacing: V Aralığı: + V: V: + Variable Width Profile Değişken Genişlik Profili + Variables Değişkenler + New Variable Yeni Değişken + Delete Variable Değişkeni Sil + List Variables Değişkenleri Listeleme + Bind Variable Değişkeni Bağla + Unbind Variable Değişkenin Bağlantısını Çöz + New Data Set Yeni Veri Seti + Delete Data Set Veri Kümesini Sil + List Data Sets Veri Kümelerini Listeleme + Select Data Set Veri Kümesini Seçin + Next Data Set Sonraki Veri Seti + Previous Data Set Önceki Veri Seti + Data Sets Veri Setleri + New Variable… Yeni Değişken… + New Data Set… Yeni Veri Seti… + Edit Data Set… Veri Kümesini Düzenle… + Data Set Veri Seti + Kind tür + Variable Değişken + no document belge yok + no data set to edit düzenlenecek veri kümesi yok + Make Text Dynamic Metni Dinamik Hale Getirin + Make Visibility Dynamic Görünürlüğü Dinamik Hale Getirin + Capture Data Set Veri Kümesini Yakala + Update Data Set Veri Kümesini Güncelle + Rename Data Set… Veri Kümesini Yeniden Adlandır… + Variable Options… Değişken Seçenekler… + Select Bound Object Bağlı Nesneyi Seç + Select All Bound Objects Tüm Bağlı Nesneleri Seç + Multiple objects Çoklu nesneler + New Name Yeni Ad + no variable to rename yeniden adlandırılacak değişken yok + no data set to rename yeniden adlandırılacak veri kümesi yok + Variation: Varyasyon: + Vector Effects Vektör Efektleri + Vector Halftone Vektör Noktalı Resim + Vector Halftone… Vektör Noktalı Resim… + Vector illustration — fast, open, scriptable. Vektör illüstrasyonu — hızlı, açık, yazılabilir. + VectorCraft didn't quit normally last time. These documents had unsaved changes: VectorCraft geçen sefer normal şekilde kapanmamıştı. Bu belgelerde kaydedilmemiş değişiklikler vardı: + VectorCraft Help… VectorCraft Yardımı… + VectorCraft on getartcraft.com Getartcraft.com'da VectorCraft + VectorCraft on GitHub GitHub'da VectorCraft + Vectors Vektörler + Version {version} — open-source vector illustration in pure Rust. Sürüm {version} — saf Rust'ta açık kaynaklı vektör çizimi. + Version: Sürüm: + Vertical Dikey + Vertical % Dikey % + Vertical Align Bottom Dikey Alta Hizala + Vertical Align Center Dikey Hizalama Merkezi + Horizontal & Vertical Align Center Yatay ve Dikey Hizalama Merkezi + Vertical Align Top Dikey Hizalama Üst + Vertical Area Type Tool Dikey Alan Metni Aracı + Vertical Distribute Bottom Dikey Dağıtım Alt + Vertical Distribute Center Dikey Dağıtım Merkezi + Vertical Distribute Space Dikey Dağıtım Alanı + Vertical Distribute Top Dikey Dağıt Üst + Vertical Scale % Dikey Ölçek %'si + Vertical Type on a Path Tool Dikey Yola Yazma Aracı + Vertical Type Tool Dikey Metin Aracı + Vertical: Dikey: + Very High Çok Yüksek + View Görünüm + View Artboard Only Yalnızca Çalışma Yüzeyini Görüntüle + View Opacity Mask Opaklık Maskesini Görüntüle + View PDF after saving Kaydettikten sonra PDF'yi görüntüle + View {n} {n}'yi görüntüle + Viewing Angle: Görüş Açısı: + Viewing Distance: Görüş Mesafesi: + W: W: + Warnings Uyarılar + Warp Çözgü + Warp Options Çözgü Seçenekleri + Warp Tool Çarpıtma Aracı + Wave… Dalga… + Web 1920×1080 İnternet 1920×1080 + Web Safe 216 Web Güvenliği 216 + Web Safe RGB Web Güvenli RGB + Web Shift Web Değişimi + Web Snap: Web Snap: + Weight Ağırlık + Weight: Ağırlık: + Welcome to VectorCraft VectorCraft'a hoş geldiniz + When pasting text Metin yapıştırırken + White Beyaz + Widget Position: Widget Konumu: + Width Genişlik + Width and Height Genişlik ve Yükseklik + Width Point Genişlik Noktası + Width Point Edit Genişlik Noktası Düzenle + Width Point Edit… Genişlik Noktası Düzenle… + Width Profile Genişlik Profili + Width Profiles Genişlik Profilleri + Width Tool Genişlik Aracı + Width: Genişlik: + Window Pencere + Windows Windows + Working CMYK CMYK'de çalışıyor + Working CMYK (don't tag) CMYK çalışıyor (etiketlemeyin) + Workspace Çalışma Alanı + Workspace {i} Çalışma Alanı {i} + Wrinkle Tool Kırışıklık Aracı + Write Layers Katmanları Yaz + XML ID XML kimliği + Yards Yarda + Yellow Sarı + Your browser downloads the files (several as one .zip). Tarayıcınız dosyaları indirir (birkaç tanesi tek bir .zip olarak). + Your changes will be lost if you don't save them. Kaydetmezseniz değişiklikleriniz kaybolacaktır. + Zig Zag… Zig Zag… + Zoom In Yakınlaştır + Zoom Out Uzaklaştır + Zoom to Selection Seçimi Yakınlaştır + Zoom Tool Yakınlaştırma Aracı + Zoom with Mouse Wheel Fare Tekerleği ile Yakınlaştırma + Scrub Numeric Fields by Dragging Sayısal Alanları Sürükleyerek Fırçalayın + {count} file(s) in {folder} {note}. Show the package folder? {folder}{note} içindeki {count} dosyası/dosyaları. Paket klasörü gösterilsin mi? + {count} linked file(s) not found {count} bağlantılı dosya(lar) bulunamadı + fonts not copied: {fonts} kopyalanmayan yazı tipleri: {fonts} + {count} linked image(s) changed since this document was saved. Show the new versions? {count} bağlantılı görseller bu belgenin kaydedilmesinden bu yana değiştirildi. Yeni sürümler gösterilsin mi? + {count} linked image(s) changed on disk. Show the new versions? {count} bağlantılı görseller diskte değiştirildi. Yeni sürümler gösterilsin mi? + {count} more rules: Export writes every one. {count} daha fazla kural: Dışa aktarma her birini yazar. + {count} unnamed objects left out: name them, or turn on Unnamed Objects in the panel menu. {count} adsız nesneler dışarıda bırakıldı: bunlara ad verin veya panel menüsünde Adsız Nesneler'i açın. + {effect} is already applied {effect} zaten uygulandı + {format} Options {format} Seçenekleri + {kind} Gradient {kind} Degrade + {kind} Style Options… {kind} Stil Seçenekleri… + {kind}, {model} {kind}, {model} + {name} (spot) {name} (nokta) + {pages} in all, {count} per copy Toplam {pages}, kopya başına {count} + {page} of {count} {count}'nin {page}'si + {percent}% opacity {percent}% opaklık + {secs} sec {secs} sn + {shortcut} with the tool sizes the brush Fırçayı boyutlandırma aracıyla {shortcut} + {title} (selection) {title} (seçim) + {tool} Options {tool} Seçenekleri + {tool} Tool Options {tool} Araç Seçenekleri + {warning}: click to correct to the closest color ({hex}) {warning}: en yakın renge ({hex}) düzeltmek için tıklayın + “{file}” is protected by a password. “{file}” bir şifre ile korunmaktadır. + ● Recording ● Kayıt +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 {n} Layer|{n} Layers {n} Katman +@plural {n} Object|{n} Objects {n} Nesne +@plural {n} area highlighted|{n} areas highlighted {n} alan vurgulandı +@plural {n} color|{n} colors {n} renk +@plural {n} fill|{n} fills {n} dolgu +@plural {n} glyph|{n} glyphs {n} glif +@plural {n} object highlighted|{n} objects highlighted {n} nesne vurgulandı +@plural {n} star|{n} stars {n} yıldız +@plural {n} step|{n} steps {n} adım +@plural {n} stroke|{n} strokes {n} kontur + the object to select | seçilecek nesne | + to select multiple objects | birden fazla nesne seçmek için | + the object to duplicate çoğaltılacak nesne + an anchor or path segment to select it | seçmek için bir bağlantı veya yol parçası | + to move | taşınmak | + to add eklemek + to add a corner point | köşe noktası eklemek için | + to add a smooth point | düzgün bir nokta eklemek için | + to close | kapatmak için | + to finish bitirmek + to add smooth points | düzgün noktalar eklemek için | + to toggle corner | köşeyi değiştirmek için | + to create point type | nokta türü oluşturmak için | + to create area type | alan türü oluşturmak için | + to exit editing düzenlemeden çıkmak için + to draw | çizmek | + to constrain proportions | oranları sınırlamak için | + from center | merkezden | + for exact size tam boyut için + to set the reference point | referans noktasını ayarlamak için | + to transform | dönüştürmek | + for exact values kesin değerler için + to pan the view görünümü kaydırmak için + to zoom in | yakınlaştırmak için | + to zoom out | uzaklaştırmak için | + to zoom into an area bir alanı yakınlaştırmak için + Hold Basılı tutun + right or left to zoom in or out | yakınlaştırmak veya uzaklaştırmak için sağa veya sola | + to keep zooming yakınlaştırmaya devam etmek + an object to copy its attributes | niteliklerini kopyalayacak bir nesne | + to apply the selection's to it | seçimi ona uygulamak için | + to sample a color bir rengi örneklemek + across a selected object to set the gradient direction degrade yönünü ayarlamak için seçilen bir nesnenin üzerinde + to select an artboard | çalışma yüzeyi seçmek için | + on the canvas to create one bir tane oluşturmak için tuval üzerinde + a stroke to add a width point | genişlik noktası eklemek için kontur | + to change one side | bir tarafı değiştirmek için | + to select more points | daha fazla nokta seçmek için | + a point to edit it | düzenlemek için bir nokta | + to remove points noktaları kaldırmak için + the art to add a pin | raptiye ekleme sanatı | + to select more pins | daha fazla pin seçmek için | + a pin to warp | çözgü için bir iğne | + near a selected pin to rotate | döndürmek için seçilen raptiyenin yakınında | + to remove pins pinleri çıkarmak için + across paths to push them along | onları ilerletmek için yollar boyunca | + to size the brush ( fırçayı boyutlandırmak için ( + keeps its proportions) oranlarını korur) + over paths, and hold still to keep going | yolların üzerinden geçin ve ilerlemeye devam etmek için hareketsiz kalın | + over paths to roughen their outlines | ana hatlarını pürüzlendirmek için yollar üzerinde | + to move in perspective | perspektifte hareket etmek için | + to copy | kopyalamak için | + while dragging to move perpendicular to the plane | düzleme dik olarak taşımak için sürüklerken | + to scale in perspective perspektifte ölçeklendirmek + an object, then another to blend them | bir nesne, ardından bunları karıştırmak için başka bir nesne | + to blend from it | ondan harmanlamak için | + to set spacing and orientation aralığı ve yönlendirmeyi ayarlamak için + : : + Isolate Selected Group Seçili Grubu İzole Et + Isolate Selected Object Seçili Nesneyi Ayır + Release Clipping Mask Kırpma Maskesini Serbest Bırakın + Make Compound Path Bileşik Yol Yap + Release Compound Path Bileşik Yolu Serbest Bırak + — substituted by {style} - {style} ile değiştirildi + — {n} characters from another font — Başka bir yazı tipinden {n} karakterler + [Normal Character Style] [Normal Karakter Stili] + [Normal Paragraph Style] [Normal Paragraf Stili] + Top-to-Top Leading Tepeden Tepeye Liderlik + Bottom-to-Bottom Leading Aşağıdan Aşağıya Öncülük + Burasagari Burasagari + Force kuvvet + About Hakkında + Contributors Katkıda Bulunanlar + Models Modeller + Username Kullanıcı adı + Display name Görünen ad + Real name Gerçek isim + Sort Sırala + Reverse the order Sırayı tersine çevir + Grab bag Çantayı al + Table Tablo + Name (A–Z) Ad (A–Z) + Merged PRs Birleştirilmiş PR'ler + Commits Taahhütler + Lines added Satırlar eklendi + Lines deleted Satırlar silindi + Line delta Çizgi deltası + Binary assets added İkili varlıklar eklendi + Binary assets removed İkili varlıklar kaldırıldı + First commit İlk taahhüt + Last commit Son taahhüt + First İlk + Last Son + {people} contributors · {commits} commits {people} katkıda bulunanlar · {commits} taahhütleri + No contributor data was built into this copy. Bu kopyaya hiçbir katkıda bulunan verisi eklenmedi. + No model credits were built into this copy. Bu kopyaya hiçbir model kredisi eklenmemiştir. + Company Şirket + Version Sürüm + % of all commits Tüm taahhütlerin yüzdesi + Lines +/− Çizgiler +/− + Joins Katılıyor + Miter Gönye + Bevel Eğim + Offset Ofset + Axis Eksen +axis Horizontal Yatay +axis Vertical Dikey +axis Both Her İkisi + Drag to move Taşımak için sürükleyin + Drag to move out of the set Setin dışına çıkmak için sürükleyin + Drag to float as a toolbar Bağımsız araç çubuğu yapmak için sürükleyin + Put back in the toolbar Araç çubuğuna geri yerleştir + Float Panel Paneli Yüzer Yap + Dock Panel Bağlantı İstasyonu Paneli + Drag to float the panel group Panel grubunu ayırmak için sürükleyin + Put back in the dock Dock'a geri koy + Drag to float the toolbar Araç çubuğunu ayırmak için sürükleyin + Drag to the window's left edge to dock Sabitlemek için pencerenin sol kenarına sürükleyin + Float Tool Group Araç Grubunu Yüzer Yap +@msg no library `{id}` `{id}` kütüphanesi yok +@msg select some type first önce bir tür seçin +@msg {_1} has no {_2} style {_1}'nin {_2} stili yok +@msg a layer couldn't be read bir katman okunamadı +@msg it has more than one artboard birden fazla çalışma yüzeyi var +@msg it has no page sayfası yok +@msg it has too many text objects çok fazla metin nesnesi var +@msg it has too much text çok fazla metin var +@msg its editing data doesn't say where its artboard is düzenleme verileri çalışma yüzeyinin nerede olduğunu söylemiyor +@msg its editing data has no layers düzenleme verilerinin katmanı yok +@msg its editing data is damaged düzenleme verileri zarar görmüş +@msg its editing data is for a different page düzenleme verileri farklı bir sayfaya aittir +@msg its layers use {_1}, which aren't read yet katmanları henüz okunmamış {_1} kullanıyor +@msg its page has text, and its layers have no text objects to put it in sayfasında metin var ve katmanlarında onu koyacak metin nesneleri yok +@msg its type is to come in as outlines, which only its page has türü, yalnızca kendi sayfasında bulunan ana hatlar olarak gelmektir +@msg the art on them differs from the page's in {_1}% of it üzerlerindeki görseller sayfadakilerden %{_1} oranında farklı +@msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same üzerlerindeki görseller sayfanınkinden %{_1} oranında farklıdır: yine de düzenleme verilerinden gelirler +@msg the file's layers weren't read ({_1}): it comes in as its page, in one layer dosyanın katmanları okunmadı ({_1}): tek katmanda kendi sayfası olarak geliyor +@msg the file's layers weren't read from its editing data ({_1}): they come from its PDF part dosyanın katmanları düzenleme verilerinden okunmadı ({_1}): PDF kısmından geliyorlar +@msg `to` must be "corner" or "smooth" "to" "köşe" veya "pürüzsüz" olmalıdır +@msg BMP files are RGB or grayscale: colorModel rgb or gray BMP dosyaları RGB veya gri tonlamalıdır: colorModel rgb veya gri +@msg Blend colors: select at least three objects with filled colours Renkleri karıştır: dolgulu renklere sahip en az üç nesne seçin +@msg Combine Slices: the slices have no bounds Dilimleri Birleştir: dilimlerin sınırları yoktur +@msg Convert to Artboards: selection has no area Çalışma Yüzeylerine Dönüştür: seçimin alanı yok +@msg Convert to Shape: no rectangles or ellipses selected Şekle Dönüştür: seçili dikdörtgen veya elips yok +@msg Copied the CSS of {rules} object(s) {rules} nesnelerinin CSS'si kopyalandı +@msg Copied the script report Komut dosyası raporu kopyalandı +@msg Corners edits the corners of a path Köşeler bir yolun köşelerini düzenler +@msg Couldn't copy to the system clipboard: {e} Sistem panosuna kopyalanamadı: {e} +@msg Couldn't open {name}: {e} {name} açılamadı: {e} +@msg Couldn't place {name}: the document it was dropped on was closed {name} yerleştirilemedi: bırakıldığı belge kapatıldı +@msg Couldn't place {name}: the document it was placed in was closed {name} yerleştirilemedi: yerleştirildiği belge kapatıldı +@msg Couldn't read {name}: {e} {name} okunamadı: {e} +@msg Couldn't save recovery data: {e} Kurtarma verileri kaydedilemedi: {e} +@msg Create Outlines: select text objects Anahatlar Oluştur: metin nesnelerini seçin +@msg Create from Guides: the document has no artboard Kılavuzlardan Oluştur: belgede çalışma yüzeyi yok +@msg Create from Guides: the guides make more than {MAX_PIECES} slices Kılavuzlardan Oluşturun: kılavuzlar {MAX_PIECES} dilimlerinden daha fazlasını oluşturur +@msg Create from Selection: the selection has no area Seçimden Oluştur: Seçimin alanı yok +@msg Data Recovery has nowhere to keep copies: set Preferences → File Handling → Data Recovery → Folder (recoveryFolder) Veri Kurtarma'nın kopyaları saklayacak yeri yoktur: Tercihleri ayarlayın → Dosya İşleme → Veri Kurtarma → Klasör (recoveryFolder) +@msg direct-select anchor points to cut at (not the end points of open paths) kesilecek bağlantı noktalarını doğrudan seçin (açık yolların bitiş noktalarını değil) +@msg Divide Objects Below: select a path Aşağıdaki Nesneleri Böl: bir yol seçin +@msg Divide Objects Below: select exactly one path Aşağıdaki Nesneleri Böl: tam olarak bir yol seçin +@msg Downloaded {_1} file(s) İndirilen {_1} dosya(lar)ı +@msg Downloaded {what} {what} indirildi +@msg Expand Appearance: the selection has a basic appearance Görünümü Genişlet: seçimin temel bir görünümü vardır +@msg Expand: no brush strokes selected Genişlet: seçili fırça darbesi yok +@msg Expand: no gradient fills Genişlet: degrade dolgu yok +@msg Expand: nothing to expand Genişlet: genişletilecek bir şey yok +@msg Exported {count} file(s) to {folder} {count} dosyası/dosyaları {folder}'ye aktarıldı +@msg Exported {path} Dışa aktarılan {path} +@msg Flatten Transparency: select objects to flatten Düzleştir Şeffaflığı: düzleştirilecek nesneleri seçin +@msg Flatten Transparency: {e} Şeffaflığı Düzleştir: {e} +@msg Image Trace: the traced image is missing Görüntü İzleme: izlenen görüntü eksik +@msg Imported {what}: {_1} İthal {what}: {_1} +@msg Installed plug-in {_1} Yüklü eklenti {_1} +@msg Internal error: {msg} (please report this bug) Dahili hata: {msg} (lütfen bu hatayı bildirin) +@msg Live Paint Merge: select a Live Paint group and the paths to add Canlı Boyama Birleştirme: Canlı Boyama grubunu ve eklenecek yolları seçin +@msg Live Paint: select paths or compound paths Canlı Boyama: yolları veya bileşik yolları seçin +@msg Live Paint: these paths are too degenerate to paint Canlı Boyama: Bu yollar boyanamayacak kadar yozlaşmış +@msg Make Guides: select paths Kılavuzlar Oluşturun: yolları seçin +@msg No saved selections (Select → Save Selection…) Kaydedilmiş seçim yok (Seç → Seçimi Kaydet…) +@msg No saved views (View → New View…) Kaydedilmiş görünüm yok (Görünüm → Yeni Görünüm…) +@msg Offset Path: select paths Yolu Ofset: yolları seçin +@msg Offset Path: the offset leaves nothing of the selected paths (an inset larger than the shapes) Yolu Ofset: ofset, seçilen yollardan hiçbir şey bırakmaz (şekillerden daha büyük bir ek) +@msg Opened with {_1} note(s): {_2} {_1} notu/notları ile açıldı: {_2} +@msg Opened {url} {url} açıldı +@msg Outline Stroke: no stroked paths selected Anahat Konturu: konturlu yol seçilmedi +@msg PDF settings are not an object PDF ayarları bir nesne değildir +@msg PDF security settings are not an object PDF güvenlik ayarları bir nesne değildir +@msg PDF writer error: {0} PDF yazıcı hatası: {0} +@msg Packaged {n} file(s) in {folder} {note} {folder}{note} biçiminde paketlenmiş {n} dosyası/dosyaları +@msg Packaged {n} file(s){note} Paketlenmiş {n} dosyası/dosyaları{note} +@msg Preview in Browser needs the desktop app (the web downloads files instead) Tarayıcıdaki önizleme için masaüstü uygulaması gerekir (bunun yerine web, dosyaları indirir) +@msg Release Guides: no guides Sürüm Kılavuzları: kılavuz yok +@msg Release: no layer to put the rectangle in Serbest bırakma: dikdörtgeni yerleştirecek katman yok +@msg Replace Spine: select a blend and a path Omurgayı Değiştir: bir karışım ve yol seçin +@msg select two or more objects to distribute dağıtılacak iki veya daha fazla nesne seçin +@msg select paths or anchor points yolları veya bağlantı noktalarını seçin +@msg SVG parse error: {0} SVG ayrıştırma hatası: {0} +@msg Save for Web: the image is empty Web için Kaydet: resim boş +@msg Save writes {_1}, not {_2}: use document.export for other formats Kaydetme {_2} değil, {_1} yazar: diğer formatlar için document.export'u kullanın +@msg Saved recovery data for {title} {title} için kayıtlı kurtarma verileri +@msg Saved to User Defined: {_1} Kullanıcı Tanımlı'ya kaydedildi: {_1} +@msg Saved {path} {path} kaydedildi +@msg Select text or click into text with the Type tool to insert glyphs Glif eklemek için metni seçin veya Yazım aracıyla metne tıklayın +@msg Shape Builder: no region under the pointer Şekil Oluşturucu: işaretçinin altında bölge yok +@msg Shape Builder: select paths or compound paths Şekil Oluşturucu: yolları veya bileşik yolları seçin +@msg Show All Hidden Attributes: nothing is hidden Tüm Gizli Özellikleri Göster: hiçbir şey gizli değildir +@msg Split Into Grid: select paths large enough for the grid Izgaraya Böl: ızgara için yeterince büyük yollar seçin +@msg Targa files are RGB: colorModel rgb Targa dosyaları RGB'dir: colorModel rgb +@msg Text Wrap: select a wrap object Metin Sarma: bir sarma nesnesi seçin +@msg This shortcut file is in a form VectorCraft can't read. Bu kısayol dosyası VectorCraft'ın okuyamayacağı bir biçimdedir. +@msg `aspect` must be a number (%) "boyut" bir sayı olmalıdır (%) +@msg `at` must be [x, y] `at` [x, y] olmalıdır +@msg `color` must be a colour "renk" bir renk olmalıdır +@msg `command` must be a command whose menu item opens a dialog (see ui.menuDialog) `komut`, menü öğesi bir iletişim kutusu açan bir komut olmalıdır (bkz. ui.menuDialog) +@msg `freeform.points` must be an array "freeform.points" bir dizi olmalıdır +@msg `freeform.lines` must be an array "freeform.lines" bir dizi olmalıdır +@msg `gradient` must be an object "gradyan" bir nesne olmalıdır +@msg `library` is a library id or name (see {list}) 'kitaplık' bir kitaplık kimliği veya adıdır (bkz. {list}) +@msg `mode` applies to freeform gradients "mod" serbest biçimli degradeler için geçerlidir +@msg `sampleSize` must be 1, 3 or 5 "sampleSize" 1, 3 veya 5 olmalıdır +@msg `stops` must be an array "duraklar" bir dizi olmalıdır +@msg `svg` must be an object of SVG options (see document.formats) `svg`, SVG seçeneklerinin bir nesnesi olmalıdır (bkz. document.formats) +@msg `{_1}` is no save command `{_1}` kaydetme komutu değil +@msg `{area}` is no recovery area name `{area}`, kurtarma alanı adı değildir +@msg `{color}` is not a colour (#rrggbb or a preset name) `{color}` bir renk değildir (#rrggbb veya önceden ayarlanmış bir ad) +@msg `{id}` has no envelope dialog `{id}`de zarf iletişim kutusu yok +@msg `{keep}` is not a side of this axis `{keep}` bu eksenin bir tarafı değil +@msg `{key}` must be `auto` or a language code such as `en` or `zh-hant` "{key}", "auto" veya "en" veya "zh-hant" gibi bir dil kodu olmalıdır +@msg `{key}` must be a #rrggbb colour "{key}" #rrggbb rengi olmalıdır +@msg `{key}` must be a number "{key}" bir sayı olmalıdır +@msg `{key}` must be a string "{key}" bir dize olmalıdır +@msg `{key}` must be a whole number `{key}` bir tam sayı olmalıdır +@msg `{key}` must be a whole number between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında bir tam sayı olmalıdır ({x}'yi aldı) +@msg `{key}` must be between {min} and {max} (got {x}) `{key}`, {min} ile {max} arasında olmalıdır ({x}'yi aldı) +@msg `{key}` must be one of {_1} `{key}`, {_1}'den biri olmalıdır +@msg `{key}` must be true or false "{key}" doğru veya yanlış olmalıdır +@msg `mode` must be grayscale, rgb, hsb, cmyk or websafe "mod" gri tonlamalı, rgb, hsb, cmyk veya web uyumlu olmalıdır +@msg `{k}` must be [x, y] "{k}" [x, y] olmalıdır +@msg `{name}` has no options `{name}`nin seçeneği yok +@msg `{name}` is a built-in workspace; choose another name `{name}` yerleşik bir çalışma alanıdır; başka bir isim seç +@msg `{name}` is a process colour: tints apply to global and spot colours `{name}` bir proses renktir: renk tonları global ve spot renklere uygulanır +@msg `{name}` is no recovery entry name `{name}`, kurtarma girişi adı değil +@msg `{name}` isn't a colour swatch `{name}` bir renk örneği değil +@msg `{path}` must be a bool or an object of bools `{path}` bir bool veya bool nesnesi olmalıdır +@msg `{s}` is not a colour (#rrggbb or a preset name) `{s}` bir renk değildir (#rrggbb veya önceden ayarlanmış bir ad) +@msg `{tool}` isn't a Liquify tool `{tool}` bir Sıvılaştırma aracı değil +@msg `{tool}` isn't a freehand tool `{tool}` serbest bir araç değil +@msg `{to}` is not an available name `{to}` mevcut bir ad değil +@msg a PostScript error ({name} in `{op}`) PostScript hatası (`{op}`de {name}) +@msg a PostScript error ({name} in `{op}`), in {procs} {procs}'de PostScript hatası (`{op}`de {name}) +@msg a PostScript error ({name} in `{op}`: {at}) PostScript hatası (`{op}`de {name}: {at}) +@msg a PostScript error ({name} in `{op}`: {at}), in {procs} {procs} içinde PostScript hatası (`{op}` içinde {name}: {at}) +@msg a PostScript error ({name}) PostScript hatası ({name}) +@msg a bitmap is empty bir bitmap boş +@msg a bitmap is too large to read bir bitmap okunamayacak kadar büyük +@msg a colour index is 0 to {_1}, not {n} renk indeksi {n} değil, 0 ila {_1}'dir +@msg a colour is an index, #rrggbb or a preset name, not {v} renk {v} değil, bir dizin, #rrggbb veya önceden ayarlanmış bir addır +@msg a document needs at least one artboard bir belgenin en az bir çalışma yüzeyine ihtiyacı var +@msg a document needs at least one layer bir belgenin en az bir katmana ihtiyacı vardır +@msg a freeform gradient keeps at least one point serbest biçimli bir degrade en az bir noktayı korur +@msg a gradient needs at least two stops bir degradenin en az iki durağa ihtiyacı vardır +@msg a graphic style named `{to}` already exists `{to}` adlı bir grafik stili zaten mevcut +@msg a page is missing bir sayfa eksik +@msg a page out of range aralık dışında bir sayfa +@msg a page tree kid isn't a reference sayfa ağacı çocuğu bir referans değildir +@msg a page tree node has no kids sayfa ağacı düğümünün çocuğu yok +@msg a page's placement is not finite bir sayfanın yerleşimi sınırlı değildir +@msg a preset named `{_1}` exists `{_1}` adında bir ön ayar mevcut +@msg a slice size must be a positive number of points dilim boyutu pozitif sayıda nokta olmalıdır +@msg a stream runs past the end bir akış sonundan geçiyor +@msg a stream's length is wrong bir akışın uzunluğu yanlış +@msg a stream's length isn't a number bir akışın uzunluğu bir sayı değildir +@msg add a format to export dışa aktarılacak formatı ekleme +@msg already editing a pattern zaten bir desen düzenliyor +@msg an object can't be read bir nesne okunamıyor +@msg an object in perspective would leave the edited grid perspektifteki bir nesne düzenlenen ızgaradan ayrılır +@msg antiAlias `{id}`: none, art or type antiAlias `{id}`: yok, resim veya tür +@msg artboard {0} does not exist {0} çalışma yüzeyi mevcut değil +@msg at this scale the drawing is {w} × {h} pt, more than the canvas holds: choose a smaller scale or fit it to the artboard bu ölçekte çizim {w} × {h} nk, kanvasın sığabileceğinden daha fazladır: daha küçük bir ölçek seçin veya çalışma yüzeyine sığdırın +@msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` eksen "{v}" değil, "dikey", "yatay" veya derece cinsinden bir açı olmalıdır +@msg background {other}: transparent, white, black or a colour such as "#ff8800" arka plan {other}: şeffaf, beyaz, siyah veya "#ff8800" gibi bir renk +@msg bad color {v} kötü renk {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio 0,1 ile 10 arasında bir sayı olmalıdır +@msg the Color Picker is not open Renk Seçici açık değil +@msg the screen color sampler is already open ekran rengi örnekleyici zaten açık +@msg this system has no screen color sampler bu sistemin ekran rengi örnekleyicisi yok +@msg bad colour `{hex}` (#rrggbb) kötü renk `{hex}` (#rrggbb) +@msg bad page tree kötü sayfa ağacı +@msg bad shortcut kötü kısayol +@msg bad stream length Kötü akış uzunluğu +@msg bad trailer kötü römork +@msg balance must be between 0 and 100 bakiye 0 ile 100 arasında olmalıdır +@msg batch step {_1} (`{id}`) failed: {e} toplu adım {_1} (`{id}`) başarısız oldu: {e} +@msg brightness must be dark|mediumDark|mediumLight|light parlaklık koyu|ortaKoyu|ortaAçık|açık olmalıdır +@msg can't encode the document: {0} belge kodlanamıyor: {0} +@msg can't parse shortcut `{c}` `{c}` kısayolu ayrıştırılamıyor +@msg can't parse shortcut `{s}` for `{k}` `{k}` için `{s}` kısayolu ayrıştırılamıyor +@msg can't save version {0}: VectorCraft writes versions {MIN_VERSION} to {VERSION} {0} sürümü kaydedilemiyor: VectorCraft, {MIN_VERSION} sürümlerini {VERSION}'ye yazıyor +@msg can't write .{ext} images .{ext} görselleri yazılamıyor +@msg cannot distort a zero-size bounding box sıfır boyutlu bir sınırlayıcı kutuyu bozamaz +@msg cannot read PDF: {0} PDF okunamıyor: {0} +@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 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 +@msg choose a direction to divide in bölmek için bir yön seçin +@msg choose a folder to export to dışa aktarılacak bir klasör seçin +@msg choose a location for the package paket için bir yer seçin +@msg clipboard is empty pano boş +@msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}`, "#ff8800" gibi bir renk değil +@msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: en fazla {MAX_TABLE} renginde +@msg command `{0}` is not available right now: {1} `{0}` komutu şu anda kullanılamıyor: {1} +@msg compound paths can only contain paths bileşik yollar yalnızca yollar içerebilir +@msg compressed (RLE) bitmaps can't be read yet sıkıştırılmış (RLE) bitmapler henüz okunamıyor +@msg damaged hasarlı +@msg decimals must be {_1}–{_2} (got {_3}) ondalık sayılar {_1}–{_2} olmalıdır ({_3}'yi aldı) +@msg divide into 1 to {MAX_PIECES} slices 1 ila {MAX_PIECES} dilimlerine bölün +@msg effect {k} is `{_1}`, not `{id}` {k} efekti "{id}" değil "{_1}"dir +@msg empty brush stroke boş fırça darbesi +@msg empty cutter boş kesici +@msg empty path boş yol +@msg file version {0} is newer than this VectorCraft supports ({VERSION}) dosya sürümü {0}, VectorCraft'ın desteklediğinden daha yenidir ({VERSION}) +@msg freeform point {i} needs `at` [x, y] and a valid `color` serbest biçimli nokta {i}'nin "[x, y]'de" ve geçerli bir "renk"te olması gerekir +@msg freeform line {i} must be an array of point indices Serbest biçimli çizgi {i}, bir nokta indeks dizisi olmalıdır +@msg give both `start` and `end` [x, y] (or neither) hem "başlangıç" hem de "son" [x, y] verin (ya da ikisini de) +@msg give corners of the path: anchors where its sides meet at an angle yolun köşelerini verin: kenarlarının belli bir açıyla buluştuğu noktalara sabitleyin +@msg give horizontal or vertical yatay veya dikey ver +@msg give the path `id` and the width point `index` yol `id`sini ve genişlik noktası `index`i verin +@msg guides are locked kılavuzlar kilitli +@msg image '{key}' has no pixels and was left out '{key}' resminde piksel yok ve dışarıda bırakıldı +@msg image data `{_1}` is missing `{_1}` resim verisi eksik +@msg image would be too large; lower the resolution resim çok büyük olur; çözünürlüğü düşür +@msg index must be a number or first, previous, next or last dizin bir sayı veya ilk, önceki, sonraki veya son olmalıdır +@msg index must be a number or first, previous, next or last, not `{other}` dizin bir sayı veya ilk, önceki, sonraki veya son olmalıdır, "{other}" değil +@msg installing from a path is not available on the web; pass the module as `dataBase64` bir yoldan kurulum web'de mümkün değildir; modülü `dataBase64` olarak iletin +@msg internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug) `{cmd}`de dahili hata: {msg} (belge daha önce olduğu gibi bırakıldı; lütfen bu hatayı bildirin) +@msg internal error: {msg} (please report this bug) dahili hata: {msg} (lütfen bu hatayı bildirin) +@msg invalid PDF setting: {0} geçersiz PDF ayarı: {0} +@msg invalid image data for `{0}` `{0}` için geçersiz resim verileri +@msg invalid parameters for `{cmd}`: {msg} `{cmd}` için geçersiz parametreler: {msg} +@msg is not an image VectorCraft reads VectorCraft'ın okuduğu bir resim değil +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know VectorCraft'ın PostScript okuyucusunun bilmediği `{at}`yi kullanıyor +@msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know, in {procs} {procs}'de VectorCraft'ın PostScript okuyucusunun bilmediği `{at}`yi kullanıyor +@msg its Windows metafile preview was opened instead, which may simplify the art bunun yerine Windows meta dosyası önizlemesi açıldı, bu da işi basitleştirebilir +@msg its preview was placed instead, at screen resolution bunun yerine önizlemesi ekran çözünürlüğüne yerleştirildi +@msg join needs open paths katılmak için açık yollara ihtiyaç var +@msg lang must be one of {_1} dil {_1}'den biri olmalıdır +@msg layers can't be wrap objects katmanlar sarma nesneleri olamaz +@msg missing `folder` 'klasör' eksik +@msg missing `id` (see plugin.list) `kimlik` eksik (bkz. eklenti.liste) +@msg missing `name` "isim" eksik +@msg missing `tool` 'araç' eksik +@msg missing index eksik dizin +@msg New Window isn't available yet: a document opens in one window Yeni Pencere henüz kullanılamıyor: bir belge tek pencerede açılır +@msg name the file dosyayı adlandırın +@msg no CSS to copy: select objects with CSS of their own kopyalanacak CSS yok: kendilerine ait CSS'ye sahip nesneleri seçin +@msg no Live Paint edge near that point bu noktanın yakınında Canlı Boyama kenarı yok +@msg no Live Paint face at that point bu noktada Canlı Boyama yüzü yok +@msg no PNG encoder PNG kodlayıcı yok +@msg no active document etkin belge yok +@msg no active perspective plane aktif perspektif düzlemi yok +@msg no anchor points selected bağlantı noktası seçilmedi +@msg no app to open files here burada dosyaları açacak uygulama yok +@msg no artboard çalışma yüzeyi yok +@msg no artboard {i} (0-based; the document has {_1}) çalışma yüzeyi yok {i} (0 tabanlı; belgede {_1} var) +@msg no artboard {i} (0-based; the document has {count}) çalışma yüzeyi yok {i} (0 tabanlı; belgede {count} var) +@msg no artboards named adlandırılmış çalışma yüzeyi yok +@msg no artwork sanat eseri yok +@msg no blend options karışım seçeneği yok +@msg no bounds sınır yok +@msg no brush named `{name}` `{name}` adlı fırça yok +@msg no brush selected fırça seçilmedi +@msg no catalog katalog yok +@msg no changes since the last save son kaydetmeden bu yana değişiklik yok +@msg no clock to keep a heartbeat by kalp atışını tutacak saat yok +@msg no colour group `{g}` renk grubu yok `{g}` +@msg no cross-reference table çapraz referans tablosu yok +@msg no current layer geçerli katman yok +@msg no data veri yok +@msg no dialog open iletişim kutusu açık değil +@msg no document belge yok +@msg no document open hiçbir belge açık değil +@msg no effect applied yet henüz hiçbir efekt uygulanmadı +@msg no effect at index {k} {k} endeksinde etkisi yok +@msg no end record son kayıt yok +@msg no file manager here burada dosya yöneticisi yok +@msg no file reader dosya okuyucu yok +@msg no file system here burada dosya sistemi yok +@msg no file writer dosya yazarı yok +@msg no files to place yerleştirilecek dosya yok +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` bir VectorCraft belgesi, bir PDF ya da PDF ile uyumlu bir .ai dosyası değil +@msg no format to export to dışa aktarılacak format yok +@msg no free recovery area ücretsiz kurtarma alanı yok +@msg no gradient stop or freeform point is selected degrade durağı veya serbest biçim noktası seçili değil +@msg no graphic style `{name}` grafik stili yok `{name}` +@msg no graphic style `{n}` grafik stili yok `{n}` +@msg no interaction in progress herhangi bir etkileşim devam etmiyor +@msg no key object anahtar nesne yok +@msg no library `{key}` (see {list}) `{key}` kitaplığı yok (bkz. {list}) +@msg no native format yerel format yok +@msg no pages sayfa yok +@msg no pattern `{name}` "{name}" modeli yok +@msg no perspective grid preset named `{name}` `{name}` adında perspektif ızgarası ön ayarı yok +@msg no plug-in {0} is installed {0} eklentisi kurulu değil +@msg no plug-in {id} is installed {id} eklentisi kurulu değil +@msg no point {i} (the gradient has {_1}) {i} noktası yok (gradyan {_1}'ye sahiptir) +@msg no preset editor önceden ayarlanmış düzenleyici yok +@msg no previous transform önceki dönüşüm yok +@msg no print preset named `{base}` `{base}` adlı yazdırma ön ayarı yok +@msg no print preset named `{name}` `{name}` adlı yazdırma ön ayarı yok +@msg no print service baskı hizmeti yok +@msg no revert command geri alma komutu yok +@msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) `{name}` adında kayıtlı bir PDF hazır ayarı yok (yerleşik hazır ayarlar salt okunurdur: onlardan yeni bir tane başlatın) +@msg no saved presets kayıtlı ön ayar yok +@msg no saved print preset named `{name}` ({DEFAULT_PRESET} is protected: start a new one from it) `{name}` adında kayıtlı bir yazdırma ön ayarı yok ({DEFAULT_PRESET} korumalı: ondan yeni bir tane başlatın) +@msg no saved profiles kayıtlı profil yok +@msg no saved selection recalled kayıtlı seçim geri çağrılmadı +@msg no saved selections kayıtlı seçim yok +@msg no saved themes kayıtlı tema yok +@msg no segment {segment} on line {line} {line} hattında {segment} segmenti yok +@msg no selected slice in the image: select slices, or save all of them görüntüde seçili dilim yok: dilimleri seçin veya hepsini kaydedin +@msg no selection bounds seçim sınırı yok +@msg no slice selected dilim seçilmedi +@msg no such action öyle bir eylem yok +@msg no such anchor öyle bir çapa yok +@msg no such artboard böyle bir çalışma yüzeyi yok +@msg no such document öyle bir belge yok +@msg no such layer or object böyle bir katman veya nesne yok +@msg no such object {0} böyle bir nesne yok {0} +@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 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 +@msg no such user library böyle bir kullanıcı kütüphanesi yok +@msg no such view öyle bir görüş yok +@msg no such width point böyle bir genişlik noktası yok +@msg no swatch `{name}` renk örneği yok `{name}` +@msg no swatch library `{l}` (see swatch.library.list) renk örneği kitaplığı yok `{l}` (bkz. swatch.library.list) +@msg no symbol instances selected sembol örneği seçilmedi +@msg no target object has an opacity mask: select one or give `id` hiçbir hedef nesnenin opaklık maskesi yok: birini seçin veya "id" verin +@msg no threaded text selected dizili metin seçilmedi +@msg no user library folder here burada kullanıcı kitaplığı klasörü yok +@msg no user workspace `{from}` kullanıcı çalışma alanı yok `{from}` +@msg node {_1} is not text {_1} düğümü metin değil +@msg not a DXF drawing DXF çizimi değil +@msg not a VectorCraft file: {0} bir VectorCraft dosyası değil: {0} +@msg not a VectorCraft shortcut set (missing `overrides`) VectorCraft kısayol seti değil ("geçersiz kılmalar" eksik) +@msg not a graphic style library (format `{_1}`) grafik stili kitaplığı değil (`{_1}` biçimi) +@msg not a shortcut set: the text doesn't start with a slash name and an opening brace bir kısayol seti değil: metin eğik çizgi adı ve açılış paranteziyle başlamıyor +@msg not a stored central record saklanan merkezi bir kayıt değil +@msg not a valid plug-in module: {0} geçerli bir eklenti modülü değil: {0} +@msg not an EMF or WMF file EMF veya WMF dosyası değil +@msg not an RTF file: it doesn't start with {\\rtf bir RTF dosyası değil: {\\rtf ile başlamıyor +@msg not found bulunamadı +@msg not in pattern editing mode desen düzenleme modunda değil +@msg nothing encoded kodlanmış hiçbir şey yok +@msg nothing selected hiçbir şey seçilmedi +@msg nothing to align hizalanacak hiçbir şey yok +@msg nothing to distort çarpıtılacak hiçbir şey yok +@msg nothing to export: the document has no visible art (or turn on Use Artboards) dışa aktarılacak bir şey yok: belgede görünür resim yok (veya Çalışma Yüzeylerini Kullan'ı açın) +@msg nothing to preview önizlenecek bir şey yok +@msg nothing to print yazdırılacak bir şey yok +@msg nothing to redo yeniden yapılacak bir şey yok +@msg nothing to save: the document has no visible art kaydedilecek bir şey yok: belgede görünür resim yok +@msg nothing to undo geri alınacak hiçbir şey yok +@msg nothing to warp çarpıtılacak hiçbir şey yok +@msg nothing to wrap sarılacak bir şey yok +@msg object {0} cannot have children {0} nesnesinin çocuğu olamaz +@msg object {id} is not a path {id} nesnesi bir yol değil +@msg object {id} is not a selected top-level object {id} nesnesi seçilmiş bir üst düzey nesne değil +@msg page size {w} × {h} sayfa boyutu {w} × {h} +@msg page {0} does not exist (the PDF has {1}) sayfa {0} mevcut değil (PDF'de {1} var) +@msg paste unavailable yapıştırma kullanılamıyor +@msg path is already closed yol zaten kapalı +@msg paths contain invalid coordinates yollar geçersiz koordinatlar içeriyor +@msg paths: give at least one file yollar: en az bir dosya verin +@msg replace: the object to replace was changed or deleted değiştir: değiştirilecek nesne değiştirildi veya silindi +@msg pattern missing desen eksik +@msg patterns nested too deeply are filled with mid-grey çok derin iç içe geçmiş desenler orta gri renkle dolu +@msg percent must be above 0 and at most 6400, not {p} yüzde {p} değil, 0'ın üzerinde ve en fazla 6400 olmalıdır +@msg pick a colour, gradient or pattern to save as a swatch renk örneği olarak kaydetmek için bir renk, degrade veya desen seçin +@msg plane must be left, right or ground düzlem solda, sağda veya yerde olmalıdır +@msg plug-in ABI: {0} eklenti ABI: {0} +@msg plug-in exceeded its {0} eklenti {0} sınırını aştı +@msg plug-in failed: {0} eklenti başarısız oldu: {0} +@msg plug-in manifest: {0} eklenti bildirimi: {0} +@msg plug-in output: {0} eklenti çıkışı: {0} +@msg plug-in parameters: {0} eklenti parametreleri: {0} +@msg plug-in trapped: {0} eklenti sıkıştı: {0} +@msg print settings are not an object yazdırma ayarları bir nesne değil +@msg printing isn't available here yazdırma burada mevcut değil +@msg range `{s}` is empty `{s}` aralığı boş +@msg range `{s}`: `{part}` names no number aralık `{s}`: `{part}` adları sayı yok +@msg range `{s}`: `{part}` runs backwards aralık `{s}`: `{part}` geriye doğru çalışır +@msg range `{s}`: `{t}` is not a number from 1 to {count} aralık `{s}`: `{t}` 1'den {count}'ye kadar bir sayı değil +@msg recovery copy `{file}`: {e} kurtarma kopyası `{file}`: {e} +@msg resolutions must be between 1 and 2400 ppi çözünürlükler 1 ile 2400 ppi arasında olmalıdır +@msg result would exceed the canvas (coordinates out of range) sonuç tuvali aşacaktır (koordinatlar aralık dışındadır) +@msg objects can't be nested more than 32 deep nesneler 32'den fazla derinliğe yerleştirilemez +@msg the SVG's elements are nested more than {0} deep SVG'nin öğeleri {0} derinliğinden daha fazla yuvalanmıştır +@msg scale must be a number from {_1} to {_2}, not {_3} ölçek {_3} değil, {_1} ile {_2} arasında bir sayı olmalıdır +@msg scale must be a number from {_1} to {_2}, not {s} ölçek {s} değil, {_1} ile {_2} arasında bir sayı olmalıdır +@msg select (or target) objects whose appearance isn't basic Görünümü temel olmayan nesneleri seçin (veya hedefleyin) +@msg select a Live Paint group bir Canlı Boyama grubu seçin +@msg select a blend bir karışım seç +@msg select a clip group bir klip grubu seçin +@msg select a gradient mesh bir degrade ağı seçin +@msg select a linked image: an embedded one has no file bağlantılı bir görsel seçin: gömülü görselin dosyası yok +@msg select a repeat bir tekrar seç +@msg select an Intertwine group bir Intertwine grubu seçin +@msg select a stroke with a variable width değişken genişliğe sahip bir kontur seçin +@msg select a symbol instance bir sembol örneği seçin +@msg select an Image Trace object bir Görüntü İzleme nesnesi seçin +@msg could not decode image: {0} görüntünün kodu çözülemedi: {0} +@msg image is empty resim boş +@msg the image is {width} × {height} pixels, more than Image Trace takes ({max} megapixels) görüntü {width} × {height} pikseldir; Görüntü İzleme'nin aldığından daha fazladır ({max} megapiksel) +@msg this trace would make more than {max} anchor points, more than the document can show smoothly: raise Noise, lower Paths or Colors, or pick a lower-fidelity preset bu iz, {max} bağlantı noktalarından daha fazlasını, yani belgenin düzgün bir şekilde gösterebileceğinden daha fazlasını oluşturur: Gürültüyü artırın, Yolları veya Renkleri düşürün veya aslına uygunluğu daha düşük bir ön ayar seçin +@msg select an envelope bir zarf seç +@msg select an image bir resim seç +@msg select an object in perspective perspektifte bir nesne seç +@msg select artwork (or a colour group) to recolor yeniden renklendirilecek resmi (veya bir renk grubunu) seçin +@msg select at least two objects en az iki nesne seç +@msg select objects, not layers katmanları değil nesneleri seçin +@msg select one path, or give its `id` bir yol seçin veya "kimliğini" verin +@msg select paths to join katılmak için yolları seçin +@msg select the art to repeat tekrarlanacak resmi seçin +@msg select the artwork to warp çarpıtılacak resmi seçin +@msg select the objects to transform in perspective perspektifte dönüştürülecek nesneleri seçin +@msg select two or more graphic styles to merge birleştirmek için iki veya daha fazla grafik stili seçin +@msg select type in perspective perspektifte yazı seç +@msg selection has no bounds Seçimin sınırı yok +@msg shadings of an unknown type were left out bilinmeyen türdeki gölgeler dışarıda bırakıldı +@msg shadings of more than 4096 patches or triangles were left out 4096'dan fazla yamanın veya üçgenin gölgeleri dışarıda bırakıldı +@msg shadings whose functions are of an unknown type are filled with their middle colour İşlevi bilinmeyen tipteki gölgelikler orta renkle doldurulur +@msg shortcut for `{k}` must be a string "{k}" kısayolu bir dize olmalıdır +@msg stop {i} needs `offset` {i}'nin `ofset`e ihtiyacı var +@msg stop {i} needs a valid `color` stop {i}'nin geçerli bir "renk"e ihtiyacı var +@msg stop {i}: `{k}` must be a number {i}'yi durdur: `{k}` bir sayı olmalıdır +@msg the EMF header is damaged EMF başlığı hasarlı +@msg the PDF has no pages PDF'de sayfa yok +@msg the PDF is no longer loaded PDF artık yüklenmiyor +@msg the PDF is password-protected: give its password PDF şifre korumalıdır: şifresini verin +@msg the PDF password is wrong PDF şifresi yanlış +@msg the Save PDF dialog didn't open PDF'yi Kaydet iletişim kutusu açılmadı +@msg the WMF header is damaged WMF başlığı hasarlı +@msg the art could not be sampled sanat örneği alınamadı +@msg the art has no bounds sanatın sınırı yoktur +@msg the art is too light for any dots sanat herhangi bir nokta için çok hafif +@msg the artboard to fit must be a positive size up to {_1} pt sığacak çalışma yüzeyi {_1} pt'ye kadar pozitif boyutta olmalıdır +@msg the background worker stopped arka plan çalışanı durdu +@msg the compressed file unpacks to more than {_1} MB sıkıştırılmış dosya {_1} MB'den daha fazlasına açılıyor +@msg the document has never been saved belge hiç kaydedilmedi +@msg the document has no artboard belgede çalışma yüzeyi yok +@msg the document has no artboards belgede çalışma yüzeyi yok +@msg the document has no patterns belgede desen yok +@msg the document has no slices belgede dilim yok +@msg the document has no symbols belgede sembol yok +@msg the drawing has no layout `{_1}` (it has {_2}) çizimde `{_1}` düzeni yok ({_2} var) +@msg the drawing is no longer loaded çizim artık yüklenmiyor +@msg the drawing is too large to open çizim açılamayacak kadar büyük +@msg the export region is empty ihracat bölgesi boş +@msg the export region is not finite ihracat bölgesi sınırlı değil +@msg the export returned unreadable data dışa aktarma okunamayan veriler döndürdü +@msg the export wrote no file dışa aktarma hiçbir dosya yazmadı +@msg the file holds only a placeholder page, not its art (it was saved without PDF compatibility): save it again with PDF compatibility on, or as PDF or SVG dosya, resmini değil, yalnızca bir yer tutucu sayfayı içerir (PDF uyumluluğu olmadan kaydedilmiştir): PDF uyumluluğuyla veya PDF veya SVG olarak yeniden kaydedin +@msg the file was read up to an error ({why}): what follows it was left out dosya bir hataya kadar okundu ({why}): bundan sonra yazılanlar dışarıda bırakıldı +@msg the file's preview header points outside the file dosyanın önizleme başlığı dosyanın dışına işaret ediyor +@msg the image's pixels don't match its size resmin pikselleri boyutuyla eşleşmiyor +@msg the layer has no objects to clip by katmanda kırpılacak nesne yok +@msg the layer or group is locked katman veya grup kilitli +@msg the guide is on a hidden or locked layer kılavuz gizli veya kilitli bir katmandadır +@msg the target layer is locked or hidden hedef katman kilitli veya gizli +@msg the layer to flatten into is hidden düzleştirilecek katman gizli +@msg the object already has an opacity mask nesnenin zaten bir opaklık maskesi var +@msg the object has no geometry nesnenin geometrisi yoktur +@msg the object has no opacity mask nesnenin opaklık maskesi yok +@msg the object has no stroke nesnenin konturu yok +@msg the object is beyond the plane's horizon nesne uçağın ufkunun ötesinde +@msg the object isn't in perspective nesne perspektifte değil +@msg the object isn't on a perspective plane nesne perspektif düzleminde değil +@msg the object would cross the horizon of the perspective plane nesne perspektif düzleminin ufkunu geçecektir +@msg the objects would leave the grid nesneler ızgarayı terk ederdi +@msg the package came back damaged paket hasarlı olarak geri geldi +@msg the pointer is beyond the plane's horizon işaretçi uçağın ufkunun ötesinde +@msg the preview image is empty önizleme resmi boş +@msg the print job has no data yazdırma işinde veri yok +@msg the scale is not a usable number ölçek kullanılabilir bir sayı değil +@msg the selected stroke's profile is already listed as {_1} seçilen konturun profili zaten {_1} olarak listeleniyor +@msg the selection has no URL (or they differ) seçimin URL'si yok (veya farklılar) +@msg the selection isn't in perspective seçim perspektifte değil +@msg the stroke has no weight konturun kalınlığı yok +@msg the thumbnail image is malformed küçük resim görüntüsü bozuk +@msg the top object must be a path, compound path or text object to use as a clipping mask üstteki nesne, kırpma maskesi olarak kullanılacak bir yol, bileşik yol veya metin nesnesi olmalıdır +@msg the {tool} tool has no options {tool} aracının seçeneği yok +@msg there are no objects to release serbest bırakılacak hiçbir nesne yok +@msg there are no printer settings to open here burada açılacak yazıcı ayarı yok +@msg this PostScript file can't be read ({why}): save it as PDF or SVG in the app that made it bu PostScript dosyası okunamıyor ({why}): onu oluşturan uygulamada PDF veya SVG olarak kaydedin +@msg this file's PostScript couldn't be read ({why}): {what} bu dosyanın PostScript'i okunamadı ({why}): {what} +@msg this format holds one artboard: export several as PDF or with document.exportForScreens bu formatta bir çalışma yüzeyi bulunur: birkaçını PDF olarak veya document.exportForScreens ile dışa aktarın +@msg this is a DWG drawing, which can't be read: save it from the CAD app as an ASCII DXF and open that bu okunamayan bir DWG çizimidir: CAD uygulamasından ASCII DXF olarak kaydedin ve açın. +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), not a PDF: File › Open and File › Place read it as EPS bu bir PostScript dosyasıdır (eski bir formatta kaydedilmiş veya PDF uyumluluğu olmayan bir .ai veya bir EPS dosyası), PDF değil: Dosya › Aç ve Dosya › Yerleştir, EPS olarak oku +@msg this is not a PostScript file bu bir PostScript dosyası değil +@msg tool shortcuts must be a single key (optionally with Shift) araç kısayolları tek tuş olmalıdır (isteğe bağlı olarak Shift ile) +@msg truncated kesik +@msg unknown command `{0}` bilinmeyen komut `{0}` +@msg unknown effect `{id}` bilinmeyen etki `{id}` +@msg unknown format bilinmeyen format +@msg unknown format `{f}` (Save writes {_1}) bilinmeyen format `{f}` (Kaydet, {_1} yazar) +@msg unknown freeform mode `{m}` (points, lines) bilinmeyen serbest biçimli mod `{m}` (noktalar, çizgiler) +@msg unknown gradient kind `{k}` (linear, radial, freeform) bilinmeyen gradyan türü `{k}` (doğrusal, radyal, serbest biçimli) +@msg unknown highlight `{h}` bilinmeyen vurgu `{h}` +@msg unknown option `{path}.{k}` bilinmeyen seçenek '{path}.{k}' +@msg unknown overprints `{o}` (preserve, simulate or discard) bilinmeyen üst baskılar `{o}` (koru, benzet veya at) +@msg unknown panel `{raw}` bilinmeyen panel `{raw}` +@msg unknown preference `{key}` bilinmeyen tercih `{key}` +@msg unknown preset (one of {_1}) bilinmeyen ön ayar ({_1}'den biri) +@msg unknown preset `{name}` (high, medium, low or a saved one: see flattener.presets.list) bilinmeyen ön ayar `{name}` (yüksek, orta, düşük veya kaydedilmiş bir ayar: bkz. flattener.presets.list) +@msg unknown preset `{name}` (see flattener.presets.list) bilinmeyen ön ayar `{name}` (bkz. düzleştirici.presets.list) +@msg unknown preset `{name}` (see webExport.presets.list) bilinmeyen ön ayar `{name}` (bkz. webExport.presets.list) +@msg unknown side `{other}` (left|right|top|bottom) bilinmeyen taraf `{other}` (sol|sağ|üst|alt) +@msg unknown tool `{tool}` bilinmeyen araç `{tool}` +@msg unknown tool `{t}` bilinmeyen araç `{t}` +@msg unknown tool or command `{key}` bilinmeyen araç veya komut `{key}` +@msg unknown workspace (one of {_1}) bilinmeyen çalışma alanı ({_1}'den biri) +@msg unsupported: {0} desteklenmeyen: {0} +@msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files {0} sürümü dosyaları sıkıştırılamaz: yalnızca {COMPRESSED_SINCE} sürümü ve sonraki sürümler sıkıştırılmış dosyaları açar +@msg width and height are at least 1 pixel genişlik ve yükseklik en az 1 pikseldir +@msg workspace name can't be empty çalışma alanı adı boş olamaz +@msg {C}: no effect at index {from} {C}: {from} endeksinde etkisi yok +@msg {_1} failed: {e} {_1} başarısız oldu: {e} +@msg {_1} files can be opened but not written (see document.formats) {_1} dosyaları açılabilir ancak yazılamaz (bkz. document.formats) +@msg {_1} font families available {_1} yazı tipi aileleri mevcut +@msg {_1} is a live effect (Effect › Plug-ins) {_1} canlı bir efekttir (Efekt › Eklentiler) +@msg {_1} is already used by {_2} {_1} zaten {_2} tarafından kullanılıyor +@msg {_1} linked file(s) can't be read here: their previews show {_1} bağlantılı dosya(lar) burada okunamıyor: önizlemeleri gösteriliyor +@msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} bağlantılı görseller diskte değiştirildi: Güncelleme Bağlantıları yeni sürümleri gösteriyor +@msg Updated {_1} linked file(s) changed on disk Güncellenmiş {_1} bağlantılı dosya(lar) diskte değiştirildi +@msg {_1}: not saved, so not closed {_1}: kaydedilmedi, dolayısıyla kapatılmadı +@msg {bpp}-bit bitmaps can't be read {bpp} bit bit eşlemleri okunamıyor +@msg {command} didn't create an object {command} bir nesne yaratmadı +@msg {label} produced an empty result {label} boş bir sonuç üretti +@msg {label}: lost track of a shape's style {label}: şeklin stilinin izini kaybettirdi +@msg {label}: select paths {label}: yolları seç +@msg {label}: select paths, compound paths, groups or text {label}: yolları, bileşik yolları, grupları veya metni seçin +@msg {label}… {label}… +@msg {name}: CRC mismatch {name}: CRC uyumsuzluğu +@msg {name}: no local header {name}: yerel başlık yok +@msg {name}: no such recovery entry {name}: böyle bir kurtarma girişi yok +@msg {path} can't be found: relink it first {path} bulunamıyor: önce yeniden bağlayın +@msg {path}: no file system here (pass dataBase64 to open; omit the path to get dataBase64) {path}: burada dosya sistemi yok (açmak için dataBase64'ü iletin; dataBase64'ü alma yolunu atlayın) +@msg {path}: not a file {path}: dosya değil +@msg {path}: {e} {path}: {e} +@msg {status} ({e}) {status} ({e}) +@msg Couldn't place {name}: {e} {name} yerleştirilemedi: {e} +@msg Couldn't paste from the clipboard: {e} Panodan yapıştırılamadı: {e} +@msg Saved {path} with {_1} note(s): {first} {path}, {_1} notlarıyla kaydedildi: {first} +@msg Exported {what} with {_1} note(s): {first} {_1} notlarıyla birlikte dışa aktarılan {what}: {first} +@msg Exported {what} Dışa aktarılan {what} +@msg Saved {n} files beside {path} {path}'nin yanında kaydedilen {n} dosyaları +@msg Saving Kaydediliyor +@msg Saving {_1} {_1} kaydediliyor +@msg Exporting {_1} {_1}'yi dışa aktarma +@msg Packaging {_1} {_1} paketleniyor +@msg Placing {_1} {_1} yerleştiriliyor +@msg Saving recovery data for {_1} {_1} için kurtarma verileri kaydediliyor +@msg Discarded {_1} recovered document(s) Atılan {_1} kurtarılan belge(ler) +@msg Restored {_1} recovered document(s): save them to keep them Geri yüklenen {_1} kurtarılan belge(ler): saklamak için bunları kaydedin +@msg Embedded {n} image(s); {missing} missing: relink them first Gömülü {n} görselleri; {missing} eksik: önce onları yeniden bağlayın +@msg Embedded {n} image(s) Gömülü {n} görselleri +@msg Relinked {_1} image(s) Yeniden bağlanan {_1} görselleri +@msg Relinked {n} image(s) Yeniden bağlanan {n} görselleri +@msg Relinked {n} image(s); not in that folder: {_1} Yeniden bağlanan {n} görselleri; bu klasörde değil: {_1} +@msg Updated {_1} image(s) Güncellenmiş {_1} görselleri +@msg Unembedded to {_1} {_1}'ye gömülü değil +@msg Reset {_1} {_1}'yi sıfırla +@msg Sent to {_1} {_1}'ye gönderildi +@msg {_1} files {_1} dosyaları +@msg The graphics were lost and have been restarted Grafikler kayboldu ve yeniden başlatıldı +@msg The graphics processor tried first couldn't show the window, so VectorCraft started again on {summary} İlk önce denenen grafik işlemcisi pencereyi gösteremedi, bu yüzden VectorCraft {summary}'de yeniden başladı +@msg The last start of VectorCraft ended on {name} before its window appeared, so it started on {summary}. To try that graphics processor again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft'ın son başlangıcı, penceresi görünmeden önce {name}'de sona erdi, bu nedenle {summary}'de başladı. Bu grafik işlemciyi tekrar denemek için Tercihler › Performans › Grafik İşlemcisi seçeneğini değiştirin veya WGPU_BACKEND ayarıyla başlayın +@msg The last start of VectorCraft ended while it listed the graphics processors, so it started on {summary} with OpenGL alone. To try the other graphics backends again, change Preferences › Performance › Graphics Processor or start with WGPU_BACKEND set VectorCraft'ın son başlangıcı grafik işlemcilerini listelerken sona erdi ve {summary}'de yalnızca OpenGL ile başladı. Diğer grafik arka uçlarını tekrar denemek için Tercihler › Performans › Grafik İşlemci seçeneğini değiştirin veya WGPU_BACKEND ayarıyla başlayın +@msg Loading VectorCraft… VectorCraft Yükleniyor… +@msg VectorCraft failed to start: {e} VectorCraft başlatılamadı: {e} +@msg A browser with WebGPU or WebGL2 is required. WebGPU veya WebGL2'ye sahip bir tarayıcı gereklidir. +@msg The graphics were lost and couldn't be restarted. Grafikler kayboldu ve yeniden başlatılamadı. +@msg Your unsaved changes are kept in this browser: reload the page to get them back. Kaydedilmemiş değişiklikleriniz bu tarayıcıda tutuluyor: onları geri almak için sayfayı yeniden yükleyin. +@msg Reload the page to go on (changes not saved yet may be lost). Devam etmek için sayfayı yeniden yükleyin (henüz kaydedilmemiş değişiklikler kaybolabilir). +@msg tool (a tool id) is required araç (araç kimliği) gerekli +@msg {tool} isn't in a toolbar group of several tools {tool} çeşitli araçlardan oluşan bir araç çubuğu grubunda değil +@msg onto must name a panel floating in another group üzerine başka bir grupta yüzen bir panel adı verilmelidir +@msg below and above must name a panel floating in another group alt ve üst, başka bir grupta yüzen bir panele ad vermelidir +@msg give only one of onto, below and above üstüne, altına ve üstüne yalnızca birini verin +@msg column must be true or a number from 1 sütun doğru veya 1'den bir sayı olmalıdır +@msg x and y must both be numbers x ve y'nin her ikisi de sayı olmalıdır +@msg "{0}" is not a colour: write it as #rrggbb "{0}" bir renk değildir: #rrggbb olarak yazın +@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead bu düz renkli bir logo değildir (düz alanların %{_1}'si birkaç düz renkle eşleşir), dolayısıyla Düz Logo modu degradelerini yamalara dönüştürür: bunun yerine Renk modunu kullanın +@msg `{what}` closes nothing `{what}` hiçbir şeyi kapatmaz +@msg `{what}` closes something it didn't open `{what}` açmadığı bir şeyi kapatıyor +@msg a path has too many points bir yolun çok fazla noktası var +@msg it ends inside a layer bir katmanın içinde bitiyor +@msg it has art outside its layers katmanlarının dışında sanat var +@msg it has more than {MAX_NODES} objects {MAX_NODES}'den daha fazla nesneye sahip +@msg it has no layers katmanı yok +@msg it has too many values in a row arka arkaya çok fazla değer var +@msg it has {_1}, which VectorCraft doesn't read from it yet VectorCraft'ın henüz okumadığı {_1} var +@msg its objects are nested too deeply nesneleri çok derin bir şekilde iç içe geçmiş +@msg its section ending with `%{end}` doesn't end `%{end}` ile biten bölümü bitmiyor +@msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet çeşitli dolgulara veya konturlara, efektlere veya fırçalara sahip nesneler çizilmiş görünümleri olarak geldi ({_1}): görünümleri henüz düzenlenemez +@msg gradient “{name}” isn't defined in the file: a plain colour stands in for it “{name}” degradesi dosyada tanımlı değil: onun yerine düz bir renk geliyor +@msg an image VectorCraft can't read was left out VectorCraft'ın okuyamadığı bir resim dışarıda bırakıldı +@msg an image whose data is cut short was left out verileri kısa kesilen bir resim dışarıda bırakıldı +@msg linked pictures show the low-resolution preview saved in the file until their files are found: relink them in the Links panel bağlantılı resimler, dosyaları bulunana kadar dosyaya kaydedilen düşük çözünürlüklü önizlemeyi gösterir: bunları Bağlantılar panelinde yeniden bağlayın +@msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise çerçevelerdeki alan türü, daha uzun çerçevelerde gösterdiği yazı tipinin bir satırı için çok kısa ({_1}): bunların türü aksi halde gösterilmez +@msg its editing data doesn't say where its art is düzenleme verileri sanatın nerede olduğunu söylemiyor +@msg this file was saved without PDF compatibility, and its editing data can't be read ({why}) bu dosya PDF uyumluluğu olmadan kaydedildi ve düzenleme verileri okunamıyor ({why}) +@msg No fonts are missing in this document Bu belgede eksik yazı tipi yok +@msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft diğer uygulamaların veya sistemin klasörlerini aramaz: {path} +@msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft, Kullanıcılar'daki Paylaşılan veya Genel klasörü aramıyor: {path} +@msg {path} is not a folder {path} bir klasör değil +@msg the search couldn't start: try again in a moment arama başlatılamadı: birazdan tekrar deneyin +@msg searching a folder isn't available on the web Web'de klasör arama özelliği kullanılamıyor +@msg every search thread stopped on an internal error (please report this bug) her arama dizisi dahili bir hata nedeniyle durduruldu (lütfen bu hatayı bildirin) +@msg VectorCraft's Fonts folder isn't set here: the desktop app and vectorcraft-cli set it VectorCraft'ın Fonts klasörü burada ayarlanmamış: masaüstü uygulaması ve Vectorcraft-cli bunu ayarlıyor +@msg choose the font files to add eklenecek yazı tipi dosyalarını seçin +@msg {n} font file(s) are now in {folder} {n} yazı tipi dosyası/dosyaları artık {folder}'de +@msg {n} font file(s) are now in {folder}; {failed} couldn't be added: {reason} {n} yazı tipi dosyası/dosyaları artık {folder}'de; {failed} eklenemedi: {reason} +@msg {n} font(s) aren't available here: their text shows in another font {n} yazı tipleri burada mevcut değil: metinleri başka bir yazı tipinde gösteriliyor +@msg not a file dosya değil +@msg not a font file yazı tipi dosyası değil +@msg that document is no longer open bu belge artık açık değil +@msg larger than 256 MB 256 MB'tan büyük +@msg the files come to more than 1 GB: add fewer at a time dosyalar 1 GB'tan fazla geliyor: bir kerede daha azını ekleyin +@msg every numbered name for it is taken in the Fonts folder bunun için numaralandırılmış her ad Yazı Tipleri klasöründe alınır +@msg every numbered name for {name} is taken in the document's folder {name} için numaralandırılmış her ad belgenin klasöründe alınır +@msg No linked files to look for Aranacak bağlantılı dosya yok +@msg Relinked {n} image(s); {name} couldn't be relinked: {reason} Yeniden bağlanan {n} görselleri; {name} yeniden bağlanamadı: {reason} + Services Hizmetler + Hide VectorCraft VectorCraft'ı gizle + Zoom Yakınlaştır + Bring All to Front Herkesi Öne Çıkarın + Settings Ayarlar + Break Link Bağlantıyı Kes + On: one locked object showing the document, updated when it changes. Açık: Belgeyi gösteren, değiştiğinde güncellenen kilitli bir nesne. + Off: an editable copy of its art, embedded in this document. Kapalı: Bu belgeye yerleştirilmiş resmin düzenlenebilir bir kopyası. + Images: link to the file instead of only embedding it. Resimler: dosyayı yalnızca gömmek yerine dosyaya bağlantı verin. + VectorCraft documents: one locked object, updated when the file changes. VectorCraft belgeleri: Dosya değiştiğinde güncellenen tek bir kilitli nesne. + A file dialog is open: choose there first Bir dosya iletişim kutusu açık: önce orayı seçin + The document was closed while its file dialog was open Belge, dosya iletişim kutusu açıkken kapatıldı + The dialog was closed before its file dialog answered Dosya iletişim kutusu yanıtlanmadan iletişim kutusu kapatıldı + Shaper Şekillendirici + Expand Shaper Group Shaper Grubunu Genişlet + Release Shaper Group Şekillendirici Grubunu Yayınla + Select Shaper Face Şekillendirici Yüz'ü seçin + Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. Resimleri, renkleri ve metin stillerini belgelerde yeniden kullanmak için buraya sürükleyin. Kitaplıklar bu makinede depolanır; hesap gerekmez. + 1 Color Job 1 Renk İşi + 2 Color Job 2 Renkli İş + 3 Color Job 3 Renkli İş + CMYK Equivalents CMYK Eşdeğerleri + 3 x 3 Average 3 x 3 Ortalama + 5 x 5 Average 5 x 5 Ortalama + PDF File PDF Dosyası + CMYK profile in effect CMYK profili yürürlükte + Artboard Çalışma Yüzeyi + Constrain Sınırla + Exit Çıkış + Free Transform Serbest Dönüşüm + Perspective Distort Perspektif Deformasyonu + Insert Inline Symbol Satır İçi Sembol Ekle + ICF Top/Right ICF Üst/Sağ + ICF Bottom/Left ICF Alt/Sol +@msg `id` is the step reference {r}, which only `run` and `command_batch` resolve "id", yalnızca "run" ve "command_batch" çözümleyen {r} adım referansıdır +@msg `ids` must be an array of object ids, not {v} "kimlikler" {v} değil, bir nesne kimlikleri dizisi olmalıdır +@msg `corners` must be an array of anchor indices, not {v} "köşeler" {v} değil, bir bağlantı indeksleri dizisi olmalıdır +@msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: {i} adımının sonucunun böyle bir değeri yok (değişmez $ için $$ yazın) +@msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: bundan önce {i} adımı yok (adımlar 0'dan itibaren sayılır; $$ yerine $ yazın) +@msg object ids are non-negative integers, not {v} nesne kimlikleri {v} değil, negatif olmayan tam sayılardır +@msg automation filesystem access is not granted: read authority is absent: {path} otomasyon dosya sistemine erişim izni verilmedi: okuma yetkisi yok: {path} +@msg automation filesystem access is not granted: write authority is absent: {path} otomasyon dosya sistemine erişim izni verilmedi: yazma yetkisi yok: {path} +@msg automation path rejected: outside the read root {root}: {path} otomasyon yolu reddedildi: okuma kökünün dışında {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} otomasyon yolu reddedildi: okuma köklerinin dışında {roots}: {path} +@msg automation path rejected: outside the write root {root}: {path} otomasyon yolu reddedildi: yazma kökünün dışında {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} otomasyon yolu reddedildi: yazma köklerinin dışında {roots}: {path} +@msg automation path rejected: path is empty otomasyon yolu reddedildi: yol boş +@msg automation path rejected: a link that leads nowhere: {path} otomasyon yolu reddedildi: hiçbir yere gitmeyen bağlantı: {path} +@msg automation path rejected: cannot resolve {path}: {e} otomasyon yolu reddedildi: {path} çözülemiyor: {e} +@msg automation path rejected: parent traversal below a folder that doesn't exist: {path} otomasyon yolu reddedildi: var olmayan bir klasörün altında üst öğe geçişi: {path} +@msg automation path rejected: reserved device names are not allowed: {path} otomasyon yolu reddedildi: ayrılmış cihaz adlarına izin verilmiyor: {path} +@msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode Çok kanallı PSD dosyaları okunamıyor: RGB, CMYK veya Gri Tonlama modunda kaydedin +@msg not a PSD file PSD dosyası değil +@msg the PSD file has an unknown colour mode ({m}) PSD dosyasında bilinmeyen bir renk modu var ({m}) +@msg the PSD file's image data uses an unknown compression ({m}) PSD dosyasının görüntü verileri bilinmeyen bir sıkıştırma kullanıyor ({m}) +@msg the PSD image is too large to read ({_1} × {_2} pixels) PSD görüntüsü okunamayacak kadar büyük ({_1} × {_2} piksel) +@msg zipped 32-bit PSD image data with prediction can't be read Tahmin içeren sıkıştırılmış 32 bit PSD görüntü verileri okunamıyor +@msg {depth}-bit PSD files in this colour mode can't be read Bu renk modunda {depth}-bit PSD dosyaları okunamaz +@msg the PSD file is cut short PSD dosyası kısa kesildi +@msg the PSD file's header is damaged PSD dosyasının başlığı hasarlı +@msg the PSD file's image data can't be read: {e} PSD dosyasının görüntü verileri okunamıyor: {e} + Video video + De-Interlace… Taramasızlaştırma… + De-Interlace Taramasızlaştırma + NTSC Colors NTSC Renkleri + Eliminate Ortadan kaldır + Create New Fields by Şuna göre Yeni Alanlar Oluştur: + Odd Fields Tek Alanlar + Even Fields Çift Alanlar + Duplication Çoğaltma + Interpolation Enterpolasyon +@msg Imported library {name} İçe aktarılan kitaplık {name} + RGB: RGB: + CMYK: CMYK: + Texture Doku + Craquelure… Küçük çatlak… + Grain… Tahıl… + Mosaic Tiles… Mozaik Fayans… + Patchwork… yama işi… + Stained Glass… Vitray… + Texturizer… Dokulaştırıcı… + Craquelure Küçük çatlak + Grain Tahıl + Mosaic Tiles Mozaik Fayans + Patchwork yama işi + Stained Glass Vitray + Texturizer Dokulaştırıcı + Crack Spacing Çatlak Aralığı + Crack Depth Çatlak Derinliği + Crack Brightness Çatlak Parlaklığı + Intensity Yoğunluk + Grain Type Tahıl Türü + Tile Size Döşeme Boyutu + Grout Width Derz Genişliği + Lighten Grout Harcı hafifletmek + Square Size Kare Boyutu + Relief Rahatlama + Border Thickness Kenar Kalınlığı + Light Intensity Işık Yoğunluğu + Light Direction Işık Yönü + Sprinkles serpintiler + Clumped Topaklanmış + Contrasty Kontrastlı + Enlarged Büyütülmüş + Stippled noktalanmış + Speckle Benek + Brick Tuğla + Burlap Çuval bezi + Canvas Tuval + Sandstone Kumtaşı + Bottom Left Sol Alt + Top Left Sol Üst + Top Right Sağ Üst + Bottom Right Sağ Alt +effect Soft Yumuşak + Edge Kenar + System Title Bar Sistem Başlık Çubuğu + Value Axis Değer Ekseni + On Left Side Sol Tarafta + On Right Side Sağ Tarafta + On Both Sides Her İki Tarafta + Short Kısa + Full Width Tam Genişlik + Distort Deforme etmek + Diffuse Glow… Yaygın Parıltı… + Glass… Cam… + Ocean Ripple… Okyanus Dalgalanması… + Diffuse Glow Yaygın Parıltı + Glass Cam + Ocean Ripple Okyanus Dalgalanması + Graininess Taneciklilik + Glow Amount Işıma Miktarı + Clear Amount Tutarı Temizle + Ripple Size Dalgalanma Boyutu + Ripple Magnitude Dalgalanma Büyüklüğü + Blocks Bloklar + Frosted Buzlu + Tiny Lens Minik Mercek + Loaded Texture Yüklü Doku + Load Texture… Doku Yükle… + Texture Image Doku Görüntüsü + Rename Data Set Veri Kümesini Yeniden Adlandır + Rename Variable Değişkeni Yeniden Adlandır + Variable Options Değişken Seçenekler + Edit Data Set Veri Kümesini Düzenle + Define a variable first Önce bir değişken tanımlayın + Text Metin + Visibility Görünürlük + Activate Panel Paneli Etkinleştir + Change Panel Layout Panel Düzenini Değiştir + Close Panel Paneli Kapat + Move Panel Paneli Taşı + Group with Şununla grupla: + Resize panels Panelleri yeniden boyutlandır + Resize panel window Panel penceresini yeniden boyutlandır +@msg Invalid panel layout; restored the default dock Geçersiz panel düzeni; varsayılan dock'u geri yükledim +@msg action is required eylem gerekli +@msg anchor must name a registered panel çapa kayıtlı bir panele isim vermelidir +@msg before must name a panel önce bir panele isim vermeliyim +@msg docking action contains an unknown panel or an excessive split path yerleştirme eylemi bilinmeyen bir panel veya aşırı bölünmüş yol içeriyor +@msg group must be true or false grup doğru veya yanlış olmalıdır +@msg move requires a destination panel taşıma bir hedef paneli gerektirir +@msg move requires an existing destination panel taşıma mevcut bir hedef paneli gerektirir +@msg onto must name a floating panel üzerine kayan bir panel adı verilmelidir +@msg panel group is empty panel grubu boş +@msg panel must name a registered panel panel kayıtlı bir panele isim vermelidir +@msg saved panel layout is invalid; reset the workspace first kayıtlı panel düzeni geçersiz; önce çalışma alanını sıfırlayın +@msg zone must be a string bölge bir dize olmalıdır +@msg zone must be tab, center, left, right, top or bottom bölge sekme, orta, sol, sağ, üst veya alt olmalıdır +@msg {key} must be a finite coordinate {key} sonlu bir koordinat olmalıdır +@msg x and y must be provided together x ve y birlikte sağlanmalıdır +@msg invalid restored panel group geçersiz geri yüklenen panel grubu + Lighting Aydınlatma + Revolve angle Dönme açısı + Axis side Eksen tarafı + Rotation X Rotasyon X + Rotation Y Y Dönüşü + Rotation Z Z dönüşü + Shading Gölgeleme + Segments Segmentler + Show axis Ekseni göster + Reset all Tümünü sıfırla + Reset to default Varsayılana sıfırla + Reset all Revolve settings to their defaults. Tüm Revolve ayarlarını varsayılan değerlerine sıfırlayın. + Drag values or labels. Double-click a value to type. Değerleri veya etiketleri sürükleyin. Yazmak için bir değeri çift tıklayın. + Drag rings to rotate. Shift snaps to 15°. Döndürmek için halkaları sürükleyin. Kaydırma 15°'ye sabitlenir. + Drag the light, or its rings. Shift snaps to 15°. Işığı veya halkalarını sürükleyin. Kaydırma 15°'ye sabitlenir. + Keep visible surfaces only when expanding Görünür yüzeyleri yalnızca genişletirken koruyun + Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Görünümü Genişlet'te kapsanan geometriyi kırpın. Canlı önizleme tüm yüzeyi korur. Şeffaf boyalar ve görünürlük sınırlarını aşan yüzeyler tüm yüzleri korur. +lighting Light Işık + Accented Edges… Vurgulu Kenarlar… + Angled Strokes… Açılı Vuruşlar… + Crosshatch… Çapraz tarama… + Dark Strokes… Karanlık Vuruşlar… + Ink Outlines… Mürekkep Ana Hatları… + Spatter… Sıçrama… + Sprayed Strokes… Püskürtülmüş Vuruşlar… + Sumi-e… Sumi-e… + Accented Edges Vurgulu Kenarlar + Angled Strokes Açılı Vuruşlar + Crosshatch Çapraz tarama + Dark Strokes Karanlık Vuruşlar + Ink Outlines Mürekkep Ana Hatları + Spatter Sıçrama + Sprayed Strokes Püskürtülmüş Vuruşlar + Sumi-e Sumi-e + Direction Balance Yön Dengesi + Stroke Length Strok Uzunluğu + Sharpness Keskinlik + Strength Güç + Balance Bakiye + Black Intensity Siyah Yoğunluğu + White Intensity Beyaz Yoğunluğu + Dark Intensity Karanlık Yoğunluk + Spray Radius Püskürtme Yarıçapı + Stroke Direction Strok Yönü + Stroke Width Kontur Genişliği + Stroke Pressure Strok Basıncı + Right Diagonal Sağ Diyagonal + Left Diagonal Sol Çapraz + Vertical Guide Dikey Kılavuz + Horizontal Guide Yatay Kılavuz + Move Guide to Layer Kılavuzu Katmana Taşı +@msg invalid native panel set geçersiz yerel panel seti +@msg missing panel layout eksik panel düzeni +@msg unsupported native docking operation desteklenmeyen yerel yerleştirme işlemi + Linux Display Server Linux Görüntü Sunucusu + X11 (XWayland) X11 (XWayland) + Ruler Origin Cetvelin Kökeni + Reset Ruler Origin Cetvel Başlangıç Noktasını Sıfırla + Artistic Sanatsal + Film Grain… Film Tahıl… + Highlight Area Alanı Vurgula +@msg the compressed file unpacks to more than VectorCraft reads ({_1} MB of document, {_2} MB per image) sıkıştırılmış dosya, VectorCraft'ın okuduğundan daha fazlasına açılır (belgenin {_1} MB'ı, görüntü başına {_2} MB) +@msg the document is too large to save compressed ({0}): save it with Use Compression off belge sıkıştırılmış olarak kaydedilemeyecek kadar büyük ({0}): Sıkıştırmayı Kullan kapalı olarak kaydedin +@msg the file stopped being read (please report this bug) dosyanın okunması durduruldu (lütfen bu hatayı bildirin) + Concentric Dividers: Eşmerkezli Bölücüler: + Radial Dividers: Radyal Bölücüler: + Skew: Eğim: + Inside İçeride + Outside Dışarıda + Use Outside Rectangle As Frame Dış Dikdörtgeni Çerçeve Olarak Kullan + Create Compound Path From Ellipses Elipslerden Bileşik Yol Oluşturma + Fill Grid Izgarayı Doldur + Select two or more objects, or a group İki veya daha fazla nesneyi veya bir grubu seçin + Back Geri + Both sides Her iki taraf + Classic Klasik + Cove Koy + Depth Derinlik + Front Ön + Isometric Bottom İzometrik Alt + Isometric Left İzometrik Sol + Isometric Right İzometrik Sağ + Isometric Top İzometrik Üst + Off-Axis Back Eksen Dışı Geri + Off-Axis Bottom Eksen Dışı Alt + Off-Axis Front Eksen Dışı Ön + Off-Axis Left Eksen Dışı Sol + Off-Axis Right Eksen Dışı Sağ + Off-Axis Top Eksen Dışı Üst + Ogee Ogee + Reset all 3D settings to their defaults. Tüm 3D ayarlarını varsayılan değerlerine sıfırlayın. + Step Adım + Taper Konik + Twist Büküm + Volume Hacim +3d Cap Kapak +3d Solid Katı +3d Hollow İçi Boş +3d Bevel Eğim +3d Rounded Yuvarlatılmış +3d Space Boşluk + Bevel Shape Eğim Şekli + Bevel Inside Eğim İçi + Bevel both sides Her iki tarafa da eğim verin + Presets Ön ayarlar +@msg An object takes one 3D effect. Edit the existing effect's options, or remove it first. Bir nesne bir 3D efekti alır. Mevcut efektin seçeneklerini düzenleyin veya önce onu kaldırın. +@msg {label} currently requires a path or compound path. Convert type, images and groups to paths first. {label} şu anda bir yol veya bileşik yol gerektiriyor. Önce türü, görüntüleri ve grupları yollara dönüştürün. +@msg {label} requires a shape with at least two anchors {label} en az iki bağlantı noktasına sahip bir şekil gerektirir +@msg The shape is too complex for a 3D effect Şekli 3 boyutlu bir efekt için fazla karmaşık +@msg The 3D surface is too complex; simplify the shape 3 boyutlu yüzey çok karmaşık; şekli basitleştir + Transform Objects Nesneleri Dönüştür +@msg unknown parameter `{key}`; did you mean `{near}`? bilinmeyen parametre `{key}`; `{near}` mi demek istediniz? +@msg unknown parameter `{key}`; the command takes no parameters bilinmeyen parametre `{key}`; komut hiçbir parametre almaz +@msg unknown parameter `{key}`; the command takes: {keys} bilinmeyen parametre `{key}`; komut şunları alır: {keys} + 1 Layer 1 Katman + 1 bit 1 bit + 16 Colors 16 Renk + 16 bit 16 bit + 16 bits/pixel 16 bit/piksel + 2-Up 2'li + 24 bit 24 bit + 24 bits/pixel 24 bit/piksel + 256 Colors 256 Renk + 2K Cinema 2K Sinema + 2up 2yukarı + 3 Colors 3 Renk + 32 bit 32 bit + 32 bits/pixel 32 bit/piksel + 4 bit 4 bit + 4-Up 4'lü Yukarı + 4K Cinema 4K Sinema + 4K UHD 4K UHD + 4up 4'lü + 6 Colors 6 Renk + 8 Colors 8 Renk + 8 bit 8 bit + 8K UHD 8K UHD + @{login}: {prs}, {commits}, +{added} / −{deleted} lines (Δ {delta}), +{binary_added} / −{binary_deleted} binary assets, {first} – {last} @{login}: {prs}, {commits}, +{added} / −{deleted} hatları (Δ {delta}), +{binary_added} / −{binary_deleted} ikili varlıklar, {first} – {last} + A3 A3 + A4 A4 + A5 A5 + A6 A6 + Absolute Colorimetric Mutlak Kolorimetrik + Adaptive Uyarlanabilir + All Affected Objects Etkilenen Tüm Nesneler + All Layers Tüm Katmanlar + All Rasterized Regions Tüm Rasterleştirilmiş Bölgeler + All Slices Tüm Dilimler + All shortcuts reset to defaults. Tüm kısayollar varsayılanlara sıfırlanır. + Alt+Click Alt+Tıklama + Alt+Drag Alt+Sürükle + Analogous Benzer + Analogous 2 Benzer 2 + Anchors Çapalar + Any except extracting pages Sayfaların çıkarılması dışında herhangi biri + Area Type Alan Türü + Arrow Ok + Arrow (open) Ok (açık) + Art & Illustration Sanat ve İllüstrasyon + Art Optimized Sanat İçin Optimize Edilmiş + Art and Bright Colors Sanat ve Parlak Renkler + Ascent Yükseliş + Auto Size Otomatik Boyut + B4 B4 + B5 B5 + Bar Bar + Barbed Dikenli + Baseline (Standard) Temel (Standart) + Baseline Optimized Temel Optimize Edilmiş + Basic Temel + Bicubic Bikübik + Black & White Siyah Beyaz + Black Outline Siyah Anahat + Bloat şişkinlik + Bounding Box Sınırlayıcı Kutu + Bracket Braket + Branding Markalaşma + Brown Kahverengi + Business Card 3.5 × 2 in Kartvizit 3,5 × 2 inç + Business Card 85 × 55 mm Kartvizit 85×55 mm + Business Card 91 × 55 mm Kartvizit 91×55 mm + CRLF (Windows) CRLF (Windows) + Cap Height Kapak Yüksekliği + Chevron Şerit + Circle (open) Daire (açık) + Click, then press the new shortcut (Esc cancels) Tıklayın, ardından yeni kısayola basın (Esc iptal edilir) + Clip Group Klip Grubu + Color Table (4 colors) Renk Tablosu (4 renk) + Command Komut + Commenting, filling in form fields and signing Yorum yapma, form alanlarını doldurma ve imzalama + Complementary Tamamlayıcı + Complementary 2 Tamamlayıcı 2 + Complete Tamamlandı + Composite Kompozit + Compound Bileşik + Compound 2 Bileşik 2 + Concave içbükey + Convert to Global Global'e Dönüştür + Convert to destination Hedefe dönüştür + Convert to destination (preserve numbers) Hedefe dönüştür (numaraları koru) + Cool Harika + Copyright and Contact Info Telif Hakkı ve İletişim Bilgileri + Copyrighted Telif hakkıyla korunuyor + Cross Çapraz + Current Colors Güncel Renkler + Current Colors (0) Güncel Renkler (0) + Custom Scale Özel Ölçek + DWG (use DXF) DWG (DXF kullanın) + Default Graphic Style Varsayılan Grafik Stili + Diamond (open) Elmas (açık) + Differences from VectorCraft Default: VectorCraft Varsayılanından Farklar: + Differences from [Default]: [Varsayılan]'dan farkları: + Diffusion Difüzyon + Direct Selection Doğrudan Seçim + Distance Mesafe + Distance: Mesafe: + Do Not Scale Ölçeklendirme + Don't include profiles Profilleri ekleme + Dot Gain 15% Nokta Kazanımı %15 + Dot on Bar Çubuktaki Nokta + Double Arrow Çift Ok + Double Bar Çift Çubuk + Double Slash Çift Eğik Çizgi + Down (right reading) Aşağı (sağ okuma) + Drop Bırak + Envelope Zarf + Envelope #10 Zarf #10 + Envelope DL Zarf DL + Exact tam + Expand 1 Selected 1 Seçileni Genişlet + Expand Object Nesneyi Genişlet + Expanded Patterns Genişletilmiş Desenler + Feather tüy + Filling in form fields and signing Form alanlarını doldurma ve imzalama + Film & Video Film ve Video + First Baseline İlk Temel + Fit to Page Sayfaya Sığdır + Fork Çatal + Freeform Serbest biçimli + GIF, 128 colours GIF, 128 renk + Go to Conflict Çatışmaya git + Graphic Styles (4) Grafik Stilleri (4) + Gray Gri + Gray Gamma 2.2 Gri Gama 2.2 + Greeting Card Tebrik Kartı + Grey Gri + Grid Repeat Izgara Tekrarı + Group Selection Grup Seçimi + Guide Rehber + Gutter Oluk + Gutter: Oluk: + HDTV 1080p HDTV1080p + HDTV 720p HDTV720p + HTML and Images HTML ve Görseller + Half Arrow (left) Yarım Ok (sol) + Half Arrow (right) Yarım Ok (sağ) + Half Circle Yarım Daire + Hand El + Harmony Rules Uyum Kuralları + Heavy Ink Ağır Mürekkep + Hexagon Altıgen + Hexagon (open) Altıgen (açık) + High Contrast Yüksek Kontrast + High Contrast 2 Yüksek Kontrast 2 + High Contrast 3 Yüksek Kontrast 3 + High Fidelity Photo Yüksek Kaliteli Fotoğraf + High Quality Print Yüksek Kaliteli Baskı + High Resolution Yüksek Çözünürlük + High resolution Yüksek çözünürlük + Image Trace (Make and Expand) Görüntü İzleme (Yap ve Genişlet) + Images Only Yalnızca Görseller + Imported. Press OK to keep the imported set. İthal. İçe aktarılan seti korumak için Tamam'a basın. + Include all profiles Tüm profilleri dahil et + Include destination profile Hedef profilini dahil et + Include tagged source profiles Etiketli kaynak profillerini dahil et + Inserting, deleting and rotating pages Sayfa ekleme, silme ve döndürme + Joins: Katılma: + Justify İki Yana Yasla + Keeps the document editable: VectorCraft reopens the PDF with nothing lost. For files you'll edit again. Belgeyi düzenlenebilir tutar: VectorCraft, PDF'yi hiçbir şey kaybolmadan yeniden açar. Dosyalar için tekrar düzenleme yapacaksınız. + Keyboard Shortcuts Klavye Kısayolları + Kite Uçurtma + LF (macOS, Linux) LF (macOS, Linux) + Lab Spot Colors Laboratuvar Spot Renkleri + Landscape (flipped) Manzara (ters çevrilmiş) + Language Level 2 Dil Seviyesi 2 + Language Level 3 Dil Seviyesi 3 + Lasso Kement + Leaf Yaprak + Left Complement Sol Tamamlayıcı + Legal Yasal + Length Uzunluk + Length: Uzunluk: + Letterhead A4 Antetli Kağıt A4 + Letterhead Letter Antetli Mektup + Line Art Hat sanatı + Linear Doğrusal + Logo 1000×1000 logo 1000×1000 + Low Fidelity Photo Düşük Kaliteli Fotoğraf + Low Resolution Düşük Çözünürlük + Low resolution (150 ppi) Düşük çözünürlük (150 ppi) + Make Text Wrap Metin Sarma Yap + Maximize Editability Düzenlenebilirliği En Üst Düzeye Çıkarın + Measure Ölçü + Medium Resolution Orta Çözünürlük + Menu Commands Menü Komutları + Mesh ağ + Minimum Asgari + Mirror Repeat Ayna Tekrarı + Mobile Mobil + Monochromatic Tek renkli + Monochrome Tek renkli + Muted Sessize alındı + Negative Negatif + No Dither Titreşim Yok + No Slices Dilim Yok + No conversion Dönüşüm yok + No downsampling Alt örnekleme yok + None (Color Preview) Yok (Renk Önizleme) + Orange Turuncu + Outlined Strokes Anahatlı Konturlar + Outlined Text Özetlenen Metin + Oval oval + PDF 1.7 PDF1.7 + PDF/X-1a:2001 PDF/X-1a:2001 + PDF/X-3:2002 PDF/X-3:2002 + PDF/X-4:2010 PDF/X-4:2010 + PRs PR'ler + Page Cover 1640×624 Sayfa Kapağı 1640×624 + Part of ArtCraft. MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Icons: Lucide (ISC) + VectorCraft. ArtCraft'ın bir parçası. MIT VEYA Apache-2.0. Yazı Tipleri: Source Sans 3, Inter, JetBrains Mono, Noto Sans SC (OFL). Simgeler: Lucide (ISC) + VectorCraft. + Pattern Swatches Desen Örnekleri + Pen Kalem + Pentagram Pentagram + Perceptual algısal + Phone 360×800 Telefon 360×800 + Phone 375×667 Telefon 375×667 + Phone 375×812 Telefon 375×812 + Phone 393×852 Telefon 393×852 + Phone 412×915 Telefon 412×915 + Phone 430×932 Telefon 430×932 + Photo Low Fidelity Fotoğrafın Düşük Doğruluğu + Pink Pembe + Pixel Piksel + Placed Document Yerleştirilen Belge + Plus Artı + Points: Punto: + Portrait (flipped) Portre (ters çevrilmiş) + Positive Olumlu + Poster 18 × 24 in Poster 18 × 24 inç + Poster 24 × 36 in Poster 24 × 36 inç + Preserve Koru + Preserve Appearance Görünümü Koru + Preserve Tints Renk Tonlarını Koru + Preset {number} Ön ayarlı {number} + Press Quality Baskı Kalitesi + Press keys… Tuşlara basın… + Print & Proof Baskı ve Prova + Printing & Proofing Baskı ve Prova + Profile Cover 1500×500 Profil Kapağı 1500×500 + Public Domain Kamu Alanı + Pucker Büzülme + Purple Mor + RECENT (1) SON (1) + RGB RGB + Radial Repeat Radyal Tekrar + Rasterized Complex Regions Rasterleştirilmiş Karmaşık Bölgeler + Relative Colorimetric Bağıl Kolorimetrik + Release Image Trace Görüntü İzlemeyi Serbest Bırak + Release Text Wrap Metin Sarmayı Serbest Bırak + Remove the shortcut Kısayolu kaldır + Reset Automation Otomasyonu Sıfırla + Reset Essentials Classic Essentials Classic'i sıfırla + Reset Layout Düzeni Sıfırla + Reset Painting Boyamayı Sıfırla + Reset Printing and Proofing Yazdırma ve Prova İşlemini Sıfırla + Reset Tracing İzlemeyi Sıfırla + Reset Typography Tipografiyi Sıfırla + Reset Web Web'i Sıfırla + Reset to Defaults Varsayılanlara Sıfırla + Restricted Colors Kısıtlı Renkler + Reverse Triangle Ters Üçgen + Right Complement Sağ Tamamlayıcı + Roman Roma + Rotate View Görünümü Döndür + Run Length Koşu Uzunluğu + SVG 1.1 SVG1.1 + Save Selection Seçimi Kaydet + Scale Tints Renk Tonlarını Ölçeklendir + Scale X Ölçek X + Scale X: Ölçek X: + Scale Y Y Ölçeği + Scale Y: Ölçek Y: + Scallop tarak + Scissors Makas + Selected Slices Seçilen Dilimler + Selected Styles Only (0) Yalnızca Seçilen Stiller (0) + Selected Swatches Only (0) Yalnızca Seçilen Renk Örnekleri (0) + Selective seçici + Separations Ayrılıklar + Set: Ayarla: + Shades Gölgeler + Shades of Gray Grinin Tonları + Shear: Kesme: + Shift+Click Shift+Tıklama + Shift+Drag Shift+Sürükle + Shortcut Kısayol + Shrink Text to Fit Metni Sığacak Şekilde Küçült + Silhouettes Siluetler + Simplified Chinese Basitleştirilmiş Çince + Simulate Simülasyon + Simulate prints overprints as Overprint Preview shows them; separations always keep them. A preset flattens transparency before printing (PostScript files always flatten it). Üst Baskı Önizlemesi'nin gösterdiği şekilde baskı üst baskılarını simüle edin; ayrılıklar onları hep korur. Bir ön ayar, yazdırmadan önce şeffaflığı düzleştirir (PostScript dosyaları her zaman düzleştirir). + Sketched Art Eskiz Sanatı + Slash Eğik çizgi + Smallest File Size En Küçük Dosya Boyutu + Social Sosyal + Social Landscape Post 1200×630 Sosyal Peyzaj Gönderisi 1200×630 + Social Portrait Post 1080×1350 Sosyal Portre Gönderisi 1080×1350 + Social Story 1080×1920 Sosyal Hikaye 1080×1920 + Social Tall Post 1000×1500 Sosyal Uzun Mesaj 1000×1500 + Sort by Hue Ton'a göre sırala + Sort by Luminance Parlaklığa göre sırala + Sort by Popularity Popülerliğe Göre Sırala + Spectrum Spektrum + Split Complementary Bölünmüş Tamamlayıcı + Spot Colors (0) Nokta Renkleri (0) + Square (open) Kare (açık) + Square 8 × 8 in Kare 8 × 8 inç + Subsample Alt örnek + Subset Alt küme + Sunshine Güneş ışığı + Swallowtail Kırlangıç kuyruğu + Symbol Sembol + TIFF (8-bit Color) TIFF (8 bit Renkli) + TIFF (Black & White) TIFF (Siyah Beyaz) + Tablet 1024×1366 Tablet 1024×1366 + Tablet 1024×768 Tablet 1024×768 + Tablet 768×1024 Tablet 768×1024 + Tablet 800×1280 Tablet 800×1280 + Tablet 820×1180 Tablet 820×1180 + Tablet 834×1194 Tablet 834×1194 + Tabloid Tabloid + Tag Etiket + Tag (open) Etiket (açık) + Technical Drawing Teknik Çizim + Tetrad dörtlü + Tetrad 2 dörtlü 2 + Tetrad 3 dörtlü 3 + Text Displayed in Cell Hücrede Görüntülenen Metin + Tile Imageable Areas Görüntülenebilir Alanları Döşeme + Tool Araç + Touch Type Dokunma Türü + Transparent Objects Şeffaf Nesneler + Triad üçlü + Triad 2 Üçlü 2 + Triad 3 Üçlü 3 + Triangle Üçgen + Triangle (open) Üçgen (açık) + Triangle to Bar Üçgenden Çubuğa + True Colors Gerçek Renkler + Twirl Burgu + Type Optimized Tür Optimize Edildi + UTF-16 (Unicode) UTF-16 (Unicode) + Unknown Bilinmiyor + Unsorted Sıralanmamış + Up (right reading) Yukarı (sağ okuma) + Use Default Varsayılanı Kullan + Variation Varyasyon + VectorCraft Default VectorCraft Varsayılanı + VectorCraft Defaults VectorCraft Varsayılanları + VectorCraft Generic CMYK (SWOP-like) VectorCraft Genel CMYK (SWOP benzeri) + VectorCraft Swatches (.vcswatches) VectorCraft Renk Örnekleri (.vcswatches) + Vertical Align Dikey Hizalama + Vertical Area Type Dikey Alan Türü + Vertical Type Dikey Tip + Vertical Type on a Path Yola Dikey Yazım + Video 1920×1080 Video 1920×1080 + Video 3840×2160 Video 3840×2160 + Video Banner 2560×1440 Video Banner'ı 2560×1440 + Video Thumbnail 1280×720 Video Küçük Resmi 1280×720 + Visible & Printable Layers Görünür ve Yazdırılabilir Katmanlar + Visible Layers Görünür Katmanlar + Vivid Canlı + Warm Sıcak + Watch 184×224 184×224 izle + Watch 198×242 198×242 izle + Web 1024×768 İnternet 1024×768 + Web 1280×720 İnternet 1280×720 + Web 1280×800 İnternet 1280×800 + Web 1366×768 İnternet 1366×768 + Web 1440×900 İnternet 1440×900 + Web 1600×900 İnternet 1600×900 + WebP WebP + Working RGB Çalışan RGB + Wrinkle Kırışıklık + X: X: + Y: Y: + [Custom] [Özel] + [Default] [Varsayılan] + [High Quality Print] [Yüksek Kaliteli Baskı] + [High Resolution] [Yüksek Çözünürlük] + [Low Resolution] [Düşük Çözünürlük] + [Medium Resolution] [Orta Çözünürlük] + [PDF/X-1a:2001] [PDF/X-1a:2001] + [PDF/X-3:2002] [PDF/X-3:2002] + [PDF/X-4:2010] [PDF/X-4:2010] + [Press Quality] [Basın Kalitesi] + [Smallest File Size] [En Küçük Dosya Boyutu] + [VectorCraft Default] [VectorCraft Varsayılanı] + of arasında + sRGB IEC61966-2.1 sRGB IEC61966-2.1 + x Height x Yükseklik + ▾ Default Actions ▾ Varsayılan Eylemler + ⚠ {chord} was already used by {names} — it has been removed there. ⚠ {chord} zaten {names} tarafından kullanılmıştı — oradan kaldırıldı. +@id file.close Kapat +@id pathfinder.add Birleştir +@msg give corners of the path: anchors between two straight sides yolun köşelerini verin: iki düz kenar arasındaki çapalar +@msg this kind of brush has no options dialog yet bu tür bir fırçanın henüz seçenekler iletişim kutusu yok +@plural {n} PR|{n} PRs {n} PR +@plural {n} commit|{n} commits {n} commit +@plural {n} shortcut is assigned more than once|{n} shortcuts are assigned more than once {n} kısayol birden fazla kez atanmış +diagnostics render işleme +diagnostics ui arayüz +effect Horizontal Yatay +effect Points Noktalar +effect Regular Düzenli +effect Vertical Dikey +file-filter All placeable files Yerleştirilebilir tüm dosyalar +file-filter All readable files Okunabilir tüm dosyalar +file-filter Flattener presets Düzleştirici önayarları +file-filter PDF presets PDF önayarları +file-filter PDF-compatible .ai PDF ile uyumlu .ai +file-filter Print presets Baskı önayarları +file-filter SVG Compressed Sıkıştırılmış SVG +file-filter Swatch libraries Renk örneği kitaplıkları +file-filter Templates Şablonlar +file-filter VectorCraft Template VectorCraft Şablonu +gradient-kind Radial Radyal +mark-style Japanese Japon +theme Appearance Görünüm + Load EPS into Clipboard EPS'yi Panoya Yükle +@msg an image of {w} × {h} pixels is too large to draw {w} × {h} piksellik bir görüntü çizilemeyecek kadar büyük +@msg the image can't be read as a texture görüntü doku olarak okunamıyor + Fill and Stroke Controls Dolgu ve Kontur Kontrolleri + Coloring Controls Renklendirme Kontrolleri + Draw Mode Controls Çizim Modu Kontrolleri + Screen Mode Controls Ekran Modu Kontrolleri + Drawer menu Çekmece menüsü + Toolbar: Show Controls Araç Çubuğu: Kontrolleri Göster +@msg control must be fillStroke, coloring, drawMode or screenMode kontrol fillStroke, colouring, DrawMode veya screenMode olmalıdır +@msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, colouring,drawMode veya screenMode +@msg give a fraction `t` from 0 to 1 0'dan 1'e kadar bir `t` kesri verin +@msg give the path `id` `id` yolunu ver +@msg invalid stroke widths geçersiz kontur genişlikleri +@msg object has no weighted stroke nesnenin ağırlıklı konturu yok + Single Corner Radius Tek Köşe Yarıçapı + Top Left: Sol Üst: + Top Right: Sağ Üst: + Bottom Left: Sol Alt: + Bottom Right: Sağ Alt: + Softness Yumuşaklık + Ambient Light Ortam Işığı + Light behind the object Nesnenin arkasındaki ışık + Registration: Kayıt: + Dimension Boyut + Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : Gerçek boyut — 1:10 etiketler sayfada 10 adet olarak 1 adettir. + Preview (1 inch on the page) Önizleme (sayfada 1 inç) + Set as Default Varsayılan Olarak Ayarla + out from a highlighted edge to place its dimension | boyutunu yerleştirmek için vurgulanan kenardan dışarı | + cancels | Double-click the tool for options iptal | Seçenekler için araca çift tıklayın + Angular Açısal + Away from the Line Çizgiden Uzakta + Between Line and Object Çizgi ile Nesne Arasında + Drag out from a segment, or click two points, then click to place. Bir parçadan dışarı sürükleyin veya iki noktayı tıklayın, ardından yerleştirmek için tıklayın. + Drag out from a corner, or click two straight lines, then click to place. Bir köşeden dışarı sürükleyin veya iki düz çizgiyi tıklayın, ardından yerleştirmek için tıklayın. + Drag out from a circle or arc for its radius, inwards for its diameter. Yarıçapı için bir daire veya yaydan dışarı, çapı için içeriye doğru sürükleyin. +@msg Choose a circle or a perfect arc Bir daire veya mükemmel bir yay seçin +@msg Choose a precision from 0 to 6 0'dan 6'ya kadar bir hassasiyet seçin + Create Dimension Boyut Oluştur + Dimension Tool Boyut Aracı + Dimension Tool Options Ölçümlendirme Aracı Seçenekleri + Display Ekran + Font Family: Yazı Tipi Ailesi: + Font Style: Yazı Tipi Stili: + Precision: Hassasiyet: + Update Dimensions Boyutları Güncelle +@msg Angles require two straight segments Açılar iki düz parça gerektirir +@msg Choose two different points İki farklı nokta seçin +@msg Choose two non-parallel straight segments Paralel olmayan iki düz parça seçin +@msg Choose two nonzero angle arms Sıfır olmayan iki açılı kol seçin +@msg Dimension point is outside the canvas Boyut noktası tuvalin dışında +@msg Dimension size is outside its supported range Boyut boyutu desteklenen aralığın dışında +@msg First measured segment no longer exists İlk ölçülen segment artık mevcut değil +@msg Invalid dimension distance Geçersiz boyut mesafesi +@msg Invalid dimension font name Geçersiz boyut yazı tipi adı +@msg Invalid drawing scale Geçersiz çizim ölçeği +@msg Invalid real scale Geçersiz gerçek ölçek +@msg Measured anchor no longer exists Ölçülen çapa artık mevcut değil +@msg Measured segment no longer exists Ölçülen segment artık mevcut değil +@msg Non-finite dimension Sonlu olmayan boyut +@msg Scale must be drawing:real, for example 1:10 Ölçek çizim olmalıdır:gerçek, örneğin 1:10 +@msg Scale ratio must be between 1:1000000 and 1000000:1 Ölçek oranı 1:1000000 ile 1000000:1 arasında olmalıdır +@msg Second measured segment no longer exists Ölçülen ikinci segment artık mevcut değil +@msg Unknown dimension mode Bilinmeyen boyut modu +@msg Unknown dimension option {k} Bilinmeyen boyut seçeneği {k} +@msg Unknown dimension style Bilinmeyen boyut stili +@msg Unknown unit Bilinmeyen birim + Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Açık profillerin dairesel uçlarını kapatın. Kısmi süpürmeler kesik taraflarını açık tutar. +@msg span must be "object" or "selection", got {v} yayılma "nesne" veya "seçim" olmalıdır, {v} var +@msg span: "selection" places a `gradient`, not a swatch yayılma: "seçim" bir renk örneği değil, bir "degrade" yerleştirir +@msg span: "selection" places a gradient: give `gradient` span: "selection" bir degrade yerleştirir: "gradient" verir +@msg span: "selection" places a linear or radial gradient, not a freeform one yayılma: "seçim" serbest biçimli bir degrade değil, doğrusal veya radyal bir degrade yerleştirir +@msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" degradeyi kendisi yerleştirir: "start" ve "end"i dışarıda bırakın (bunu zaten belge koordinatlarına yerleştirdiler) + Make Image Dynamic Görüntüyü Dinamik Hale Getirin + Import Data Sets… Veri Kümelerini İçe Aktar… + Export Data Sets… Veri Kümelerini Dışa Aktarma… + Make Graph Dynamic Grafiği Dinamik Hale Getirin + Batch… Toplu… + Source Folder: Kaynak Klasörü: + Destination Folder: Hedef Klasör: + Overrides Geçersiz kılmalar + Uses every data set of the open document. Açık belgenin her veri kümesini kullanır. +@msg Batch: {count} processed Toplu iş: {count} işlenmiş +@msg Batch: {count} processed, {failed} failed Toplu iş: {count} işlendi, {failed} başarısız oldu +@msg choose a destination folder bir hedef klasör seçin +@msg choose a source folder bir kaynak klasör seçin +@msg open a document with data sets veri kümeleri içeren bir belge açın +@msg the action has no steps eylemin hiçbir adımı yok + Accent Color Vurgu Rengi + Amber kehribar + Coral Mercan + Violet Menekşe + Sketch Eskiz + Halftone Pattern… Noktalı Resim Deseni… + Halftone Pattern Noktalı Resim Deseni + Pattern Type Desen Türü + Dot Nokta + Color Scheme Renk Şeması + Modern Çağdaş + Export Type: İhracat Türü: + Movie Clip Film Klibi +@msg name is empty isim boş +@msg no symbol named `{n}` `{n}` adında bir sembol yok + Artboard Options: edit with the Artboard tool Çalışma Yüzeyi Seçenekleri: Çalışma Yüzeyi aracıyla düzenleme +@msg this is a PostScript file (an .ai saved in an older format or without PDF compatibility, or an EPS file), which can't be opened yet: save it as PDF, PDF-compatible .ai or SVG bu henüz açılamayan bir PostScript dosyasıdır (eski bir formatta kaydedilmiş veya PDF uyumluluğu olmayan bir .ai veya bir EPS dosyası): PDF, PDF uyumlu .ai veya SVG olarak kaydedin + Add Event Etkinlik Ekle + Add JavaScript File JavaScript Dosyası Ekle + Apply Data Set Veri Kümesini Uygula + Cancel Pattern Editing Desen Düzenlemeyi İptal Et + Change to Artboard Rulers Çalışma Yüzeyi Cetvellerine Geç + Chop Design Tasarımı Kırp + Clear Events Etkinlikleri Temizle + Close Gaps With Paths Boşlukları Yollarla Kapat + Color wheel Renk tekerleği + Column Type: Sütun Türü: + Connect Bağla + Copy Appearance Görünümü Kopyala + Copy Guide Kılavuzu Kopyala + Copy in Perspective Perspektifte Kopyala + Create Slice from Selection Seçimden Dilim Oluştur + Create Slices from Guides Kılavuzlardan Dilimler Oluştur + Cutout Kesme + Cutout… Kesme… + Delete All Slices Tüm Dilimleri Sil + Delete Data Sets Veri Kümelerini Sil + Delete Graphic Styles Grafik Stillerini Sil + Delete Guide Kılavuzu Sil + Delete Guides Kılavuzları Sil + Delete Slice Dilimi Sil + Delete Style Stili Sil + Delete Variables Değişkenleri Sil + Delete View Görünümü Sil + Done Editing Pattern Desen Düzenlemeyi Bitir + Duplicate Rows Satırları Çoğalt + Each Design Represents: Her Tasarımın Temsil Ettiği: + Edge Fidelity Kenar Doğruluğu + Edge Intensity Kenar Yoğunluğu + Edge Simplicity Kenar Sadeliği + Edge Thickness Kenar Kalınlığı + Edit Perspective Grid Perspektif Izgarasını Düzenle + Enable Guides for 9-Slice Scaling 9 Dilimli Ölçekleme İçin Kılavuzları Etkinleştir + Event: Etkinlik: + Expand Blend Karışımı Genişlet + Expand Envelope Zarfı Genişlet + Expand Image Trace Görüntü İzlemeyi Genişlet + Expand Live Paint Canlı Boyamayı Genişlet + File or URL: Dosya veya URL: + First Artboard İlk Çalışma Yüzeyi + For Fractions: Kesirler İçin: + Gap Detection Boşluk Algılama + Gap Options Boşluk Seçenekleri + Gap Preview Color: Boşluk Önizleme Rengi: + Glow Brightness Parıltı Parlaklığı + Glow Size Parıltı Boyutu + Graph Column Grafik Sütunu + JavaScript Files JavaScript Dosyaları + JavaScript: JavaScript: + Large Gaps Büyük Boşluklar + Last Artboard Son Çalışma Yüzeyi + Levels Düzeyler + Library Panel Options Kütüphane Paneli Seçenekleri + Link JavaScript Files… JavaScript Dosyalarını Bağla… + Live Shape Canlı Şekil + Lock Variables Değişkenleri Kilitle + Make Blend Karışım Yap + Make Dynamic Dinamik Yap + Make Intertwine İç İçe Geçir + Make Live Paint Canlı Boyama Yap + Make Pattern Desen Yap + Make Slice Dilim Yap + Medium Gaps Orta Boşluklar + Merge Live Paint Canlı Boyamayı Birleştir + Mesh Point Color Ağ Noktası Rengi + Move Envelope Point Zarf Noktasını Taşı + Move Plane and Copy Objects Düzlemi Taşı ve Nesneleri Kopyala + Move Plane and Objects Düzlemi ve Nesneleri Taşı + Move Slice Dilimi Taşı + Move Type on a Path Yol Üzerindeki Yazıyı Taşı + Neon Glow Neon Parıltısı + Neon Glow… Neon Parıltısı… + New Artboard Options… Yeni Çalışma Yüzeyi Seçenekleri… + New Guide Yeni Kılavuz + Next Artboard Sonraki Çalışma Yüzeyi + Paint Stops At: Boyanın Durduğu Yer: + Persistent Kalıcı + Place from Library Kütüphaneden Yerleştir + Poster Edges Poster Kenarları + Poster Edges… Poster Kenarları… + Posterization Posterleştirme + Previous Artboard Önceki Çalışma Yüzeyi + Rearrange Artboards Çalışma Yüzeylerini Yeniden Düzenle + Release Blend Karışımı Serbest Bırak + Release Envelope Zarfı Serbest Bırak + Release Intertwine İç İçe Geçirmeyi Serbest Bırak + Release Live Paint Canlı Boyamayı Serbest Bırak + Release Repeat Yinelemeyi Serbest Bırak + Release Slice Dilimi Serbest Bırak + Release Threaded Text Bağlantılı Metni Serbest Bırak + Remove Color Profile… Renk Profilini Kaldır… + Remove JavaScript File JavaScript Dosyasını Kaldır + Remove Selected Entry Seçili Girişi Kaldır + Rename Design Tasarımı Yeniden Adlandır + Rename Style Stili Yeniden Adlandır + Rename View Görünümü Yeniden Adlandır + Reorder Appearance Görünümü Yeniden Sırala + Reorder Artboards Çalışma Yüzeylerini Yeniden Sırala + Repeating Yinelenen + Reshape Spine Omurgayı Yeniden Şekillendir + Resize Yeniden Boyutlandır + Resize Artboard Çalışma Yüzeyini Yeniden Boyutlandır + Resize Slice Dilimi Yeniden Boyutlandır + Resize Type Area Yazı Alanını Yeniden Boyutlandır + Scale Design Tasarımı Ölçekle + Scale in Perspective Perspektifte Ölçekle + Shape Builder Delete Şekil Oluşturucu Silme + Sliding Kayar + Small Gaps Küçük Boşluklar + Sort Swatches Renk Örneklerini Sırala + Step up or down Bir adım yukarı veya aşağı + Symbol Screener Sembol Saydamlaştırıcı + Symbol Scruncher Sembol Sıkıştırıcı + Symbol Shifter Sembol Kaydırıcı + Symbol Sizer Sembol Boyutlandırıcı + Symbol Spinner Sembol Döndürücü + Symbol Stainer Sembol Renklendirici + Symbol Styler Sembol Stil Verici + Text Orientation Metin Yönü + The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Kayıt panelindeki ad “{typed}” idi; VectorCraft dosya türünün uzantısını ekledi. Dosyayı değiştirmek içeriğinin üzerine yazar. + Thread Text Metni Bağla + Trackpad Gesture to Rotate View Görünümü Döndürmek İçin İzleme Dörtgeni Hareketi + Typing Yazma + Uniformly Scaled Tekdüze Ölçeklenmiş + Unlock Variables Değişkenlerin Kilidini Aç + Variable Font Değişken Yazı Tipi + Vertically Scaled Dikey Ölçeklenmiş + “{name}” already exists. Replace it? “{name}” zaten var. Değiştirilsin mi? +@msg Persistent libraries not found: {files} Kalıcı kütüphaneler bulunamadı: {files} +@msg Save writes {_1}, not {_2}: use file.saveAsTemplate to write it Kaydet {_1} yazar, {_2} değil: yazmak için file.saveAsTemplate kullanın +@msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` bir kütüphane paneli değil (bkz. window.swatchLibrary) +@msg `{key}` must be a #rrggbb color `{key}` bir #rrggbb rengi olmalıdır +@msg a key object sits on that point bu noktanın üzerinde bir anahtar nesne bulunuyor +@msg a linked image can't become a gradient mesh: embed it first bağlantılı bir görüntü degrade ağına dönüştürülemez: önce görüntüyü gömün +@msg a mask has more than one art block bir maskede birden fazla çizim bloğu var +@msg a spine keeps at least two points bir omurga en az iki nokta barındırır +@msg cancelled; nothing was changed iptal edildi; hiçbir şey değiştirilmedi +@msg handle must be in or out handle in veya out olmalıdır +@msg index must be ≥ 0 dizin ≥ 0 olmalıdır +@msg library panel `{given}` isn't open `{given}` kütüphane paneli açık değil +@msg mask art ends outside a mask dictionary maske çizimi bir maske sözlüğünün dışında bitiyor +@msg missing number `{key}` `{key}` sayısı eksik +@msg no such spine point böyle bir omurga noktası yok +@msg no such spine segment böyle bir omurga segmenti yok +@msg not a blend bir karışım değil +@msg select a blend or give `id` bir karışım seçin veya `id` belirtin +@msg t, dx and dy must be finite t, dx ve dy sonlu olmalıdır +@msg the blend has no spine karışımın omurgası yok +@msg the image has no usable size or position görüntünün kullanılabilir bir boyutu veya konumu yok +@msg the image's pixels can't be read görüntünün pikselleri okunamıyor +@msg the spine has no segments omurganın segmenti yok +@msg too many library panels were opened in this session bu oturumda çok fazla kütüphane paneli açıldı +@msg variables are locked değişkenler kilitli +@msg x and y must be finite x ve y sonlu olmalıdır +@msg aiVersion 7 writes legacy .ai files; a .ait template is always PDF-compatible aiVersion 7 eski .ai dosyalarını yazar; bir .ait şablonu her zaman PDF uyumludur +@msg Edit Original opens image and document files only; “{name}” is not one Özgünü Düzenle yalnızca görüntü ve belge dosyalarını açar; “{name}” bunlardan biri değil +@msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page düzenleme verileri {w} × {h} pt boyutunda bir sayfa içindir, dosyanın {pw} × {ph} pt sayfası için değil +@msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} düzenleme verileri {w} × {h} pt boyutunda bir sayfa içindir, dosyanın {scale} ölçeğindeki {pw} × {ph} pt sayfası +@msg no history to clear temizlenecek geçmiş yok +@msg only http, https and mailto URLs open in the browser, not “{url}” tarayıcıda yalnızca http, https ve mailto URL'leri açılır, “{url}” açılmaz +@msg {_1} files are written by file.saveAsTemplate {_1} dosyaları file.saveAsTemplate tarafından yazılır +@msg {id} opens another app: only the person at the keyboard can run it {id} başka bir uygulamayı açar: yalnızca klavye başındaki kişi çalıştırabilir +@msg “{name}” is not a file “{name}” bir dosya değil +@msg `{key}` must be a number, not {v} `{key}` bir sayı olmalıdır, {v} değil +@msg missing integer `{key}` `{key}` tam sayısı eksik +@msg {key} must be an array of non-negative integer object ids {key}, negatif olmayan tam sayı nesne kimlikleri dizisi olmalıdır +@msg invalid object id {value}: expected a non-negative integer geçersiz nesne kimliği {value}: negatif olmayan bir tam sayı bekleniyordu +@msg no such object id {value} böyle bir nesne kimliği yok: {value} +@msg {key} must be Unix seconds (an integer) or null {key}, Unix saniyesi (bir tam sayı) veya null olmalıdır +@msg its zlib compression is damaged zlib sıkıştırması hasarlı +@msg its Zstandard compression is damaged Zstandard sıkıştırması hasarlı +@msg it decompresses to more than {_1} MB açıldığında {_1} MB'tan fazla alan kaplıyor +@msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them dosyanın değişkenleri ve veri kümeleri okunmadı (değişkenler: {variables}, veri kümeleri: {data_sets}): Değişkenler paneli bunları listelemiyor +@msg nothing in data set {name} was applied: no object is bound to a variable it has a value for {name} veri kümesinden hiçbir şey uygulanmadı: değer içerdiği bir değişkene bağlı hiçbir nesne yok +@msg select an Objects on Path group bir Yol Üzerindeki Nesneler grubu seçin +@msg the Simplify slider is not open Basitleştirme kaydırıcısı açık değil +@msg {_1} is an exporter (File › Export › Export As…) {_1} bir dışa aktarma eklentisidir (Dosya › Dışa Aktar › Farklı Dışa Aktar…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bayt (sınır {MAX_DOCUMENT_DATA}) + Alt key toggles to Smooth Tool Alt tuşu Düzleştirme Aracına geçiş yapar + Auto-Simplify Otomatik Basitleştir + Drag an item here, or right-click one, to put it in this group. Bu gruba eklemek için bir öğeyi buraya sürükleyin veya sağ tıklayın. + Drop here to take it out of its group Grup dışına çıkarmak için buraya bırakın + Locate Selected Objects Seçili Nesneleri Bul + Open this dialog with these settings next time Bu iletişim kutusunu bir sonraki seferde bu ayarlarla aç + Open this setting's help page Bu ayarın yardım sayfasını aç + Overlap Örtüşme + Select Fills Dolguları Seç + Select Strokes Konturları Seç + Snap to Unit Birim Değerine Yasla + The plug-in writes the whole document. Eklenti tüm belgeyi yazar. + Attach İliştir + Attach Objects on Path Yol Üzerindeki Nesneleri İliştir + Bas Relief Alçak Rölyef + Bas Relief… Alçak Rölyef… + Chalk & Charcoal Tebeşir ve Kömür + Chalk & Charcoal… Tebeşir ve Kömür… + Charcoal Kömür Kalem + Charcoal… Kömür Kalem… + Chrome Krom + Chrome… Krom… + Expand Objects on Path Yol Üzerindeki Nesneleri Genişlet + Graphic Pen Grafik Kalemi + Graphic Pen… Grafik Kalemi… + Move Anchor Point Bağlantı Noktasını Taşı + Move Library Group Kitaplık Grubunu Taşı + Note Paper Not Kâğıdı + Note Paper… Not Kâğıdı… + Objects on Path Yol Üzerindeki Nesneler + Objects on Path Options Yol Üzerindeki Nesneler Seçenekleri + Objects on Path Tool Yol Üzerindeki Nesneler Aracı + Photocopy Fotokopi + Photocopy… Fotokopi… + Plaster Alçı + Plaster… Alçı… + Plastic Wrap Streç Film + Plastic Wrap… Streç Film… + Reticulation Ağlanma + Reticulation… Ağlanma… + Smudge Stick Dağıtma Çubuğu + Smudge Stick… Dağıtma Çubuğu… + Sponge Sünger + Sponge… Sünger… + Stamp Damga + Stamp… Damga… + Watercolor Sulu Boya + Watercolor… Sulu Boya… +@msg Scale Text Attributes: selected type has no scale to bake Metin Niteliklerini Ölçeklendir: seçili metinde uygulanacak bir ölçek yok +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} bir kıl fırça adı en fazla {max} karakter içerebilir: `{name}` {count} karakter içeriyor +@msg the artboard is locked çalışma yüzeyi kilitli +@msg brushes move only within their type fırçalar yalnızca kendi türleri içinde taşınabilir +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects bileşik yol, karışım, zarf, yineleme veya Girişme grubu içindeki bir yol nesne alamaz +@msg a clipping path can't take objects kırpma yolu nesne alamaz +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path karışım, zarf, yineleme veya Girişme grubu içindeki nesneler bir yola iliştirilemez +@msg a clipping path can't be attached to a path kırpma yolu bir yola iliştirilemez +@msg the program ends a dictionary it didn't begin program başlamadığı bir sözlüğü sonlandırıyor +@msg the program opens a file, which the reader can't do program dosya açıyor ancak okuyucu bunu yapamaz +@msg the program restores a saved state that was already restored program zaten geri yüklenmiş bir kayıtlı durumu geri yüklüyor +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none programın okuduğu veriler hasarlı veya kesilmiş ya da program dosyada bulunmayan bir konumu istiyor +@msg the program goes past one of the reader's limits program okuyucunun sınırlarından birini aşıyor +@msg the path has no point to continue from yolun devam edeceği bir nokta yok +@msg a value is outside the range the operator takes bir değer işlecin kabul ettiği aralığın dışında +@msg the operator needs more values than the stack holds işleç yığının içerdiğinden daha fazla değere ihtiyaç duyuyor +@msg part of the program isn't valid PostScript text programın bir kısmı geçerli bir PostScript metni değil +@msg a value has the wrong type for the operator bir değer işleç için yanlış türe sahip +@msg the program runs, deletes or renames a file, which the reader can't do program bir dosyayı çalıştırıyor, siliyor veya yeniden adlandırıyor ancak okuyucu bunu yapamaz +@msg the program asks for a resource that isn't defined program tanımlanmamış bir kaynak istiyor +@msg a calculation has no result, as in a division by zero sıfıra bölmede olduğu gibi bir hesaplamanın sonucu yok +@msg the operator looks for a mark on the stack and finds none işleç yığında bir işaret arıyor ancak bulamıyor + Art Brush Resim Fırçası + Artwork Çizim + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Siyah ve beyaz olduğu gibi kalır. Koyu renkler kontur renginin tonları, açık renkler ise açık tonları haline gelir. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Siyah kontur rengi olur ve beyaz beyaz kalır. Diğer renkler açıklıklarına göre kontur renginin açık tonları haline gelir. + Bottom-Justified Tab Aşağı Hizalı Sekme + Bristle Brush Kıl Fırça + Calligraphic Brush Kaligrafi Fırçası + Colorization Tips Renklendirme İpuçları + Consider Open Filled Paths as Closed Açık Dolgulu Yolları Kapalı Kabul Et + Cursor Swatch Preview İmleç Renk Örneği Önizlemesi + Diffuse Yayılma + Eyedropper: click the art to sample the key color Damlalık: anahtar rengi örneklemek için çizime tıklayın + Highlight: Vurgu: + In Merge Mode, Clicking Stroke Splits the Path Birleştirme modunda kontura tıklamak yolu böler + Insert Kashidas Keşide Ekle + Load Variable Library… Değişken Kitaplığı Yükle… + Long Uzun + New Brush Yeni Fırça + Pattern Brush Desen Fırçası + Pick Color From: Rengi Buradan Al: + Scatter Brush Serpme Fırçası + Select art to make a scatter, art or pattern brush. Bir serpme, resim veya desen fırçası oluşturmak için çizim seçin. + Selection: Seçim: + Standard Standart + Straight Line Düz Çizgi + Stroke When Editable Düzenlenebilir olduğunda kontur + Stylistic Biçimsel + The art keeps its own colors. Çizim kendi renklerini korur. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Anahtar renk kontur rengi olur. Diğer renkler aynı miktarda renk tonu kaydırır ve doygunluk ile parlaklıklarını korur. + The samples show this brush on a blue stroke. Örnekler bu fırçayı mavi bir kontur üzerinde gösterir. + Tips İpuçları + Top-Justified Tab Yukarı Hizalı Sekme + on the canvas to create one ( tuval üzerinde bir tane oluşturmak için ( + selects several, or draws one inside an artboard) birkaç tanesini seçer veya bir çalışma yüzeyi içinde çizer) + 3D Lighting Preset 3D Aydınlatma Hazır Ayarı + Brush Art Fırça Çizimi + Colored Pencil… Kuru Boya… + Colored Pencil Kuru Boya + Dark Rough Koyu Pürüzlü + Dry Brush… Kuru Fırça… + Dry Brush Kuru Fırça + Hard Mix Sert Karışım + Hide Live Paint Gaps Canlı Boyama Boşluklarını Gizle + Light Rough Açık Pürüzlü + Load Variable Library Değişken Kitaplığı Yükle + Locate Library… Kitaplığı Bul… + Move Brush Fırçayı Taşı + New Without Dialog İletişim Kutusu Olmadan Yeni + Paint Daubs… Boya Lekeleri… + Paint Daubs Boya Lekeleri + Palette Knife… Palet Bıçağı… + Palette Knife Palet Bıçağı + Rough Pastels… Pürüzlü Pasteller… + Rough Pastels Pürüzlü Pasteller + Ruby… Ruby… + Ruby Ruby + Scale Text Attributes Metin Niteliklerini Ölçeklendir + Show Live Paint Gaps Canlı Boyama Boşluklarını Göster + Simple Basit + Soft Mix Yumuşak Karışım + Sparkle Parıltı + Underpainting… Astar Boyama… + Underpainting Astar Boyama + Water Paper… Islak Kâğıt… + Water Paper Islak Kâğıt + Wide Blurry Geniş Bulanık + Wide Sharp Geniş Keskin +@msg a PostScript error ({name} in `{op}`; {meaning}) PostScript hatası (`{op}` içinde {name}; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} {procs} içinde PostScript hatası (`{op}` içinde {name}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript hatası (`{op}` içinde {name}: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} {procs} içinde PostScript hatası (`{op}` içinde {name}: {at}; {meaning}) +@msg a PostScript error ({name}; {meaning}) PostScript hatası ({name}; {meaning}) + Character Direction Karakter Yönü + Repeat Objects Yineleme Nesneleri +@msg a swatch or color group named `{n}` exists `{n}` adlı bir renk örneği veya renk grubu zaten var +@msg an image has no pixels and was left out piksel içermeyen bir görüntü dışarıda bırakıldı +@msg thumbnail: no page {n} (the file has 1) thumbnail: {n}. sayfa yok (dosyada 1 sayfa var) +@msg variables that aren't text were left out: {kinds} metin olmayan değişkenler dışarıda bırakıldı: {kinds} + Collapse Panel Group Panel Grubunu Daralt +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} panel kayan durumda; grubunu window.panel.float {panel, collapsed} ile daraltın +@msg panel is not in a dock group panel bir yuva grubunda değil +@msg only a dock group above or below others collapses, not one beside another yalnızca diğerlerinin üstünde veya altındaki bir yuva grubu daraltılabilir, yanındaki değil +@msg icon must be true or false icon true veya false olmalı +@msg icon only docks a panel in the icon column, with no anchor icon, bir paneli yalnızca simge sütununa yerleştirir, anchor olmadan +@msg column docks a set as a dock column, with no anchor or icon column, bir kümeyi yuva sütunu olarak yerleştirir, anchor veya icon olmadan +3d Light Işık + Torn Edges… Yırtık Kenarlar… + Torn Edges Yırtık Kenarlar + Conté Crayon… Conté Kalemi… + Conté Crayon Conté Kalemi + Foreground Level Ön plan düzeyi + Background Level Arka plan düzeyi + Brush Size Fırça Boyutu + Texture Coverage Doku Kapsamı + Definition Tanım + Brush Detail Fırça Ayrıntısı + Shadow Intensity Gölge Yoğunluğu + Thickness Kalınlık + Detail Ayrıntı + Light/Dark Balance Açık/Koyu Dengesi +@msg library panel `{given}` isn't missing its library `{given}` kitaplık panelinin kitaplığı eksik değil +@msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Görüntü {_1} × {_2} piksel, bir kenarı {LARGE_TRACE_SIDE} değerinden büyük: izlenir, ancak daha yavaş. Daha düşük doğruluklu bir ön ayar veya görüntünün daha küçük bir kopyası daha hızlı izlenir. +@msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Görüntü {_3} × {_4} boyutundan küçültülerek {_1} × {_2} pikselde izlendi (en fazla {_5} megapiksel). + Gap Length: Boşluk Uzunluğu: +@plural {n} more export waiting|{n} more exports waiting {n} dışa aktarma daha bekliyor|{n} dışa aktarma daha bekliyor +@msg Export cancelled Dışa aktarma iptal edildi + Add filters from the list above Yukarıdaki listeden filtre ekleyin + Add to Stack Yığına Ekle + Applied Filters Uygulanan Filtreler + Categories Kategoriler + Choose a filter Bir filtre seçin + Effect Gallery Efekt Galerisi + Filters Filtreler + Select artwork to apply effects Efekt uygulamak için çizim seçin +@msg {label} keeps 1 of the document's {n} artboards: the file reopens with one. Turn on Preserve Editing Capabilities to keep them all. {label}, belgenin {n} çalışma yüzeyinden 1 tanesini korur: dosya tek çalışma yüzeyiyle yeniden açılır. Hepsini korumak için Düzenleme Yeteneklerini Koru seçeneğini açın. + Ease In Yavaş Başla + Ease Out Yavaş Bitir + Ease In Out Yavaş Başla ve Bitir + Ease Out In Yavaş Bitir ve Başla + Ramp: Eğim: + Shift: Kaydırma: + Fresco… Fresk… + Fresco Fresk + Effect Gallery… Efekt Galerisi… + Effect Gallery Dialog Efekt Galerisi İletişim Kutusu + Arc Segment Tool Options Yay Segmenti Aracı Seçenekleri + Base Along: Taban boyunca: + Closed Kapalı + Convex dışbükey + Fill Arc Yayı doldur + Length X-Axis: X ekseni uzunluğu: + Length Y-Axis: Y ekseni uzunluğu: + Slope: Eğim: +axis X Axis X ekseni +axis Y Axis Y ekseni + +@msg Packaged {n} file(s) in {folder} {folder} biçiminde paketlenmiş {n} dosyası/dosyaları +@msg Packaged {n} file(s) {note} Paketlenmiş {n} dosyası/dosyaları {note} +@msg Packaged {n} file(s) Paketlenmiş {n} dosyası/dosyaları +# Artwork drag between document tabs. + Drop Artwork Çizimi Bırak +@msg {key} must be [x, y] with finite coordinates {key}, sonlu koordinatlı [x, y] olmalı +@msg ids must contain 1–10000 object ids ids 1–10000 nesne kimliği içermeli + Packaged {n} file(s) Paketlenmiş {n} dosyası/dosyaları + Packaged {n} file(s) in {folder} {folder} biçiminde paketlenmiş {n} dosyası/dosyaları + Packaged {n} file(s) {note} Paketlenmiş {n} dosyası/dosyaları {note} + {count} file(s) in {folder}. Show the package folder? {folder} içindeki {count} dosyası/dosyaları. Paket klasörü gösterilsin mi? + Clockwise Saat yönünde + Dither Titreme + Fine noise hides banding in the gradient fill İnce gürültü, degrade dolgudaki bantları gizler +@msg `dither` applies to gradient fills (stroke: false) `dither` degrade dolgulara uygulanır (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` degrade dolgulara uygulanır (paint.setFill) +@msg `dither` must be true or false "dither" doğru veya yanlış olmalıdır + Snapping Yaslama + Snap to Tangent Teğete Yasla + Snap to Perpendicular Dikeye Yasla + Geometric Guides Geometrik Kılavuzlar +@msg `method` must be classic or perceptual `method` classic veya perceptual olmalıdır +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Büyük bir hesaplama bütçesi isteyen “{name}” yüklensin mi? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Her çalıştırma {minutes} dakikaya kadar sürebilir ve diğer eklentilere izin verilen işin {times} katını yapabilir. Arka planda çalışır ve iptal edebilirsiniz. + It may take minutes, so it runs in the background, where you can cancel it. Dakikalar sürebileceği için arka planda çalışır; orada iptal edebilirsiniz. + The plug-in is computing… Eklenti hesaplıyor… + Inspect Plug-in Eklentiyi İncele + Cancel Plug-in Run Eklenti Çalıştırmasını İptal Et +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} bitti, ancak bu sırada belge veya eklenti değişti: yeniden çalıştırın +@msg Running “{name}”… “{name}” çalıştırılıyor… +@msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz +@msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin +# Actions panel: action sets (#3712). + New Set… Yeni Set… + New Set Yeni Set + Set Options… Set Seçenekleri… + Set Options Set Seçenekleri + Create New Set Yeni Set Oluştur + Stop Global Edit Genel Düzenlemeyi Durdur + Global Edit Options Genel Düzenleme Seçenekleri + Match Eşleştir + On Artboards Çalışma Yüzeylerinde + All Artboards Tüm Çalışma Yüzeyleri + Include Objects On Canvas Tuvaldeki Nesneleri Dahil Et + Result(s): {count} Sonuç(lar): {count} +@msg select one object bir nesne seçin +@msg Global Edit works on paths, compound paths and symbol instances Genel düzenleme yollar, bileşik yollar ve sembol örnekleri üzerinde çalışır +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again benzer nesne bulunamadı (döndürülmüş veya yansıtılmış kopyalar eşleşmez); Genel Düzenleme Seçeneklerini değiştirip yeniden deneyin +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ya da 1, 3-5 gibi bir aralık olmalı, {v} değil + Add to Symbols Sembollere Ekle + Basic Shapes Temel Şekiller + Open Symbol Library Sembol Kitaplığını Aç + Save Symbol Library Sembol Kitaplığını Kaydet + Save Symbol Library… Sembol Kitaplığını Kaydet… + Save Symbols… Sembolleri Kaydet… + Selected Symbols Only Yalnızca Seçilen Semboller + Symbol Libraries Menu Sembol Kitaplıkları Menüsü + Symbol Library Sembol Kitaplığı + User Symbol Library 1 Kullanıcı Sembol Kitaplığı 1 + User Symbol Library 2 Kullanıcı Sembol Kitaplığı 2 + User Symbol Library 3 Kullanıcı Sembol Kitaplığı 3 + User Symbol Library 4 Kullanıcı Sembol Kitaplığı 4 + User Symbol Library 5 Kullanıcı Sembol Kitaplığı 5 + User Symbol Library 6 Kullanıcı Sembol Kitaplığı 6 + User Symbol Library 7 Kullanıcı Sembol Kitaplığı 7 + User Symbol Library 8 Kullanıcı Sembol Kitaplığı 8 + User Symbol Library 9 Kullanıcı Sembol Kitaplığı 9 + User Symbol Library 10 Kullanıcı Sembol Kitaplığı 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` içinde yerleştirilecek sembol yok +@msg missing `library` (see symbol.libraries) `library` eksik (bkz. symbol.libraries) +@msg no symbol `{n}` `{n}` sembolü yok +@msg no symbol `{n}` in `{_1}` `{_1}` içinde `{n}` sembolü yok +@msg no symbol library `{key}` (see symbol.libraries) `{key}` sembol kitaplığı yok (bkz. symbol.libraries) +@msg the document has no symbols to save belgede kaydedilecek sembol yok +@msg Trap: select two or more objects filled with solid colours Tuzak: düz renkle doldurulmuş iki veya daha fazla nesne seçin +@msg Trap: no edges of different colours meet among the selected objects Tuzak: seçili nesneler arasında farklı renkli kenarlar birleşmiyor +@msg thickness must be from 0.01 to 5000 pt thickness 0,01 ile 5000 pt arasında olmalıdır +@msg heightWidth must be from 1 to 1000 % heightWidth 1 ile 1000 % arasında olmalıdır +@msg tintReduction must be from 0 to 100 % tintReduction 0 ile 100 % arasında olmalıdır + Rectangular Grid Tool Options Dikdörtgen Izgara Aracı Seçenekleri + Polar Grid Tool Options Yuvarlak Izgara Aracı Seçenekleri + Fill Line Çizgiyi doldur diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8808380b6..ea830de1c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -7,7 +7,7 @@ # монтажна область, шар, обведення, заливка, контур, опорна точка, зразки, пензель, виділення. # Shortcut key names (Shift, Alt, Ctrl, Esc), command ids and placeholders remain unchanged. # -# Coverage: every menu string, tl! literal, plural, status and error message (enforced in tests). +# Coverage: every menu string, tl! literal, plural, status and error message (`untranslated_report` lists gaps). # Product, language, built-in workspace and perspective preset names follow MENU_KEEP_AS_IS. # Keep placeholders, ellipses and leading/trailing spaces of fragments when editing. Glowing Edges… Сяйливі краї… @@ -36,6 +36,13 @@ Channel 4 Канал 4 Cell Size Розмір комірки Fine Dots Дрібні точки + + Simple Простий + Light Rough Світла грубість + Dark Rough Темна грубість + Wide Sharp Широкий різкий + Wide Blurry Широкий розмитий + Sparkle Іскра Medium Dots Середні точки Grainy Dots Зернисті точки Coarse Dots Великі точки @@ -181,6 +188,8 @@ Anti-aliased Artwork Згладжений малюнок Anti-aliasing: Згладжування: Any Kind Будь-який тип + Font Technology Технологія шрифту +font-technology Variable Варіативні Appearance Оформлення Appearance Attribute Атрибут оформлення Appearance Item Елемент оформлення @@ -264,6 +273,11 @@ Average… Усереднити… Axis: Вісь: Background Color: Колір тла: + Show Center Mark Показати центральну позначку + Show Cross Hairs Показати перехрестя + Show Video Safe Areas Показати безпечні зони відео + Video Ruler Pixel Aspect Ratio: Співвідношення сторін пікселя відео-лінійок: + Hide Video Rulers Приховати відео-лінійки Background Contents Вміст фону Background Contents: Вміст фону: Background: Фон: @@ -301,6 +315,7 @@ Blur Розмиття Bottom Внизу Bottom in Front Нижній спереду + Bottom-Justified Tab Табуляція з вирівнюванням по нижньому краю Bottom: Внизу: Bounds Межі Bounds (Fill Proportionally) Межі (заповнювати пропорційно) @@ -331,6 +346,13 @@ Built-in presets don't change: New… starts an editable copy. Вбудовані набори не змінюються: «Створити…» створює копію, що редагується. Bulge… Випуклість… Bullet Маркер + Bullets Маркери + Bullets and Numbering Маркери та нумерація + Apply Bullets Застосувати маркери + Filled Dot Заповнена крапка + Dash Тире + Hollow Circle Порожнє коло + Filled Square Заповнений квадрат Butt Cap Пряме закінчення Byte Order: Порядок байтів: Calligraphic Каліграфічна @@ -1046,6 +1068,7 @@ Eyedropper Picks Up: Піпетка бере: Eyedropper Tool Інструмент «Піпетка» Eyedropper: click the art to sample the stop's colour Піпетка: натисніть на малюнок, щоб взяти колір точки переходу + Eyedropper: click the art to sample the key color Піпетка: натисніть на малюнок, щоб взяти ключовий колір Favorites Вибране Feather… Розтушування… Feet Фути @@ -1054,6 +1077,11 @@ Fidelity: Точність: Figure Space Цифрова шпація Figure: Цифри: +# Artwork drag between document tabs. + Drop Artwork Розмістити графіку +@msg {key} must be [x, y] with finite coordinates {key} має бути [x, y] зі скінченними координатами +@msg ids must contain 1–10000 object ids ids має містити від 1 до 10000 ідентифікаторів об’єктів + File Файл File Dimensions Розміри файлу File Format: Формат файлу: @@ -1075,6 +1103,16 @@ Alt key toggles to Smooth Tool Клавіша Alt перемикає на інструмент «Згладжування» Keep selected Залишати вибраним Merge only with selection Об’єднувати лише з виділеним + Consider Open Filled Paths as Closed Вважати відкриті контури із заливкою замкненими + In Merge Mode, Clicking Stroke Splits the Path У режимі об’єднання клацання по обведенню розділяє контур + Pick Color From: Джерело кольору: + Artwork Графіка + Cursor Swatch Preview Зразки біля вказівника + Selection: Виділення: + Freeform Довільно + Straight Line Пряма лінія + Highlight: Підсвічування: + Stroke When Editable Обведення, яке можна редагувати Fill Color Колір заливки Fill New Type Objects With Placeholder Text Заповнювати нові текстові об’єкти текстом-заповнювачем Fill Rule Правило заливки @@ -1203,6 +1241,8 @@ file-filter Templates Шаблони Large Gaps Великі проміжки Gap Preview Color: Колір перегляду проміжків: Close Gaps With Paths Закрити проміжки контурами + Show Live Paint Gaps Показати проміжки швидкої заливки + Hide Live Paint Gaps Приховати проміжки швидкої заливки Select Fills Виділяти заливки Select Strokes Виділяти обведення Gaussian Blur… Розмиття за Гауссом… @@ -1309,6 +1349,7 @@ file-filter Templates Шаблони Hand-Drawn Намальований від руки Handles Маркери Hard Light Жорстке світло + Hard Mix Жорстке накладання Harmony Rules: Правила гармонії: Harmony Sets Набори гармонії Height Висота @@ -1358,7 +1399,7 @@ file-filter Templates Шаблони Clear History Очистити історію Set History Limit… Задати межу історії… History States Стан історії -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Застосувати набір даних Area Type Текст в області Bloat Роздування @@ -1847,6 +1888,9 @@ file-filter Templates Шаблони Modified: Змінено: Modify Змінити Mojikumi Mojikumi + Insert Kashidas Вставляти кашиди + Long Довгі + Stylistic Стилістичні Mojikumi Set Набір mojikumi Kinsoku Set Набір kinsoku Hard Суворий @@ -1881,6 +1925,7 @@ file-filter Templates Шаблони Move Slices Перемістити фрагменти Move Spine Point Перемістити точку осі Reshape Spine Segment Змінити форму сегмента осі + Move Brush Перемістити пензель Move Swatch Перемістити зразок Move Up Перемістити вгору Move… Перемістити… @@ -1936,6 +1981,13 @@ file-filter Templates Шаблони Tints and Shades Відтінки й тіні Hue Shift Зсув тону Key Color: Ключовий колір: + Tips Поради + Colorization Tips Поради щодо перефарбування + The samples show this brush on a blue stroke. Зразки показують цей пензель на синьому обведенні. + The art keeps its own colors. Малюнок зберігає власні кольори. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Чорний стає кольором обведення, а білий залишається білим. Інші кольори стають відтінками кольору обведення, і що світліший колір, то світліший його відтінок. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Чорний залишається чорним, а білий залишається білим. Інші кольори стають тінями кольору обведення, якщо вони темні, і його відтінками, якщо вони світлі. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Ключовий колір стає кольором обведення. Інші кольори зсуваються по колірному колу на стільки ж, на скільки ключовий колір, і зберігають насиченість та яскравість. Roundness: Округлість: Fixed Фіксований Pressure Натиск @@ -1956,6 +2008,14 @@ file-filter Templates Шаблони Round Fan Кругла віялова Flat Fan Пласка віялова New Brush… Новий пензель… + New Brush Новий пензель + Calligraphic Brush Каліграфічний пензель + Scatter Brush Пензель-розпорошення + Art Brush Художній пензель + Bristle Brush Щетинний пензель + Pattern Brush Візерунковий пензель + Select art to make a scatter, art or pattern brush. Виділіть малюнок, щоб створити пензель-розпорошення, художній або візерунковий пензель. + Brush Art Малюнок пензля New Calligraphic Brush Новий каліграфічний пензель New Character Style Новий стиль символу New Color Group Нова колірна група @@ -1986,6 +2046,7 @@ file-filter Templates Шаблони New View Новий вигляд New View… Новий вигляд… New Window Нове вікно + New Without Dialog Створити без діалогового вікна New Workspace Нове робоче середовище New Workspace… Нове робоче середовище… New {kind} Style… Новий стиль {kind}… @@ -2192,7 +2253,7 @@ file-filter Templates Шаблони Package Упакувати Package collects a saved document with its linked files and fonts. Упаковка збирає збережений документ із його пов’язаними файлами та шрифтами. Package created Пакет створено - Packaged {n} file(s) in {folder}{note} Упаковано файли: {n} {folder}{note} + Packaged {n} file(s) in {folder} {note} Упаковано файли: {n} {folder}{note} Packaged {n} file(s){note} Упаковано файли: {n}{note} Package… Упакувати… Page Сторінка @@ -2255,7 +2316,7 @@ file-filter Templates Шаблони Performance Продуктивність diagnostics render відмальовування diagnostics ui інтерфейс - Performance & Storage Продуктивність та зберігання + Plug-ins & Scratch Disks Модулі & Робочі диски Permissions Дозволи Permissions password: Пароль дозволів: Perspective Перспектива @@ -2377,6 +2438,7 @@ mark-style Japanese Японський Proof Setup Налаштування кольоропроби Properties Властивості Proportional Metrics Пропорційні метрики + Character Direction Напрямок символів Digits Цифри Arabic Арабські Hindi Гінді @@ -2571,6 +2633,7 @@ mark-style Japanese Японський Reorder Змінити порядок Reorder Appearance Item Змінити порядок елемента оформлення Repeat Повторити + Repeat Objects Об’єкти повтору Repeat Options Параметри повтору Repeat Pathfinder Повторити обробку контурів Repeat Tab Повторити табуляцію @@ -2578,7 +2641,7 @@ mark-style Japanese Японський Replace Замінити Replace Font Замінити шрифт Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замініть його іншим файлом або ігноруйте: зображення продовжать показувати попередній перегляд, збережений з документом. - Replace Spine Замінити кістяк + Replace Spine Замінити вісь Replace Symbol Замінити символ Replace With Font Замінити шрифтом Replaces the saved preset of that name. Замінює збережений набір із таким ім’ям. @@ -2782,6 +2845,7 @@ mark-style Japanese Японський Scale Cursor Proportional to UI Масштабувати курсор пропорційно до інтерфейсу Scale Lineweights Масштабувати товщини ліній Scale Strokes & Effects Масштабувати обведення та ефекти + Scale Text Attributes Масштабувати атрибути тексту Scale to Fit Artboard Масштабувати по монтажній області Scale Tool Інструмент «Масштаб» Scale: Масштаб: @@ -3055,6 +3119,7 @@ effect Points Точки Snapping Tolerance Допуск прив’язки Social Square Post 1080×1080 Квадратний пост для соцмереж 1080×1080 Soft Light М’яке світло + Soft Mix М’яке змішування Solid Суцільний Sort by Kind Сортувати за типом Sort by Name Сортувати за назвою @@ -3065,6 +3130,7 @@ effect Points Точки Space Before Paragraph Інтервал перед абзацом Spaces Пробіли Spacing Guides Напрямні інтервалів + Invoke Distance Guides Викликати напрямні відстаней Spacing: Інтервал: Spanish Іспанська Specified Distance Задана відстань @@ -3202,7 +3268,6 @@ effect Points Точки Show all open documents Показати всі відкриті документи Symbol Instance Екземпляр символу Symbol Libraries Бібліотеки символів - Symbol Libraries (on the roadmap) Бібліотеки символів (у плані розвитку) Symbol Screener Tool Інструмент «Прозорість символів» Symbol Scruncher Tool Інструмент «Стиск символів» Symbol Shifter Tool Інструмент «Зсув символів» @@ -3295,6 +3360,7 @@ effect Points Точки Tools Інструменти Top Вгорі Top in Front Верхній спереду + Top-Justified Tab Табуляція з вирівнюванням по верхньому краю Top: Вгорі: Total Width: Загальна ширина: Touch Type Налаштування тексту @@ -3330,6 +3396,13 @@ effect Points Точки Transparent Background Прозоре тло Transverse (the page a quarter turn on the paper) Поперечна (сторінка повернута на чверть оберту на аркуші) Trap… Трепінг… + Trap Трепінг + Height/Width Висота/ширина + Tint Reduction Зменшення відтінку + Traps with Process Color Трепінг тріадними кольорами + Reverse Traps Зворотний трепінг + Precision Точність + Remove Redundant Points Вилучити зайві точки Trim Підрізати Trim mark weight: Товщина міток підрізування: Trim marks Мітки підрізування @@ -3561,7 +3634,7 @@ effect Points Точки Zoom Tool Інструмент «Масштаб» Zoom with Mouse Wheel Масштабувати колесом миші Scrub Numeric Fields by Dragging Змінювати числові поля перетягуванням - {count} file(s) in {folder}{note}. Show the package folder? файлів: {count} {folder}{note}. Показати папку пакета? + {count} file(s) in {folder} {note}. Show the package folder? файлів: {count} {folder}{note}. Показати папку пакета? {count} linked file(s) not found не знайдено пов’язаних файлів: {count} fonts not copied: {fonts} шрифти не скопійовано: {fonts} {count} linked image(s) changed since this document was saved. Show the new versions? пов’язані зображення ({count}) змінилися з моменту збереження цього документа. Показати нові версії? @@ -3634,7 +3707,8 @@ imageTrace Outline Контур to sample a color для взяття зразка кольору across a selected object to set the gradient direction по виділеному об’єкту, щоб задати напрям градієнта to select an artboard | для вибору монтажної області | - on the canvas to create one на полотні для створення однієї + on the canvas to create one ( на полотні для створення однієї ( + selects several, or draws one inside an artboard) вибирає кілька або створює нову всередині монтажної області) a stroke to add a width point | обведення для додавання точки ширини | to change one side | для зміни однієї сторони | to select more points | для виділення інших точок | @@ -3759,6 +3833,7 @@ axis Both Обидві @msg Couldn't copy to the system clipboard: {e} Не вдалося скопіювати до системного буфера обміну: {e} @msg Couldn't open {name}: {e} Не вдалося відкрити {name}: {e} @msg Couldn't place {name}: the document it was dropped on was closed Не вдалося помістити {name}: документ, у який його перетягли, було закрито +@msg Couldn't place {name}: the document it was placed in was closed Не вдалося помістити {name}: документ, у який його поміщали, було закрито @msg Couldn't read {name}: {e} Неможливо прочитати {name}: {e} @msg Couldn't save recovery data: {e} Не вдалося зберегти дані відновлення: {e} @msg Create Outlines: select text objects Створення контурів: виділіть текстові об’єкти @@ -3795,13 +3870,14 @@ axis Both Обидві @msg Opened {url} Відкрито {url} @msg Outline Stroke: no stroked paths selected Обведення контуру: не виділено контури з обведенням @msg PDF settings are not an object Параметри PDF не є об’єктом +@msg PDF security settings are not an object Параметри безпеки PDF не є об’єктом @msg PDF writer error: {0} Помилка запису PDF: {0} -@msg Packaged {n} file(s) in {folder}{note} Упаковано файли: {n} {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Упаковано файли: {n} {folder}{note} @msg Packaged {n} file(s){note} Упаковано файли: {n}{note} @msg Preview in Browser needs the desktop app (the web downloads files instead) Перегляд у браузері вимагає настільної програми (web-версія завантажує файли) @msg Release Guides: no guides Звільнення напрямних: немає напрямних @msg Release: no layer to put the rectangle in Звільнення: немає шару для приміщення прямокутника -@msg Replace Spine: select a blend and a path Заміна кістяка: виділіть перехід і контур +@msg Replace Spine: select a blend and a path Заміна осі: виділіть перехід і контур @msg select two or more objects to distribute виділіть два або більше об’єктів для розподілу @msg select paths or anchor points виділіть контури або опорні точки @msg SVG parse error: {0} Помилка розбору SVG: {0} @@ -3866,6 +3942,25 @@ axis Both Обидві @msg a PostScript error ({name} in `{op}`: {at}) помилка PostScript ({name} у `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} помилка PostScript ({name} `{op}`: {at}), {procs} @msg a PostScript error ({name}) помилка PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) помилка PostScript ({name} у `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} помилка PostScript ({name} у `{op}`; {meaning}), у {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) помилка PostScript ({name} у `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} помилка PostScript ({name} у `{op}`: {at}; {meaning}), у {procs} +@msg a PostScript error ({name}; {meaning}) помилка PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin програма закриває словник, який вона не відкривала +@msg the program opens a file, which the reader can't do програма відкриває файл, а читач PostScript цього не вміє +@msg the program restores a saved state that was already restored програма відновлює збережений стан, який уже відновлено +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none дані, які читає програма, пошкоджені або обірвані, або програма запитує позицію у файлі, який її не має +@msg the program goes past one of the reader's limits програма виходить за одну з меж читача PostScript +@msg the path has no point to continue from контур не має точки, від якої можна продовжити +@msg a value is outside the range the operator takes значення поза діапазоном, який приймає оператор +@msg the operator needs more values than the stack holds операторові потрібно більше значень, ніж є в стеку +@msg part of the program isn't valid PostScript text частина програми не є допустимим текстом PostScript +@msg a value has the wrong type for the operator значення має неправильний тип для оператора +@msg the program runs, deletes or renames a file, which the reader can't do програма запускає, видаляє або перейменовує файл, а читач PostScript цього не вміє +@msg the program asks for a resource that isn't defined програма запитує ресурс, який не визначено +@msg a calculation has no result, as in a division by zero обчислення не має результату, як під час ділення на нуль +@msg the operator looks for a mark on the stack and finds none оператор шукає мітку в стеку й не знаходить її @msg a bitmap is empty растрове зображення порожнє @msg a bitmap is too large to read растрове зображення занадто велике для читання @msg a colour index is 0 to {_1}, not {n} індекс кольору від 0 до {_1}, а не {n} @@ -3898,6 +3993,7 @@ axis Both Обидві @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis має бути "vertical", "horizontal" або кутом у градусах, а не `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" фон {other}: transparent, white, black або колір, наприклад "#ff8800" @msg bad color {v} неприпустимий колір {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio має бути числом від 0,1 до 10 @msg the Color Picker is not open палітра кольорів не відкрита @msg the Simplify slider is not open повзунок «Спростити» не відкрито @msg the screen color sampler is already open засіб вибору кольору з екрана вже відкритий @@ -3948,7 +4044,7 @@ axis Both Обидві @msg give the path `id` and the width point `index` вкажіть `id` контуру та `index` точки ширини @msg guides are locked напрямні заблоковані @msg handle must be in or out handle має бути in або out -@msg image '{key}' has no pixels and was left out зображення '{key}' не має пікселів і було пропущено +@msg an image has no pixels and was left out зображення без пікселів пропущено @msg image data `{_1}` is missing дані зображення `{_1}` відсутні @msg image would be too large; lower the resolution зображення буде завеликим; зменште роздільну здатність @msg index must be a number or first, previous, next or last index має бути числом або first, previous, next, last @@ -3975,7 +4071,7 @@ axis Both Обидві @msg missing index відсутня index @msg missing number `{key}` відсутнє число `{key}` @msg missing integer `{key}` відсутнє ціле число `{key}` -@msg multiple windows land with M11.5 кілька вікон з’являться в M11.5 +@msg New Window isn't available yet: a document opens in one window Нове вікно поки недоступне: документ відкривається в одному вікні @msg name the file дайте файлу ім’я @msg no CSS to copy: select objects with CSS of their own ні CSS для копіювання: виділіть об’єкти з власним CSS @msg no Live Paint edge near that point поряд з цією точкою немає межі швидкої заливки @@ -3993,7 +4089,9 @@ axis Both Обидві @msg no blend options немає параметрів переходу @msg no bounds немає кордонів @msg no brush named `{name}` немає пензля з ім’ям `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} найбільша довжина імені щетинного пензля (символів): {max}; у `{name}` символів: {count} @msg no brush selected пензель не вибрано +@msg brushes move only within their type пензлі можна переміщувати лише в межах їхнього типу @msg no catalog немає каталогу @msg no changes since the last save немає змін з моменту останнього збереження @msg no clock to keep a heartbeat by немає годинника для підтримки сигналу @@ -4012,6 +4110,7 @@ axis Both Обидві @msg no file system here тут немає файлової системи @msg no file writer немає засобу запису файлів @msg no files to place немає файлів для розміщення +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` не є документом VectorCraft, PDF або файлом .ai, сумісним із PDF @msg no format to export to немає формату для експорту @msg no free recovery area немає вільної області відновлення @msg no gradient stop or freeform point is selected не виділено ні точку переходу градієнта, ні довільну точку @@ -4047,6 +4146,7 @@ axis Both Обидві @msg no such action такої дії немає @msg no such anchor такої опорної точки немає @msg no such artboard такої монтажної області немає +@msg the artboard is locked монтажна область заблокована @msg no such document такого документа немає @msg no such layer or object такого шару чи об’єкта немає @msg no such object {0} об’єкт {0} не існує @@ -4103,6 +4203,7 @@ axis Both Обидві @msg path is already closed контур вже замкнутий @msg paths contain invalid coordinates контури містять неприпустимі координати @msg paths: give at least one file paths: вкажіть щонайменше один файл +@msg replace: the object to replace was changed or deleted replace: об’єкт для заміни було змінено або видалено @msg pattern missing візерунок відсутній @msg patterns nested too deeply are filled with mid-grey надто глибоко вкладені візерунки заповнюються сірим @msg percent must be above 0 and at most 6400, not {p} відсоток має бути більше 0 і не більше 6400, а не {p} @@ -4138,6 +4239,10 @@ axis Both Обидві @msg select a repeat виділіть повтор @msg select an Intertwine group виділіть групу переплетення @msg select an Objects on Path group виділіть групу об’єктів на контурі +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects до контуру всередині складеного контуру, переходу, оболонки, повтору або групи переплетення не можна прикріпити об’єкти +@msg a clipping path can't take objects до відсічного контуру не можна прикріпити об’єкти +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path об’єкти всередині переходу, оболонки, повтору або групи переплетення не можна прикріпити до контуру +@msg a clipping path can't be attached to a path відсічний контур не можна прикріпити до контуру @msg select a stroke with a variable width виділіть обведення зі змінною шириною @msg select a symbol instance виділіть екземпляр символу @msg select an Image Trace object виділіть об’єкт трасування зображення @@ -4257,6 +4362,7 @@ axis Both Обидві @msg unknown workspace (one of {_1}) невідоме робоче середовище (одна з: {_1}) @msg unsupported: {0} не підтримується: {0} @msg variables are locked змінні заблоковані +@msg variables that aren't text were left out: {kinds} змінні, що не є текстовими, пропущено: {kinds} @msg version {0} files can't be compressed: only version {COMPRESSED_SINCE} and later open compressed files файли версії {0} не можуть бути стиснуті: тільки версія {COMPRESSED_SINCE} і новіші відкривають стислі файли @msg width and height are at least 1 pixel ширина та висота повинні бути не менше 1 пікселя @msg workspace name can't be empty ім’я робочого середовища не може бути порожнім @@ -4268,6 +4374,7 @@ axis Both Обидві @msg {_1} font families available доступно сімейства шрифтів: {_1} @msg {_1} is a live effect (Effect › Plug-ins) {_1} - динамічний ефект (Ефект › Модулі) @msg {_1} is an exporter (File › Export › Export As…) {_1} - модуль експорту (Файл › Експорт › Експортувати як…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` займає {size} байт (межа {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} вже використовується {_2} @msg {_1} linked file(s) can't be read here: their previews show тут неможливо прочитати пов’язані файли: {_1}: відображаються їх попередні перегляди @msg {_1} linked image(s) changed on disk: Update Links shows the new versions пов’язаних зображень ({_1}) змінилися на диску: «Оновити зв’язки» покаже нові версії @@ -4297,6 +4404,8 @@ axis Both Обидві @msg Saving Збереження @msg Saving {_1} Збереження {_1} @msg Exporting {_1} Експорт {_1} +@msg Packaging {_1} Пакування {_1} +@msg Placing {_1} Розміщення {_1} @msg Saving recovery data for {_1} Збереження даних відновлення для {_1} @msg Discarded {_1} recovered document(s) Скасовано відновлених документів: {_1} @msg Restored {_1} recovered document(s): save them to keep them Відновлено документів: {_1}: збережіть їх, щоб залишити @@ -4403,7 +4512,9 @@ axis Both Обидві @msg automation filesystem access is not granted: read authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на читання: {path} @msg automation filesystem access is not granted: write authority is absent: {path} автоматизація не має доступу до файлової системи: немає права на запис: {path} @msg automation path rejected: outside the read root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для читання {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для читання {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} шлях автоматизації відхилено: поза кореневою папкою для запису {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} шлях автоматизації відхилено: поза кореневими папками для запису {roots}: {path} @msg automation path rejected: path is empty шлях автоматизації відхилено: шлях порожній @msg automation path rejected: a link that leads nowhere: {path} шлях автоматизації відхилено: посилання, яке нікуди не веде: {path} @msg automation path rejected: cannot resolve {path}: {e} шлях автоматизації відхилено: не вдається визначити {path}: {e} @@ -4509,6 +4620,7 @@ axis Both Обидві Activate Panel Активувати панель Change Panel Layout Змінити розташування панелей Close Panel Закрити панель + Collapse Panel Group Згорнути групу панелей Move Panel Перемістити панель Group with Згрупувати з Resize panels Змінити розмір панелей @@ -4519,6 +4631,12 @@ axis Both Обидві @msg before must name a panel before має вказувати панель @msg docking action contains an unknown panel or an excessive split path Дія прикріплення містить невідому панель або надто довгий шлях поділів @msg group must be true or false group має мати значення true або false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} панель від’єднана; згорніть її групу через window.panel.float {panel, collapsed} +@msg panel is not in a dock group панель не входить до групи дока +@msg only a dock group above or below others collapses, not one beside another згорнути можна лише групу дока над або під іншими, а не поруч з ними +@msg icon must be true or false icon має бути true або false +@msg icon only docks a panel in the icon column, with no anchor icon лише закріплює панель у стовпці значків, без anchor +@msg column docks a set as a dock column, with no anchor or icon column закріплює набір як стовпець дока, без anchor та icon @msg move requires a destination panel Для переміщення потрібна цільова панель @msg move requires an existing destination panel Для переміщення потрібна наявна цільова панель @msg onto must name a floating panel onto має вказувати плаваючу панель @@ -4595,12 +4713,24 @@ lighting Light Світло Reset Ruler Origin Скинути початок координат лінійок Artistic Художні Film Grain… Зернистість плівки… + Colored Pencil… Кольоровий олівець… + Colored Pencil Кольоровий олівець Cutout… Аплікація… Cutout Аплікація + Dry Brush… Суха кисть… + Dry Brush Суха кисть Neon Glow… Неонове сяйво… + Palette Knife… Ніж палітри… Neon Glow Неонове сяйво + Paint Daubs… Мазки фарби… + Paint Daubs Мазки фарби + Palette Knife Ніж палітри Poster Edges… Плакатні краї… + Rough Pastels… Грубі пастелі… Poster Edges Плакатні краї + Rough Pastels Грубі пастелі + Underpainting… Підмалювання… + Underpainting Підмалювання Levels Рівні Edge Simplicity Простота країв Edge Fidelity Точність країв @@ -4690,6 +4820,9 @@ lighting Light Світло Softness М'якість Ambient Light Навколишнє світло Light behind the object Світло позаду об'єкта + 3D Lighting Preset Попереднє налаштування 3D освітлення + Standard Стандартне + Diffuse Розсіяне # 3D light management. Lights Джерела світла Add Light Додати світло @@ -4736,6 +4869,7 @@ lighting Light Світло @msg Measured anchor no longer exists Виміряна опорна точка більше не існує @msg Measured segment no longer exists Виміряний сегмент більше не існує @msg Non-finite dimension Розмір не є скінченним числом +@msg Scale Text Attributes: selected type has no scale to bake Масштабувати атрибути тексту: у вибраного тексту немає масштабу для застосування @msg Scale must be drawing:real, for example 1:10 Масштаб має бути у вигляді креслення:реальність, наприклад 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Співвідношення масштабу має бути від 1:1000000 до 1000000:1 @msg Second measured segment no longer exists Другий виміряний сегмент більше не існує @@ -4754,6 +4888,7 @@ lighting Light Світло Make Image Dynamic Зробити зображення динамічним Import Data Sets… Імпортувати набори даних… Export Data Sets… Експортувати набори даних… + Load Variable Library… Завантажити бібліотеку змінних… Make Graph Dynamic Зробити діаграму динамічною Batch… Пакетна обробка… Source Folder: Вихідна папка: @@ -4777,6 +4912,12 @@ lighting Light Світло Sketch Ескіз Halftone Pattern… Напівтоновий візерунок… Halftone Pattern Напівтоновий візерунок + Torn Edges… Порвані краї… + Torn Edges Порвані краї + Conté Crayon… Крейда Конте… + Conté Crayon Крейда Конте + Foreground Level Рівень переднього плану + Background Level Рівень тла Pattern Type Тип візерунка Dot Крапка # User Interface › Color Scheme @@ -4795,6 +4936,7 @@ lighting Light Світло Watercolor… Акварель… Watercolor Акварель Brush Size Розмір пензля + Texture Coverage Покриття текстурою Definition Чіткість Brush Detail Деталізація пензля Shadow Intensity Інтенсивність тіней @@ -4823,13 +4965,21 @@ lighting Light Світло @msg the image's pixels can't be read не вдається прочитати пікселі зображення Persistent Постійна Library Panel Options Параметри панелі бібліотеки + Locate Library… Знайти бібліотеку… @msg Persistent libraries not found: {files} Постійні бібліотеки не знайдено: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` — не панель бібліотеки (див. window.swatchLibrary) @msg library panel `{given}` isn't open панель бібліотеки `{given}` не відкрита +@msg library panel `{given}` isn't missing its library бібліотека панелі бібліотеки `{given}` не відсутня @msg too many library panels were opened in this session у цьому сеансі відкрито забагато панелей бібліотек @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Зображення {_1} × {_2} пікселів, понад {LARGE_TRACE_SIDE} з одного боку: трасування виконується, але повільніше. Стиль із нижчою точністю або менша копія зображення трасуються швидше. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Зображення трасовано в розмірі {_1} × {_2} пікселів, зменшене з {_3} × {_4} (щонайбільше {_5} мегапікселів). + Snapping Прив’язка + Snap to Tangent Прив’язати до дотичної + Snap to Perpendicular Прив’язати до перпендикуляра +@msg unknown snap option `{key}` (use {options}) невідомий параметр прив’язки `{key}` (використовуйте {options}) + Gap Length: Довжина проміжку: Artboard {n} of {total} Монтажна область {n} з {total} + File {n} of {total} Файл {n} з {total} Encoding and writing the file… Кодування та запис файлу… @plural {n} more export waiting|{n} more exports waiting Ще {n} експорт в очікуванні|Ще {n} експорти в очікуванні|Ще {n} експортів в очікуванні Continue in Background Продовжити у фоні @@ -4846,6 +4996,14 @@ lighting Light Світло Minimum must not exceed Desired, nor Desired Maximum. Мінімум не може перевищувати бажане значення, а бажане — максимум. Minimum Мінімум Justification Вирівнювання за шириною + Add filters from the list above Додайте фільтри зі списку вище + Add to Stack Додати до стека + Applied Filters Застосовані фільтри + Categories Категорії + Choose a filter Виберіть фільтр + Effect Gallery Галерея ефектів + Filters Фільтри + Select artwork to apply effects Виберіть графіку, щоб застосувати ефекти @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them змінні та набори даних файлу не прочитано (змінних: {variables}, наборів даних: {data_sets}): панель «Змінні» їх не показує # Select by Color (select.color) Select by Color Виділити за кольором @@ -4867,3 +5025,107 @@ lighting Light Світло Plaster Гіпс Note Paper… Поштовий папір… Note Paper Поштовий папір + Water Paper… Мокрий папір… + Water Paper Мокрий папір + Ruby… Рубі… + Ruby Рубі + Geometric Guides Геометричні напрямні + Ease In Плавний початок + Ease Out Плавне завершення + Ease In Out Плавний початок і завершення + Ease Out In Плавне завершення і початок + Ramp: Сила: + Shift: Зсув: + Fresco… Фреска… + Fresco Фреска + Effect Gallery… Галерея ефектів… + Effect Gallery Dialog Діалог «Галерея ефектів» + Arc Segment Tool Options Параметри інструменту «Дуговий сегмент» + Base Along: Основа вздовж: + Closed Замкнений + Concave Увігнутий + Convex Опуклий + Fill Arc Заливка дуги + Length X-Axis: Довжина вздовж осі X: + Length Y-Axis: Довжина вздовж осі Y: + Slope: Ухил: +axis X Axis Вісь X +axis Y Axis Вісь Y +@msg Packaged {n} file(s) in {folder} Упаковано файли: {n} {folder} +@msg Packaged {n} file(s) {note} Упаковано файли: {n} {note} +@msg Packaged {n} file(s) Упаковано файли: {n} + Packaged {n} file(s) Упаковано файли: {n} + Packaged {n} file(s) in {folder} Упаковано файли: {n} {folder} + Packaged {n} file(s) {note} Упаковано файли: {n} {note} + {count} file(s) in {folder}. Show the package folder? файлів: {count} {folder}. Показати папку пакета? + Clockwise За годинниковою стрілкою + Dither Дизеринг + Fine noise hides banding in the gradient fill Дрібний шум приховує смуги в градієнтній заливці +@msg `dither` applies to gradient fills (stroke: false) `dither` застосовується до градієнтних заливок (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` застосовується до градієнтних заливок (paint.setFill) +@msg `dither` must be true or false `dither` має бути true або false + Perceptual Перцептивний +@msg `method` must be classic or perceptual `method` має бути classic або perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Встановити «{name}», якому потрібен великий обчислювальний бюджет? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Кожен запуск може тривати до {minutes} хвилин і виконувати в {times} разів більше роботи, ніж дозволено іншим модулям. Він працює у фоні, і його можна скасувати. + It may take minutes, so it runs in the background, where you can cancel it. Це може тривати кілька хвилин, тож модуль працює у фоні, де його можна скасувати. + The plug-in is computing… Модуль виконує обчислення… + Inspect Plug-in Перевірити модуль + Cancel Plug-in Run Скасувати запуск модуля +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} завершився, але документ або модуль тим часом змінився: запустіть його знову +@msg Running “{name}”… Виконується «{name}»… +@msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy` +@msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться +# Actions panel: action sets (#3712). + New Set… Новий набір… + New Set Новий набір + Set Options… Параметри набору… + Set Options Параметри набору + Create New Set Створити новий набір + Stop Global Edit Завершити глобальне редагування + Global Edit Options Параметри глобального редагування + Match Збіг + On Artboards На монтажних областях + All Artboards Усі монтажні області + Include Objects On Canvas Включати об’єкти на полотні + Result(s): {count} Результати: {count} +@msg select one object виберіть один об’єкт +@msg Global Edit works on paths, compound paths and symbol instances Глобальне редагування працює з контурами, складеними контурами та екземплярами символів +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again схожих об’єктів не знайдено (повернуті чи віддзеркалені копії не враховуються); змініть параметри глобального редагування та спробуйте ще раз +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all або діапазон, наприклад 1, 3-5, а не {v} + Add to Symbols Додати до символів + Basic Shapes Базові фігури + Open Symbol Library Відкрити бібліотеку символів + Save Symbol Library Зберегти бібліотеку символів + Save Symbol Library… Зберегти бібліотеку символів… + Save Symbols… Зберегти символи… + Selected Symbols Only Тільки вибрані символи + Symbol Libraries Menu Меню "Бібліотеки символів" + Symbol Library Бібліотека символів + User Symbol Library 1 Користувацька бібліотека символів 1 + User Symbol Library 2 Користувацька бібліотека символів 2 + User Symbol Library 3 Користувацька бібліотека символів 3 + User Symbol Library 4 Користувацька бібліотека символів 4 + User Symbol Library 5 Користувацька бібліотека символів 5 + User Symbol Library 6 Користувацька бібліотека символів 6 + User Symbol Library 7 Користувацька бібліотека символів 7 + User Symbol Library 8 Користувацька бібліотека символів 8 + User Symbol Library 9 Користувацька бібліотека символів 9 + User Symbol Library 10 Користувацька бібліотека символів 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place У `{_1}` немає символів для розміщення +@msg missing `library` (see symbol.libraries) не вказано `library` (див. symbol.libraries) +@msg no symbol `{n}` немає символу `{n}` +@msg no symbol `{n}` in `{_1}` немає символу `{n}` у `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) немає бібліотеки символів `{key}` (див. symbol.libraries) +@msg the document has no symbols to save у документі немає символів для збереження +@msg Trap: select two or more objects filled with solid colours Трепінг: виберіть два або більше об’єктів із суцільною заливкою +@msg Trap: no edges of different colours meet among the selected objects Трепінг: серед вибраних об’єктів немає дотичних країв різних кольорів +@msg thickness must be from 0.01 to 5000 pt thickness має бути від 0,01 до 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth має бути від 1 до 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction має бути від 0 до 100 % + Rectangular Grid Tool Options Параметри інструменту «Прямокутна сітка» + Polar Grid Tool Options Параметри інструменту «Полярна сітка» + Fill Line Заливка відрізка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index c5fa8d782..53216bd00 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -24,7 +24,8 @@ from center | từ tâm | keeps its proportions) giữ nguyên tỷ lệ) near a selected pin to rotate | gần ghim đã chọn để xoay | - on the canvas to create one trên vùng vẽ để tạo một cái + on the canvas to create one ( trên vùng vẽ để tạo một cái ( + selects several, or draws one inside an artboard) chọn nhiều cái hoặc tạo cái mới bên trong bảng vẽ) over paths to roughen their outlines | qua đường dẫn để làm thô đường bao | over paths, and hold still to keep going | qua đường dẫn và giữ yên để tiếp tục | right or left to zoom in or out | sang phải hoặc trái để phóng to hoặc thu nhỏ | @@ -211,6 +212,8 @@ Anti-aliased Artwork Tác phẩm khử răng cưa Anti-aliasing: Khử răng cưa: Any Kind Bất kỳ loại + Font Technology Công nghệ phông chữ +font-technology Variable Biến thiên Appearance Diện mạo Appearance Attribute Thuộc tính diện mạo Appearance Item Mục diện mạo @@ -252,6 +255,7 @@ Arrange: Bring to Front Sắp xếp: Đưa lên trên cùng Arrowheads: Đầu mũi tên: Art Tác phẩm + Art Brush Cọ bản vẽ Art Brush Options Tùy chọn cọ bản vẽ Art using a deleted global swatch keeps its colour. Tác phẩm dùng mẫu màu toàn cục đã xóa vẫn giữ màu của nó. Artboard Bảng vẽ @@ -298,6 +302,11 @@ Axis: Trục: Back Sau Background Color: Màu nền: + Show Center Mark Hiện dấu tâm + Show Cross Hairs Hiện đường chữ thập + Show Video Safe Areas Hiện vùng an toàn video + Video Ruler Pixel Aspect Ratio: Tỷ lệ khung điểm ảnh của thước video: + Hide Video Rulers Ẩn thước video Background Contents Nội dung nền Background Contents: Nội dung nền: Background: Nền: @@ -354,6 +363,7 @@ Bottom Left Dưới trái Bottom Right Dưới phải Bottom to Top Dưới lên trên + Bottom-Justified Tab Tab căn dưới Bottom-to-Bottom Leading Giãn dòng đáy đến đáy Bottom: Dưới: Bounds Giới hạn @@ -374,6 +384,7 @@ Bring Forward Đưa lên trước Bring to Front Đưa lên trên cùng Bristle Lông cứng + Bristle Brush Cọ lông Bristle Brush Options Tùy chọn cọ lông Bristle Brush Strokes Nét cọ lông cứng Bristle Density: Mật độ lông cọ: @@ -381,6 +392,7 @@ Bristle Thickness: Độ dày lông cọ: Browser Trình duyệt Brush Affects: Cọ tác động: + Brush Art Đối tượng của cọ Brush Definition Định nghĩa cọ Brush Libraries Thư viện cọ Brush Libraries (on the roadmap) Thư viện cọ (trong lộ trình) @@ -394,11 +406,19 @@ Built-in presets don't change: New… starts an editable copy. Thiết lập sẵn tích hợp không thay đổi: Mới… tạo một bản sao có thể chỉnh sửa. Bulge… Phình… Bullet Dấu đầu dòng + Bullets Dấu đầu dòng + Bullets and Numbering Dấu đầu dòng và đánh số + Apply Bullets Áp dụng dấu đầu dòng + Filled Dot Chấm đặc + Dash Gạch ngang + Hollow Circle Hình tròn rỗng + Filled Square Hình vuông đặc Burasagari Burasagari Burlap Vải thô Butt Cap Đầu nét vuông Byte Order: Thứ tự byte: Calligraphic Thư pháp + Calligraphic Brush Cọ thư pháp Calligraphic Brush Options Tùy chọn cọ thư pháp Can't open “{name}”. Không thể mở “{name}”. Can't save “{name}”. Không thể lưu “{name}”. @@ -548,6 +568,7 @@ Close All Đóng tất cả Close Library Đóng thư viện Close Panel Đóng bảng + Collapse Panel Group Thu gọn nhóm bảng Close Path Đóng đường dẫn Close paths when ends are within: Đóng đường dẫn khi hai đầu cách nhau trong: Clumped Dạng cụm @@ -869,6 +890,7 @@ Differences from {preset}: Khác biệt so với {preset}: Diffuse Glow Ánh sáng khuếch tán Diffuse Glow… Ánh sáng khuếch tán… + Character Direction Hướng ký tự Digits Chữ số Dim Copies to: Làm mờ bản sao đến: Dim Images to: Làm mờ ảnh đến: @@ -1214,6 +1236,7 @@ Eyedropper Picks Up: Ống hút màu lấy mẫu: Eyedropper Tool Công cụ Ống hút màu Eyedropper: click the art to sample the stop's colour Ống hút màu: bấm vào tác phẩm để lấy mẫu màu điểm dừng + Eyedropper: click the art to sample the key color Ống hút màu: bấm vào tác phẩm để lấy mẫu màu chủ Farsi Farsi Favorites Yêu thích Feather… Làm mờ dần… @@ -1223,6 +1246,11 @@ Fidelity: Độ trung thực: Figure Space Khoảng trắng chữ số Figure: Chữ số: +# Artwork drag between document tabs. + Drop Artwork Thả hình đồ họa +@msg {key} must be [x, y] with finite coordinates {key} phải là [x, y] với tọa độ hữu hạn +@msg ids must contain 1–10000 object ids ids phải chứa từ 1 đến 10000 mã định danh đối tượng + File Tệp File Dimensions Kích thước tệp File Format: Định dạng tệp: @@ -1246,6 +1274,16 @@ Alt key toggles to Smooth Tool Phím Alt chuyển sang công cụ Làm mượt Keep selected Giữ trạng thái đã chọn Merge only with selection Chỉ hợp nhất với vùng chọn + Consider Open Filled Paths as Closed Coi đường dẫn mở có tô là đường dẫn đóng + In Merge Mode, Clicking Stroke Splits the Path Ở chế độ hợp nhất, nhấp vào nét sẽ tách đường dẫn + Pick Color From: Lấy màu từ: + Artwork Tác phẩm + Cursor Swatch Preview Xem trước mẫu màu cạnh con trỏ + Selection: Cách chọn: + Freeform Tự do + Straight Line Đường thẳng + Highlight: Làm nổi bật: + Stroke When Editable Nét khi có thể chỉnh sửa Fill New Type Objects With Placeholder Text Tô đối tượng chữ mới bằng văn bản giữ chỗ Fill Rule Quy tắc tô Fill with Placeholder Text Tô bằng văn bản giữ chỗ @@ -1253,12 +1291,24 @@ Fill: click for swatches, Shift-click for the color mixer Tô: bấm để xem mẫu màu, Shift-bấm để trộn màu Fills Vùng tô Film Grain… Hạt phim… + Colored Pencil… Bút chì màu… + Colored Pencil Bút chì màu Cutout… Cắt dán… Cutout Cắt dán + Dry Brush… Cọ khô… + Dry Brush Cọ khô Neon Glow… Ánh neon… + Palette Knife… Dao palette… Neon Glow Ánh neon + Paint Daubs… Vệt sơn… + Paint Daubs Vệt sơn + Palette Knife Dao palette Poster Edges… Viền áp phích… + Rough Pastels… Pastel thô… Poster Edges Viền áp phích + Rough Pastels Pastel thô + Underpainting… Lớp sơn nền… + Underpainting Lớp sơn nền Levels Số mức Edge Simplicity Độ đơn giản của cạnh Edge Fidelity Độ trung thực của cạnh @@ -1280,6 +1330,13 @@ Find Next Tìm tiếp Find Objects Tìm đối tượng Fine Dots Chấm mịn + + Simple Đơn giản + Light Rough Thô sáng + Dark Rough Thô tối + Wide Sharp Rộng sắc + Wide Blurry Rộng mờ + Sparkle Lấp lánh First Đầu tiên First commit Commit đầu tiên First Page Trang đầu @@ -1395,6 +1452,8 @@ file-filter Templates Mẫu Large Gaps Khoảng cách lớn Gap Preview Color: Màu xem trước khoảng cách: Close Gaps With Paths Đóng khoảng cách bằng đường dẫn + Show Live Paint Gaps Hiện khoảng cách tô trực tiếp + Hide Live Paint Gaps Ẩn khoảng cách tô trực tiếp Select Fills Chọn vùng tô Select Strokes Chọn nét Gaussian Blur Mờ Gaussian @@ -1507,6 +1566,7 @@ file-filter Templates Mẫu Handles Tay nắm Hard Cứng Hard Light Ánh sáng mạnh + Hard Mix Trộn cứng Harmony Rules: Quy tắc hài hòa màu: Harmony Sets Bộ hài hòa màu Height Chiều cao @@ -1557,7 +1617,7 @@ file-filter Templates Mẫu Clear History Xóa lịch sử Set History Limit… Đặt giới hạn lịch sử… History States Trạng thái lịch sử -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set Áp dụng tập dữ liệu Area Type Chữ vùng Bloat Phình @@ -2124,6 +2184,9 @@ file-filter Templates Mẫu Modified: Đã sửa đổi: Modify Sửa đổi Mojikumi Mojikumi + Insert Kashidas Chèn kashida + Long Dài + Stylistic Kiểu cách Mojikumi Set Bộ Mojikumi Monitor RGB RGB màn hình Mono Đơn sắc @@ -2161,6 +2224,7 @@ file-filter Templates Mẫu Move Rows Di chuyển hàng Move Slices Di chuyển lát cắt Move Spine Point Di chuyển điểm trục + Move Brush Di chuyển cọ Move Swatch Di chuyển mẫu màu Move to Group Di chuyển vào nhóm Move Up Di chuyển lên @@ -2186,6 +2250,7 @@ file-filter Templates Mẫu New Art Has Basic Appearance Bản vẽ mới có diện mạo cơ bản New Artboard Bảng vẽ mới New Bristle Brush Cọ lông mới + New Brush Cọ mới New Brush… Cọ mới… New Calligraphic Brush Cọ thư pháp mới New Character Style Kiểu ký tự mới @@ -2226,6 +2291,7 @@ file-filter Templates Mẫu New View Chế độ xem mới New View… Chế độ xem mới… New Window Cửa sổ mới + New Without Dialog Mới không qua hộp thoại New Workspace Không gian làm việc mới New Workspace… Không gian làm việc mới… New {kind} Style… Kiểu {kind} mới… @@ -2468,7 +2534,7 @@ file-filter Templates Mẫu Package Gói Package collects a saved document with its linked files and fonts. Gói thu thập tài liệu đã lưu cùng tệp liên kết và phông chữ. Package created Đã tạo gói - Packaged {n} file(s) in {folder}{note} Đã đóng gói {n} tệp trong {folder}{note} + Packaged {n} file(s) in {folder} {note} Đã đóng gói {n} tệp trong {folder}{note} Packaged {n} file(s){note} Đã đóng gói {n} tệp{note} Package… Gói… Page Trang @@ -2515,6 +2581,7 @@ file-filter Templates Mẫu Path Length Độ dài đường dẫn Paths: {paths} Anchors: {anchors} Colors: {colors} Đường dẫn: {paths} Điểm neo: {anchors} Màu: {colors} Pattern Họa tiết + Pattern Brush Cọ họa tiết Pattern Brush Options Tùy chọn cọ họa tiết Pattern Editing Chỉnh sửa họa tiết Pattern Options Tùy chọn họa tiết @@ -2539,7 +2606,7 @@ file-filter Templates Mẫu Performance Hiệu suất diagnostics render kết xuất diagnostics ui giao diện - Performance & Storage Hiệu suất & Bộ nhớ + Plug-ins & Scratch Disks Plug-in & Đĩa tạm Permissions Quyền Permissions password: Mật khẩu quyền: Perspective Phối cảnh @@ -2844,6 +2911,7 @@ mark-style Japanese Kiểu Nhật Reorder Sắp xếp lại Reorder Appearance Item Sắp xếp lại mục diện mạo Repeat Lặp lại + Repeat Objects Đối tượng lặp lại Repeat Options Tùy chọn lặp lại Repeat Pathfinder Lặp lại Pathfinder Repeat Tab Tab lặp lại @@ -3092,6 +3160,7 @@ mark-style Japanese Kiểu Nhật Scale Lineweights Tỷ lệ độ dày nét Scale Proportionately Phóng to/thu nhỏ đều Scale Strokes & Effects Tỷ lệ nét & hiệu ứng + Scale Text Attributes Tỷ lệ thuộc tính văn bản Scale to Fit Artboard Tỷ lệ vừa bảng vẽ Scale Tool Công cụ Tỷ lệ Scale: Tỷ lệ: @@ -3101,6 +3170,7 @@ mark-style Japanese Kiểu Nhật Scallop Tool Công cụ Vỏ sò Scans: Lần quét: Scatter Phân tán + Scatter Brush Cọ phân tán Scatter Brush Options Tùy chọn cọ phân tán Scatter Graph Tool Công cụ Biểu đồ phân tán Scatter: Phân tán: @@ -3156,6 +3226,7 @@ mark-style Japanese Kiểu Nhật Select art and choose Object › Pattern › Make. Chọn đối tượng và chọn Đối tượng › Họa tiết › Tạo. Select art and click New Symbol to make one. Chọn đối tượng và bấm Ký hiệu mới để tạo. Select art and use New Brush to make one. Chọn đối tượng và dùng Cọ mới để tạo. + Select art to make a scatter, art or pattern brush. Chọn đối tượng để tạo cọ phân tán, cọ bản vẽ hoặc cọ họa tiết. Select Bound Object Chọn đối tượng gắn Select Data Set Chọn tập dữ liệu Select Freeform Point Chọn điểm tự do @@ -3384,6 +3455,7 @@ mark-style Japanese Kiểu Nhật Social Square Post 1080×1080 Bài đăng vuông mạng xã hội 1080×1080 Soft Mềm Soft Light Ánh sáng mềm + Soft Mix Trộn mềm Solid Đặc Sort Sắp xếp Sort by Kind Sắp xếp theo loại @@ -3397,6 +3469,7 @@ mark-style Japanese Kiểu Nhật Spaces Khoảng trắng Spacing Khoảng cách Spacing Guides Đường căn khoảng cách + Invoke Distance Guides Gọi đường căn đo khoảng cách Spacing: Khoảng cách: Spanish Tiếng Tây Ban Nha Spatter Bắn tung tóe @@ -3525,7 +3598,6 @@ mark-style Japanese Kiểu Nhật Switch x/y Đổi x/y Symbol Instance Thực thể ký hiệu Symbol Libraries Thư viện ký hiệu - Symbol Libraries (on the roadmap) Thư viện ký hiệu (trong lộ trình) Symbol Screener Tool Công cụ Lọc ký hiệu Symbol Scruncher Tool Công cụ Thu gọn ký hiệu Symbol Set Density: Mật độ bộ ký hiệu: @@ -3617,6 +3689,13 @@ mark-style Japanese Kiểu Nhật Tint Tông màu Tints Sắc pha Tints and Shades Sắc pha và bóng + Tips Mẹo + Colorization Tips Mẹo tô màu + The samples show this brush on a blue stroke. Các mẫu hiển thị cọ này trên một nét màu xanh lam. + The art keeps its own colors. Hình vẽ giữ nguyên màu của nó. + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. Màu đen trở thành màu nét và màu trắng vẫn là màu trắng. Các màu khác trở thành sắc pha của màu nét, và màu càng sáng thì sắc pha càng sáng. + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. Màu đen vẫn là màu đen và màu trắng vẫn là màu trắng. Các màu khác trở thành bóng của màu nét khi chúng tối và sắc pha của màu nét khi chúng sáng. + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. Màu chủ trở thành màu nét. Các màu khác dịch chuyển trên bánh xe màu một khoảng bằng màu chủ và giữ nguyên độ bão hòa và độ sáng. Tiny Lens Thấu kính nhỏ Tip extends past the end point Đầu mũi tên vượt qua điểm cuối Tip on the end point Đầu mũi tên tại điểm cuối @@ -3641,6 +3720,7 @@ mark-style Japanese Kiểu Nhật Top Left Trên trái Top Right Trên phải Top to Bottom Trên xuống dưới + Top-Justified Tab Tab căn trên Top-to-Top Leading Giãn dòng đỉnh đến đỉnh Top: Trên: Total Width: Tổng chiều rộng: @@ -3684,6 +3764,13 @@ mark-style Japanese Kiểu Nhật Transpose Chuyển vị Transverse (the page a quarter turn on the paper) Ngang (trang xoay 1/4 vòng trên giấy) Trap… Chồng màu… + Trap Chồng màu + Height/Width Cao/Rộng + Tint Reduction Giảm sắc độ + Traps with Process Color Chồng màu bằng màu process + Reverse Traps Đảo chiều chồng màu + Precision Độ chính xác + Remove Redundant Points Xóa điểm thừa Trim Cắt xén Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Cắt bỏ hình học bị che khi Mở rộng diện mạo. Xem trước trực tiếp giữ toàn bộ bề mặt. Sơn trong suốt và bề mặt vượt quá giới hạn hiển thị giữ nguyên tất cả các mặt. Trim mark weight: Độ dày dấu cắt: @@ -3907,7 +3994,7 @@ mark-style Japanese Kiểu Nhật Zoom to Selection Thu phóng đến vùng chọn Zoom Tool Công cụ thu phóng Zoom with Mouse Wheel Thu phóng bằng con lăn chuột - {count} file(s) in {folder}{note}. Show the package folder? {count} tệp trong {folder}{note}. Hiện thư mục gói? + {count} file(s) in {folder} {note}. Show the package folder? {count} tệp trong {folder}{note}. Hiện thư mục gói? {count} folder(s) or file(s) couldn't be read {count} thư mục hoặc tệp không thể đọc được {count} folder(s) skipped, such as other apps' and the system's folders and hidden ones Đã bỏ qua {count} thư mục, chẳng hạn thư mục của ứng dụng khác, thư mục hệ thống và thư mục ẩn {count} linked file(s) not found Không tìm thấy {count} tệp liên kết @@ -4034,6 +4121,25 @@ theme Appearance Giao diện @msg a PostScript error ({name} in `{op}`: {at}) lỗi PostScript ({name} trong `{op}`: {at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} lỗi PostScript ({name} trong `{op}`: {at}), trong {procs} @msg a PostScript error ({name}) lỗi PostScript ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) lỗi PostScript ({name} trong `{op}`; {meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} lỗi PostScript ({name} trong `{op}`; {meaning}), trong {procs} +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) lỗi PostScript ({name} trong `{op}`: {at}; {meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} lỗi PostScript ({name} trong `{op}`: {at}; {meaning}), trong {procs} +@msg a PostScript error ({name}; {meaning}) lỗi PostScript ({name}; {meaning}) +@msg the program ends a dictionary it didn't begin chương trình kết thúc một từ điển mà nó chưa bắt đầu +@msg the program opens a file, which the reader can't do chương trình mở một tệp, việc mà trình đọc PostScript không làm được +@msg the program restores a saved state that was already restored chương trình khôi phục một trạng thái đã lưu vốn đã được khôi phục +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none dữ liệu chương trình đọc bị hỏng hoặc bị cắt cụt, hoặc chương trình yêu cầu vị trí trong một tệp không có vị trí +@msg the program goes past one of the reader's limits chương trình vượt quá một giới hạn của trình đọc PostScript +@msg the path has no point to continue from đường dẫn không có điểm để tiếp tục +@msg a value is outside the range the operator takes một giá trị nằm ngoài phạm vi mà toán tử chấp nhận +@msg the operator needs more values than the stack holds toán tử cần nhiều giá trị hơn số có trong ngăn xếp +@msg part of the program isn't valid PostScript text một phần chương trình không phải là văn bản PostScript hợp lệ +@msg a value has the wrong type for the operator một giá trị có kiểu không phù hợp với toán tử +@msg the program runs, deletes or renames a file, which the reader can't do chương trình chạy, xóa hoặc đổi tên một tệp, việc mà trình đọc PostScript không làm được +@msg the program asks for a resource that isn't defined chương trình yêu cầu một tài nguyên chưa được định nghĩa +@msg a calculation has no result, as in a division by zero một phép tính không có kết quả, như khi chia cho không +@msg the operator looks for a mark on the stack and finds none toán tử tìm một dấu mốc trên ngăn xếp nhưng không thấy @msg a preset named `{_1}` exists đã có thiết lập sẵn tên `{_1}` @msg a swatch or color group named `{n}` exists đã có mẫu màu hoặc nhóm màu tên `{n}` @msg a slice size must be a positive number of points kích thước lát cắt phải là số điểm dương @@ -4059,13 +4165,16 @@ theme Appearance Giao diện @msg automation path rejected: a link that leads nowhere: {path} đường dẫn tự động bị từ chối: liên kết không dẫn đến đâu: {path} @msg automation path rejected: cannot resolve {path}: {e} đường dẫn tự động bị từ chối: không thể phân giải {path}: {e} @msg automation path rejected: outside the read root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc đọc {root}: {path} +@msg automation path rejected: outside the read roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc đọc {roots}: {path} @msg automation path rejected: outside the write root {root}: {path} đường dẫn tự động bị từ chối: ngoài thư mục gốc ghi {root}: {path} +@msg automation path rejected: outside the write roots {roots}: {path} đường dẫn tự động bị từ chối: ngoài các thư mục gốc ghi {roots}: {path} @msg automation path rejected: parent traversal below a folder that doesn't exist: {path} đường dẫn tự động bị từ chối: duyệt cha bên dưới thư mục không tồn tại: {path} @msg automation path rejected: path is empty đường dẫn tự động bị từ chối: đường dẫn trống @msg automation path rejected: reserved device names are not allowed: {path} đường dẫn tự động bị từ chối: tên thiết bị đặt trước không được phép: {path} @msg axis must be "vertical", "horizontal" or an angle in degrees, not `{v}` axis phải là "vertical", "horizontal" hoặc góc theo độ, không phải `{v}` @msg background {other}: transparent, white, black or a colour such as "#ff8800" background {other}: transparent, white, black hoặc màu như "#ff8800" @msg bad color {v} màu không hợp lệ {v} +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio phải là một số từ 0,1 đến 10 @msg the Color Picker is not open Bộ chọn màu chưa được mở @msg the Simplify slider is not open Thanh trượt Đơn giản hóa chưa được mở @msg the screen color sampler is already open công cụ lấy mẫu màu trên màn hình đã được mở @@ -4117,6 +4226,7 @@ theme Appearance Giao diện @msg Couldn't open {name}: {e} Không thể mở {name}: {e} @msg Couldn't paste from the clipboard: {e} Không thể dán từ khay nhớ tạm: {e} @msg Couldn't place {name}: the document it was dropped on was closed Không thể đặt {name}: tài liệu mà nó được thả vào đã đóng +@msg Couldn't place {name}: the document it was placed in was closed Không thể đặt {name}: tài liệu mà nó được đặt vào đã đóng @msg Couldn't place {name}: {e} Không thể đặt {name}: {e} @msg Couldn't read {name}: {e} Không thể đọc {name}: {e} @msg Couldn't save recovery data: {e} Không thể lưu dữ liệu phục hồi: {e} @@ -4153,6 +4263,8 @@ theme Appearance Giao diện @msg Exported {what} Đã xuất {what} @msg Exported {what} with {_1} note(s): {first} Đã xuất {what} với {_1} ghi chú: {first} @msg Exporting {_1} Đang xuất {_1} +@msg Packaging {_1} Đang đóng gói {_1} +@msg Placing {_1} Đang đặt {_1} @msg file version {0} is newer than this VectorCraft supports ({VERSION}) phiên bản tệp {0} mới hơn VectorCraft hỗ trợ ({VERSION}) @msg Flatten Transparency: select objects to flatten Dẹp phẳng độ trong suốt: hãy chọn đối tượng để dẹp phẳng @msg Flatten Transparency: {e} Dẹp phẳng độ trong suốt: {e} @@ -4165,9 +4277,15 @@ theme Appearance Giao diện @msg give the path `id` and the width point `index` hãy cung cấp `id` đường dẫn và `index` điểm chiều rộng @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it chuyển sắc “{name}” không được định nghĩa trong tệp: một màu đơn thay thế @msg group must be true or false group phải là true hoặc false +@msg icon must be true or false icon phải là true hoặc false +@msg icon only docks a panel in the icon column, with no anchor icon chỉ neo bảng vào cột biểu tượng, không có anchor +@msg column docks a set as a dock column, with no anchor or icon column neo một bộ thành cột neo, không có anchor hay icon +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} bảng đang nổi; hãy thu gọn nhóm của nó bằng window.panel.float {panel, collapsed} +@msg panel is not in a dock group bảng không thuộc nhóm nào của dock +@msg only a dock group above or below others collapses, not one beside another chỉ thu gọn được nhóm dock nằm trên hoặc dưới các nhóm khác, không phải nhóm nằm cạnh @msg guides are locked đường căn bị khóa @msg handle must be in or out handle phải là in hoặc out -@msg image '{key}' has no pixels and was left out ảnh '{key}' không có pixel và bị bỏ qua +@msg an image has no pixels and was left out một ảnh không có pixel đã bị bỏ qua @msg image data `{_1}` is missing dữ liệu ảnh `{_1}` bị thiếu @msg image is empty ảnh trống @msg Image Trace: the traced image is missing Vector hóa ảnh: ảnh đã vector hóa bị thiếu @@ -4239,7 +4357,7 @@ theme Appearance Giao diện @msg move requires a destination panel move cần bảng đích @msg move requires an existing destination panel move cần bảng đích tồn tại @msg Multichannel PSD files can't be read: save it in RGB, CMYK or Grayscale mode Tệp PSD đa kênh không thể đọc: lưu ở chế độ RGB, CMYK hoặc Thang xám -@msg multiple windows land with M11.5 nhiều cửa sổ sẽ có trong M11.5 +@msg New Window isn't available yet: a document opens in one window Cửa sổ mới chưa khả dụng: tài liệu mở trong một cửa sổ @msg name the file hãy đặt tên tệp @msg no active document không có tài liệu đang hoạt động @msg no active perspective plane không có mặt phẳng phối cảnh đang hoạt động @@ -4253,7 +4371,9 @@ theme Appearance Giao diện @msg no blend options không có tùy chọn hòa trộn @msg no bounds không có giới hạn @msg no brush named `{name}` không có cọ tên `{name}` +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} tên cọ lông có tối đa {max} ký tự: `{name}` có {count} ký tự @msg no brush selected chưa chọn cọ +@msg brushes move only within their type chỉ có thể di chuyển cọ trong cùng loại của nó @msg no catalog không có danh mục @msg no changes since the last save không có thay đổi nào kể từ lần lưu cuối @msg no clock to keep a heartbeat by không có đồng hồ để duy trì nhịp tim @@ -4273,6 +4393,7 @@ theme Appearance Giao diện @msg no file system here không có hệ thống tệp ở đây @msg no file writer không có bộ ghi tệp @msg no files to place không có tệp để đặt +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` không phải là tài liệu VectorCraft, tệp PDF hay tệp .ai tương thích PDF @msg No fonts are missing in this document Không có phông chữ nào bị thiếu trong tài liệu này @msg no format to export to không có định dạng để xuất @msg no free recovery area không có vùng phục hồi trống @@ -4316,6 +4437,7 @@ theme Appearance Giao diện @msg no such action không có hành động như vậy @msg no such anchor không có điểm neo như vậy @msg no such artboard không có bảng vẽ như vậy +@msg the artboard is locked bảng vẽ bị khóa @msg no such document không có tài liệu như vậy @msg no such layer or object không có lớp hoặc đối tượng như vậy @msg no such object {0} không có đối tượng như vậy: {0} @@ -4381,7 +4503,7 @@ theme Appearance Giao diện @msg Opened with {_1} note(s): {_2} Đã mở với {_1} ghi chú: {_2} @msg Opened {url} Đã mở {url} @msg Outline Stroke: no stroked paths selected Đường bao nét: chưa chọn đường dẫn có nét -@msg Packaged {n} file(s) in {folder}{note} Đã đóng gói {n} tệp trong {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} Đã đóng gói {n} tệp trong {folder}{note} @msg Packaged {n} file(s){note} Đã đóng gói {n} tệp{note} @msg page size {w} × {h} kích thước trang {w} × {h} @msg page {0} does not exist (the PDF has {1}) trang {0} không tồn tại (PDF có {1} trang) @@ -4392,9 +4514,11 @@ theme Appearance Giao diện @msg path is already closed đường dẫn đã được đóng @msg paths contain invalid coordinates đường dẫn chứa tọa độ không hợp lệ @msg paths: give at least one file đường dẫn: hãy cung cấp ít nhất một tệp +@msg replace: the object to replace was changed or deleted thay thế: đối tượng cần thay thế đã bị thay đổi hoặc xóa @msg pattern missing thiếu họa tiết @msg patterns nested too deeply are filled with mid-grey họa tiết lồng nhau quá sâu được tô bằng màu xám trung tính @msg PDF settings are not an object Cài đặt PDF không phải là đối tượng +@msg PDF security settings are not an object Cài đặt bảo mật PDF không phải là đối tượng @msg PDF writer error: {0} Lỗi ghi PDF: {0} @msg percent must be above 0 and at most 6400, not {p} phần trăm phải lớn hơn 0 và tối đa 6400, không phải {p} @msg pick a colour, gradient or pattern to save as a swatch hãy chọn màu, chuyển sắc hoặc họa tiết để lưu làm mẫu màu @@ -4454,6 +4578,10 @@ theme Appearance Giao diện @msg select a repeat hãy chọn một đối tượng lặp @msg select an Intertwine group hãy chọn một nhóm đan xen @msg select an Objects on Path group hãy chọn một nhóm đối tượng trên đường dẫn +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects không thể gắn đối tượng lên một đường dẫn nằm trong đường dẫn phức, đối tượng hòa trộn, Envelope, đối tượng lặp hoặc nhóm đan xen +@msg a clipping path can't take objects không thể gắn đối tượng lên đường dẫn cắt +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path không thể gắn các đối tượng nằm trong đối tượng hòa trộn, Envelope, đối tượng lặp hoặc nhóm đan xen lên đường dẫn +@msg a clipping path can't be attached to a path không thể gắn đường dẫn cắt lên đường dẫn @msg select a stroke with a variable width hãy chọn nét có chiều rộng biến đổi @msg select a symbol instance hãy chọn đối tượng ký hiệu @msg select an envelope hãy chọn một Envelope @@ -4617,6 +4745,7 @@ theme Appearance Giao diện @msg Updated {_1} image(s) Đã cập nhật {_1} ảnh @msg Updated {_1} linked file(s) changed on disk Đã cập nhật {_1} tệp liên kết đã thay đổi trên đĩa @msg variables are locked biến số bị khóa +@msg variables that aren't text were left out: {kinds} các biến số không phải văn bản đã bị bỏ qua: {kinds} @msg VectorCraft doesn't search other apps' or the system's folders: {path} VectorCraft không tìm kiếm trong thư mục của ứng dụng khác hoặc hệ thống: {path} @msg VectorCraft doesn't search the Shared or Public folder in Users: {path} VectorCraft không tìm kiếm thư mục Shared hoặc Public trong Users: {path} @msg VectorCraft failed to start: {e} VectorCraft khởi động thất bại: {e} @@ -4639,6 +4768,7 @@ theme Appearance Giao diện @msg {_1} has no {_2} style {_1} không có kiểu {_2} @msg {_1} is a live effect (Effect › Plug-ins) {_1} là hiệu ứng trực tiếp (Hiệu ứng › Plug-ins) @msg {_1} is an exporter (File › Export › Export As…) {_1} là plug-in xuất (Tệp › Xuất › Xuất thành…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` có {size} byte (giới hạn {MAX_DOCUMENT_DATA}) @msg {_1} is already used by {_2} {_1} đã được dùng bởi {_2} @msg {_1} linked file(s) can't be read here: their previews show {_1} tệp liên kết không thể đọc ở đây: bản xem trước được hiển thị @msg {_1} linked image(s) changed on disk: Update Links shows the new versions {_1} ảnh liên kết đã thay đổi trên đĩa: Cập nhật liên kết hiển thị phiên bản mới @@ -4692,6 +4822,9 @@ theme Appearance Giao diện Softness Độ mềm Ambient Light Ánh sáng môi trường Light behind the object Ánh sáng phía sau đối tượng + 3D Lighting Preset Thiết lập sẵn ánh sáng 3D + Standard Tiêu chuẩn + Diffuse Khuếch tán # 3D light management. Lights Danh sách đèn Add Light Thêm đèn @@ -4736,6 +4869,7 @@ theme Appearance Giao diện @msg Measured anchor no longer exists Điểm neo được đo không còn tồn tại @msg Measured segment no longer exists Đoạn được đo không còn tồn tại @msg Non-finite dimension Kích thước không phải là số hữu hạn +@msg Scale Text Attributes: selected type has no scale to bake Tỷ lệ thuộc tính văn bản: văn bản được chọn không có tỷ lệ để áp dụng @msg Scale must be drawing:real, for example 1:10 Tỷ lệ phải có dạng bản vẽ:thực tế, ví dụ 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 Tỷ lệ phải nằm trong khoảng 1:1000000 đến 1000000:1 @msg Second measured segment no longer exists Đoạn được đo thứ hai không còn tồn tại @@ -4754,6 +4888,7 @@ theme Appearance Giao diện Make Image Dynamic Tạo hình ảnh động Import Data Sets… Nhập tập dữ liệu… Export Data Sets… Xuất tập dữ liệu… + Load Variable Library… Tải thư viện biến số… Make Graph Dynamic Tạo biểu đồ động Batch… Hàng loạt… Source Folder: Thư mục nguồn: @@ -4777,6 +4912,12 @@ theme Appearance Giao diện Sketch Phác thảo Halftone Pattern… Mẫu bán sắc… Halftone Pattern Mẫu bán sắc + Torn Edges… Cạnh rách… + Torn Edges Cạnh rách + Conté Crayon… Bút chì Conté… + Conté Crayon Bút chì Conté + Foreground Level Mức tiền cảnh + Background Level Mức nền Pattern Type Loại mẫu Dot Chấm # User Interface › Color Scheme @@ -4795,6 +4936,7 @@ theme Appearance Giao diện Watercolor… Màu nước… Watercolor Màu nước Brush Size Kích thước cọ + Texture Coverage Độ phủ kết cấu Definition Độ nét Brush Detail Chi tiết cọ Shadow Intensity Cường độ bóng @@ -4823,13 +4965,21 @@ theme Appearance Giao diện @msg the image's pixels can't be read không đọc được các điểm ảnh của ảnh Persistent Lưu giữ Library Panel Options Tùy chọn bảng thư viện + Locate Library… Xác định vị trí thư viện… @msg Persistent libraries not found: {files} Không tìm thấy thư viện lưu giữ: {files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` không phải là bảng thư viện (xem window.swatchLibrary) @msg library panel `{given}` isn't open bảng thư viện `{given}` chưa được mở +@msg library panel `{given}` isn't missing its library thư viện của bảng thư viện `{given}` không bị thiếu @msg too many library panels were opened in this session đã mở quá nhiều bảng thư viện trong phiên này @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. Ảnh có kích thước {_1} × {_2} điểm ảnh, hơn {LARGE_TRACE_SIDE} ở một cạnh: ảnh vẫn được dò, nhưng chậm hơn. Một thiết lập sẵn có độ trung thực thấp hơn hoặc một bản sao nhỏ hơn của ảnh sẽ dò nhanh hơn. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). Ảnh đã được dò ở {_1} × {_2} điểm ảnh, thu nhỏ từ {_3} × {_4} (tối đa {_5} megapixel). + Snapping Hút + Snap to Tangent Hút vào tiếp tuyến + Snap to Perpendicular Hút vào đường vuông góc +@msg unknown snap option `{key}` (use {options}) tùy chọn hút không hợp lệ `{key}` (dùng {options}) + Gap Length: Độ dài khoảng hở: Artboard {n} of {total} Bảng vẽ {n} / {total} + File {n} of {total} Tệp {n} / {total} Encoding and writing the file… Đang mã hóa và ghi tệp… @plural {n} more export waiting|{n} more exports waiting Còn {n} lượt xuất đang chờ Continue in Background Tiếp tục trong nền @@ -4846,6 +4996,14 @@ theme Appearance Giao diện Minimum must not exceed Desired, nor Desired Maximum. Giá trị tối thiểu không được vượt quá giá trị mong muốn, và giá trị mong muốn không được vượt quá tối đa. Minimum Tối thiểu Justification Căn đều + Add filters from the list above Thêm bộ lọc từ danh sách phía trên + Add to Stack Thêm vào ngăn xếp + Applied Filters Bộ lọc đã áp dụng + Categories Danh mục + Choose a filter Chọn bộ lọc + Effect Gallery Thư viện hiệu ứng + Filters Bộ lọc + Select artwork to apply effects Chọn đồ họa để áp dụng hiệu ứng @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them các biến và tập dữ liệu của tệp chưa được đọc (biến: {variables}, tập dữ liệu: {data_sets}): bảng Biến không liệt kê chúng # Select by Color (select.color) Select by Color Chọn theo màu @@ -4867,3 +5025,107 @@ theme Appearance Giao diện Plaster Thạch cao Note Paper… Giấy ghi chú… Note Paper Giấy ghi chú + Water Paper… Giấy ướt… + Water Paper Giấy ướt + Ruby… Ruby… + Ruby Ruby + Geometric Guides Đường dẫn hình học + Ease In Chậm lúc đầu + Ease Out Chậm lúc cuối + Ease In Out Chậm lúc đầu và cuối + Ease Out In Chậm lúc cuối và đầu + Ramp: Cường độ: + Shift: Dịch chuyển: + Fresco… Bích họa… + Fresco Bích họa + Effect Gallery… Thư viện hiệu ứng… + Effect Gallery Dialog Hộp thoại Thư viện hiệu ứng + Arc Segment Tool Options Tùy chọn công cụ cung phân đoạn + Base Along: Cơ sở dọc theo: + Closed Đóng + Concave Lõm + Convex Lồi + Fill Arc Tô cung + Length X-Axis: Chiều dài trục X: + Length Y-Axis: Chiều dài trục Y: + Slope: Độ dốc: +axis X Axis Trục X +axis Y Axis Trục Y +@msg Packaged {n} file(s) in {folder} Đã đóng gói {n} tệp trong {folder} +@msg Packaged {n} file(s) {note} Đã đóng gói {n} tệp {note} +@msg Packaged {n} file(s) Đã đóng gói {n} tệp + Packaged {n} file(s) Đã đóng gói {n} tệp + Packaged {n} file(s) in {folder} Đã đóng gói {n} tệp trong {folder} + Packaged {n} file(s) {note} Đã đóng gói {n} tệp {note} + {count} file(s) in {folder}. Show the package folder? {count} tệp trong {folder}. Hiện thư mục gói? + Clockwise Theo chiều kim đồng hồ + Dither Dither + Fine noise hides banding in the gradient fill Nhiễu mịn che dải màu trong vùng tô chuyển sắc +@msg `dither` applies to gradient fills (stroke: false) `dither` áp dụng cho vùng tô chuyển sắc (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` áp dụng cho vùng tô chuyển sắc (paint.setFill) +@msg `dither` must be true or false `dither` phải là true hoặc false + Perceptual Theo cảm nhận +@msg `method` must be classic or perceptual `method` phải là classic hoặc perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? Cài đặt “{name}”, plug-in yêu cầu ngân sách tính toán lớn? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. Mỗi lần chạy có thể mất tới {minutes} phút và làm khối lượng việc gấp {times} lần mức cho phép với các plug-in khác. Plug-in chạy trong nền và bạn có thể hủy. + It may take minutes, so it runs in the background, where you can cancel it. Việc này có thể mất vài phút nên plug-in chạy trong nền, nơi bạn có thể hủy. + The plug-in is computing… Plug-in đang tính toán… + Inspect Plug-in Kiểm tra plug-in + Cancel Plug-in Run Hủy lần chạy plug-in +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} đã chạy xong, nhưng tài liệu hoặc plug-in đã thay đổi trong lúc đó: hãy chạy lại +@msg Running “{name}”… Đang chạy “{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy` +@msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý +# Actions panel: action sets (#3712). + New Set… Bộ mới… + New Set Bộ mới + Set Options… Tùy chọn bộ… + Set Options Tùy chọn bộ + Create New Set Tạo bộ mới + Stop Global Edit Dừng chỉnh sửa toàn cục + Global Edit Options Tùy chọn chỉnh sửa toàn cục + Match Khớp + On Artboards Trên các bảng vẽ + All Artboards Tất cả bảng vẽ + Include Objects On Canvas Bao gồm đối tượng trên vùng vẽ + Result(s): {count} Kết quả: {count} +@msg select one object hãy chọn một đối tượng +@msg Global Edit works on paths, compound paths and symbol instances Chỉnh sửa toàn cục hoạt động trên đường dẫn, đường dẫn phức hợp và phiên bản biểu tượng +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again không tìm thấy đối tượng tương tự (các bản sao đã xoay hoặc lật không được tính); hãy thay đổi tùy chọn chỉnh sửa toàn cục rồi thử lại +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all hoặc một phạm vi như 1, 3-5, không phải {v} + Add to Symbols Thêm vào ký hiệu + Basic Shapes Hình cơ bản + Open Symbol Library Mở thư viện ký hiệu + Save Symbol Library Lưu thư viện ký hiệu + Save Symbol Library… Lưu thư viện ký hiệu… + Save Symbols… Lưu ký hiệu… + Selected Symbols Only Chỉ ký hiệu đã chọn + Symbol Libraries Menu Menu thư viện ký hiệu + Symbol Library Thư viện ký hiệu + User Symbol Library 1 Thư viện ký hiệu người dùng 1 + User Symbol Library 2 Thư viện ký hiệu người dùng 2 + User Symbol Library 3 Thư viện ký hiệu người dùng 3 + User Symbol Library 4 Thư viện ký hiệu người dùng 4 + User Symbol Library 5 Thư viện ký hiệu người dùng 5 + User Symbol Library 6 Thư viện ký hiệu người dùng 6 + User Symbol Library 7 Thư viện ký hiệu người dùng 7 + User Symbol Library 8 Thư viện ký hiệu người dùng 8 + User Symbol Library 9 Thư viện ký hiệu người dùng 9 + User Symbol Library 10 Thư viện ký hiệu người dùng 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` không có ký hiệu nào để đặt +@msg missing `library` (see symbol.libraries) thiếu `library` (xem symbol.libraries) +@msg no symbol `{n}` không có ký hiệu `{n}` +@msg no symbol `{n}` in `{_1}` không có ký hiệu `{n}` trong `{_1}` +@msg no symbol library `{key}` (see symbol.libraries) không có thư viện ký hiệu `{key}` (xem symbol.libraries) +@msg the document has no symbols to save tài liệu không có ký hiệu nào để lưu +@msg Trap: select two or more objects filled with solid colours Chồng màu: hãy chọn hai đối tượng trở lên được tô màu đặc +@msg Trap: no edges of different colours meet among the selected objects Chồng màu: không có cạnh khác màu nào tiếp giáp giữa các đối tượng đã chọn +@msg thickness must be from 0.01 to 5000 pt thickness phải từ 0,01 đến 5000 pt +@msg heightWidth must be from 1 to 1000 % heightWidth phải từ 1 đến 1000 % +@msg tintReduction must be from 0 to 100 % tintReduction phải từ 0 đến 100 % + Rectangular Grid Tool Options Tùy chọn công cụ Lưới chữ nhật + Polar Grid Tool Options Tùy chọn công cụ Lưới cực + Fill Line Tô đoạn thẳng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0676caea5..f3b42de41 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -225,6 +225,11 @@ Average… 平均… Axis: 轴: Background Color: 背景颜色: + Show Center Mark 显示中心标记 + Show Cross Hairs 显示十字线 + Show Video Safe Areas 显示视频安全区域 + Video Ruler Pixel Aspect Ratio: 视频标尺像素长宽比: + Hide Video Rulers 隐藏视频标尺 Background Contents 背景内容 Background Contents: 背景内容: Background: 背景: @@ -262,6 +267,7 @@ Blur 模糊 Bottom 下 Bottom in Front 下方置前 + Bottom-Justified Tab 底对齐制表位 Bottom-to-Bottom Leading 基线至基线行距 Burasagari 标点悬挂 Force 强制 @@ -295,6 +301,13 @@ Built-in presets don't change: New… starts an editable copy. 内置预设不会变动:“新建…”会创建可编辑的副本。 Bulge… 鼓起… Bullet 项目符号 + Bullets 项目符号 + Bullets and Numbering 项目符号和编号 + Apply Bullets 应用项目符号 + Filled Dot 实心圆点 + Dash 短划线 + Hollow Circle 空心圆 + Filled Square 实心方块 Butt Cap 平头端点 Byte Order: 字节顺序: Calligraphic 书法 @@ -1001,6 +1014,7 @@ Eyedropper Picks Up: 吸管拾取: Eyedropper Tool 吸管工具 Eyedropper: click the art to sample the stop's colour 吸管:点击图稿以取样色标的颜色 + Eyedropper: click the art to sample the key color 吸管:点击图稿以取样主色 Feather… 羽化… Feet 英尺 Feet & Inches 英尺和英寸 @@ -1008,6 +1022,11 @@ Fidelity: 精确度: Figure Space 数字空格 Figure: 数字: +# Artwork drag between document tabs. + Drop Artwork 放置图稿 +@msg {key} must be [x, y] with finite coordinates {key} 必须是具有有限坐标的 [x, y] +@msg ids must contain 1–10000 object ids ids 必须包含 1–10000 个对象 ID + File 文件 File Dimensions 文件尺寸 File Format: 文件格式: @@ -1029,6 +1048,16 @@ Alt key toggles to Smooth Tool Alt 键切换为平滑工具 Keep selected 保持选择状态 Merge only with selection 仅与所选对象合并 + Consider Open Filled Paths as Closed 将有填色的开放路径视为闭合路径 + In Merge Mode, Clicking Stroke Splits the Path 在合并模式下,单击描边可分割路径 + Pick Color From: 颜色来源: + Artwork 图稿 + Cursor Swatch Preview 在指针旁预览色板 + Selection: 选择方式: + Freeform 自由 + Straight Line 直线 + Highlight: 突出显示: + Stroke When Editable 可编辑时的描边 Fill Color 填色 Fill New Type Objects With Placeholder Text 用占位文本填充新文字对象 Fill Rule 填充规则 @@ -1156,6 +1185,8 @@ file-filter Templates 模板 Large Gaps 大间隙 Gap Preview Color: 间隙预览颜色: Close Gaps With Paths 用路径封闭间隙 + Show Live Paint Gaps 显示实时上色间隙 + Hide Live Paint Gaps 隐藏实时上色间隙 Select Fills 选择填色 Select Strokes 选择描边 Gaussian Blur… 高斯模糊… @@ -1261,6 +1292,7 @@ file-filter Templates 模板 Hand-Drawn 手绘 Handles 手柄 Hard Light 强光 + Hard Mix 实色混合 Harmony Rules: 协调规则: Harmony Sets 色彩调和组 Height 高度 @@ -1306,7 +1338,7 @@ file-filter Templates 模板 Clear History 清除历史记录 Set History Limit… 设置历史记录上限… History States 历史记录步数 -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set 应用数据集 Bloat 膨胀 Cancel Pattern Editing 取消图案编辑 @@ -1783,6 +1815,7 @@ file-filter Templates 模板 @msg x and y must be finite x 和 y 必须是有限值 @msg t, dx and dy must be finite t、dx 和 dy 必须是有限值 @msg handle must be in or out handle 必须是 in 或 out + Move Brush 移动画笔 Move Swatch 移动色板 Move Up 上移 Move… 移动… @@ -1856,6 +1889,14 @@ file-filter Templates 模板 Round Fan 圆扇头 Flat Fan 扁扇头 New Brush… 新建画笔… + New Brush 新建画笔 + Calligraphic Brush 书法画笔 + Scatter Brush 散布画笔 + Art Brush 艺术画笔 + Bristle Brush 鬃毛画笔 + Pattern Brush 图案画笔 + Select art to make a scatter, art or pattern brush. 选择图稿以创建散布画笔、艺术画笔或图案画笔。 + Brush Art 画笔图稿 New Calligraphic Brush 新建书法画笔 New Character Style 新建字符样式 New Color Group 新建颜色组 @@ -1884,6 +1925,7 @@ file-filter Templates 模板 New View 新建视图 New View… 新建视图… New Window 新建窗口 + New Without Dialog 无对话框新建 New Workspace 新建工作区 New Workspace… 新建工作区… New {kind} Style… 新建{kind}样式… @@ -2089,7 +2131,7 @@ file-filter Templates 模板 Package 打包 Package collects a saved document with its linked files and fonts. 打包会将已存储的文件连同其链接文件与字体一并收集。 Package created 已创建打包 - Packaged {n} file(s) in {folder}{note} 已将 {n} 个文件打包于 {folder}{note} + Packaged {n} file(s) in {folder} {note} 已将 {n} 个文件打包于 {folder}{note} Packaged {n} file(s){note} 已打包 {n} 个文件{note} Package… 打包… Page 页面 @@ -2153,7 +2195,7 @@ file-filter Templates 模板 Performance 性能 diagnostics render 渲染 diagnostics ui 界面 - Performance & Storage 性能和存储 + Plug-ins & Scratch Disks 增效工具 & 暂存盘 Permissions 权限 Permissions password: 权限密码: Perspective 透视 @@ -2275,6 +2317,7 @@ font-script Japanese 汉字 Proof Setup 校样设置 Properties 属性 Proportional Metrics 比例度量 + Character Direction 字符方向 Digits 数字 Arabic 阿拉伯数字 Hindi 印地数字 @@ -2467,6 +2510,7 @@ font-script Japanese 汉字 Reorder 重新排序 Reorder Appearance Item 重新排序外观项目 Repeat 重复 + Repeat Objects 重复对象 Repeat Options 重复选项 Repeat Pathfinder 重复路径查找器 Repeat Tab 重复制表位 @@ -2474,7 +2518,7 @@ font-script Japanese 汉字 Replace 替换 Replace Font 替换字体 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 将其替换为其他文件,或忽略:其图像继续显示随文档保存的预览。 - Replace Spine 替换路径 + Replace Spine 替换脊线 Replace Symbol 替换符号 Replace With Font 替换为字体 Replaces the saved preset of that name. 替换同名已存预设。 @@ -2520,7 +2564,7 @@ font-script Japanese 汉字 Reverse Path Direction 反转路径方向 Reverse Path Direction Off 关闭反转路径方向 Reverse Path Direction On 开启反转路径方向 - Reverse Spine 反转路径 + Reverse Spine 反转脊线 Revert 恢复 Revert to the saved version of “{name}”? 要恢复为“{name}”已存储的版本吗? Replace “{name}”, the file this document was opened from? 要替换“{name}”(打开此文档的源文件)吗? @@ -2648,6 +2692,7 @@ font-script Japanese 汉字 Scale Cursor Proportional to UI 光标随界面等比缩放 Scale Lineweights 缩放线宽 Scale Strokes & Effects 缩放描边和效果 + Scale Text Attributes 缩放文本属性 Scale to Fit Artboard 缩放以适合画板 Scale Tool 比例缩放工具 Scale: 缩放: @@ -2913,6 +2958,7 @@ font-script Japanese 汉字 Snapping Tolerance 吸附容差 Social Square Post 1080×1080 社交方形帖子 1080×1080 Soft Light 柔光 + Soft Mix 柔和混合 Solid 实心 Sort by Kind 按类型排序 Sort by Name 按名称排序 @@ -2923,6 +2969,7 @@ font-script Japanese 汉字 Space Before Paragraph 段前间距 Spaces 空格 Spacing Guides 间距参考线 + Invoke Distance Guides 调用距离参考线 Spacing: 间距: Spanish 西班牙语 Specified Distance 指定距离 @@ -3060,7 +3107,6 @@ font-script Japanese 汉字 Show all open documents 显示所有打开的文档 Symbol Instance 符号实例 Symbol Libraries 符号库 - Symbol Libraries (on the roadmap) 符号库 (已列入路线图) Symbol Screener Tool 符号透明度工具 Symbol Scruncher Tool 符号紧缩工具 Symbol Shifter Tool 符号移位工具 @@ -3152,9 +3198,11 @@ font-script Japanese 汉字 Tools 工具 Top 上 Top in Front 上方在前 + Top-Justified Tab 顶对齐制表位 Top-to-Top Leading 上缘至上缘行距 Top: 上: Total Width: 总宽度: + Touch 触控 Touch Type Tool 修饰文字工具 Trace 描摹 Trace the image with the Default preset 使用“默认”预设描摹图像 @@ -3188,6 +3236,13 @@ font-script Japanese 汉字 Transparent Background 透明背景 Transverse (the page a quarter turn on the paper) 横向 (页面在纸张上转四分之一圈) Trap… 陷印… + Trap 陷印 + Height/Width 高度/宽度 + Tint Reduction 色调减淡 + Traps with Process Color 使用印刷色陷印 + Reverse Traps 反向陷印 + Precision 精度 + Remove Redundant Points 删除冗余点 Trim 修边 Trim mark weight: 剪裁标记宽度: Trim marks 剪裁标记 @@ -3313,6 +3368,7 @@ font-script Japanese 汉字 Lock Variables 锁定变量 Unlock Variables 解锁变量 @msg variables are locked 变量已锁定 +@msg variables that aren't text were left out: {kinds} 已省略非文本变量:{kinds} List Variables 列出变量 Bind Variable 绑定变量 Unbind Variable 解除变量绑定 @@ -3426,7 +3482,7 @@ font-script Japanese 汉字 Zoom Tool 缩放工具 Zoom with Mouse Wheel 使用鼠标滚轮缩放 Scrub Numeric Fields by Dragging 拖动以调整数值字段 - {count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 个文件{note}。要显示打包文件夹吗? + {count} file(s) in {folder} {note}. Show the package folder? {folder} 中有 {count} 个文件{note}。要显示打包文件夹吗? {count} linked file(s) not found 找不到 {count} 个链接文件 fonts not copied: {fonts} 未复制的字体:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 自存储此文档后,有 {count} 个链接图像已更改。要显示新版本吗? @@ -3498,7 +3554,8 @@ imageTrace Outline 轮廓 to sample a color 以取样颜色 across a selected object to set the gradient direction 横跨所选对象以设置渐变方向 to select an artboard | 以选择画板 | - on the canvas to create one 在画布上以创建画板 + on the canvas to create one ( 在画布上以创建画板 ( + selects several, or draws one inside an artboard) 可选择多个,或在画板内创建画板) a stroke to add a width point | 描边可添加宽度点 | to change one side | 可只更改单侧 | to select more points | 可选择更多点 | @@ -3555,7 +3612,7 @@ imageTrace Outline 轮廓 Use Default 使用默认值 # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. # Simplified Chinese: no final 。 unless the English ends with a period; a space between Chinese text and # an adjacent Latin word, number or {placeholder}; 、 between the items of a list. @@ -3593,7 +3650,7 @@ imageTrace Outline 轮廓 @msg the scale is not a usable number 比例不是可用的数字 @msg the drawing is too large to open 图纸过大,无法打开 @msg scale must be a number from {_1} to {_2}, not {_3} 比例必须是 {_1} 到 {_2} 之间的数字,而不是 {_3} -@msg image '{key}' has no pixels and was left out 图像 '{key}' 没有像素,已略去 +@msg an image has no pixels and was left out 已略去没有像素的图像 @msg Embedded {n} image(s) 已嵌入 {n} 张图像 @msg object {0} cannot have children 对象 {0} 不能包含子对象 @msg range `{s}` is empty 范围 `{s}` 为空 @@ -3604,7 +3661,9 @@ imageTrace Outline 轮廓 @msg Show All Hidden Attributes: nothing is hidden 显示所有隐藏属性:没有隐藏任何内容 @msg Expand: no brush strokes selected 扩展:未选择画笔描边 @msg no brush named `{name}` 没有名为 `{name}` 的画笔 +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} 鬃毛画笔的名称最多 {max} 个字符:`{name}` 有 {count} 个字符 @msg no brush selected 未选择画笔 +@msg brushes move only within their type 画笔只能在同一类型内移动 @msg no symbol instances selected 未选择符号实例 @msg select a symbol instance 请选择一个符号实例 @msg the art has no bounds 图稿没有边界 @@ -3647,6 +3706,7 @@ imageTrace Outline 轮廓 @msg no saved selection recalled 未调出任何已保存的选区 @msg no saved selections 没有已保存的选区 @msg no such artboard 没有此画板 +@msg the artboard is locked 该画板已锁定 @msg cannot split an open path at its end point 无法在开放路径的端点处拆分该路径 @msg closing needs three anchors 闭合需要三个锚点 @msg empty brush stroke 画笔描边为空 @@ -3734,7 +3794,7 @@ imageTrace Outline 轮廓 @msg {path}: not a file {path}:不是文件 @msg {path}: {e} {path}:{e} @msg can't write .{ext} images 无法写入 .{ext} 图像 -@msg Replace Spine: select a blend and a path 替换混合轴:请选择一个混合对象和一条路径 +@msg Replace Spine: select a blend and a path 替换脊线:请选择一个混合对象和一条路径 @msg nothing to wrap 没有可绕排的内容 @msg select a blend 请选择一个混合对象 @msg select a gradient mesh 请选择一个渐变网格 @@ -3788,6 +3848,10 @@ imageTrace Outline 轮廓 @msg select a repeat 请选择一个重复对象 @msg select an Intertwine group 请选择一个交织组 @msg select an Objects on Path group 请选择一个路径上的对象组 +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 复合路径、混合对象、封套、重复对象或交织组中的路径无法附加对象 +@msg a clipping path can't take objects 剪切路径无法附加对象 +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 混合对象、封套、重复对象或交织组中的对象无法附加到路径 +@msg a clipping path can't be attached to a path 剪切路径无法附加到路径 @msg select the art to repeat 请选择要重复的图稿 @msg the document has no patterns 文档中没有图案 @msg no artboard 没有画板 @@ -3879,6 +3943,25 @@ imageTrace Outline 轮廓 @msg a PostScript error ({name} in `{op}`: {at}) PostScript 错误 ({name},位于 `{op}`:{at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript 错误 ({name},位于 `{op}`:{at}),在 {procs} 中 @msg a PostScript error ({name}) PostScript 错误 ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) PostScript 错误 ({name},位于 `{op}`;{meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} PostScript 错误 ({name},位于 `{op}`;{meaning}),在 {procs} 中 +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript 错误 ({name},位于 `{op}`:{at};{meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} PostScript 错误 ({name},位于 `{op}`:{at};{meaning}),在 {procs} 中 +@msg a PostScript error ({name}; {meaning}) PostScript 错误 ({name};{meaning}) +@msg the program ends a dictionary it didn't begin 程序要结束一个它没有开始的字典 +@msg the program opens a file, which the reader can't do 程序要打开文件,而 PostScript 读取器无法打开文件 +@msg the program restores a saved state that was already restored 程序要恢复一个已经恢复过的保存状态 +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none 程序读取的数据已损坏或不完整,或者程序要在没有位置的文件中获取位置 +@msg the program goes past one of the reader's limits 程序超出了 PostScript 读取器的一项限制 +@msg the path has no point to continue from 路径没有可以接着绘制的点 +@msg a value is outside the range the operator takes 某个值超出了运算符接受的范围 +@msg the operator needs more values than the stack holds 运算符需要的值多于栈中的值 +@msg part of the program isn't valid PostScript text 程序的一部分不是有效的 PostScript 文本 +@msg a value has the wrong type for the operator 某个值的类型不适用于该运算符 +@msg the program runs, deletes or renames a file, which the reader can't do 程序要运行、删除或重命名文件,而 PostScript 读取器无法执行这些操作 +@msg the program asks for a resource that isn't defined 程序请求了未定义的资源 +@msg a calculation has no result, as in a division by zero 计算没有结果,例如除以零 +@msg the operator looks for a mark on the stack and finds none 运算符在栈中找不到标记 @msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know 它使用了 `{at}`,VectorCraft 的 PostScript 读取器无法识别 @msg the file was read up to an error ({why}): what follows it was left out 文件已读取到出错处 ({why}):其后的内容已略去 @msg this file's PostScript couldn't be read ({why}): {what} 无法读取此文件的 PostScript ({why}):{what} @@ -3962,6 +4045,8 @@ imageTrace Outline 轮廓 @msg SVG parse error: {0} SVG 解析错误:{0} @msg decimals must be {_1}–{_2} (got {_3}) 小数位数必须为 {_1}–{_2} (实际为 {_3}) @msg Exporting {_1} 正在导出 {_1} +@msg Packaging {_1} 正在打包 {_1} +@msg Placing {_1} 正在置入 {_1} @msg Exported {what} 已导出 {what} @msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是颜色:请写为 #rrggbb @msg could not decode image: {0} 无法解码图像:{0} @@ -3975,6 +4060,7 @@ imageTrace Outline 轮廓 @msg {label}… {label}… @msg no blend options 没有混合选项 @msg bad color {v} 颜色 {v} 无效 +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio 必须是 0.1 到 10 之间的数字 @msg the Color Picker is not open 拾色器未打开 @msg the Simplify slider is not open 简化滑块未打开 @msg the screen color sampler is already open 屏幕取色器已打开 @@ -4004,7 +4090,7 @@ imageTrace Outline 轮廓 @msg {_1} linked file(s) can't be read here: their previews show 此处无法读取 {_1} 个链接文件:显示的是其预览 @msg {_1} linked image(s) changed on disk: Update Links shows the new versions 有 {_1} 个链接图像已在磁盘上更改:“更新链接”可显示新版本 @msg pick a colour, gradient or pattern to save as a swatch 请选取要保存为色板的颜色、渐变或图案 -@msg Packaged {n} file(s) in {folder}{note} 已将 {n} 个文件打包到 {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} 已将 {n} 个文件打包到 {folder}{note} @msg Packaged {n} file(s){note} 已打包 {n} 个文件{note} @msg choose a location for the package 请为打包选择位置 @msg missing `folder` 缺少 `folder` @@ -4017,6 +4103,7 @@ imageTrace Outline 轮廓 @msg no plug-in {id} is installed 未安装增效工具 {id} @msg {_1} is a live effect (Effect › Plug-ins) {_1} 是实时效果 (效果 › 增效工具) @msg {_1} is an exporter (File › Export › Export As…) {_1} 是导出增效工具 (文件 › 导出 › 导出为…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` 为 {size} 字节(上限 {MAX_DOCUMENT_DATA}) @msg a preset named `{_1}` exists 名为 `{_1}` 的预设已存在 @msg a swatch or color group named `{n}` exists 名为 `{n}` 的色板或颜色组已存在 @msg no print preset named `{base}` 没有名为 `{base}` 的打印预设 @@ -4034,6 +4121,7 @@ imageTrace Outline 轮廓 @msg name the file 请为文件命名 @msg unknown format 未知格式 @msg PDF settings are not an object PDF 设置不是对象 +@msg PDF security settings are not an object PDF 安全性设置不是对象 @msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) 没有名为 `{name}` 的已保存 PDF 预设 (内置预设为只读:请以它们为基础新建一个) @msg Saved to User Defined: {_1} 已保存到“用户定义”:{_1} @msg choose a direction to divide in 请选择分割方向 @@ -4081,7 +4169,7 @@ imageTrace Outline 轮廓 @msg missing `name` 缺少 `name` @msg missing `tool` 缺少 `tool` @msg missing index 缺少 index -@msg multiple windows land with M11.5 多窗口将在 M11.5 中提供 +@msg New Window isn't available yet: a document opens in one window 新建窗口尚不可用:文档在一个窗口中打开 @msg no PNG encoder 没有 PNG 编码器 @msg no document open 没有打开的文档 @msg no effect applied yet 尚未应用任何效果 @@ -4123,8 +4211,11 @@ imageTrace Outline 轮廓 @msg no swatch library `{l}` (see swatch.library.list) 没有色板库 `{l}` (请参阅 swatch.library.list) @msg no user library folder here 此处没有用户库文件夹 @msg Couldn't place {name}: the document it was dropped on was closed 无法置入 {name}:将它拖放到的文档已关闭 +@msg Couldn't place {name}: the document it was placed in was closed 无法置入 {name}:要置入的文档已关闭 @msg no files to place 没有要置入的文件 +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文档、PDF 或 PDF 兼容的 .ai 文件 @msg paths: give at least one file paths:请至少给出一个文件 +@msg replace: the object to replace was changed or deleted replace:要替换的对象已被更改或删除 @msg Couldn't place {name}: {e} 无法置入 {name}:{e} @msg no print service 没有打印服务 @msg the print job has no data 打印作业没有数据 @@ -4194,7 +4285,9 @@ imageTrace Outline 轮廓 @msg automation filesystem access is not granted: read authority is absent: {path} 未授予自动化文件系统访问权限:缺少读取权限:{path} @msg automation filesystem access is not granted: write authority is absent: {path} 未授予自动化文件系统访问权限:缺少写入权限:{path} @msg automation path rejected: outside the read root {root}: {path} 已拒绝自动化路径:位于读取根目录 {root} 之外:{path} +@msg automation path rejected: outside the read roots {roots}: {path} 已拒绝自动化路径:位于所有读取根目录 {roots} 之外:{path} @msg automation path rejected: outside the write root {root}: {path} 已拒绝自动化路径:位于写入根目录 {root} 之外:{path} +@msg automation path rejected: outside the write roots {roots}: {path} 已拒绝自动化路径:位于所有写入根目录 {roots} 之外:{path} @msg automation path rejected: path is empty 已拒绝自动化路径:路径为空 @msg automation path rejected: a link that leads nowhere: {path} 已拒绝自动化路径:链接指向不存在的位置:{path} @msg automation path rejected: cannot resolve {path}: {e} 已拒绝自动化路径:无法解析 {path}:{e} @@ -4218,6 +4311,12 @@ imageTrace Outline 轮廓 @msg before must name a panel before 必须指定面板 @msg docking action contains an unknown panel or an excessive split path 停靠操作包含未知面板或过长的拆分路径 @msg group must be true or false group 必须是 true 或 false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 面板处于浮动状态;请使用 window.panel.float {panel, collapsed} 折叠其面板组 +@msg panel is not in a dock group 面板不在停放组中 +@msg only a dock group above or below others collapses, not one beside another 只有位于其他组上方或下方的停放组可以折叠,并排的不行 +@msg icon must be true or false icon 必须为 true 或 false +@msg icon only docks a panel in the icon column, with no anchor icon 只会把面板停放到图标栏中,不能带 anchor +@msg column docks a set as a dock column, with no anchor or icon column 将一组面板停放为停放栏,不能带 anchor 或 icon @msg move requires a destination panel 移动需要目标面板 @msg move requires an existing destination panel 移动需要现有的目标面板 @msg onto must name a floating panel onto 必须指定浮动面板 @@ -4253,6 +4352,7 @@ imageTrace Outline 轮廓 @msg Measured anchor no longer exists 测量锚点已不存在 @msg Measured segment no longer exists 测量线段已不存在 @msg Non-finite dimension 尺寸标注不是有限值 +@msg Scale Text Attributes: selected type has no scale to bake 缩放文本属性:所选文本没有可应用的缩放 @msg Scale must be drawing:real, for example 1:10 比例必须为 图纸:实际,例如 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 比例必须介于 1:1000000 和 1000000:1 之间 @msg Second measured segment no longer exists 第二条测量线段已不存在 @@ -4513,6 +4613,13 @@ imageTrace Outline 轮廓 Scale Tints 缩放淡色 Tints and Shades 淡色和阴影 Hue Shift 色相偏移 + Tips 提示 + Colorization Tips 着色提示 + The samples show this brush on a blue stroke. 示例显示此画笔在蓝色描边上的效果。 + The art keeps its own colors. 图稿保留自己的颜色。 + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. 黑色变为描边颜色,白色保持白色。其他颜色变为描边颜色的淡色,颜色越浅,淡色越浅。 + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. 黑色保持黑色,白色保持白色。其他颜色较暗时变为描边颜色的阴影,较亮时变为描边颜色的淡色。 + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. 主色变为描边颜色。其他颜色在色轮上移动与主色相同的距离,并保持其饱和度和亮度。 Harmony Rules 协调规则 Variation 变化 Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required. 将图稿、颜色和文字样式拖到这里,即可在不同文档中复用。库保存在本机,无需账户。 @@ -4520,6 +4627,8 @@ imageTrace Outline 轮廓 # The Layers panel, the graphics processor preference and the font menu's filters. All Scripts 所有文字系统 Any Kind 所有类型 + Font Technology 字体技术 +font-technology Variable 可变 Applies the next time VectorCraft starts. 下次启动 VectorCraft 时生效。 Collapse to Icons 折叠为图标 Decorative 装饰 @@ -4571,6 +4680,9 @@ imageTrace Outline 轮廓 Left-to-Right Paragraph Direction 从左到右段落方向 Right-to-Left Paragraph Direction 从右到左段落方向 Line-end Punctuation Half Width 行尾标点半角 + Insert Kashidas 插入卡希达 + Long 长 + Stylistic 风格化 Mojikumi Set 标点挤压集 Kinsoku Set Kinsoku 集 Hard 强 @@ -4695,6 +4807,13 @@ axis Both 两者 Channel 4 色版 4 Cell Size 单元格大小 Fine Dots 细点 + + Simple 简单 + Light Rough 浅色粗糙 + Dark Rough 深色粗糙 + Wide Sharp 宽锐 + Wide Blurry 宽模糊 + Sparkle 闪光 Medium Dots 中等点 Grainy Dots 颗粒点 Coarse Dots 粗点 @@ -4809,6 +4928,7 @@ effect Vertical 垂直 Activate Panel 激活面板 Change Panel Layout 更改面板布局 Close Panel 关闭面板 + Collapse Panel Group 折叠面板组 Move Panel 移动面板 Group with 组合到 Resize panels 调整面板大小 @@ -4877,12 +4997,24 @@ lighting Light 光源 Reset Ruler Origin 重置标尺原点 Artistic 艺术效果 Film Grain… 胶片颗粒… + Colored Pencil… 彩色铅笔… + Colored Pencil 彩色铅笔 Cutout… 剪纸… + Dry Brush… 干画笔… Cutout 剪纸 + Dry Brush 干画笔 Neon Glow… 霓虹灯光… + Palette Knife… 调色刀… Neon Glow 霓虹灯光 + Paint Daubs… 绘画涂抹… + Paint Daubs 绘画涂抹 + Palette Knife 调色刀 Poster Edges… 海报边缘… + Rough Pastels… 粗糙蜡笔… Poster Edges 海报边缘 + Rough Pastels 粗糙蜡笔 + Underpainting… 底纹效果… + Underpainting 底纹效果 Levels 色阶数 Edge Simplicity 边缘简化度 Edge Fidelity 边缘逼真度 @@ -4963,6 +5095,9 @@ lighting Light 光源 Softness 柔和度 Ambient Light 环境光 Light behind the object 光源位于对象后方 + 3D Lighting Preset 3D 光照预设 + Standard 标准 + Diffuse 漫射 # 3D light management. Lights 光源列表 Add Light 添加光源 @@ -5000,6 +5135,7 @@ lighting Light 光源 Make Image Dynamic 使图像动态化 Import Data Sets… 导入数据集… Export Data Sets… 导出数据集… + Load Variable Library… 载入变量库… Make Graph Dynamic 使图表动态化 Batch… 批处理… Source Folder: 源文件夹: @@ -5017,6 +5153,12 @@ lighting Light 光源 Sketch 素描 Halftone Pattern… 半调图案… Halftone Pattern 半调图案 + Torn Edges… 撕边… + Torn Edges 撕边 + Conté Crayon… 孔特蜡笔… + Conté Crayon 孔特蜡笔 + Foreground Level 前景级别 + Background Level 背景级别 Pattern Type 图案类型 Dot 网点 # User Interface › Color Scheme @@ -5035,6 +5177,7 @@ lighting Light 光源 Watercolor… 水彩… Watercolor 水彩 Brush Size 画笔大小 + Texture Coverage 纹理覆盖 Definition 清晰度 Brush Detail 画笔细节 Shadow Intensity 阴影强度 @@ -5061,18 +5204,26 @@ lighting Light 光源 @msg the image's pixels can't be read 无法读取图像的像素 Persistent 持久 Library Panel Options 库面板选项 + Locate Library… 查找库… @msg Persistent libraries not found: {files} 找不到持久库:{files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` 不是库面板 (请参阅 window.swatchLibrary) @msg library panel `{given}` isn't open 库面板 `{given}` 未打开 +@msg library panel `{given}` isn't missing its library 库面板 `{given}` 的库未缺失 @msg too many library panels were opened in this session 本次会话中打开的库面板过多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 图像为 {_1} × {_2} 像素,某一边超过 {LARGE_TRACE_SIDE}:会进行描摹,但速度较慢。使用保真度较低的预设或图像的较小副本会更快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 图像已按 {_1} × {_2} 像素描摹,由 {_3} × {_4} 缩小而来(最多 {_5} 百万像素)。 + Snapping 对齐 + Snap to Tangent 对齐切线 + Snap to Perpendicular 对齐垂线 +@msg unknown snap option `{key}` (use {options}) 未知的对齐选项 `{key}`(可用:{options}) + Gap Length: 间隙长度: # Links and Attributes: files and URLs handed to other apps. @msg Edit Original opens image and document files only; “{name}” is not one 编辑原稿只能打开图像和文档文件;“{name}” 不是其中之一 @msg only http, https and mailto URLs open in the browser, not “{url}” 浏览器中只会打开 http、https 和 mailto 的 URL,不会打开 “{url}” @msg {id} opens another app: only the person at the keyboard can run it {id} 会打开其他应用程序:只有使用键盘的人才能运行它 @msg “{name}” is not a file “{name}” 不是文件 Artboard {n} of {total} 画板 {n} / {total} + File {n} of {total} 文件 {n} / {total} Encoding and writing the file… 正在编码并写入文件… @plural {n} more export waiting|{n} more exports waiting 另有 {n} 个导出等待中 Continue in Background 在后台继续 @@ -5089,6 +5240,14 @@ lighting Light 光源 Minimum must not exceed Desired, nor Desired Maximum. 最小值不能超过所需值,所需值不能超过最大值。 Minimum 最小值 Justification 对齐设置 + Add filters from the list above 从上方列表添加滤镜 + Add to Stack 添加到堆栈 + Applied Filters 已应用的滤镜 + Categories 类别 + Choose a filter 选择滤镜 + Effect Gallery 效果库 + Filters 滤镜 + Select artwork to apply effects 选择要应用效果的图稿 @msg the file's variables and data sets weren't read (variables: {variables}, data sets: {data_sets}): the Variables panel doesn't list them 文件的变量和数据集未被读取(变量:{variables},数据集:{data_sets}):“变量”面板不会列出它们 # Select by Color (select.color) Select by Color 按颜色选择 @@ -5110,3 +5269,106 @@ lighting Light 光源 Plaster 塑料效果 Note Paper… 便条纸… Note Paper 便条纸 + Water Paper… 水彩画纸… + Water Paper 水彩画纸 + Ruby… 注音… + Ruby 注音 + Geometric Guides 几何参考线 + Ease In 缓入 + Ease Out 缓出 + Ease In Out 缓入缓出 + Ease Out In 缓出缓入 + Ramp: 强度: + Shift: 偏移: + Fresco… 壁画… + Fresco 壁画 + Effect Gallery… 效果库… + Effect Gallery Dialog 效果库对话框 + Arc Segment Tool Options 弧段工具选项 + Base Along: 基线方向: + Closed 闭合 + Concave 凹 + Convex 凸 + Fill Arc 填充弧 + Length X-Axis: X 轴长度: + Length Y-Axis: Y 轴长度: + Slope: 斜率: +axis X Axis X 轴 +axis Y Axis Y 轴 +@msg Packaged {n} file(s) in {folder} 已将 {n} 个文件打包到 {folder} +@msg Packaged {n} file(s) {note} 已打包 {n} 个文件 {note} +@msg Packaged {n} file(s) 已打包 {n} 个文件 + Packaged {n} file(s) 已打包 {n} 个文件 + Packaged {n} file(s) in {folder} 已将 {n} 个文件打包到 {folder} + Packaged {n} file(s) {note} 已打包 {n} 个文件 {note} + {count} file(s) in {folder}. Show the package folder? {folder} 中有 {count} 个文件。要显示打包文件夹吗? + Clockwise 顺时针 + Dither 仿色 + Fine noise hides banding in the gradient fill 细微杂色可隐藏渐变填色中的色带 +@msg `dither` applies to gradient fills (stroke: false) `dither` 适用于渐变填色 (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` 适用于渐变填色 (paint.setFill) +@msg `dither` must be true or false `dither` 必须是 true 或 false +@msg `method` must be classic or perceptual `method` 必须是 classic 或 perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? 要安装需要大量计算预算的“{name}”吗? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次运行最多可能需要 {minutes} 分钟,并可完成其他插件所允许工作量的 {times} 倍。它在后台运行,可以随时取消。 + It may take minutes, so it runs in the background, where you can cancel it. 这可能需要几分钟,因此会在后台运行,运行时可以取消。 + The plug-in is computing… 插件正在计算… + Inspect Plug-in 检查插件 + Cancel Plug-in Run 取消运行插件 +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} 已完成,但文档或插件在此期间已更改:请重新运行 +@msg Running “{name}”… 正在运行“{name}”… +@msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy` +@msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装 +# Actions panel: action sets (#3712). + New Set… 新建组… + New Set 新建组 + Set Options… 组选项… + Set Options 组选项 + Create New Set 创建新组 + Stop Global Edit 停止全局编辑 + Global Edit Options 全局编辑选项 + Match 匹配 + On Artboards 在画板上 + All Artboards 所有画板 + Include Objects On Canvas 包括画布上的对象 + Result(s): {count} 结果:{count} +@msg select one object 请选择一个对象 +@msg Global Edit works on paths, compound paths and symbol instances 全局编辑适用于路径、复合路径和符号实例 +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 未找到相似对象(旋转或镜像的副本不会匹配);请更改全局编辑选项后重试 +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`:应为 all 或 1, 3-5 这样的范围,而不是 {v} + Add to Symbols 添加到符号 + Basic Shapes 基本形状 + Open Symbol Library 打开符号库 + Save Symbol Library 保存符号库 + Save Symbol Library… 保存符号库… + Save Symbols… 保存符号… + Selected Symbols Only 仅限选择的符号 + Symbol Libraries Menu 符号库菜单 + Symbol Library 符号库 + User Symbol Library 1 用户符号库 1 + User Symbol Library 2 用户符号库 2 + User Symbol Library 3 用户符号库 3 + User Symbol Library 4 用户符号库 4 + User Symbol Library 5 用户符号库 5 + User Symbol Library 6 用户符号库 6 + User Symbol Library 7 用户符号库 7 + User Symbol Library 8 用户符号库 8 + User Symbol Library 9 用户符号库 9 + User Symbol Library 10 用户符号库 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` 中没有可置入的符号 +@msg missing `library` (see symbol.libraries) 缺少 `library`(请参阅 symbol.libraries) +@msg no symbol `{n}` 没有符号 `{n}` +@msg no symbol `{n}` in `{_1}` `{_1}` 中没有符号 `{n}` +@msg no symbol library `{key}` (see symbol.libraries) 没有符号库 `{key}`(请参阅 symbol.libraries) +@msg the document has no symbols to save 文档中没有可保存的符号 +@msg Trap: select two or more objects filled with solid colours 陷印:请选择两个或更多用实色填充的对象 +@msg Trap: no edges of different colours meet among the selected objects 陷印:所选对象中没有不同颜色相接的边缘 +@msg thickness must be from 0.01 to 5000 pt thickness 必须介于 0.01 到 5000 pt 之间 +@msg heightWidth must be from 1 to 1000 % heightWidth 必须介于 1 到 1000 % 之间 +@msg tintReduction must be from 0 to 100 % tintReduction 必须介于 0 到 100 % 之间 + Rectangular Grid Tool Options 矩形网格工具选项 + Polar Grid Tool Options 极坐标网格工具选项 + Fill Line 填充线段 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index dc3c5487c..f090d9dad 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -152,6 +152,8 @@ Anti-aliased Artwork 消除鋸齒的圖稿 Anti-aliasing: 消除鋸齒: Any Kind 所有類型 + Font Technology 字體技術 +font-technology Variable 可變 Appearance 外觀 Appearance Attribute 外觀屬性 Appearance Item 外觀項目 @@ -236,6 +238,11 @@ Average… 平均… Axis: 軸: Background Color: 背景顏色: + Show Center Mark 顯示中心標記 + Show Cross Hairs 顯示十字線 + Show Video Safe Areas 顯示視訊安全區域 + Video Ruler Pixel Aspect Ratio: 視訊尺標像素長寬比: + Hide Video Rulers 隱藏視訊尺標 Background Contents 背景內容 Background Contents: 背景內容: Background: 背景: @@ -273,6 +280,7 @@ Blur 模糊 Bottom 下 Bottom in Front 下方置前 + Bottom-Justified Tab 靠下定位點 Bottom-to-Bottom Leading 基線至基線行距 Burasagari 標點懸掛 Force 強制 @@ -306,6 +314,13 @@ Built-in presets don't change: New… starts an editable copy. 內建預設集不會變動:「新增…」會建立可編輯的複本。 Bulge… 鼓起… Bullet 項目符號 + Bullets 項目符號 + Bullets and Numbering 項目符號與編號 + Apply Bullets 套用項目符號 + Filled Dot 實心圓點 + Dash 短破折號 + Hollow Circle 空心圓 + Filled Square 實心方塊 Butt Cap 平端點 Byte Order: 位元組順序: Calligraphic 書法 @@ -1023,6 +1038,7 @@ Eyedropper Picks Up: 滴管擷取: Eyedropper Tool 滴管工具 Eyedropper: click the art to sample the stop's colour 滴管:點擊圖稿以取樣色標的顏色 + Eyedropper: click the art to sample the key color 滴管:點擊圖稿以取樣主要顏色 Favorites 我的最愛 Feather… 羽化… Feet 英呎 @@ -1031,6 +1047,11 @@ Fidelity: 精確度: Figure Space 數字空格 Figure: 數字: +# Artwork drag between document tabs. + Drop Artwork 放置圖稿 +@msg {key} must be [x, y] with finite coordinates {key} 必須是具有有限座標的 [x, y] +@msg ids must contain 1–10000 object ids ids 必須包含 1–10000 個物件 ID + File 檔案 File Dimensions 檔案尺寸 File Format: 檔案格式: @@ -1053,6 +1074,16 @@ Alt key toggles to Smooth Tool Alt 鍵切換為平滑工具 Keep selected 保持選取狀態 Merge only with selection 僅與選取物件合併 + Consider Open Filled Paths as Closed 將有填色的開放路徑視為封閉路徑 + In Merge Mode, Clicking Stroke Splits the Path 在合併模式下,按一下筆畫可分割路徑 + Pick Color From: 顏色來源: + Artwork 圖稿 + Cursor Swatch Preview 在指標旁預覽色票 + Selection: 選取方式: + Freeform 自由 + Straight Line 直線 + Highlight: 強調顯示: + Stroke When Editable 可編輯時的筆畫 Fill New Type Objects With Placeholder Text 以預留位置文字填滿新文字物件 Fill Rule 填色規則 Fill with Placeholder Text 以預留位置文字填滿 @@ -1180,6 +1211,8 @@ file-filter Templates 範本 Large Gaps 大間隙 Gap Preview Color: 間隙預覽顏色: Close Gaps With Paths 以路徑封閉間隙 + Show Live Paint Gaps 顯示即時上色間隙 + Hide Live Paint Gaps 隱藏即時上色間隙 Select Fills 選取填色 Select Strokes 選取筆畫 Gaussian Blur… 高斯模糊… @@ -1287,6 +1320,7 @@ file-filter Templates 範本 Hand-Drawn 手繪 Handles 控制把手 Hard Light 實光 + Hard Mix 實色混合 Harmony Rules: 色彩調和規則: Harmony Sets 色彩調和組 Height 高度 @@ -1336,7 +1370,7 @@ file-filter Templates 範本 Clear History 清除步驟記錄 Set History Limit… 設定步驟記錄上限… History States 步驟記錄狀態 -# History panel: the names of undo steps (`every_undo_label_is_translated`). +# History panel: the names of undo steps (`untranslated_report` lists any that are missing). Apply Data Set 套用資料集 Area Type 區域文字 Bloat 膨脹 @@ -1560,7 +1594,7 @@ file-filter Templates 範本 Keyboard Shortcuts… 鍵盤快速鍵… # Status and error messages (`@msg`), translated where the status bar shows them; the message itself # stays English for agents and tests. `{_1}`, `{_2}` … stand for the `{}` of the format string, in order -# (`complete_languages_translate_every_message` lists any that are missing). Command ids, parameter +# (`untranslated_report` lists any that are missing). Command ids, parameter # names and values in backticks or code (`colorModel rgb`, `document.export`) stay as they are. # Traditional Chinese: no final 。 unless the English ends with a period; a space between Chinese text and # an adjacent Latin word, number or {placeholder}; 、 between the items of a list; 「」 around the name of @@ -1599,7 +1633,7 @@ file-filter Templates 範本 @msg the scale is not a usable number 比例不是可用的數字 @msg the drawing is too large to open 圖面過大,無法開啟 @msg scale must be a number from {_1} to {_2}, not {_3} 比例必須是 {_1} 到 {_2} 之間的數字,而非 {_3} -@msg image '{key}' has no pixels and was left out 影像 '{key}' 沒有像素,已略過 +@msg an image has no pixels and was left out 已略過沒有像素的影像 @msg Embedded {n} image(s) 已內嵌 {n} 個影像 @msg object {0} cannot have children 物件 {0} 不能有子物件 @msg range `{s}` is empty 範圍 `{s}` 是空的 @@ -1610,7 +1644,9 @@ file-filter Templates 範本 @msg Show All Hidden Attributes: nothing is hidden 顯示所有隱藏屬性:沒有隱藏的項目 @msg Expand: no brush strokes selected 展開:未選取筆刷筆畫 @msg no brush named `{name}` 沒有名為 `{name}` 的筆刷 +@msg a bristle brush name can have at most {max} characters: `{name}` has {count} 鬃毛筆刷的名稱最多 {max} 個字元:`{name}` 有 {count} 個字元 @msg no brush selected 未選取筆刷 +@msg brushes move only within their type 筆刷只能在同一類型內移動 @msg no symbol instances selected 未選取符號實體 @msg select a symbol instance 請選取符號實體 @msg the art has no bounds 圖稿沒有邊界 @@ -1653,6 +1689,7 @@ file-filter Templates 範本 @msg no saved selection recalled 未叫出任何已儲存的選取範圍 @msg no saved selections 沒有已儲存的選取範圍 @msg no such artboard 沒有這個工作區域 +@msg the artboard is locked 工作區域已鎖定 @msg cannot split an open path at its end point 無法在開放路徑的端點處分割路徑 @msg closing needs three anchors 封閉需要三個錨點 @msg empty brush stroke 筆刷筆畫是空的 @@ -1792,6 +1829,10 @@ file-filter Templates 範本 @msg select a repeat 請選取重複物件 @msg select an Intertwine group 請選取交織群組 @msg select an Objects on Path group 請選取路徑上的物件群組 +@msg a path inside a compound path, blend, envelope, repeat or Intertwine group can't take objects 複合路徑、漸變物件、封套、重複物件或交織群組中的路徑無法附加物件 +@msg a clipping path can't take objects 剪裁路徑無法附加物件 +@msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path 漸變物件、封套、重複物件或交織群組中的物件無法附加到路徑 +@msg a clipping path can't be attached to a path 剪裁路徑無法附加到路徑 @msg select the art to repeat 請選取要重複的圖稿 @msg the document has no patterns 文件中沒有圖樣 @msg no artboard 沒有工作區域 @@ -1883,6 +1924,25 @@ file-filter Templates 範本 @msg a PostScript error ({name} in `{op}`: {at}) PostScript 錯誤 ({name},位於 `{op}`:{at}) @msg a PostScript error ({name} in `{op}`: {at}), in {procs} PostScript 錯誤 ({name},位於 `{op}`:{at}),在 {procs} 中 @msg a PostScript error ({name}) PostScript 錯誤 ({name}) +@msg a PostScript error ({name} in `{op}`; {meaning}) PostScript 錯誤 ({name},位於 `{op}`;{meaning}) +@msg a PostScript error ({name} in `{op}`; {meaning}), in {procs} PostScript 錯誤 ({name},位於 `{op}`;{meaning}),在 {procs} 中 +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}) PostScript 錯誤 ({name},位於 `{op}`:{at};{meaning}) +@msg a PostScript error ({name} in `{op}`: {at}; {meaning}), in {procs} PostScript 錯誤 ({name},位於 `{op}`:{at};{meaning}),在 {procs} 中 +@msg a PostScript error ({name}; {meaning}) PostScript 錯誤 ({name};{meaning}) +@msg the program ends a dictionary it didn't begin 程式要結束一個它沒有開始的字典 +@msg the program opens a file, which the reader can't do 程式要開啟檔案,而 PostScript 讀取器無法開啟檔案 +@msg the program restores a saved state that was already restored 程式要還原一個已經還原過的儲存狀態 +@msg data the program reads is damaged or cut short, or the program asks for a position in a file that has none 程式讀取的資料已損壞或不完整,或者程式要在沒有位置的檔案中取得位置 +@msg the program goes past one of the reader's limits 程式超出了 PostScript 讀取器的一項限制 +@msg the path has no point to continue from 路徑沒有可以接著繪製的點 +@msg a value is outside the range the operator takes 某個值超出了運算子接受的範圍 +@msg the operator needs more values than the stack holds 運算子需要的值多於堆疊中的值 +@msg part of the program isn't valid PostScript text 程式的一部分不是有效的 PostScript 文字 +@msg a value has the wrong type for the operator 某個值的類型不適用於該運算子 +@msg the program runs, deletes or renames a file, which the reader can't do 程式要執行、刪除或重新命名檔案,而 PostScript 讀取器無法執行這些操作 +@msg the program asks for a resource that isn't defined 程式要求了未定義的資源 +@msg a calculation has no result, as in a division by zero 計算沒有結果,例如除以零 +@msg the operator looks for a mark on the stack and finds none 運算子在堆疊中找不到標記 @msg it uses `{at}`, which VectorCraft's PostScript reader doesn't know 它使用了 `{at}`,VectorCraft 的 PostScript 讀取器無法辨識 @msg the file was read up to an error ({why}): what follows it was left out 檔案已讀取到發生錯誤處 ({why}):之後的內容已略過 @msg this file's PostScript couldn't be read ({why}): {what} 無法讀取此檔案的 PostScript ({why}):{what} @@ -1966,6 +2026,8 @@ file-filter Templates 範本 @msg SVG parse error: {0} SVG 剖析錯誤:{0} @msg decimals must be {_1}–{_2} (got {_3}) 小數位數必須為 {_1}–{_2} (收到 {_3}) @msg Exporting {_1} 正在匯出 {_1} +@msg Packaging {_1} 正在封裝 {_1} +@msg Placing {_1} 正在置入 {_1} @msg Exported {what} 已匯出 {what} @msg "{0}" is not a colour: write it as #rrggbb "{0}" 不是顏色:請寫成 #rrggbb @msg could not decode image: {0} 無法解碼影像:{0} @@ -1979,6 +2041,7 @@ file-filter Templates 範本 @msg {label}… {label}… @msg no blend options 沒有漸變選項 @msg bad color {v} 顏色 {v} 無效 +@msg videoRulerPixelAspectRatio must be a number from 0.1 to 10 videoRulerPixelAspectRatio 必須是 0.1 到 10 之間的數字 @msg the Color Picker is not open 「顏色選擇器」未開啟 @msg the Simplify slider is not open 「簡化」滑桿未開啟 @msg the screen color sampler is already open 螢幕取色器已開啟 @@ -2008,7 +2071,7 @@ file-filter Templates 範本 @msg {_1} linked file(s) can't be read here: their previews show 此處無法讀取 {_1} 個連結檔案:顯示的是其預覽 @msg {_1} linked image(s) changed on disk: Update Links shows the new versions 有 {_1} 個連結影像已在磁碟上變更:「更新連結」可顯示新版本 @msg pick a colour, gradient or pattern to save as a swatch 請挑選要儲存為色票的顏色、漸層或圖樣 -@msg Packaged {n} file(s) in {folder}{note} 已將 {n} 個檔案封裝到 {folder}{note} +@msg Packaged {n} file(s) in {folder} {note} 已將 {n} 個檔案封裝到 {folder}{note} @msg Packaged {n} file(s){note} 已封裝 {n} 個檔案{note} @msg choose a location for the package 請選擇封裝的位置 @msg missing `folder` 未提供 `folder` @@ -2021,6 +2084,7 @@ file-filter Templates 範本 @msg no plug-in {id} is installed 未安裝增效模組 {id} @msg {_1} is a live effect (Effect › Plug-ins) {_1} 是即時效果 (效果 › 增效模組) @msg {_1} is an exporter (File › Export › Export As…) {_1} 是匯出增效模組 (檔案 › 匯出 › 匯出為…) +@msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` 為 {size} 位元組(上限 {MAX_DOCUMENT_DATA}) @msg a preset named `{_1}` exists 名為 `{_1}` 的預設集已存在 @msg a swatch or color group named `{n}` exists 名為 `{n}` 的色票或顏色群組已存在 @msg no print preset named `{base}` 沒有名為 `{base}` 的列印預設集 @@ -2038,6 +2102,7 @@ file-filter Templates 範本 @msg name the file 請為檔案命名 @msg unknown format 未知的格式 @msg PDF settings are not an object PDF 設定不是物件 +@msg PDF security settings are not an object PDF 安全性設定不是物件 @msg no saved PDF preset named `{name}` (built-in presets are read-only: start a new one from them) 沒有名為 `{name}` 的已儲存 PDF 預設集 (內建預設集為唯讀:請以它們為基礎新增一個) @msg Saved to User Defined: {_1} 已儲存至「使用者定義」:{_1} @msg choose a direction to divide in 請選擇分割方向 @@ -2085,7 +2150,7 @@ file-filter Templates 範本 @msg missing `name` 未提供 `name` @msg missing `tool` 未提供 `tool` @msg missing index 未提供 index -@msg multiple windows land with M11.5 多個視窗將於 M11.5 推出 +@msg New Window isn't available yet: a document opens in one window 新增視窗尚無法使用:文件會在一個視窗中開啟 @msg no PNG encoder 沒有 PNG 編碼器 @msg no document open 沒有開啟的文件 @msg no effect applied yet 尚未套用任何效果 @@ -2127,8 +2192,11 @@ file-filter Templates 範本 @msg no swatch library `{l}` (see swatch.library.list) 沒有色票資料庫 `{l}` (請參閱 swatch.library.list) @msg no user library folder here 此處沒有使用者資料庫資料夾 @msg Couldn't place {name}: the document it was dropped on was closed 無法置入 {name}:放置它的文件已關閉 +@msg Couldn't place {name}: the document it was placed in was closed 無法置入 {name}:要置入的文件已關閉 @msg no files to place 沒有要置入的檔案 +@msg `{file}` isn't a VectorCraft document, a PDF or a PDF-compatible .ai file `{file}` 不是 VectorCraft 文件、PDF 或 PDF 相容的 .ai 檔案 @msg paths: give at least one file paths:請提供至少一個檔案 +@msg replace: the object to replace was changed or deleted replace:要取代的物件已被變更或刪除 @msg Couldn't place {name}: {e} 無法置入 {name}:{e} @msg no print service 沒有列印服務 @msg the print job has no data 列印工作沒有資料 @@ -2195,7 +2263,9 @@ file-filter Templates 範本 @msg automation filesystem access is not granted: read authority is absent: {path} 未授予自動化檔案系統存取權:沒有讀取權限:{path} @msg automation filesystem access is not granted: write authority is absent: {path} 未授予自動化檔案系統存取權:沒有寫入權限:{path} @msg automation path rejected: outside the read root {root}: {path} 已拒絕自動化路徑:位於讀取根目錄 {root} 之外:{path} +@msg automation path rejected: outside the read roots {roots}: {path} 已拒絕自動化路徑:位於所有讀取根目錄 {roots} 之外:{path} @msg automation path rejected: outside the write root {root}: {path} 已拒絕自動化路徑:位於寫入根目錄 {root} 之外:{path} +@msg automation path rejected: outside the write roots {roots}: {path} 已拒絕自動化路徑:位於所有寫入根目錄 {roots} 之外:{path} @msg automation path rejected: path is empty 已拒絕自動化路徑:路徑是空的 @msg automation path rejected: a link that leads nowhere: {path} 已拒絕自動化路徑:連結指向不存在的位置:{path} @msg automation path rejected: cannot resolve {path}: {e} 已拒絕自動化路徑:無法解析 {path}:{e} @@ -2219,6 +2289,12 @@ file-filter Templates 範本 @msg before must name a panel before 必須指定面板 @msg docking action contains an unknown panel or an excessive split path 停駐動作包含未知的面板或過長的分割路徑 @msg group must be true or false group 必須是 true 或 false +@msg panel floats; collapse its group with window.panel.float {panel, collapsed} 面板處於浮動狀態;請使用 window.panel.float {panel, collapsed} 摺疊其面板群組 +@msg panel is not in a dock group 面板不在停駐群組中 +@msg only a dock group above or below others collapses, not one beside another 只有位於其他群組上方或下方的停駐群組可以摺疊,並排的不行 +@msg icon must be true or false icon 必須為 true 或 false +@msg icon only docks a panel in the icon column, with no anchor icon 只會將面板停駐到圖示欄中,不能帶 anchor +@msg column docks a set as a dock column, with no anchor or icon column 會將一組面板停駐為停駐欄,不能帶 anchor 或 icon @msg move requires a destination panel 移動需要目的地面板 @msg move requires an existing destination panel 移動需要現有的目的地面板 @msg onto must name a floating panel onto 必須指定浮動面板 @@ -2254,6 +2330,7 @@ file-filter Templates 範本 @msg Measured anchor no longer exists 測量的錨點已不存在 @msg Measured segment no longer exists 測量的線段已不存在 @msg Non-finite dimension 尺寸標註不是有限值 +@msg Scale Text Attributes: selected type has no scale to bake 縮放文字屬性:所選文字沒有可套用的縮放 @msg Scale must be drawing:real, for example 1:10 比例必須為 圖紙:實際,例如 1:10 @msg Scale ratio must be between 1:1000000 and 1000000:1 比例必須介於 1:1000000 和 1000000:1 之間 @msg Second measured segment no longer exists 第二條測量線段已不存在 @@ -2283,6 +2360,7 @@ file-filter Templates 範本 @msg Persistent libraries not found: {files} 找不到持久資料庫:{files} @msg `{given}` isn't a library panel (see window.swatchLibrary) `{given}` 不是資料庫面板 (請參閱 window.swatchLibrary) @msg library panel `{given}` isn't open 資料庫面板 `{given}` 未開啟 +@msg library panel `{given}` isn't missing its library 資料庫面板 `{given}` 的資料庫未遺失 @msg too many library panels were opened in this session 本次工作階段開啟的資料庫面板過多 @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. 影像為 {_1} × {_2} 像素,有一邊超過 {LARGE_TRACE_SIDE}:仍會描圖,但速度較慢。使用精確度較低的預設集或較小的影像拷貝,描圖會較快。 @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). 影像已以 {_1} × {_2} 像素描圖,由 {_3} × {_4} 縮小而來 (最多 {_5} 百萬像素)。 @@ -2557,6 +2635,9 @@ file-filter Templates 範本 Modified since it was read: {path} 讀取後已被修改:{path} Modified: 修改時間: Modify 修改 + Insert Kashidas 插入卡希達 + Long 長 + Stylistic 風格化 Mojikumi Set 標點間距 Kinsoku Set Kinsoku 集 Hard 強 @@ -2604,6 +2685,7 @@ file-filter Templates 範本 @msg x and y must be finite x 和 y 必須是有限值 @msg t, dx and dy must be finite t、dx 和 dy 必須是有限值 @msg handle must be in or out handle 必須是 in 或 out + Move Brush 移動筆刷 Move Swatch 移動色票 Move Up 上移 Move… 移動… @@ -2661,6 +2743,13 @@ file-filter Templates 範本 Tints and Shades 淺色與暗色 Hue Shift 色相偏移 Key Color: 主要顏色: + Tips 提示 + Colorization Tips 上色提示 + The samples show this brush on a blue stroke. 範例顯示此筆刷在藍色筆畫上的效果。 + The art keeps its own colors. 圖稿保留自己的顏色。 + Black becomes the stroke color and white stays white. Other colors become tints of the stroke color by their lightness. 黑色變為筆畫顏色,白色維持白色。其他顏色變為筆畫顏色的淺色,顏色越淺,淺色就越淺。 + Black and white stay as they are. Dark colors become shades of the stroke color and light colors become tints of it. 黑色維持黑色,白色維持白色。其他顏色較暗時變為筆畫顏色的暗色,較亮時變為筆畫顏色的淺色。 + The key color becomes the stroke color. The other colors shift their hue by the same amount and keep their saturation and brightness. 主要顏色變為筆畫顏色。其他顏色在色輪上移動與主要顏色相同的距離,並保留其飽和度和亮度。 Roundness: 圓度: Fixed 固定 Pressure 壓力 @@ -2681,6 +2770,14 @@ file-filter Templates 範本 Round Fan 圓扇頭 Flat Fan 扁扇頭 New Brush… 新增筆刷… + New Brush 新增筆刷 + Calligraphic Brush 書法筆刷 + Scatter Brush 散佈筆刷 + Art Brush 藝術筆刷 + Bristle Brush 鬃毛筆刷 + Pattern Brush 圖樣筆刷 + Select art to make a scatter, art or pattern brush. 選取圖稿以製作散佈筆刷、藝術筆刷或圖樣筆刷。 + Brush Art 筆刷圖稿 New Calligraphic Brush 新增書法筆刷 New Character Style 新增字元樣式 New Color Group 新增顏色群組 @@ -2711,6 +2808,7 @@ file-filter Templates 範本 New View 新增檢視 New View… 新增檢視… New Window 新增視窗 + New Without Dialog 新增但不開啟對話方塊 New Workspace 新增工作區 New Workspace… 新增工作區… New {kind} Style… 新增{kind}樣式… @@ -2918,7 +3016,7 @@ file-filter Templates 範本 Package 封裝 Package collects a saved document with its linked files and fonts. 封裝會將已儲存的文件連同其連結檔案與字體一併收集。 Package created 已建立封裝 - Packaged {n} file(s) in {folder}{note} 已將 {n} 個檔案封裝於 {folder}{note} + Packaged {n} file(s) in {folder} {note} 已將 {n} 個檔案封裝於 {folder}{note} Packaged {n} file(s){note} 已封裝 {n} 個檔案{note} Package… 封裝… Page 頁面 @@ -2982,7 +3080,7 @@ file-filter Templates 範本 Performance 效能 diagnostics render 繪製 diagnostics ui 介面 - Performance & Storage 效能與儲存空間 + Plug-ins & Scratch Disks 增效模組 & 暫存磁碟 Permissions 權限 Permissions password: 權限密碼: Perspective 透視 @@ -3106,6 +3204,7 @@ font-script Japanese 漢字 Proof Setup 校樣設定 Properties 內容 Proportional Metrics 比例度量 + Character Direction 字元方向 Digits 數字 Arabic 阿拉伯數字 Hindi 印地數字 @@ -3300,6 +3399,7 @@ font-script Japanese 漢字 Reorder 重新排序 Reorder Appearance Item 重新排序外觀項目 Repeat 重複 + Repeat Objects 重複物件 Repeat Options 重複選項 Repeat Pathfinder 重複路徑管理員 Repeat Tab 重複定位點 @@ -3307,7 +3407,7 @@ font-script Japanese 漢字 Replace 取代 Replace Font 取代字體 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 以其他檔案取代,或忽略:其影像會繼續顯示隨文件儲存的預覽。 - Replace Spine 取代軸線 + Replace Spine 取代脊線 Replace Symbol 取代符號 Replace With Font 取代為字體 Replaces the saved preset of that name. 取代同名的已儲存預設集。 @@ -3354,7 +3454,7 @@ font-script Japanese 漢字 Reverse Path Direction 反轉路徑方向 Reverse Path Direction Off 關閉反轉路徑方向 Reverse Path Direction On 開啟反轉路徑方向 - Reverse Spine 反轉軸線 + Reverse Spine 反轉脊線 Revert 回復 Revert to the saved version of “{name}”? 要回復為「{name}」已儲存的版本嗎? Replace “{name}”, the file this document was opened from? 要取代「{name}」(開啟此文件的來源檔案)嗎? @@ -3511,6 +3611,7 @@ font-script Japanese 漢字 Scale Cursor Proportional to UI 游標依介面比例縮放 Scale Lineweights 縮放線條寬度 Scale Strokes & Effects 縮放筆畫與效果 + Scale Text Attributes 縮放文字屬性 Scale to Fit Artboard 縮放至符合工作區域 Scale Tool 縮放工具 Scale: 縮放比例: @@ -3782,6 +3883,7 @@ font-script Japanese 漢字 Snapping Tolerance 靠齊容許度 Social Square Post 1080×1080 社群正方形貼文 1080×1080 Soft Light 柔光 + Soft Mix 柔和混合 Solid 實心 Sort by Kind 依類型排序 Sort by Name 依名稱排序 @@ -3792,6 +3894,7 @@ font-script Japanese 漢字 Space Before Paragraph 段前間距 Spaces 空格 Spacing Guides 間距參考線 + Invoke Distance Guides 叫用距離參考線 Spacing: 間距: Spanish 西班牙文 Specified Distance 指定距離 @@ -3929,7 +4032,6 @@ font-script Japanese 漢字 Show all open documents 顯示所有開啟的文件 Symbol Instance 符號範例 Symbol Libraries 符號資料庫 - Symbol Libraries (on the roadmap) 符號資料庫 (已列入路線圖) Symbol Screener Tool 符號濾色器工具 Symbol Scruncher Tool 符號緊縮器工具 Symbol Shifter Tool 符號移位器工具 @@ -4022,9 +4124,11 @@ font-script Japanese 漢字 Tools 工具 Top 上 Top in Front 上方在前 + Top-Justified Tab 靠上定位點 Top-to-Top Leading 上緣至上緣行距 Top: 上: Total Width: 總寬度: + Touch 觸控 Touch Type 觸控文字 Touch Type Tool 觸控文字工具 Trace 描圖 @@ -4059,6 +4163,13 @@ font-script Japanese 漢字 Transparent Background 透明背景 Transverse (the page a quarter turn on the paper) 橫向轉置 (頁面在紙張上轉四分之一圈) Trap… 補漏白… + Trap 補漏白 + Height/Width 高度/寬度 + Tint Reduction 色調減淡 + Traps with Process Color 使用印刷色補漏白 + Reverse Traps 反向補漏白 + Precision 精確度 + Remove Redundant Points 移除多餘的點 Trim 剪裁 Trim mark weight: 剪裁標記寬度: Trim marks 剪裁標記 @@ -4185,6 +4296,7 @@ font-script Japanese 漢字 Lock Variables 鎖定變數 Unlock Variables 解除鎖定變數 @msg variables are locked 變數已鎖定 +@msg variables that aren't text were left out: {kinds} 已略過非文字變數:{kinds} List Variables 列出變數 Bind Variable 綁定變數 Unbind Variable 解除變數綁定 @@ -4298,7 +4410,7 @@ font-script Japanese 漢字 Zoom Tool 縮放工具 Zoom with Mouse Wheel 使用滑鼠滾輪縮放 Scrub Numeric Fields by Dragging 拖曳以調整數值欄位 - {count} file(s) in {folder}{note}. Show the package folder? {folder} 中有 {count} 個檔案{note}。要顯示封裝資料夾嗎? + {count} file(s) in {folder} {note}. Show the package folder? {folder} 中有 {count} 個檔案{note}。要顯示封裝資料夾嗎? {count} linked file(s) not found 找不到 {count} 個連結檔案 fonts not copied: {fonts} 未複製的字體:{fonts} {count} linked image(s) changed since this document was saved. Show the new versions? 自儲存此文件後,有 {count} 個連結影像已變更。要顯示新版本嗎? @@ -4370,7 +4482,8 @@ imageTrace Outline 輪廓 to sample a color 可取樣顏色 across a selected object to set the gradient direction 橫越所選物件可設定漸層方向 to select an artboard | 可選取工作區域 | - on the canvas to create one 畫布可建立工作區域 + on the canvas to create one ( 畫布可建立工作區域 ( + selects several, or draws one inside an artboard) 可選取多個,或在工作區域內建立工作區域) a stroke to add a width point | 筆畫可新增寬度點 | to change one side | 可只變更單側 | to select more points | 可選取更多點 | @@ -4501,6 +4614,13 @@ axis Both 兩者 Channel 4 色版 4 Cell Size 單元大小 Fine Dots 細點 + + Simple 簡單 + Light Rough 淺色粗糙 + Dark Rough 深色粗糙 + Wide Sharp 寬銳 + Wide Blurry 寬模糊 + Sparkle 閃光 Medium Dots 中等點 Grainy Dots 顆粒點 Coarse Dots 粗點 @@ -4615,6 +4735,7 @@ effect Soft 柔和 Activate Panel 啟用面板 Change Panel Layout 變更面板配置 Close Panel 關閉面板 + Collapse Panel Group 摺疊面板群組 Move Panel 移動面板 Group with 群組至 Resize panels 調整面板大小 @@ -4683,12 +4804,24 @@ lighting Light 光源 Reset Ruler Origin 重設尺標原點 Artistic 藝術效果 Film Grain… 底片顆粒… + Colored Pencil… 彩色鉛筆… + Colored Pencil 彩色鉛筆 Cutout… 剪紙… + Dry Brush… 乾畫筆… Cutout 剪紙 + Dry Brush 乾畫筆 Neon Glow… 霓虹光… + Palette Knife… 調色刀… Neon Glow 霓虹光 + Paint Daubs… 繪畫塗抹… + Paint Daubs 繪畫塗抹 + Palette Knife 調色刀 Poster Edges… 海報邊緣… + Rough Pastels… 粗糙粉彩… Poster Edges 海報邊緣 + Rough Pastels 粗糙粉彩 + Underpainting… 底紋效果… + Underpainting 底紋效果 Levels 色階數 Edge Simplicity 邊緣簡化度 Edge Fidelity 邊緣逼真度 @@ -4770,6 +4903,9 @@ lighting Light 光源 Softness 柔和度 Ambient Light 環境光 Light behind the object 光源位於物件後方 + 3D Lighting Preset 3D 光照預設 + Standard 標準 + Diffuse 漫射 # 3D light management. Lights 光源清單 Add Light 新增光源 @@ -4809,6 +4945,7 @@ lighting Light 光源 Make Image Dynamic 使影像動態化 Import Data Sets… 匯入資料集… Export Data Sets… 匯出資料集… + Load Variable Library… 載入變數資料庫… Make Graph Dynamic 使圖表動態化 Batch… 批次處理… Source Folder: 來源資料夾: @@ -4827,6 +4964,12 @@ lighting Light 光源 Sketch 素描 Halftone Pattern… 半色調圖樣… Halftone Pattern 半色調圖樣 + Torn Edges… 撕裂邊緣… + Torn Edges 撕裂邊緣 + Conté Crayon… 孔特蠟筆… + Conté Crayon 孔特蠟筆 + Foreground Level 前景層級 + Background Level 背景層級 Pattern Type 圖樣類型 Dot 網點 # User Interface › Color Scheme @@ -4845,6 +4988,7 @@ lighting Light 光源 Watercolor… 水彩… Watercolor 水彩 Brush Size 筆刷大小 + Texture Coverage 紋理覆蓋 Definition 清晰度 Brush Detail 筆刷細節 Shadow Intensity 陰影強度 @@ -4861,8 +5005,15 @@ lighting Light 光源 Light/Dark Balance 明暗平衡 Enable Guides for 9-Slice Scaling 啟用 9-slice 縮放參考線 Persistent 持久 + Locate Library… 尋找資料庫… Library Panel Options 資料庫面板選項 + Snapping 靠齊 + Snap to Tangent 靠齊切線 + Snap to Perpendicular 靠齊垂線 +@msg unknown snap option `{key}` (use {options}) 未知的靠齊選項 `{key}`(可用:{options}) + Gap Length: 間隙長度: Artboard {n} of {total} 工作區域 {n} / {total} + File {n} of {total} 檔案 {n} / {total} Encoding and writing the file… 正在編碼並寫入檔案… @plural {n} more export waiting|{n} more exports waiting 另有 {n} 個匯出等待中 Continue in Background 在背景繼續 @@ -4879,6 +5030,14 @@ lighting Light 光源 Minimum must not exceed Desired, nor Desired Maximum. 最小值不可超過所需值,所需值不可超過最大值。 Minimum 最小值 Justification 對齊設定 + Add filters from the list above 從上方清單新增濾鏡 + Add to Stack 加入堆疊 + Applied Filters 已套用的濾鏡 + Categories 類別 + Choose a filter 選擇濾鏡 + Effect Gallery 效果庫 + Filters 濾鏡 + Select artwork to apply effects 選取要套用效果的圖稿 # Select by Color (select.color) Select by Color 依顏色選取 @msg missing integer `{key}` 未提供整數 `{key}` @@ -4905,3 +5064,107 @@ lighting Light 光源 Plaster 塑膠效果 Note Paper… 便條紙… Note Paper 便條紙 + Water Paper… 水彩畫紙… + Water Paper 水彩畫紙 + Ruby… 注音… + Ruby 注音 + Geometric Guides 幾何參考線 + Ease In 緩入 + Ease Out 緩出 + Ease In Out 緩入緩出 + Ease Out In 緩出緩入 + Ramp: 強度: + Shift: 偏移: + Fresco… 壁畫… + Fresco 壁畫 + Effect Gallery… 效果庫… + Effect Gallery Dialog 效果庫對話框 + Arc Segment Tool Options 弧段工具選項 + Base Along: 基線方向: + Closed 封閉 + Concave 凹 + Convex 凸 + Fill Arc 填色弧形 + Length X-Axis: X 軸長度: + Length Y-Axis: Y 軸長度: + Slope: 斜率: +axis X Axis X 軸 +axis Y Axis Y 軸 +@msg Packaged {n} file(s) in {folder} 已將 {n} 個檔案封裝到 {folder} +@msg Packaged {n} file(s) {note} 已封裝 {n} 個檔案 {note} +@msg Packaged {n} file(s) 已封裝 {n} 個檔案 + Packaged {n} file(s) 已封裝 {n} 個檔案 + Packaged {n} file(s) in {folder} 已將 {n} 個檔案封裝到 {folder} + Packaged {n} file(s) {note} 已封裝 {n} 個檔案 {note} + {count} file(s) in {folder}. Show the package folder? {folder} 中有 {count} 個檔案。要顯示封裝資料夾嗎? + Clockwise 順時針 + Dither 仿色 + Fine noise hides banding in the gradient fill 細微雜色可隱藏漸層填色中的色帶 +@msg `dither` applies to gradient fills (stroke: false) `dither` 適用於漸層填色 (stroke: false) +@msg `dither` applies to gradient fills (paint.setFill) `dither` 適用於漸層填色 (paint.setFill) +@msg `dither` must be true or false `dither` 必須是 true 或 false + Perceptual 感知 +@msg `method` must be classic or perceptual `method` 必須是 classic 或 perceptual +# Heavy plug-in filters (plugin.inspect, plug-in runs) + Install “{name}”, which asks for a heavy budget? 要安裝需要大量運算預算的「{name}」嗎? + Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it. 每次執行最多可能需要 {minutes} 分鐘,並可完成其他增效模組所允許工作量的 {times} 倍。它會在背景執行,您可以隨時取消。 + It may take minutes, so it runs in the background, where you can cancel it. 這可能需要數分鐘,因此會在背景執行,您可以在執行時取消。 + The plug-in is computing… 增效模組正在運算… + Inspect Plug-in 檢查增效模組 + Cancel Plug-in Run 取消執行增效模組 +@msg {_1} finished, but the document or the plug-in changed meanwhile: run it again {_1} 已完成,但文件或增效模組在此期間已變更:請重新執行 +@msg Running “{name}”… 正在執行「{name}」… +@msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy` +@msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget` +@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝 +# Actions panel: action sets (#3712). + New Set… 新增組… + New Set 新增組 + Set Options… 組選項… + Set Options 組選項 + Create New Set 建立新組 + Stop Global Edit 停止整體編輯 + Global Edit Options 整體編輯選項 + Match 符合 + On Artboards 在工作區域上 + All Artboards 所有工作區域 + Include Objects On Canvas 包含畫布上的物件 + Result(s): {count} 結果:{count} +@msg select one object 請選取一個物件 +@msg Global Edit works on paths, compound paths and symbol instances 整體編輯適用於路徑、複合路徑和符號實體 +@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again 找不到相似的物件(旋轉或鏡像的副本不會相符);請變更整體編輯選項後再試一次 +@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`:應為 all 或 1, 3-5 這類範圍,而不是 {v} + Add to Symbols 新增至符號 + Basic Shapes 基本形狀 + Open Symbol Library 開啟符號資料庫 + Save Symbol Library 儲存符號資料庫 + Save Symbol Library… 儲存符號資料庫… + Save Symbols… 儲存符號… + Selected Symbols Only 僅限選取的符號 + Symbol Libraries Menu 符號資料庫選單 + Symbol Library 符號資料庫 + User Symbol Library 1 使用者符號資料庫 1 + User Symbol Library 2 使用者符號資料庫 2 + User Symbol Library 3 使用者符號資料庫 3 + User Symbol Library 4 使用者符號資料庫 4 + User Symbol Library 5 使用者符號資料庫 5 + User Symbol Library 6 使用者符號資料庫 6 + User Symbol Library 7 使用者符號資料庫 7 + User Symbol Library 8 使用者符號資料庫 8 + User Symbol Library 9 使用者符號資料庫 9 + User Symbol Library 10 使用者符號資料庫 10 + {w} × {h} pt {w} × {h} pt +@msg `{_1}` has no symbols to place `{_1}` 中沒有可置入的符號 +@msg missing `library` (see symbol.libraries) 未提供 `library`(請參閱 symbol.libraries) +@msg no symbol `{n}` 沒有符號 `{n}` +@msg no symbol `{n}` in `{_1}` `{_1}` 中沒有符號 `{n}` +@msg no symbol library `{key}` (see symbol.libraries) 沒有符號資料庫 `{key}`(請參閱 symbol.libraries) +@msg the document has no symbols to save 文件中沒有可儲存的符號 +@msg Trap: select two or more objects filled with solid colours 補漏白:請選取兩個或更多以實色填色的物件 +@msg Trap: no edges of different colours meet among the selected objects 補漏白:所選物件中沒有不同顏色相接的邊緣 +@msg thickness must be from 0.01 to 5000 pt thickness 必須介於 0.01 到 5000 pt 之間 +@msg heightWidth must be from 1 to 1000 % heightWidth 必須介於 1 到 1000 % 之間 +@msg tintReduction must be from 0 to 100 % tintReduction 必須介於 0 到 100 % 之間 + Rectangular Grid Tool Options 矩形格線工具選項 + Polar Grid Tool Options 極座標格點工具選項 + Fill Line 填色線段 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index a2fa99485..f19e0a193 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -117,6 +117,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("dc-new-stroke", include_bytes!("../../../assets/icons/dc-new-stroke.svg")), ("dc-objects-on-path", include_bytes!("../../../assets/icons/dc-objects-on-path.svg")), ("dc-options", include_bytes!("../../../assets/icons/dc-options.svg")), + ("dc-para-bullets", include_bytes!("../../../assets/icons/dc-para-bullets.svg")), ("dc-para-center", include_bytes!("../../../assets/icons/dc-para-center.svg")), ("dc-para-justify-all", include_bytes!("../../../assets/icons/dc-para-justify-all.svg")), ("dc-para-justify-center", include_bytes!("../../../assets/icons/dc-para-justify-center.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 52476db6f..8987cc64c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -187,6 +187,31 @@ mod tests { assert!(appearance.contains(">(); + let pen = shapes(&svg("tool-pen")); + assert!(!pen.is_empty(), "the Pen's icon has shapes"); + let (add, delete) = (shapes(&svg("tool-pen-add")), shapes(&svg("tool-pen-delete"))); + for (tool, icon) in [("tool-pen-add", &add), ("tool-pen-delete", &delete)] { + for shape in &pen { + assert!(icon.contains(shape), "{tool}'s icon lacks the Pen's {shape}"); + } + assert!(icon.len() > pen.len(), "{tool}'s icon has a badge of its own"); + } + assert_ne!(add, delete, "plus and minus differ"); + } + #[test] fn shared_catalog_preserves_fallback_and_tool_aliases() { let ImageSource::Bytes { uri, bytes } = source("missing-icon") else { panic!("expected SVG bytes") }; diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 9ff9a4d63..35c27b8ba 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -26,10 +26,8 @@ pub fn open_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: Opti let opens = opens_as(name); // A WebAssembly plug-in is installed. if opens == Some(OpensAs::Plugin) { - let r = - app.run("plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}))?; - app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or(name))); - return Ok(Value::Null); + let module = json!({"dataBase64": vectorcraft_format::base64_encode(bytes), "name": path.as_deref().unwrap_or(name)}); + return install_module(app, module).map(|_| Value::Null); } if opens == Some(OpensAs::ShortcutSet) { return dialogs::shortcut_import::open(app, name, bytes).map(|_| Value::Null); @@ -146,15 +144,47 @@ fn pick_open(app: &mut VectorcraftApp, pick: &FilePick) -> Result) -> Result { - let path = match path { - Some(p) => p, - None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), - None => pick_open(app, &FilePick { filters: vec![("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?, - }; - let r = app.run("plugin.install", json!({ "path": path }))?; +/// Object › Plug-ins › Install Plug-in…: installs the module `p` gives (`path`, or `dataBase64` +/// with `name`), else a picked `.wasm` (the web's file picker hands the file to [`open_bytes`], +/// which installs it) → [`install_module`]'s result. +pub fn install_plugin(app: &mut VectorcraftApp, p: &Value) -> Result { + let given = |k: &str| p.get(k).and_then(Value::as_str).is_some_and(|v| !v.is_empty()); + if given("path") || given("dataBase64") { + return install_module(app, p.clone()); + } + if app.services.open_async.is_some() { + return open_dialog(app).map(|_| Value::Null); + } + let path = + pick_open(app, &FilePick { filters: vec![FilePick::filter("Plug-ins", vectorcraft_engine::cmd::plugin::EXTS)], ..Default::default() })?; + install_module(app, json!({ "path": path })) +} + +/// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64` +/// with `name`) → `plugin.install`'s result. A filter that asks for a heavy budget the user hasn't +/// allowed yet (`allowHeavy`) asks them first, and OK installs it → `{pending}` while it asks. +pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result { + if module.get("allowHeavy").and_then(Value::as_bool) != Some(true) { + let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone())); + let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect()); + let info = app.run("plugin.inspect", source.clone())?; + if info["budget"] == "heavy" { + let name = info["name"].as_str().unwrap_or_default(); + let (minutes, times) = vectorcraft_plugins::heavy_terms(); + let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", name)]); + let detail = crate::i18n::fmt( + tl!( + "Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it." + ), + &[("minutes", &minutes.to_string()), ("times", ×.to_string())], + ); + let mut allowed = source; + allowed["allowHeavy"] = json!(true); + dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed); + return Ok(json!({ "pending": dialogs::confirm::KIND })); + } + } + let r = app.run("plugin.install", module)?; app.status(format!("Installed plug-in {}", r["name"].as_str().unwrap_or_default())); Ok(r) } @@ -165,7 +195,7 @@ pub fn open_dialog(app: &mut VectorcraftApp) -> Result<(), String> { f(); return Ok(()); } - let path = pick_open(app, &FilePick { filters: fileio::open_filters().collect(), ..Default::default() })?; + let path = pick_open(app, &FilePick { filters: FilePick::filters(fileio::open_filters()), ..Default::default() })?; open_reporting(app, &path).map(|_| ()) } @@ -212,6 +242,44 @@ pub fn open_reporting(app: &mut VectorcraftApp, path: &str) -> Result Result { + if let Some(r) = try_activate_open_document(app, path)? { + return Ok(r); + } + open_reporting(app, path) +} + +fn try_activate_open_document(app: &mut VectorcraftApp, path: &str) -> Result, String> { + let Some(index) = document_index_for_path(app, path) else { + return Ok(None); + }; + let st = &app.session.documents()[index]; + let r = json!({ + "index": index, + "title": st.title(), + "format": st.format, + "warnings": [], + "restored": false, + "activated": true, + }); + app.run("document.activate", json!({ "index": index }))?; + opened(app, &r); + Ok(Some(r)) +} + +fn document_index_for_path(app: &VectorcraftApp, path: &str) -> Option { + app.session.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_open_path(p, path))) +} + +fn same_open_path(a: &str, b: &str) -> bool { + match (std::fs::canonicalize(a), std::fs::canonicalize(b)) { + (Ok(a), Ok(b)) => a == b, + _ => a == b, + } +} + fn read(app: &VectorcraftApp, path: &str) -> Result, String> { app.services.read.as_ref().ok_or("no file reader")?(path) } @@ -264,7 +332,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Resu Some(p) => p, None if app.services.open_async.is_some() => return open_dialog(app).map(|_| Value::Null), None => { - let filters = std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters()).collect(); + let filters = FilePick::filters(std::iter::once(("Templates", TEMPLATE_EXTS)).chain(fileio::open_filters())); pick_open(app, &FilePick { folder: fileio::templates_dialog_folder(&app.session.prefs), filters, ..Default::default() })? } }; @@ -297,7 +365,7 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: None => { let (_, folder) = suggested(app, ""); let filters = file_types(&fileio::extension(name)); - pick_path(app, &FilePick { name: name.to_string(), folder, filters })? + pick_path(app, &FilePick { name: name.to_string(), folder, filters }, |_| false)? } }; write_reporting(app, &path, bytes)?; @@ -305,19 +373,32 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: } /// Where a file goes when no path was given: the suggested name on the web (a download), else the -/// save panel's choice, given the suggested name's extension when it names no file type (a name -/// typed without one). -fn pick_path(app: &mut VectorcraftApp, pick: &FilePick) -> Result { +/// save panel's choice with the suggested name's extension, [`with_written_extension`]: a name +/// typed with the extension of a format `keeps` takes stays as typed too. +fn pick_path(app: &mut VectorcraftApp, pick: &FilePick, keeps: impl Fn(&Format) -> bool) -> Result { let ext = fileio::extension(&pick.name); - pick_finished(app, pick, |picked, _| with_extension(picked, &ext, |_| true)) + pick_finished(app, pick, |picked, _| with_written_extension(picked, &ext, &keeps)) +} + +/// `path` as typed in a save panel for a file with extension `ext`: kept when its extension is +/// `ext` in any letter case, or names the format `ext` names (`photo.jpeg` for a `.jpg` file) or +/// a format `keeps` takes. An extension that names another format (`card.svg` for a `.png` file) +/// is replaced with `ext`, and `.ext` is added to any other path (`v1.2` becomes `v1.2.png`). +fn with_written_extension(path: &str, ext: &str, keeps: impl Fn(&Format) -> bool) -> String { + let written = fileio::format(ext); + let keeps = |g: &Format| written.is_some_and(|w| w.id == g.id) || keeps(g); + if !ext.is_empty() && !fileio::extension(path).eq_ignore_ascii_case(ext) && fileio::format_for_name(path).is_some_and(|g| !keeps(g)) { + return std::path::Path::new(path).with_extension(ext).to_string_lossy().into_owned(); + } + with_extension(path, ext, keeps) } /// The suggested name made into a path by `finish` on the web (a download), else the save panel's /// choice made into one, with the extension of the file type picked in the panel when it reports -/// one ([`crate::picks::PickAnswer`]). When `finish` changed the choice (an extension added) and a -/// file is there, which the panel did not ask about, a question asks before replacing it -/// ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK runs what -/// asked again. +/// one ([`crate::picks::PickAnswer`]). When `finish` changed the choice (an extension added or +/// replaced) and a file is there, which the panel did not ask about, a question asks before +/// replacing it ([`crate::picks::ask_to_replace`]) and this answers "cancelled"; the question's OK +/// runs what asked again. fn pick_finished(app: &mut VectorcraftApp, pick: &FilePick, finish: impl Fn(&str, Option<&str>) -> String) -> Result { if is_web(app) { return Ok(finish(&pick.name, None)); @@ -367,23 +448,35 @@ const FILE_TYPES: &[(&str, &[&str])] = &[ /// The file type a save panel shows for a file with extension `ext`: its format's, else its /// [`FILE_TYPES`] row; none for any other extension. -fn file_types(ext: &str) -> Vec<(&'static str, &'static [&'static str])> { +fn file_types(ext: &str) -> Vec { if let Some(f) = fileio::format(ext) { - return vec![(f.label, f.extensions)]; + return vec![FilePick::filter(f.label, f.extensions)]; } let ext = ext.trim_start_matches('.').to_ascii_lowercase(); - FILE_TYPES.iter().find(|(_, exts)| exts.contains(&ext.as_str())).copied().into_iter().collect() + FilePick::filters(FILE_TYPES.iter().find(|(_, exts)| exts.contains(&ext.as_str())).copied()) } /// `path`, else one picked in a save panel (the web: the suggested name) for the active document -/// with extension `ext`, filtered to its file type when it has one ([`file_types`]). +/// with extension `ext`, filtered to its file type when it has one ([`file_types`]). A name typed +/// with another format's extension gets `ext` instead ([`with_written_extension`]). pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { + target_path_keeping(app, path, ext, |_| false) +} + +/// [`target_path`] for an export in format `f` (Export As's OK and [`export`]): a name typed with +/// `.svgz` for an SVG stays as typed, and [`export`] writes it compressed. +pub(crate) fn export_path(app: &mut VectorcraftApp, path: Option, f: &Format) -> Result { + target_path_keeping(app, path, f.extensions[0], |g| f.id == "svg" && g.id == "svgz") +} + +/// [`target_path`], keeping a typed extension of a format `keeps` takes. +fn target_path_keeping(app: &mut VectorcraftApp, path: Option, ext: &str, keeps: impl Fn(&Format) -> bool) -> Result { if let Some(p) = path { return Ok(p); } let (name, folder) = suggested(app, ext); let filters = file_types(ext); - pick_path(app, &FilePick { name, folder, filters }) + pick_path(app, &FilePick { name, folder, filters }, keeps) } /// Keep the active document's saved view current before a save (native files reopen at it). @@ -395,25 +488,30 @@ pub fn remember_view(app: &mut VectorcraftApp) { } /// A path typed in a save panel: kept when its extension names one of the panel's file types (a -/// save format, or a template format for Save as Template, `mode`); otherwise `f`'s extension is -/// added. +/// save format, or a template format for Save as Template, `mode`). An extension that names +/// another format (`card.png`, or `card.ait` in Save As) is replaced with `f`'s, and `f`'s is +/// added to any other path (`notes.v2` becomes `notes.v2.vectorcraft`). fn with_save_extension(path: &str, f: &Format, mode: SaveMode) -> String { let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; + if fileio::format_for_name(path).is_some_and(|g| !formats.contains(&g.id)) { + return std::path::Path::new(path).with_extension(f.extensions[0]).to_string_lossy().into_owned(); + } with_extension(path, f.extensions[0], |g| formats.contains(&g.id)) } /// [`with_save_extension`] for a save panel that reports the file type picked in it (`picked`, /// that type's extension; the XDG portal's panel on Linux and the BSDs), which then takes `f`'s -/// place. A path that still ends in the extension of the name the panel suggested (`suggested`) -/// gets the picked type's extension instead. Without a picked type, or with one the panel did not +/// place. A path that still ends in the extension of the name the panel suggested (`suggested`), +/// in any letter case, gets the picked type's extension instead, unless that extension is one of +/// the picked type's; such a path stays as typed. Without a picked type, or with one the panel did not /// offer, [`with_save_extension`] with `f`. fn with_picked_type(path: &str, suggested: &str, picked: Option<&str>, f: &Format, mode: SaveMode) -> String { let formats = if mode == SaveMode::Template { TEMPLATE_FORMATS } else { SAVE_FORMATS }; let Some(g) = picked.and_then(fileio::format).filter(|g| formats.contains(&g.id)) else { return with_save_extension(path, f, mode); }; - let has = std::path::Path::new(path).extension(); - if has.is_some() && has == std::path::Path::new(suggested).extension() { + let has = fileio::extension(path); + if !has.is_empty() && has == fileio::extension(suggested) && !g.extensions.contains(&has.as_str()) { return std::path::Path::new(path).with_extension(g.extensions[0]).to_string_lossy().into_owned(); } with_save_extension(path, g, mode) @@ -461,10 +559,10 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if is_web(app) && ask_options { return Ok(open_options(app, mode.command(), first.format, &first.name, true)); } - let filters = match mode { + let filters = FilePick::filters(match mode { SaveMode::Template => fileio::template_filters(first.format.id), _ => fileio::save_filters(first.format.id), - }; + }); let pick = FilePick { name: first.name.clone(), folder: first.folder.clone(), filters }; let picked = pick_finished(app, &pick, |picked, file_type| with_picked_type(picked, &first.name, file_type, first.format, mode))?; // The picked file type wins over a `format` param. @@ -480,9 +578,7 @@ pub fn save(app: &mut VectorcraftApp, mode: SaveMode, p: &Value, ask_options: bo if ask && asks_options(mode, chosen.format) { let path = chosen.path.clone().unwrap_or_default(); let r = ask_format_options(app, mode, chosen.format, &path); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert(crate::dialogs::AFTER_PANEL.into(), json!(true)); - } + crate::dialogs::opened_by_panel(app); return r; } write_plan(app, chosen) @@ -705,6 +801,9 @@ pub fn hide_exports(app: &mut VectorcraftApp) -> Value { /// document keeps its path. → `{path, warnings, files?}` (`files`: every file when there are /// several). pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option, params: &Value) -> Result { + // Options given as anything but an object are none: the path and format still reach the + // encoder (an exporter plug-in is told the file it writes). + let params = &if params.is_object() { params.clone() } else { json!({}) }; let f = fileio::writable_format(format, path.as_deref())?; let st = app.session.active().ok_or("no document")?; let doc = match fileio::export_source(st, params).map_err(|e| e.to_string())? { @@ -712,7 +811,7 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option std::sync::Arc::new(d), }; let doc = fileio::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, std::sync::Arc::new); - let path = target_path(app, path, f.extensions[0])?; + let path = export_path(app, path, f)?; let mut again = params.clone(); if let Some(o) = again.as_object_mut() { o.insert("format".into(), json!(f.id)); @@ -810,14 +909,18 @@ fn take_output(v: &mut Value) -> Result, String> { /// Run an engine command that returns `{dataBase64}` without its `path` and write the bytes to /// that path (else a picked or suggested name) → (path, the command's result without the data). /// The command runs before a path is asked for, so bad params never open a save dialog, except -/// for Export Selection without a `format`, which takes it from the picked path. +/// for Export Selection without a `format`, which takes it from the picked path. Export Selection +/// with a `format` picks a file with that format's extension in place of `ext`. pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut params: Value) -> Result<(String, Value), String> { let mut path = params.as_object_mut().and_then(|o| o.remove("path")).and_then(|p| p.as_str().map(str::to_string)); + let selection = id == "document.exportSelection"; + let given = params.get("format").and_then(Value::as_str).and_then(fileio::format).filter(|_| selection); + let ext = given.map_or(ext, |f| f.extensions[0]); if let Some(o) = params.as_object_mut() && o.get("format").is_none() - && id == "document.exportSelection" + && selection { - let picked = target_path(app, path, ext)?; + let picked = target_path_keeping(app, path, ext, |_| true)?; let e = Some(fileio::extension(&picked)).filter(|e| !e.is_empty()).unwrap_or_else(|| ext.into()); o.insert("format".into(), serde_json::json!(e)); path = Some(picked); @@ -900,10 +1003,47 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result Result { let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); - let r = app.run(id, params)?; + // Without a folder the files come back in the reply, which the caller waits for. + let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); + if !in_background { + // Made at once, after the exports and saves started before it. + background::wait_all(app); + let r = app.run(id, params)?; + return report_exported(app, r, &folder, open_location); + } + let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; + let label = format!("Exporting {}", fileio::file_name(&folder)); + // The progress dialog counts the files, and its Cancel stops the export before the next one. + let progress = Progress::of_files(); + let (reporting, asked) = (progress.clone(), progress.clone()); + let work = move |write: Writer| { + progress::scope(&reporting, || { + let mut write = |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + export.write(&mut write).map_err(|e| e.to_string()) + }) + }; + background::run_with_progress(app, true, label, None, Some(progress), work, move |app, r| { + // Files written before the Cancel arrived stay in the folder. + if r.is_err() && asked.cancelled() { + app.status(EXPORT_CANCELLED); + return Ok(json!({ "cancelled": true })); + } + let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; + report_exported(app, r, &folder, open_location) + }) +} + +/// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count +/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, +/// the file manager shows the first file written. +fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { let count = r["files"].as_array().map_or(0, Vec::len); let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); // Files that came back as bytes: (name, base64). @@ -917,7 +1057,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); let mut status = format!("Exported {count} file(s) to {folder}"); if warned { - status = saved_status(r["path"].as_str().unwrap_or(&folder), &r); + status = saved_status(r["path"].as_str().unwrap_or(folder), &r); } if open_location && let Some(first) = first @@ -943,9 +1083,10 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> Ok(r) } -/// Place a file's bytes (no path, so embedded) centred in the view: `file.place`. +/// Place a file's bytes (no path, so embedded) centered in the view: `file.place`, read off the UI +/// thread on the desktop ([`crate::place::run_for_user`]). pub fn place_bytes(app: &mut VectorcraftApp, name: &str, bytes: &[u8]) -> Result<(), String> { - crate::place::run(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) + crate::place::run_for_user(app, &serde_json::json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).map(|_| ()) } #[cfg(test)] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7d63c21a8..c236a0835 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,7 @@ pub mod export_progress; pub mod find_font; pub mod floating; pub mod font_menu; +mod frame_rate; mod free_transform; pub mod graphics; pub mod i18n; @@ -43,6 +44,8 @@ mod panel_docking; pub mod panels; pub mod picks; pub mod place; +pub mod placing; +pub mod plugin_runs; pub mod prefs_dialog; pub mod print; pub mod recovery; @@ -52,6 +55,7 @@ mod scrub; pub mod shortcut_editor; pub mod shortcuts; pub mod state; +pub mod stylus; pub mod sysclip; pub mod theme; pub mod thumb_worker; @@ -63,6 +67,7 @@ pub mod unsaved; pub mod widgets; pub mod workspaces; +mod document_drag; #[cfg(test)] mod tests_adjust; #[cfg(test)] @@ -82,6 +87,8 @@ mod tests_distortkeys; #[cfg(test)] mod tests_docsetup; #[cfg(test)] +mod tests_document_drag; +#[cfg(test)] mod tests_font_menu; #[cfg(test)] mod tests_fonts; @@ -112,6 +119,8 @@ mod tests_picks; #[cfg(test)] mod tests_place; #[cfg(test)] +mod tests_plugin_runs; +#[cfg(test)] mod tests_plugins; #[cfg(test)] mod tests_printps; @@ -174,14 +183,18 @@ pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket}; pub use state::{UiState, View}; pub use sysclip::{ClipboardProbeFactory, SystemClipboard}; +/// A file type a dialog filters by: its label, which shows translated, and its extensions without +/// the dot. +pub type FileFilter = (String, Vec); + /// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the /// file-type filters. #[derive(Clone, Debug, Default, PartialEq)] pub struct FilePick { pub name: String, pub folder: Option, - /// `(label, extensions without the dot)`, the default type first; empty = every file. - pub filters: Vec<(&'static str, &'static [&'static str])>, + /// The default type first; empty = every file. + pub filters: Vec, } impl FilePick { @@ -189,6 +202,16 @@ impl FilePick { pub fn named(name: &str) -> Self { Self { name: name.to_string(), ..Default::default() } } + + /// The file type `label` with `extensions`. + pub fn filter(label: &str, extensions: &[&str]) -> FileFilter { + (label.to_owned(), extensions.iter().map(|e| (*e).to_owned()).collect()) + } + + /// The file types of `list`, as the engine lists them: `(label, extensions)`. + pub fn filters<'a>(list: impl IntoIterator) -> Vec { + list.into_iter().map(|(label, extensions)| Self::filter(label, extensions)).collect() + } } pub type PickOpen = Box Option>; @@ -299,6 +322,11 @@ pub struct Services { /// default one would never come back once the canvas hid it: the canvas keeps the system /// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450). pub no_cursor_theme: bool, + /// The pen pressure and pen end the host reads from the system itself (desktop Linux under + /// X11 and XWayland). The canvas uses the pressure when a frame's events carry no touch force, + /// and the end to switch to the Eraser tool and back. Without it, pen pressure comes from + /// touch events only ([`stylus`]). + pub stylus: Option, } /// Cached canvas raster. @@ -306,6 +334,10 @@ pub struct CanvasCache { pub renderer: vectorcraft_render::Renderer, pub texture: Option, pub key: Option, + /// The document the frame shown was rendered from, and the options it was rendered with, with + /// [`Self::key`]: when only the document changes, the canvas renders again only where it + /// changed ([`vectorcraft_render::changed`]). + pub shown: Option<(std::sync::Arc, vectorcraft_render::RenderOptions)>, pub last_ms: f64, /// The frame shown drew raster effects from rasters made before their objects moved, or at a /// drag's lower resolution ([`vectorcraft_render::FrameStats::moved`]): the next frame outside @@ -314,12 +346,21 @@ pub struct CanvasCache { /// The frame shown was rendered on the worker: an interaction's frames stay there /// ([`canvas::show`]). pub from_worker: bool, + /// Set in the web build, which has no render worker: while the view changes on a document whose + /// last render took more than 8 ms, the canvas draws the frame shown reprojected and renders + /// once the view has been still for [`canvas::STILL_SECS`]. + pub waits_for_still: bool, + /// The cache key the canvas last asked for and when it first asked for it (seconds, egui's + /// input time), kept while [`Self::waits_for_still`] is set. + pub still: Option<(CacheKey, f64)>, pub worker: Option, pub worker_started: bool, /// The slices as laid out for (document uid, revision): the canvas draws them every frame. pub slices: Option, /// The print tiling's pages for (document uid, revision) (View → Show Print Tiling). pub print_tiling: Option, + /// The gap lines View → Show Live Paint Gaps draws for (document uid, revision). + pub live_paint_gaps: Option, /// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame /// that does not draw them drops them ([`canvas::show`]). pub hidden_chars: canvas::HiddenChars, @@ -331,11 +372,21 @@ pub struct CanvasCache { pub content_bounds: Option<((u64, u64), Option)>, /// The tools' cursors as OS cursor bitmaps. pub cursors: cursors::Images, + /// The view the canvas last drew and when it last changed (egui's input time): while it moves, + /// the canvas doesn't hit-test the art under the pointer ([`canvas::hover_wait`], #2452). + pub view_moved: Option<(canvas::ViewKey, f64)>, + /// The cursor the active tool last gave over the canvas, with the tool and the modifier keys + /// it was for: kept while the view moves. + pub hover_cursor: Option<(&'static str, vectorcraft_tools::Mods, vectorcraft_tools::Cursor)>, } /// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision). pub type SliceCache = ((u64, u64), std::sync::Arc>); +/// [`CanvasCache::live_paint_gaps`]: the gap lines of the selected Live Paint groups of (document +/// uid, revision). +pub type LivePaintGapsCache = ((u64, u64), std::sync::Arc>); + /// [`CanvasCache::print_tiling`]: the print tiling of (document uid, revision). pub type PrintTilingCache = ((u64, u64), std::sync::Arc>); @@ -354,8 +405,10 @@ pub struct CacheKey { pub h: u32, pub outline: bool, pub trim: bool, - /// View › Hide Template: template layers left out ([`vectorcraft_render::RenderOptions::skip_templates`]). + /// View › Hide Template: template layers left out ([`vectorcraft_render::RenderOptions::hide_templates`]). pub templates: bool, + /// View › Show/Hide Guides: whether guide paths are visible in the artwork raster. + pub guides: bool, pub ppp: f32, pub hidden: Vec, pub rot: f64, @@ -418,7 +471,8 @@ pub struct VectorcraftApp { /// Installed fonts added to the UI's for characters its own fonts lack (CJK names…). ui_fonts: ui_fonts::UiFonts, frame: u64, - last_time: f64, + /// The status bar's frame rate ([`frame_rate`]). + frame_rate: frame_rate::FrameRate, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, /// Hover position in document coordinates. @@ -462,6 +516,7 @@ pub struct VectorcraftApp { /// the answer. font_check: Option<(std::sync::mpsc::Receiver, bool)>, /// When installed fonts were last checked in line (app time, s). + #[cfg(not(target_arch = "wasm32"))] font_install_check_at: f64, /// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]). pub(crate) paste_chord: shortcuts::PasteChord, @@ -469,6 +524,10 @@ pub struct VectorcraftApp { pub background: background::Background, /// Documents being read off the UI thread ([`opening`]). pub opening: opening::Opening, + /// Heavy plug-in filters running off the UI thread ([`plugin_runs`]). + pub plugin_runs: plugin_runs::PluginRuns, + /// Files being read off the UI thread for Place ([`placing`]). + pub placing: placing::Placing, /// Data Recovery's timer and startup question ([`recovery`]). pub recovery: recovery::Timer, /// Modifiers the keyboard holds (from the host's input), given back after synthetic input. @@ -482,9 +541,13 @@ pub struct VectorcraftApp { pub(crate) ime_discard: bool, /// Preferences were confirmed (see [`Self::take_prefs_changed`]). pub(crate) prefs_changed: bool, + /// Whether the last frame's input reported a touch screen (Preferences › Devices › Touch Workspace). + pub touch_screen: bool, /// A numeric field is being scrubbed: the document's edits meanwhile are one undo step /// ([`scrub::begin_frame`]). scrub_group: bool, + /// The undo step of the scrub in progress ([`vectorcraft_engine::Session::begin_undo_scope`]). + scrub_scope: Option, /// The folders control requests, and the input they inject, may read and write /// (`--automation-read-root`, `--automation-write-root`); `None`: anywhere. See /// [`Self::with_automation_roots`]. @@ -492,6 +555,9 @@ pub struct VectorcraftApp { /// This frame carries input the control channel injected ([`Self::raw_input_hook`]): it runs /// confined to [`Self::automation_roots`]. synthetic_frame: bool, + /// The pen end the canvas saw last and the tool its eraser end stands in for + /// ([`stylus::sync_eraser_tool`]). + pub(crate) pen_end: stylus::PenEnd, } /// A window capture asked for through the control channel (`ui.screenshot`), answered once a @@ -509,15 +575,22 @@ struct PendingScreenshot { /// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; /// Seconds between looks for fonts a font manager activated while the app stays in front (#2576). +#[cfg(not(target_arch = "wasm32"))] const FONT_INSTALL_POLL: f64 = 2.0; impl VectorcraftApp { + /// The app around `session`, reaching the platform through `services` (file pickers, the + /// clipboard, files and the like). It starts cataloguing the installed fonts at once, gives + /// Data Recovery the host's store and makes a view for each open document. pub fn new(mut session: Session, services: Services) -> Self { // The font menus and the first file opened need the installed fonts: catalog them now. vectorcraft_text::FontDb::global().scan_in_background(); if let Some(store) = &services.recovery_store { session.recovery.set_store(store.clone()); } + // The place cursor's clicks and drags place their files through the app + // ([`place::place_from_cursor`]). + session.place_cursor_requests = true; let views = session.documents().iter().map(View::of).collect(); Self { session, @@ -528,18 +601,24 @@ impl VectorcraftApp { renderer: vectorcraft_render::Renderer::new(), texture: None, key: None, + shown: None, last_ms: 0.0, moved: false, from_worker: false, + waits_for_still: cfg!(target_arch = "wasm32"), + still: None, worker: None, worker_started: false, slices: None, print_tiling: None, + live_paint_gaps: None, hidden_chars: Default::default(), selection_box: None, selection_bounds: None, content_bounds: None, cursors: Default::default(), + view_moved: None, + hover_cursor: None, }, perf: Perf::default(), titlebar_inset: 0.0, @@ -561,7 +640,7 @@ impl VectorcraftApp { context: 0, ui_fonts: Default::default(), frame: 0, - last_time: 0.0, + frame_rate: Default::default(), canvas_rect: None, hover_doc: None, custom_titlebar: false, @@ -574,19 +653,25 @@ impl VectorcraftApp { picks: picks::Picks::default(), screen_sampler: None, font_check: None, + #[cfg(not(target_arch = "wasm32"))] font_install_check_at: f64::NEG_INFINITY, paste_chord: Default::default(), background: Default::default(), opening: Default::default(), + plugin_runs: Default::default(), + placing: Default::default(), recovery: Default::default(), host_modifiers: Default::default(), synthetic_modifiers: false, ime_marked: None, ime_discard: false, prefs_changed: false, + touch_screen: false, scrub_group: false, + scrub_scope: None, automation_roots: None, synthetic_frame: false, + pen_end: Default::default(), } } @@ -656,6 +741,44 @@ impl VectorcraftApp { } } + /// The active document's uid, how many times the tool has started afresh + /// ([`vectorcraft_engine::Session::tool_resets`]) and the artboards the Artboard tool has + /// selected (null with another tool), which [`Self::follow_artboard_tool`] compares before and + /// after a command. + fn artboard_tool_state(&self) -> (Option, u64, Value) { + let selected = if self.session.tool_id() == "artboard" { self.session.tool_options()["selected"].clone() } else { Value::Null }; + (self.session.active().map(|d| d.uid), self.session.tool_resets(), selected) + } + + /// After a command run with the Artboard tool, given [`Self::artboard_tool_state`] from before + /// it: when the command chose another document, or started the tool afresh in the same one + /// (File › Revert, a `command.batch` or `action.batch` that reverts it or switches to another + /// document and back), the tool takes the document's active artboard, as choosing its tab + /// does. When the command changed the artboards the tool has selected (Paste and Cut with the + /// tool, `tool.setOption`), also after starting it afresh (a batch step after the revert or the + /// switch), the tool's active artboard becomes the active one (the view's), as a click with the + /// tool makes it. + fn follow_artboard_tool(&mut self, before: (Option, u64, Value)) { + self.sync_views(); + if self.session.tool_id() != "artboard" { + return; + } + let Some(n) = self.session.active().map(|d| d.doc.artboards.len()).filter(|n| *n > 0) else { return }; + let (doc, resets, selected) = self.artboard_tool_state(); + // The tool started afresh during the command, and no later step (a Paste or Cut) set its options. + let reset = resets != before.1; + let fresh = reset && !self.session.tool_set_since_reset(); + if fresh || (doc != before.0 && !reset) { + panels::artboards::select(self, panels::artboards::selected(self, n)); + } else if (reset || selected != before.2) + && let Some(i) = self.session.tool_options()["active"].as_u64().and_then(|i| usize::try_from(i).ok()) + && i < n + && let Some(v) = self.view_mut() + { + v.artboard = i; + } + } + pub fn view(&self) -> Option<&View> { self.session.active_index().and_then(|i| self.views.get(i)) } @@ -723,7 +846,9 @@ impl VectorcraftApp { if cfg!(debug_assertions) { menus::check_params(&self.session, id, ¶ms)?; } + let artboards = self.artboard_tool_state(); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + self.follow_artboard_tool(artboards); return r; } let mut params = params; @@ -780,6 +905,8 @@ impl VectorcraftApp { self.publish_clipboard(); } self.sync_views(); + // File › Revert replaces the document in its tab, keeps the tab's view and starts the tool afresh. + self.follow_artboard_tool(artboards); match &r { Err(e) => self.ui.status = e.clone(), Ok(v) => { @@ -796,11 +923,16 @@ impl VectorcraftApp { self.ui.view.pixel_preview = true; } } - if id == "text.setStyle" + // Choosing a data set, or stepping to one, clears the status bar. The reply's `warning` + // below then shows when no object is bound to a variable the data set has a value for. + if matches!(id, "dataset.select" | "dataset.next" | "dataset.prev") { + self.ui.status.clear(); + } + if matches!(id, "text.setStyle" | "text.setRangeStyle") && let Some(font) = params.get("font").and_then(Value::as_str) { let r = &mut self.ui.recent_fonts; - r.retain(|f| f != font); + r.retain(|f| !f.eq_ignore_ascii_case(font)); r.insert(0, font.to_string()); r.truncate(MAX_RECENT_FONTS); } @@ -853,9 +985,24 @@ impl VectorcraftApp { /// Select a tool (also used by the toolbar and shortcuts). pub fn select_tool(&mut self, id: &str) { + // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice. + self.pen_end.forget_tool(); + self.activate_tool(id, true); + } + + /// Go back to tool `id` after a tool that stood in for it (the pen's eraser end, [`stylus`]): + /// a perspective grid hidden meanwhile stays hidden. + pub(crate) fn return_to_tool(&mut self, id: &str) { + self.activate_tool(id, false); + } + + /// Make tool `id` active: chosen afresh (`fresh`, [`vectorcraft_engine::Session::select_tool`]), + /// or back from a tool that stood in for it ([`vectorcraft_engine::Session::switch_tool`]). + fn activate_tool(&mut self, id: &str, fresh: bool) { let v = self.view_info(); let before = self.session.tool_id(); - if let Err(e) = self.session.select_tool(id, v) { + let switched = if fresh { self.session.select_tool(id, v) } else { self.session.switch_tool(id, v) }; + if let Err(e) = switched { self.ui.status = e.to_string(); } // A newly chosen Artboard tool starts on the active artboard (the view's). @@ -1112,7 +1259,7 @@ impl VectorcraftApp { { return b; } - let b = match st.selection.anchor_bounds(&st.doc, self.session.prefs.use_preview_bounds) { + let b = match vectorcraft_effects::preview::anchor_bounds(&st.selection, &st.doc, self.session.prefs.use_preview_bounds) { Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)), None => self.session.transform_box(&st.selection.objects), }; @@ -1120,9 +1267,9 @@ impl VectorcraftApp { b } - /// The selection's visual bounds (stroke and effects included), square to the page: measured - /// once per revision, as the canvas reads it every frame (a traced photo selects a group of - /// hundreds of thousands of paths). + /// The selection's visual bounds (strokes included, effects left out), square to the page: + /// measured once per revision, as the canvas reads it every frame (a traced photo selects a + /// group of hundreds of thousands of paths). pub fn selection_bounds(&mut self) -> Option { let st = self.session.active()?; let key = (st.uid, st.revision); @@ -1174,6 +1321,9 @@ impl VectorcraftApp { } } + /// [`Self::logic`]'s work, inside its guard: the UI language and styling, the clipboard and + /// font polls, the results of work done in the background (saves, exports, files being opened, + /// heavy plug-in filters) and the window's close button. fn logic_frame(&mut self, ctx: &egui::Context) { let lang = self.ui_language(); i18n::set_current(lang); @@ -1192,11 +1342,7 @@ impl VectorcraftApp { } self.frame += 1; let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; - } - self.last_time = now; + self.perf.fps = self.frame_rate.frame(now, self.perf.frame_ms / 1000.0); self.sync_views(); // Read the system clipboard only when that alone decides whether Paste is enabled. A // background thread reads it where the host installs one (an unresponsive owner must never @@ -1225,9 +1371,15 @@ impl VectorcraftApp { background::poll(self); screen_color::poll(self, ctx); opening::poll(self); + plugin_runs::poll(self); + placing::poll(self); #[cfg(not(target_arch = "wasm32"))] self.tick_link_updates(ctx, now); - if !self.background.jobs.is_empty() || !self.opening.pending.is_empty() { + if !self.background.jobs.is_empty() + || !self.opening.pending.is_empty() + || !self.plugin_runs.running.is_empty() + || !self.placing.pending.is_empty() + { // Keep the status bar's progress moving and pick the result up when it arrives. ctx.request_repaint_after(std::time::Duration::from_millis(100)); } @@ -1428,6 +1580,8 @@ impl VectorcraftApp { } } + /// [`Self::ui`]'s work, inside its guard: the bars, panels and canvas, then the dialogs and + /// progress windows over them. fn ui_frame(&mut self, ui: &mut egui::Ui) { crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size); let ctx = ui.ctx().clone(); @@ -1440,6 +1594,8 @@ impl VectorcraftApp { scrub::begin_frame(self, &ctx); font_menu::end_stale_preview(self, &ctx); floating::track(self, &ctx); + // A selection move released over a panel ends before any panel sees the release (#2826). + canvas::release_over_panel(self, &ctx); let t = theme::Tokens::get(&ctx); if self.ui.screen_mode < 2 { chrome::app_bar(self, ui); @@ -1478,6 +1634,7 @@ impl VectorcraftApp { floating::show(self, &ctx); dialogs::show(self, &ctx); export_progress::show(self, &ctx); + plugin_runs::show(self, &ctx); palette::show(self, &ctx); if self.custom_titlebar { titlebar::resize_zones(ui); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 557ddfccd..9bc8dcf5f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -162,10 +162,16 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", - "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager)", + "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})", ), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), + ( + "file.newWithoutDialog", + "New Without Dialog", + "Cmd+Alt+N", + "{} makes a document with the settings the New Document dialog opens on (the first Recent entry, else the Print category's Letter), named after the next untitled document, without opening the dialog → {index, previewMode}", + ), ("app.home", "Home", "", "{} shows the Home screen (new file presets, Open) over the open documents; choosing a document tab returns to it"), ("edit.preferences", "Preferences…", "Cmd+K", "{category?} open Preferences (engine: prefs.get / prefs.set / prefs.list)"), ("edit.keyboardShortcuts", "Keyboard Shortcuts…", "Cmd+Alt+Shift+K", "{}"), @@ -201,6 +207,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.textThreads", "Hide Text Threads", "Cmd+Shift+Y", "{} toggle the thread lines between threaded text frames"), ("view.gradientAnnotator", "Hide Gradient Annotator", "Cmd+Alt+G", "{} toggle the Gradient tool's annotator"), + ( + "view.livePaintGaps", + "Show Live Paint Gaps", + "", + "{} toggle drawing the gaps of the selected Live Paint groups (those livePaint.info lists) in each group's Gap Preview Color while edges show → true when on", + ), ("type.hiddenCharacters", "Show Hidden Characters", "Cmd+Alt+I", "{} toggle markers for spaces, paragraph ends and story ends"), ("type.alignLeft", "Align left", "Cmd+Shift+L", "{} align the selected type's paragraphs (or those the Type tool's selection touches) left"), ( @@ -212,6 +224,102 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("type.alignRight", "Align right", "Cmd+Shift+R", "{} align the selected type's paragraphs (or those the Type tool's selection touches) right"), ("type.justifyLeft", "Justify with last line aligned left", "Cmd+Shift+J", "{} justify the selected type's paragraphs, last line aligned left"), ("type.justifyAll", "Justify all lines", "Cmd+Shift+F", "{} justify every line of the selected type's paragraphs"), + ( + "type.applyBullets", + "Apply Bullets", + "", + "{bullet?: \"dot\"|\"dash\"|\"circle\"|\"square\"|\"none\"} Type › Bullets and Numbering › Apply Bullets: the selected type's paragraphs (or those the Type tool's selection touches) become a bulleted list with that bullet (text.setFormat {bullet}); without it, a list keeps its bullet and other paragraphs take the dot (text.setFormat {list: \"bullet\"}); \"none\" turns the list off", + ), + ( + "align.horizontalLeft", + "Horizontal Align Left", + "", + "{} the Align panel's Horizontal Align Left button: object.align {horizontal: \"left\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalCenter", + "Horizontal Align Center", + "", + "{} the Align panel's Horizontal Align Center button: object.align {horizontal: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalRight", + "Horizontal Align Right", + "", + "{} the Align panel's Horizontal Align Right button: object.align {horizontal: \"right\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.horizontalVerticalCenter", + "Horizontal & Vertical Align Center", + "", + "{} the Align panel's Horizontal & Vertical Align Center button: object.align {horizontal: \"center\", vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalTop", + "Vertical Align Top", + "", + "{} the Align panel's Vertical Align Top button: object.align {vertical: \"top\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalCenter", + "Vertical Align Center", + "", + "{} the Align panel's Vertical Align Center button: object.align {vertical: \"center\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.verticalBottom", + "Vertical Align Bottom", + "", + "{} the Align panel's Vertical Align Bottom button: object.align {vertical: \"bottom\"} with to: \"key\" while there is a key, else \"artboard\" for one selected object without selected anchor points, else the Align To choice", + ), + ( + "align.distributeVerticalTop", + "Vertical Distribute Top", + "", + "{} the Align panel's Vertical Distribute Top button: object.distribute {vertical: \"top\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalCenter", + "Vertical Distribute Center", + "", + "{} the Align panel's Vertical Distribute Center button: object.distribute {vertical: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalBottom", + "Vertical Distribute Bottom", + "", + "{} the Align panel's Vertical Distribute Bottom button: object.distribute {vertical: \"bottom\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalLeft", + "Horizontal Distribute Left", + "", + "{} the Align panel's Horizontal Distribute Left button: object.distribute {horizontal: \"left\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalCenter", + "Horizontal Distribute Center", + "", + "{} the Align panel's Horizontal Distribute Center button: object.distribute {horizontal: \"center\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalRight", + "Horizontal Distribute Right", + "", + "{} the Align panel's Horizontal Distribute Right button: object.distribute {horizontal: \"right\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeVerticalSpace", + "Vertical Distribute Space", + "", + "{} the Align panel's Vertical Distribute Space button: object.distributeSpacing {axis: \"vertical\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), + ( + "align.distributeHorizontalSpace", + "Horizontal Distribute Space", + "", + "{} the Align panel's Horizontal Distribute Space button: object.distributeSpacing {axis: \"horizontal\"} with to: \"key\" while there is a key, else the Align To choice, and spacing: the Distribute Spacing value while there is a key", + ), ( "type.bold", "Bold", @@ -251,6 +359,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{} template layers left off the canvas (Show Template draws them again, dimmed as before) → shown", ), ("view.rulers", "Show Rulers", "Cmd+R", "{}"), + ( + "view.videoRulers", + "Show Video Rulers", + "", + "{} the rulers show (as Show Rulers shows them) and count the active artboard in video pixels from its top-left corner, a pixel as wide as its Video Ruler Pixel Aspect Ratio says (artboard.setProps videoRulerPixelAspectRatio) and one point tall; again (Hide Video Rulers), they count in the General unit from the ruler origin as before → whether video rulers show", + ), ("view.boundingBox", "Hide Bounding Box", "Cmd+Shift+B", "{}"), ("view.guides", "Hide Guides", "Cmd+;", "{}"), ("view.smartGuides", "Smart Guides", "Cmd+U", "{}"), @@ -357,6 +471,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("window.panel.activate", "Activate Panel", "", "{panel} show and select a panel in its current group"), ("window.panel.close", "Close Panel", "", "{panel} hide a panel"), + ( + "window.panel.collapse", + "Collapse Panel Group", + "", + "{panel, collapsed?: bool} show the dock group holding a panel as its tabs only (or its panel again), as a double-click on its tab does; the panel becomes the group's shown one. Without `collapsed` it toggles. Floating groups collapse through window.panel.float {panel, collapsed}. Returns {panel, collapsed}", + ), ( "window.panel.float", "Float Panel", @@ -367,7 +487,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.panel.dock", "Dock Panel", "", - "{panel: id or \"tools\", group?: bool, column?: true or n} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}", + "{panel: id or \"tools\", group?: bool, column?: true or n, anchor?: id, zone?: \"tab\" | \"top\" | \"bottom\" | \"left\" | \"right\" | \"center\", before?: id, icon?: bool} put a floating panel (with `group`, its whole group) back in the dock, as dropping it on the dock or its group's × does: Properties, Layers and Libraries in the tabbed group, the other panels in the icon column; with `anchor`, in the dock group holding that panel instead: `zone` \"tab\" (the default) as a tab of it (before the `before` panel's tab), as dropping it on that group's tabs does, \"top\" / \"bottom\" as a new group above / below it, as dropping it on that group's upper / lower half does (\"left\", \"right\" and \"center\" split or join it likewise); with `icon: true` (no `anchor`), as icons in the icon column, the tabbed group's panels too, as dropping it on the icon column does: it lives there from then on and pops out of its icon; with `column`, dock its group and the rest of its set as a dock column instead, as dropping it on the left edge of the icon column or of a dock column does: `true` at the dock's left, n at that place (1: next to the icon column, counting left); \"tools\" docks the Tools panel at the window's left edge. Returns {panel, floating, column}, or with `anchor` or `icon: true` (the shared layout) {panel, floating, layout} and no `column`", ), ( "window.collapseDock", @@ -376,8 +496,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "{collapsed?: bool} collapse the dock's Properties | Layers | Libraries group to icons (true), expand it (false) or toggle (omitted), as the double arrow at the top of the dock does; returns the new state", ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), - ("window.workspace", "Workspace", "", "{name} switch workspace (Essentials, Essentials Classic, Painting, …)"), - ("window.workspace.reset", "Reset Essentials", "", "{} reset the current workspace"), + ( + "window.workspace", + "Workspace", + "", + "{name} switch workspace (Essentials, Essentials Classic, Painting, …): the one left keeps its layout changes, and the one switched to comes back as it was left (as saved if never changed)", + ), + ( + "window.workspace.reset", + "Reset Essentials", + "", + "{} reset the current workspace to the way it was saved, dropping the layout changes made in it", + ), ("window.workspace.new", "New Workspace…", "", "{name?} save the current layout"), ("window.workspace.manage", "Manage Workspaces…", "", "{}"), ("window.workspace.delete", "Delete Workspace", "", "{name}"), @@ -442,7 +572,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.colorPicker", "Color Picker…", "", - "{stroke?: bool (default: the active proxy), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's colour)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke", + "{stroke?: bool (default: the active proxy), artboard?: index (the picker is for that artboard's background color instead), color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray} (default: the proxy's color, or the artboard's color, white when it has none)} open the Color Picker (fields: hex or color, channel, webOnly, swatches); OK runs paint.setFill / paint.setStroke, or artboard.setProps {index, color} for an artboard", ), ( "ui.sampleScreenColor", @@ -450,11 +580,17 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} or {stroke?: bool (default: the active proxy), picker?: bool (the open Color Picker instead)} show the system's color sampler (macOS; browsers with an EyeDropper) and answer at once; the color the person picks anywhere on the screen then runs paint.setFill / paint.setStroke (one undo step) or becomes the Color Picker's color (field `color`), converted from display sRGB into the working RGB space; Escape cancels it and the colors stay as they were. ui.inspect reports `screenSampler: true` until it answers. An error without a sampler, while one is open, or with `picker` and no Color Picker open", ), + ( + "ui.newBrush", + "New Brush…", + "", + "{ids?} (default: the selected art) open New Brush, what the Brushes panel's New Brush button and art dropped on the panel open (dialog `newBrush`, field `type`: calligraphic|scatter|art|bristle|pattern, Art when there is art, else Calligraphic; scatter, art and pattern need art, else OK gives the error `nothing selected`); OK opens that type's Brush Options for the new brush (dialog `brushOptions` with the fields of ui.brushOptions and `__create`; name: New Brush, numbered when taken; the `ids` given in `__ids`), whose OK runs brush.new {type, name, params, ids?} (a name another brush has gets a number) and whose Cancel adds no brush", + ), ( "ui.brushOptions", "Brush Options…", "", - "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift and keyColor; OK runs brush.options", + "{name?} (default: the selected path's brush, else the current brush) open its Brush Options, what double-clicking a brush in the Brushes panel opens (dialog `brushOptions`): Calligraphic (fields name, angle −180..180, roundness 0..100, size 0..1296 pt, angleMode/roundnessMode/sizeMode: fixed|random|pressure, angleVariation/roundnessVariation/sizeVariation), Bristle (fields name, shape, size, length, density, thickness, opacity, stiffness), Scatter (fields sizeMin/sizeMax/sizeMode, spacingMin/spacingMax/spacingMode, scatterMin/scatterMax/scatterMode, rotationMin/rotationMax/rotationMode with modes fixed|random|pressure, rotationRelativeTo: page|path), Art (fields width %, scaleMode: proportional|stretch|betweenGuides, guideStart/guideEnd % of the art length, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flipAlong, flipAcross) or Pattern (fields scale %, spacing %, fit: stretch|addSpace|approximate, flipAlong, flipAcross); Scatter, Art and Pattern also colorization: none|tints|tintsAndShades|hueShift, keyColor, and tips: true opens Colorization Tips (what each method does, beside a sample of the brush painted with it), as the Tips button beside Method does; OK runs brush.options", ), ( "ui.graphicStyleOptions", @@ -465,14 +601,14 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ( "ui.symbolOptions", "Symbol Options…", - "", + "F8", "{name?} open Symbol Options (dialog `symbolOptions`, fields `name`, `exportType`: movieClip|graphic, `registration` 0..8). Without `name` and with a selection: New Symbol (default name the first free \"New Symbol\"/\"New Symbol N\", Export Type Movie Clip, Registration centre); OK runs symbol.new {name, exportType, registration}. With `edit: true` or `name`: edit that symbol; OK runs symbol.options {name, newName?, exportType, registration}", ), ( "tool.options", "Tool Options…", "", - "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values})", + "{tool: id} what double-clicking a tool button opens: hand → fits the artboard in the window (view.fitArtboard), zoom → 100% (view.actualSize), rotate|scale|reflect|shear → that Object › Transform dialog (dialog `rotate`, `scale`, `reflect` or `shear`; error `nothing selected` without a selection), selection|directSelection|groupSelection → the Move dialog (dialog `move`; the same error), gradient|magicWand → the Gradient or Magic Wand panel (window.panel), artboard → Artboard Options of the active artboard (dialog `artboardOptions`, fields index, name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.setProps, and with other artboards selected with the Artboard tool a new width or height goes to each of them too, in its `artboards` list, each resized about the reference point the form used for that size, or about its top-left corner when the size was set in the fields directly), flare → Flare Tool Options (dialog `flareOptions`, fields diameter and pathLength (pt), opacity, brightness, growth, fuzziness, longest, rayFuzziness, largest (%), rays, rings, direction (°), raysOn, ringsOn; OK runs tool.setOption {tool: \"flare\", values}: the next flare dragged out uses them, and a Flare click's dialog, which also has x and y, draws one there with them), lineSegment|arc|rectangularGrid|polarGrid → that tool's Tool Options, its click dialog without x and y (dialog kind = the tool id, `__toolOptions: true`; lineSegment: length (pt), angle (°), fillLine; arc: lengthX, lengthY (pt), reference (0–8), arcType: open|closed, baseAlong: x|y, slope (−100…100), fillArc; rectangularGrid: width, height (pt, the Default Size), reference (0–8), rows, columns, rowSkew, columnSkew (%), frame, fill; polarGrid: the same with concentric, radial, concentricSkew, radialSkew and compound for rows, columns, the skews and frame; OK runs tool.setOption {tool, values} and draws nothing: the next drag uses them (Fill Line, the arc's type, base, slope and fill, the grids' dividers, skews and switches) and a click's dialog opens at them), columnGraph|stackedColumnGraph|barGraph|stackedBarGraph|lineGraph|areaGraph|scatterGraph|pieGraph|radarGraph → Graph Type for the selected graph (dialog `command` running graph.setType; an error without one), symbolSprayer|symbolShifter|symbolScruncher|symbolSizer|symbolSpinner|symbolStainer|symbolScreener|symbolStyler → Symbolism Tools Options (dialog `symbolismOptions`, fields tool, diameter (pt), intensity and density (1–10), shared by the eight tools; OK runs tool.setOption {tool, values}), blend → Blend Options, perspectiveGrid → Perspective Grid Options, eyedropper → Eyedropper Options (dialog `eyedropperOptions`, fields sampleSize, pickUp, apply; OK runs eyedropper.setOptions), printTiling → resets the print tiling (print.tiling.set {reset: true}), warp|twirl|pucker|bloat|scallop|crystallize|wrinkle → that tool's Tool Options (dialog `liquifyOptions`, fields tool, width, height, angle, intensity %, usePressure, detail, simplify, simplifyOn, rate, complexity, horizontal %, vertical %, affectAnchors, affectIn, affectOut, showBrush; OK runs tool.setOption {tool, values}), pencil|paintbrush|smooth|blobBrush|eraser → that tool's Tool Options (dialog `freehandOptions`, fields tool and the options the tool keeps: fidelity (pt), fill, keepSelected (on for pencil and paintbrush, off for blobBrush), mergeOnlySelection (off), closeWithin and editWithin (px, 0 is off), size (pt); OK runs tool.setOption {tool, values}), livePaintSelection → Live Paint Selection Tool Options (dialog `livePaintSelectionOptions`, fields selectFills and selectStrokes (both on), color (the highlight color, #rrggbb or a layer color's name; #ff2a2a), width (the highlight's width, 1–20 px; 4); OK runs tool.setOption {tool: \"livePaintSelection\", values}), shapeBuilder → Shape Builder Tool Options (dialog `shapeBuilderOptions`, fields gapDetection (off), gapLength (pt, 0–72; 3), openFilledAsClosed (on), clickStrokeSplitsPath (off), pickColorFrom: artwork|swatches (artwork), cursorSwatchPreview (off), selection: freeform|straightLine (freeform), highlightFill (on), highlightStroke (on), highlightColor (#rrggbb or a layer color's name; #ff4f4f); OK runs tool.setOption {tool: \"shapeBuilder\", values})", ), ( "ui.colorGuideOptions", @@ -496,7 +632,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.swatchLibrary", "Swatch Library", "", - "{library: id or name (see swatch.library.list) | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", + "{library: id or name (see swatch.library.list), or the id of a persistent panel's missing library | null (close every swatch library panel)} open the swatch library in a read-only panel of its own, floating (panel id `library:swatches:`, which window.panel.* moves, docks and closes), or bring its panel forward when it is open; clicking a swatch there runs swatch.library.add with apply. ui.libraryPanel sets its view, find field and Persistent (ui.inspect: ui.library_panels) → {open: id, panel, name, count}", ), ( "window.swatchLibrary.other", @@ -510,6 +646,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{panel: a library panel's id (`library:swatches:pastels`, as window.swatchLibrary answers), library?: id or name of another library of its kind to show in the panel (Previous / Next Library; one open in a panel of its own is brought forward instead), view?: smallThumb|mediumThumb|largeThumb|smallList|largeList, showFind?: bool, persistent?: bool (reopen the panel when the app starts)} set an open library panel, or a closed persistent one's view, Show Find Field and Persistent; kept with the preferences (ui.inspect: ui.library_panels) → {panel, kind, library, view, showFind, persistent, open}", ), + ( + "ui.libraryPanel.locate", + "Locate Library…", + "", + "{panel: an open library panel's id, path?} (default: pick a file) Locate Library… of a persistent library panel whose library's file is missing: load the library from the file, as Other Library… does, and show it in the panel, which keeps its view, Show Find Field and Persistent. When the file's library has a panel open already, the panel closes and that one is brought forward, made persistent when the closed one was → what ui.libraryPanel answers", + ), ( "ui.saveSwatchLibrary", "Save Swatch Library…", @@ -541,14 +683,14 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ( "ui.newLayer", "New Layer Options…", - "", + "Cmd+Alt+L", "{sublayer?: bool} open Layer Options for a new layer (or a sublayer of the current layer), named and coloured as it would be: dialog `layerOptions` with mode new|newSublayer; OK runs layer.new or layer.newSublayer with the fields", ), ( "ui.newArtboard", "New Artboard Options…", "", - "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", + "{} open Artboard Options for a new artboard, filled in as artboard.new {} would make it (Artboard N, the last artboard's size, 20 pt to its right): dialog `artboardOptions` with new: true, fields name, x, y, width, height, showCenterMark, showCrossHairs; OK runs artboard.new with the fields and makes the new artboard the active one; Cancel adds nothing", ), ( "ui.layersPanelOptions", @@ -596,7 +738,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "window.graphicStyleLibrary", "Graphic Style Library", "", - "{library: id or name (see graphicStyle.libraries) | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", + "{library: id or name (see graphicStyle.libraries), or the id of a persistent panel's missing library | null (close every graphic style library panel)} open the graphic style library in a read-only panel of its own, floating (panel id `library:graphicStyles:`), or bring its panel forward when it is open; clicking a style there runs graphicStyle.addFromLibrary with apply (Alt: add). ui.libraryPanel sets its view, find field and Persistent → {open: id, panel, name, count}", ), ( "window.graphicStyleLibrary.other", @@ -625,6 +767,34 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("window.userGraphicStyleLibrary8", "User Graphic Style Library 8", "", "{} open the 8. User Defined graphic style library"), ("window.userGraphicStyleLibrary9", "User Graphic Style Library 9", "", "{} open the 9. User Defined graphic style library"), ("window.userGraphicStyleLibrary10", "User Graphic Style Library 10", "", "{} open the 10. User Defined graphic style library"), + ( + "window.symbolLibrary", + "Symbol Library", + "", + "{library: id or name (see symbol.libraries) | null (close)} open a symbol library in a library panel of its own (UI state `library_panels`, kind symbols); clicking a symbol there runs symbol.addFromLibrary, dragging one onto the canvas runs it with place → {open, panel, name, count}", + ), + ( + "window.symbolLibrary.other", + "Other Library…", + "", + "{path?} (default: pick a file) load the symbols of another document or a saved symbol library (engine: symbol.loadLibrary), and open them in a library panel", + ), + ( + "ui.saveSymbolLibrary", + "Save Symbols…", + "", + "{names?: [the symbol selected in the Symbols panel]} open Save Symbol Library (dialog `saveSymbolLibrary`: name, user: save to the user library folder, selectedOnly); OK runs symbol.saveLibrary", + ), + ("window.userSymbolLibrary1", "User Symbol Library 1", "", "{} open the 1. User Defined symbol library (symbol.libraries, category user)"), + ("window.userSymbolLibrary2", "User Symbol Library 2", "", "{} open the 2. User Defined symbol library"), + ("window.userSymbolLibrary3", "User Symbol Library 3", "", "{} open the 3. User Defined symbol library"), + ("window.userSymbolLibrary4", "User Symbol Library 4", "", "{} open the 4. User Defined symbol library"), + ("window.userSymbolLibrary5", "User Symbol Library 5", "", "{} open the 5. User Defined symbol library"), + ("window.userSymbolLibrary6", "User Symbol Library 6", "", "{} open the 6. User Defined symbol library"), + ("window.userSymbolLibrary7", "User Symbol Library 7", "", "{} open the 7. User Defined symbol library"), + ("window.userSymbolLibrary8", "User Symbol Library 8", "", "{} open the 8. User Defined symbol library"), + ("window.userSymbolLibrary9", "User Symbol Library 9", "", "{} open the 9. User Defined symbol library"), + ("window.userSymbolLibrary10", "User Symbol Library 10", "", "{} open the 10. User Defined symbol library"), ( "ui.expandDialog", "Expand…", @@ -671,7 +841,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.simplifyAuto", "Auto-Simplify", "", - "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, 50 as it opens, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open", + "{} the open Simplify slider (Object › Path › Simplify opens it: dialog `simplifySlider` with field `level` 0..100, object.path.simplify's level, opening at the level object.path.simplify {auto: true} picks for the selection, previewed on the canvas until ui.dialog.confirm keeps it as one undo step; after OK in the Simplify dialog with `retain` ticked, the menu item opens the dialog `simplify` with the fields of that OK instead) moves to the level object.path.simplify {auto: true} picks for the selection and previews it → {level}; an error when the slider isn't open", ), ( "ui.simplifyMoreOptions", @@ -727,6 +897,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} open Document Raster Effects Settings (dialog `rasterEffectsSettings`: the document.rasterEffectsSettings fields); OK runs it. The menu item opens it too", ), + ( + "ui.effectGalleryDialog", + "Effect Gallery Dialog", + "", + "{} open Effect Gallery (dialog `effectGallery`: browse raster filters, build a stack, OK applies it as separate effects in one undo step). Effect → Effect Gallery… opens it too", + ), ( "ui.pdfPresetsDialog", "PDF Presets…", @@ -892,7 +1068,19 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.installPlugin", "Install Plug-in…", "", - "{path?: a .wasm file} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too)", + "{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A filter that asks for a heavy budget asks the user first: OK installs it → plugin.install's result | {pending: \"confirm\"} while it asks", + ), + ( + "plugin.cancelRuns", + "Cancel Plug-in Run", + "", + "{} stop every heavy filter running in the background (its progress window's Cancel): it stops at the next slice of its budget and changes nothing → {cancelled: n}", + ), + ( + "plugin.hideRuns", + "Continue in Background", + "", + "{} close the progress window of the heavy filters running in the background; they go on, shown in the status bar → {hidden: n}", ), ( "ui.checkScript", @@ -1059,6 +1247,9 @@ pub fn check_params(session: &vectorcraft_engine::Session, id: &str, params: &Va session.check_params(id, params).map_err(|e| e.to_string()) } +/// Runs `id` when the UI runs it itself: one of [`UI_COMMANDS`], or an engine command the UI +/// handles its own way (a Window menu panel item that toggles, …) → `None` to leave it to the +/// engine. pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { // The Window menu's panel items toggle like its other checked items (#2361); the panel's // other ways in (shortcuts, Show Properties Panel, a tool's double-click) only show it. @@ -1105,6 +1296,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::new_document_without_dialog(app), "app.home" => { app.ui.home = Some(home_key(app)); Ok(Value::Null) @@ -1129,7 +1321,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Ok(io::cancel_exports(app)), "file.exportInBackground" => Ok(io::hide_exports(app)), id if id.starts_with("file.openRecent") => match recent_slot(app, id).cloned() { - Some(path) => io::open_reporting(app, &path), + Some(path) => io::open_recent_reporting(app, &path), None => Err("no such recent file".into()), }, "type.findFont" => { @@ -1223,7 +1415,10 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { crate::panels::paragraph::align(app, justify) } + "type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p), + id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b), "view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator), + "view.livePaintGaps" => flag(&mut app.ui.view.live_paint_gaps), "effect.last" => match app.last_effect.clone() { Some((e, params)) => { let label = vectorcraft_effects::effect_info(&e).map(|i| i.label.trim_end_matches('…').to_string()).unwrap_or_else(|| e.clone()); @@ -1247,12 +1442,28 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.view.artboards), "view.template" => flag(&mut app.ui.view.templates), "view.rulers" => flag(&mut app.ui.view.rulers), + "view.videoRulers" => { + let v = &mut app.ui.view; + let show = !(v.video_rulers && v.rulers); + v.video_rulers = show; + v.rulers |= show; + Ok(json!(show)) + } "view.boundingBox" => flag(&mut app.ui.view.bounding_box), "view.guides" => { let r = flag(&mut app.ui.view.guides); - // Hidden guides can't stay selected (selected, they are all that is). - if !app.ui.view.guides && app.session.active().is_some_and(|d| !d.selection.guides.is_empty()) { - // Deselecting a document that is open can't fail. + // Hidden ruler guides and guide paths cannot stay selected: their selection overlay + // would otherwise reveal geometry that Hide Guides just hid. + let hidden_selection = !app.ui.view.guides + && app.session.active().is_some_and(|d| { + !d.selection.guides.is_empty() + || d.selection + .objects + .iter() + .any(|id| matches!(d.doc.node(*id).map(|n| &n.kind), Some(vectorcraft_doc::NodeKind::Path { guide: true, .. }))) + }); + if hidden_selection { + // Deselecting a document that is open cannot fail. let _ = app.run("select.none", json!({})); } r @@ -1470,7 +1681,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("brightness must be dark|mediumDark|mediumLight|light".into()), }, - "window.newWindow" => Err("multiple windows land with M11.5".into()), + "window.newWindow" => Err("New Window isn't available yet: a document opens in one window".into()), "tool.select" => match s("tool") { Some(t) if vectorcraft_tools::tool_info(&t).is_some() => { app.select_tool(&t); @@ -1541,6 +1752,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::new_brush::open(app, p.get("ids").cloned()), "ui.brushOptions" => crate::dialogs::brush_options::open(app, s("name").as_deref()), "tool.options" => crate::toolbar::open_options(app, &s("tool").unwrap_or_default()), "ui.colorGuideOptions" => { @@ -1587,6 +1799,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::flattener_preview::command(app, p), "window.graphicStyleLibrary" => crate::panels::graphic_styles::open_library(app, p), "ui.libraryPanel" => crate::panels::library_panel::command(app, p), + "ui.libraryPanel.locate" => crate::panels::library_panel::locate(app, p), "window.graphicStyleLibrary.other" => crate::panels::graphic_styles::other_library(app, s("path")), "ui.saveGraphicStyleLibrary" => { let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); @@ -1596,6 +1809,16 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), None => Err("no such user library".into()), }, + "window.symbolLibrary" => crate::panels::symbols::open_library(app, p), + "window.symbolLibrary.other" => crate::panels::symbols::other_library(app, s("path")), + "ui.saveSymbolLibrary" => { + let names = p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + crate::dialogs::save_symbol_library::open(app, names.unwrap_or_default()) + } + id if id.starts_with(crate::panels::symbols::USER_SLOT) => match crate::panels::symbols::user_library(app, id) { + Some(l) => crate::panels::symbols::open_library(app, &json!({ "library": l.id })), + None => Err("no such user library".into()), + }, "ui.expandDialog" => crate::dialogs::expand::open(app), "ui.simplifyAuto" => crate::dialogs::simplify_slider::auto(app), "ui.simplifyMoreOptions" => crate::dialogs::simplify_slider::more_options(app), @@ -1626,6 +1849,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option io::export(app, s("format").as_deref(), s("path"), p), "ui.fileInfoDialog" => crate::dialogs::file_info::open(app), "ui.rasterEffectsSettingsDialog" => crate::dialogs::raster_effects::open(app), + "ui.effectGalleryDialog" => crate::dialogs::effect_gallery::open(app, p), "ui.pdfPresetsDialog" => { crate::dialogs::pdf_presets::open(app, s("selected").as_deref()); Ok(Value::Null) @@ -1694,7 +1918,9 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::dialogs::print::open_preset(app, p), "plugin.dialog" => crate::dialogs::plugin::open(app, p), - "ui.installPlugin" => io::install_plugin(app, s("path")), + "ui.installPlugin" => io::install_plugin(app, p), + "plugin.cancelRuns" => Ok(crate::plugin_runs::cancel_all(app)), + "plugin.hideRuns" => Ok(crate::plugin_runs::hide_all(app)), "ui.checkScript" => crate::dialogs::script_check::command(app, p), "ui.perspectiveGridDialog" => crate::dialogs::perspective_grid::open(app), "ui.perspectivePresetsDialog" => { @@ -1856,6 +2082,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { "view.textThreads" => if v.text_threads { "Hide Text Threads" } else { "Show Text Threads" }.into(), "type.hiddenCharacters" => if v.hidden_chars { "Hide Hidden Characters" } else { "Show Hidden Characters" }.into(), "view.gradientAnnotator" => if v.gradient_annotator { "Hide Gradient Annotator" } else { "Show Gradient Annotator" }.into(), + "view.livePaintGaps" => if v.live_paint_gaps { "Hide Live Paint Gaps" } else { "Show Live Paint Gaps" }.into(), "effect.last" => match &app.last_effect { Some((e, _)) => format!("Last Effect: {}", vectorcraft_effects::effect_info(e).map(|i| i.label).unwrap_or(e.as_str())), None => label.into(), @@ -1871,6 +2098,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { id if saved_selection_slot(id).is_some() => saved_selection_name(app, id).unwrap_or_else(|| "—".into()), "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), + "view.videoRulers" => if v.video_rulers && v.rulers { "Hide Video Rulers" } else { "Show Video Rulers" }.into(), "view.globalRulers" => if app.session.active().is_some_and(|d| d.doc.rulers == vectorcraft_doc::RulerMode::Global) { "Change to Artboard Rulers" } else { @@ -1900,6 +2128,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { }); if editing { "Edit Envelope" } else { "Edit Contents" }.into() } + "select.globalEdit" => { + if app.session.active().is_some_and(|d| d.global_edit.is_some()) { "Stop Global Edit" } else { "Start Global Edit" }.into() + } "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), id if id.starts_with(crate::panels::swatches::USER_SLOT) => { @@ -1908,6 +2139,9 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => { crate::panels::graphic_styles::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) } + id if id.starts_with(crate::panels::symbols::USER_SLOT) => { + crate::panels::symbols::user_library(app, id).map_or_else(|| "—".into(), |l| l.name) + } id if id.starts_with(crate::dialogs::perspective_presets::SLOT) => { crate::dialogs::perspective_presets::slot_preset(app, id).unwrap_or_else(|| "—".into()) } @@ -1923,12 +2157,13 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { /// plug-in? Such names are shown as they are, never translated (a workspace the user calls /// "Layers" stays "Layers"). fn shows_a_name(id: &str, label: &str) -> bool { - const SLOTS: [&str; 6] = [ + const SLOTS: [&str; 7] = [ "file.openRecent", "view.goto", "type.recentFont", crate::panels::swatches::USER_SLOT, crate::panels::graphic_styles::USER_SLOT, + crate::panels::symbols::USER_SLOT, crate::dialogs::perspective_presets::SLOT, ]; SLOTS.iter().any(|s| id.starts_with(s)) @@ -2014,6 +2249,7 @@ fn hidden_when_disabled(id: &str) -> bool { || id.starts_with("file.openRecent") || id.starts_with(crate::panels::swatches::USER_SLOT) || id.starts_with(crate::panels::graphic_styles::USER_SLOT) + || id.starts_with(crate::panels::symbols::USER_SLOT) || id.starts_with(crate::dialogs::perspective_presets::SLOT) } @@ -2253,6 +2489,8 @@ pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool { || id.starts_with("edit.paste")) } +/// Can command `id` run now? An engine command asks the engine, a UI command the app's state; +/// none that would act on text an input method is still composing. pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { if waits_for_ime(app, id) { return false; @@ -2266,8 +2504,12 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()), "type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(), id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(), + "type.applyBullets" => crate::panels::character::text_style(app).is_some(), + id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()), "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), "file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()), + "plugin.cancelRuns" => crate::plugin_runs::any_running(app), + "plugin.hideRuns" => crate::plugin_runs::any_shown(app), "file.place" | "file.export.svg" | "file.export.png" @@ -2290,7 +2532,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id if id.starts_with("view.goto") => { id["view.goto".len()..].parse::().is_ok_and(|n| n >= 1 && app.session.active().is_some_and(|d| n <= d.doc.views.len())) } - "effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), + "effect.dialog" | "ui.recolorDialog" | "ui.effectGalleryDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.cropImage" => selected_image(app, |_| true), "effect.applyLast" | "effect.last" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" | "ui.savePdfDialog" | "ui.fileInfoDialog" | "ui.rasterEffectsSettingsDialog" => app.session.active().is_some(), @@ -2300,6 +2542,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.graphicStyleOptions" => app.session.active().is_some(), "ui.newArtboard" => app.session.active().is_some(), "ui.sampleScreenColor" => crate::screen_color::available(app), + "ui.newBrush" => app.session.active().is_some(), "ui.brushOptions" => crate::dialogs::brush_options::available(app), "ui.hyphenation" | "ui.justification" => crate::panels::character::text_style(app).is_some(), "ui.colorBalanceDialog" | "ui.saturateDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), @@ -2308,6 +2551,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.flattenTransparencyDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.saveGraphicStyleLibrary" => app.session.active().is_some(), id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => crate::panels::graphic_styles::user_library(app, id).is_some(), + "ui.saveSymbolLibrary" => app.session.active().is_some_and(|d| !d.doc.symbols.is_empty()), + id if id.starts_with(crate::panels::symbols::USER_SLOT) => crate::panels::symbols::user_library(app, id).is_some(), "ui.expandDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "ui.spotColors" => app.session.active().is_some(), "ui.menuDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), @@ -2329,6 +2574,8 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { app.session.active().is_some() && crate::dialogs::perspective_presets::slot_preset(app, id).is_some() } "ui.perspectivePlane" => app.session.active().is_some(), + // Only one window per document so far: greyed out, as Window › Arrange's items are (#3724). + "window.newWindow" => false, _ => true, } } @@ -2754,8 +3001,10 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { ], ), cp("Smart Punctuation…", "type.smartPunctuation", json!({"quotes": true, "dashes": true, "ellipsis": true, "scope": "selection"})), + sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets")]), Sep, c("Create Outlines", "type.createOutlines"), + c("Scale Text Attributes", "type.scaleTextAttributes"), c("Optical Margin Alignment", "type.opticalMarginAlignment"), Sep, c("Fill with Placeholder Text", "type.fillPlaceholder"), @@ -2818,7 +3067,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Area Text Objects", "select.object.areaText"), ], ), - todo("Start Global Edit"), + c("Start Global Edit", "select.globalEdit"), Sep, c("Save Selection…", "select.save"), c("Update Selection", "select.update"), @@ -2878,12 +3127,20 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { c("Lock Slices", "view.slices.lock"), c("Hide Template", "view.template"), Sep, - sub("Rulers", vec![c("Show Rulers", "view.rulers"), c("Change to Global Rulers", "view.globalRulers"), todo("Show Video Rulers")]), + sub( + "Rulers", + vec![ + c("Show Rulers", "view.rulers"), + c("Change to Global Rulers", "view.globalRulers"), + c("Show Video Rulers", "view.videoRulers"), + ], + ), c("Hide Bounding Box", "view.boundingBox"), c("Show Transparency Grid", "view.transparencyGrid"), c("Hide Text Threads", "view.textThreads"), c("Hide Gradient Annotator", "view.gradientAnnotator"), c("Hide Corner Widget", "view.cornerWidget"), + c("Show Live Paint Gaps", "view.livePaintGaps"), Sep, sub( "Guides", @@ -2997,7 +3254,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { sub("Brush Libraries", library_placeholders()), sub("Graphic Style Libraries", crate::panels::graphic_styles::window_menu()), sub("Swatch Libraries", crate::panels::swatches::window_menu()), - sub("Symbol Libraries", library_placeholders()), + sub("Symbol Libraries", crate::panels::symbols::window_menu()), ], ), ( @@ -3123,6 +3380,10 @@ pub fn context_items(app: &VectorcraftApp) -> Vec { if any(|k| matches!(k, NodeKind::Compound { .. })) { v.push(c("Release Compound Path", "object.compoundPath.release")); } + // The paragraphs of the selected type, or those the Type tool's selection touches. + if crate::panels::character::text_style(app).is_some() { + v.push(sub("Bullets and Numbering", vec![c("Apply Bullets", "type.applyBullets")])); + } v.extend([ c("Make Guides", "view.guides.make"), Sep, @@ -3392,7 +3653,7 @@ fn menu_dialog(app: &VectorcraftApp, id: &str) -> Option<(&'static str, Value)> // Simplify opens its slider, or the dialog on the fields it was last confirmed with when // "Open this dialog with these settings next time" was ticked. "object.path.simplify" if app.ui.simplify.is_object() => ("simplify", app.ui.simplify.clone()), - "object.path.simplify" => (crate::dialogs::simplify_slider::KIND, crate::dialogs::simplify_slider::fields()), + "object.path.simplify" => (crate::dialogs::simplify_slider::KIND, crate::dialogs::simplify_slider::fields(app)), "object.path.splitIntoGrid" => { // The grid starts at the size of the selection. let size = @@ -3906,6 +4167,7 @@ fn effect_menu() -> Vec { if !raster.is_empty() { out.push(Sep); out.push(Item::Header("Raster Effects")); + out.push(c("Effect Gallery…", "ui.effectGalleryDialog")); out.extend(raster); } // Anything not placed above (future effects) still shows up. @@ -3954,10 +4216,14 @@ pub const DYNAMIC_LABELS: &[&str] = &[ "Show Hidden Characters", "Hide Gradient Annotator", "Show Gradient Annotator", + "Hide Live Paint Gaps", + "Show Live Paint Gaps", "Hide Artboards", "Show Artboards", "Hide Rulers", "Show Rulers", + "Hide Video Rulers", + "Show Video Rulers", "Change to Global Rulers", "Change to Artboard Rulers", "Hide Bounding Box", @@ -3978,6 +4244,8 @@ pub const DYNAMIC_LABELS: &[&str] = &[ "Show Print Tiling", "Edit Envelope", "Edit Contents", + "Start Global Edit", + "Stop Global Edit", "Undo", "Redo", "Unlock Grid", @@ -4054,6 +4322,7 @@ pub fn menu_strings() -> std::collections::BTreeSet { out.extend(crate::prefs_dialog::UI_FIELDS.iter().map(|f| f.2.to_string())); out.extend(crate::dialogs::button_labels().into_iter().map(str::to_string)); out.extend(crate::dialogs::FILM_GRAIN_FIELDS.iter().map(|(_, label, _)| label.to_string())); + out.extend(crate::dialogs::PAINT_DAUB_BRUSH_TYPE_LABELS.iter().map(|label| label.to_string())); out.extend(crate::chrome::hint_strings().into_iter().map(str::to_string)); out.remove(""); out @@ -4227,6 +4496,7 @@ mod tests { for (id, on, off) in [ ("view.textThreads", "Hide Text Threads", "Show Text Threads"), ("view.gradientAnnotator", "Hide Gradient Annotator", "Show Gradient Annotator"), + ("view.livePaintGaps", "Show Live Paint Gaps", "Hide Live Paint Gaps"), ("type.hiddenCharacters", "Show Hidden Characters", "Hide Hidden Characters"), ("view.template", "Hide Template", "Show Template"), ] { @@ -4253,10 +4523,14 @@ mod tests { _ => panic!("{id} is not a menu item"), } }; - assert_eq!(shown(&app, "edit.undo"), "ぼかしを取り消す"); + let step = |template: &str, name: &str| { + let name = crate::panels::history::step_name(ja, name).unwrap_or_else(|| name.to_string()); + crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &name)]) + }; + assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Feather")); app.run("edit.undo", json!({})).unwrap(); - assert_eq!(shown(&app, "edit.redo"), "ぼかしをやり直す"); - assert_eq!(shown(&app, "edit.undo"), "長方形を取り消す", "a step with a plain row"); + assert_eq!(shown(&app, "edit.redo"), step("Redo {name}", "Feather")); + assert_eq!(shown(&app, "edit.undo"), step("Undo {name}", "Rectangle"), "a step with a plain row"); crate::i18n::set_current(crate::i18n::Lang::EN); assert_eq!(shown(&app, "edit.redo"), "Redo Feather"); } @@ -4361,10 +4635,15 @@ mod tests { let text = app.run("text.create", json!({"x": 0, "y": 40, "text": "Hello"})).unwrap()["id"].clone(); app.run("charStyle.new", json!({"name": "Brush"})).unwrap(); app.run("charStyle.apply", json!({"name": "Brush", "id": text})).unwrap(); - crate::i18n::set_current(crate::i18n::Lang::from_code("ja").expect("ja registered")); - assert_eq!(display_label(&app, "edit.undo", "Undo"), "スタイル「Brush」を適用を取り消す"); + let ja = crate::i18n::Lang::from_code("ja").expect("ja registered"); + crate::i18n::set_current(ja); + let step = |template: &str| { + let applied = crate::i18n::fmt(crate::i18n::tr(ja, "Apply {name}"), &[("name", "Brush")]); + crate::i18n::fmt(crate::i18n::tr(ja, template), &[("name", &applied)]) + }; + assert_eq!(display_label(&app, "edit.undo", "Undo"), step("Undo {name}")); app.run("edit.undo", json!({})).unwrap(); - assert_eq!(display_label(&app, "edit.redo", "Redo"), "スタイル「Brush」を適用をやり直す"); + assert_eq!(display_label(&app, "edit.redo", "Redo"), step("Redo {name}")); crate::i18n::set_current(crate::i18n::Lang::EN); assert_eq!(display_label(&app, "edit.redo", "Redo"), "Redo Apply Brush"); } @@ -4689,4 +4968,15 @@ mod tests { assert!(tall.height() > short.height() + 100.0, "{} vs {}", tall.height(), short.height()); assert!(tall.bottom() < 3000.0); } + + /// #3724: Window › New Window is greyed out until VectorCraft can open a second window on a + /// document, instead of being enabled and answering with an internal milestone note. + #[test] + fn new_window_is_disabled_until_it_opens_a_window() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + assert!(!enabled(&app, "window.newWindow"), "New Window is enabled but opens no window"); + let e = app.run("window.newWindow", json!({})).unwrap_err(); + assert!(!e.contains("M11.5"), "a user-facing message, not a milestone: {e}"); + } } diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs index 83427dd58..ad4c02e86 100644 --- a/crates/ui-egui/src/native_menu.rs +++ b/crates/ui-egui/src/native_menu.rs @@ -365,7 +365,7 @@ pub fn mac_layout(app: &VectorcraftApp, bar: &MenuBar, lang: Lang) -> Layout { /// with the `interfaceLanguage` preference checked. fn language_node(app: &VectorcraftApp, lang: Lang) -> Node { let mut children = vec![Node::Item(native_item(app, "Automatic", "app.language", json!({"lang": "auto"}))), Node::Separator]; - children.extend(Lang::all().map(|l| Node::Item(native_item(app, l.name(), "app.language", json!({"lang": l.code()}))))); + children.extend(Lang::for_picker().into_iter().map(|l| Node::Item(native_item(app, l.name(), "app.language", json!({"lang": l.code()}))))); Node::Submenu { label: tr(lang, "Language").into(), children } } diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index bfe2b3ed0..37d5d85b1 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -29,9 +29,12 @@ pub struct Opening { } /// Is a long operation running that should show the busy cursor and status spinner (background -/// save/export with a label, or a file opening off the UI thread)? +/// save/export with a label, a file opening or being read for Place off the UI thread, or a heavy plug-in filter)? pub fn long_operation_busy(app: &VectorcraftApp) -> bool { - app.background.jobs.iter().any(|j| !j.label.is_empty() && !j.cancelled()) || !app.opening.pending.is_empty() + app.background.jobs.iter().any(|j| !j.label.is_empty() && !j.cancelled()) + || !app.opening.pending.is_empty() + || !app.plugin_runs.running.is_empty() + || !app.placing.pending.is_empty() } /// Does opening happen off the UI thread here? Not on wasm (no threads), in unit tests (they look @@ -49,13 +52,14 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result) -> Result = first.layers.iter().flat_map(|l| l.children().into_iter().flatten()).filter_map(|n| n.name.clone()).collect(); + assert_eq!(names, ["a.png"], "the drag placed its file"); + } + + /// A file that finishes opening while a preview runs waits for the preview to end, so the + /// preview is not canceled: a slider's preview with the tool idle, a slider's preview while the + /// Dimension tool waits after its first point, and the Dimension tool's line following the + /// pointer after its second point. + #[test] + fn a_file_ready_during_a_preview_waits_for_it_to_end() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let view = vectorcraft_engine::ViewInfo::default(); + let click = |app: &mut VectorcraftApp, x: f64| { + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(app, &PointerEvent::new(kind, x, 20.0), view); + } + }; + for case in ["slider", "slider between clicks", "line"] { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + if case != "slider" { + app.select_tool("dimension"); + click(&mut app, 20.0); + } + if case == "line" { + click(&mut app, 80.0); + crate::canvas::dispatch(&mut app, &PointerEvent::new(PointerKind::Move, 50.0, 60.0), view); + } else { + app.session.begin_interaction("Preview").unwrap(); + } + assert_eq!(app.session.tool_busy(), case != "slider", "{case}"); + start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); + let t0 = std::time::Instant::now(); + while t0.elapsed().as_millis() < 400 { + poll(&mut app); + std::thread::yield_now(); + } + assert!(app.session.documents().len() == 1 && app.session.in_interaction(), "the file waits for the preview: {case}"); + if case == "line" { + click(&mut app, 50.0); + } else { + app.session.cancel_interaction().unwrap(); + } + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.documents().len(), 2, "{case}"); + } + } + + /// A file that finishes opening while a tool waits between its clicks with no preview open (the + /// Dimension tool after its first point, then after its second before the pointer moves) opens + /// without waiting for the next click, and the tool starts afresh in it, as when the user + /// switches documents. + #[test] + fn a_file_ready_between_a_tools_clicks_opens() { + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.select_tool("dimension"); + let view = vectorcraft_engine::ViewInfo::default(); + let click = |app: &mut VectorcraftApp, x: f64| { + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(app, &PointerEvent::new(kind, x, 20.0), view); + } + }; + let opened = |app: &mut VectorcraftApp| { + let t0 = std::time::Instant::now(); + while !app.opening.pending.is_empty() && t0.elapsed().as_secs() < 30 { + poll(app); + std::thread::yield_now(); + } + }; + click(&mut app, 20.0); + assert!(app.session.tool_busy() && !app.session.in_interaction(), "the first point waits for the second"); + start(&mut app, "/art/Second.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(400.0, 300.0), false)).unwrap(); + opened(&mut app); + assert_eq!(app.session.documents().len(), 2, "the file opens after one click"); + assert!(!app.session.tool_busy(), "the tool starts afresh"); + click(&mut app, 20.0); + click(&mut app, 80.0); + assert!(app.session.tool_busy() && !app.session.in_interaction(), "the second point waits for the line's place"); + start(&mut app, "/art/Third.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(120.0, 80.0), false)).unwrap(); + opened(&mut app); + assert_eq!(app.session.documents().len(), 3, "the file opens after two clicks"); + assert!(!app.session.tool_busy() && !app.session.in_interaction(), "the tool starts afresh"); + let second = &app.session.documents()[1].doc; + assert!(second.layers.iter().all(|l| l.children().is_none_or(|c| c.is_empty())), "the unfinished dimension is not made"); + } + #[test] fn a_file_read_off_the_ui_thread_opens_as_one_read_at_once() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/panel_docking.rs b/crates/ui-egui/src/panel_docking.rs index a351a982e..48426a45a 100644 --- a/crates/ui-egui/src/panel_docking.rs +++ b/crates/ui-egui/src/panel_docking.rs @@ -7,10 +7,12 @@ use crate::VectorcraftApp; #[derive(Clone, Default)] struct LegacyDrag { + panel: String, layout: Option>, open_panel: Option, floating: Vec, icons: std::collections::BTreeSet, + collapsed: std::collections::BTreeSet, } #[derive(Clone)] @@ -18,6 +20,8 @@ struct RootGroupDrag { panel: String, layout: Layout, hidden: std::collections::BTreeMap>, + collapsed: std::collections::BTreeSet, + open_panel: Option, generation: u64, } @@ -29,6 +33,7 @@ fn cancel_legacy_drag(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { app.ui.floating_panels = previous.floating; app.ui.docking = previous.layout; app.ui.docking_icons = previous.icons; + app.ui.docking_collapsed = previous.collapsed; craft_ui::docking::cancel_drag::(ctx, egui::Id::new("vectorcraft-panel-docking")); true } else { @@ -142,6 +147,74 @@ fn number(params: &Value, key: &str, default: f32) -> Result { } } +const LEGACY_REORDER_BAD_DEST: &str = "move requires an existing destination panel"; + +enum LegacyStripReorder { + DockStrip, + FloatingGroup(usize), +} + +fn legacy_strip_reorder_target( + app: &VectorcraftApp, + before: &str, + dock_strip: bool, + panel_group: Option, +) -> Result { + if dock_strip { + return Ok(LegacyStripReorder::DockStrip); + } + let gi = panel_group.ok_or(LEGACY_REORDER_BAD_DEST)?; + if crate::floating::group_of(&app.ui, before) != Some(gi) { + return Err(LEGACY_REORDER_BAD_DEST.into()); + } + Ok(LegacyStripReorder::FloatingGroup(gi)) +} + +/// Legacy workspaces (no saved `docking` layout): reorder tabs in the dock strip or a floating +/// group without promoting to a shared layout. +fn legacy_same_strip_reorder(app: &mut VectorcraftApp, panel: &str, before: &str, target: LegacyStripReorder) -> Result { + use crate::state::DockTab; + match target { + LegacyStripReorder::DockStrip => { + let mut order: Vec = crate::floating::docked_tabs(&app.ui).collect(); + let from = order.iter().position(|t| t.info().0 == panel).ok_or(LEGACY_REORDER_BAD_DEST)?; + let to = order.iter().position(|t| t.info().0 == before).ok_or(LEGACY_REORDER_BAD_DEST)?; + if from != to { + let item = order.remove(from); + order.insert(to, item); + crate::floating::set_dock_tab_order(&mut app.ui, order); + } + } + LegacyStripReorder::FloatingGroup(gi) => { + let g = app.ui.floating_panels.get_mut(gi).ok_or("invalid restored panel group")?; + let from = g.panels.iter().position(|id| id == panel).ok_or(LEGACY_REORDER_BAD_DEST)?; + let to = g.panels.iter().position(|id| id == before).ok_or(LEGACY_REORDER_BAD_DEST)?; + crate::floating::reorder_group_tab_indices(g, from, to); + if let Some(layout) = app.ui.docking.as_mut() { + let panels = g.panels.clone(); + let active = g.active.min(panels.len().saturating_sub(1)); + if let Some(group) = layout.floating.iter_mut().find(|group| group.panels.iter().any(|id| id == panel)) { + group.panels = panels; + group.active = active; + } + } + } + } + Ok(json!({ "panel": panel, "before": before })) +} + +/// Same-strip tab reorder for `window.panel.move` and in-frame tab drags (`app.run` during egui deadlocks). +pub(crate) fn strip_tab_reorder(app: &mut VectorcraftApp, panel: &str, before: &str) -> Result { + use crate::state::DockTab; + let panel_group = crate::floating::group_of(&app.ui, panel); + let dock_strip_reorder = DockTab::from_id(panel).is_some() + && DockTab::from_id(before).is_some() + && panel_group.is_none() + && crate::floating::group_of(&app.ui, before).is_none(); + let target = legacy_strip_reorder_target(app, before, dock_strip_reorder, panel_group)?; + legacy_same_strip_reorder(app, panel, before, target) +} + fn placement(params: &Value) -> Result, String> { if let Some(before) = params.get("before") { let id = before.as_str().and_then(normalize).ok_or("before must name a panel")?; @@ -163,7 +236,18 @@ fn placement(params: &Value) -> Result, String> { } pub(crate) fn command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Option> { + if id == "window.panel.collapse" { + return Some(collapse(app, params)); + } + // Docking into the dock's tree or its icon column is the shared layout's, whatever set the + // panel floats in. A dock column is the native sets', so it doesn't combine with either. + let to_icons = params.get("icon").is_some_and(|icon| icon != &Value::Bool(false)); + let into_tree = id == "window.panel.dock" && (params.get("anchor").is_some() || to_icons); + if into_tree && params.get("column").is_some() { + return Some(Err("column docks a set as a dock column, with no anchor or icon".into())); + } if matches!(id, "window.panel.float" | "window.panel.dock") + && !into_tree && (params.get("above").is_some() || params.get("below").is_some() || params.get("column").is_some() @@ -270,6 +354,16 @@ fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result Some(panel.clone()), _ => None, }; + // A panel moved, floated or closed leaves its group: that group isn't collapsed any more, and + // the panel doesn't take the collapse along. + let moved = match &action { + Action::Move { panel, .. } + | Action::OpenAt { panel, .. } + | Action::Open { panel, .. } + | Action::Float { panel, .. } + | Action::Close { panel } => Some(panel.clone()), + _ => None, + }; layout.apply(action).map_err(|error| error.to_string())?; restore_float_fallback(&mut layout, fallback)?; if let Some((panel, location)) = returning { @@ -280,6 +374,9 @@ fn apply_action(app: &mut VectorcraftApp, action: Action) -> Result) -> Result Vec { + let Some(layout) = &ui.docking else { + return crate::floating::shown_tab(ui).map(|tab| tab.info().0.to_string()).into_iter().collect(); + }; + let mut out = Vec::new(); + // Each node, and whether it may be shortened (the root, or in a top/bottom split). + let mut pending: Vec<(&Node, bool)> = layout.root.iter().map(|root| (root, true)).collect(); + while let Some((node, shortens)) = pending.pop() { + match node { + Node::Tabs { panels, active } if shortens => out.extend(panels.get(*active).cloned()), + Node::Split { axis, first, second, .. } => { + let vertical = *axis == craft_ui::layout::SplitAxis::Vertical; + pending.extend([(second.as_ref(), vertical), (first.as_ref(), vertical)]); + } + _ => {} + } + } + out +} + +/// Whether the group of the shared layout's tree holding `panel` as a tab can be shortened to its +/// tabs (the whole tree, or above or below another group); `None` when `panel` isn't a tab of one +/// (it floats, or is in an accordion). +fn tab_group_shortens(layout: &Layout, panel: &str) -> Option { + let mut pending: Vec<(&Node, bool)> = layout.root.iter().map(|root| (root, true)).collect(); + while let Some((node, shortens)) = pending.pop() { + match node { + Node::Tabs { panels, .. } if panels.iter().any(|id| id == panel) => return Some(shortens), + Node::Split { axis, first, second, .. } => { + let vertical = *axis == craft_ui::layout::SplitAxis::Vertical; + pending.extend([(second.as_ref(), vertical), (first.as_ref(), vertical)]); + } + _ => {} + } + } + None +} + +/// Forget the collapsed groups that show another panel now (another tab clicked, or the panel +/// moved or closed): those are expanded. +pub(crate) fn prune_collapsed(ui: &mut crate::state::UiState) { + if ui.docking_collapsed.is_empty() { + return; + } + let shown = dock_groups_shown(ui); + ui.docking_collapsed.retain(|id| shown.contains(id)); +} + +/// `window.panel.collapse`: show the dock group holding `panel` as its tabs only, or expanded. +fn collapse(app: &mut VectorcraftApp, params: &Value) -> Result { + let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + let collapsed = crate::menus::opt_bool(params, "collapsed")?; + if crate::floating::group_of(&app.ui, panel).is_some() { + return Err("panel floats; collapse its group with window.panel.float {panel, collapsed}".into()); + } + match app.ui.docking.as_mut() { + Some(layout) => { + match tab_group_shortens(layout, panel) { + None => return Err("panel is not in a dock group".into()), + Some(false) => return Err("only a dock group above or below others collapses, not one beside another".into()), + Some(true) => {} + } + layout.apply(Action::Activate { panel: panel.into() }).map_err(|e| e.to_string())?; + sync_legacy(app); + } + None => { + let tab = crate::state::DockTab::from_id(panel) + .filter(|_| crate::floating::docked_tabs(&app.ui).any(|t| t.info().0 == panel)) + .ok_or("panel is not in a dock group")?; + app.ui.dock_tab = tab; + } + } + prune_collapsed(&mut app.ui); + let now = collapsed.unwrap_or(!app.ui.docking_collapsed.contains(panel)); + if now { + app.ui.docking_collapsed.insert(panel.into()); + } else { + app.ui.docking_collapsed.remove(panel); + } + Ok(json!({"panel": panel, "collapsed": now})) +} + +/// `node` as drawn with its collapsed groups (showing a panel in `collapsed`) as tall as their +/// tabs in a top/bottom split. The paths of the splits so sized go in `fixed`, the panels of +/// collapsed groups in `members`. Also returns the node's height when it is only collapsed groups. +fn collapse_node( + node: &Node, + collapsed: &std::collections::BTreeSet, + (tab, gap): (f32, f32), + path: &mut Vec, + fixed: &mut Vec>, + members: &mut std::collections::BTreeSet, +) -> (Node, Option) { + use craft_ui::layout::{SplitAxis, SplitSize}; + match node { + Node::Tabs { panels, active } if panels.get(*active).is_some_and(|id| collapsed.contains(id)) => { + members.extend(panels.iter().cloned()); + (node.clone(), Some(tab)) + } + Node::Split { axis, size, first, second } => { + path.push(false); + let (a, ha) = collapse_node(first, collapsed, (tab, gap), path, fixed, members); + path.pop(); + path.push(true); + let (b, hb) = collapse_node(second, collapsed, (tab, gap), path, fixed, members); + path.pop(); + let (sized, height) = match (axis, ha, hb) { + (SplitAxis::Vertical, Some(ha), Some(hb)) => (Some(SplitSize::FixedFirst(ha)), Some(ha + hb + gap)), + (SplitAxis::Vertical, Some(ha), None) => (Some(SplitSize::FixedFirst(ha)), None), + (SplitAxis::Vertical, None, Some(hb)) => (Some(SplitSize::FixedSecond(hb)), None), + _ => (None, None), + }; + if sized.is_some() { + fixed.push(path.clone()); + } + (Node::Split { axis: *axis, size: sized.unwrap_or(*size), first: Box::new(a), second: Box::new(b) }, height) + } + _ => (node.clone(), None), + } +} + fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result { let panel = params.get("panel").and_then(Value::as_str).and_then(normalize).ok_or("panel must name a registered panel")?; + if operation == "move" && params.get("before").is_some() { + let before = params.get("before").and_then(Value::as_str).and_then(normalize).ok_or("before must name a panel")?; + use crate::state::DockTab; + let panel_group = crate::floating::group_of(&app.ui, panel); + let dock_strip_reorder = DockTab::from_id(panel).is_some() + && DockTab::from_id(before).is_some() + && panel_group.is_none() + && crate::floating::group_of(&app.ui, before).is_none(); + if app.ui.docking.is_none() || panel_group.is_some() || dock_strip_reorder { + return strip_tab_reorder(app, panel, before); + } + } if app.ui.docking.as_ref().is_some_and(|layout| !valid(layout)) { return Err("saved panel layout is invalid; reset the workspace first".into()); } @@ -306,11 +539,24 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result *value, Some(_) => return Err("group must be true or false".into()), }; + let to_icons = match params.get("icon") { + None => false, + Some(Value::Bool(value)) => *value, + Some(_) => return Err("icon must be true or false".into()), + }; + if to_icons && (operation != "dock" || params.get("anchor").or_else(|| params.get("onto")).is_some()) { + return Err("icon only docks a panel in the icon column, with no anchor".into()); + } let members = if whole_group { group_members(&layout, panel) } else { vec![panel.to_string()] }; let mut hidden: std::collections::BTreeMap<_, _> = app.ui.docking_hidden.iter().filter(|(id, _)| normalize(id).is_some()).take(64).map(|(id, at)| (id.clone(), at.clone())).collect(); let mut icons = icon_origins(app); - let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel); + if to_icons { + // Any panel the icon column can show lives there once dropped on it, the tabbed group's + // too; the others (library panels) dock as they would without `icon`. + icons.extend(members.iter().filter(|id| crate::state::rail_panel(id).is_some()).cloned()); + } + let icon_panel = crate::state::ICON_PANELS.iter().any(|entry| entry.0 == panel) || icons.contains(panel); if icon_panel && !layout.contains(&panel.to_string()) { icons.insert(panel.to_string()); } @@ -481,6 +727,12 @@ fn change(app: &mut VectorcraftApp, operation: &str, params: &Value) -> Result Result { app: &'a mut VectorcraftApp, commands: Vec<(&'static str, Value)>, + /// The panels of groups collapsed to their tabs: no body. + shut: std::collections::BTreeSet, } impl craft_ui::docking::DockContent for PanelContent<'_> { fn body(&mut self, ui: &mut egui::Ui, panel: &String) { + if self.shut.contains(panel) { + return; + } let t = crate::theme::Tokens::get(ui.ctx()); let back = ui.max_rect(); if back.is_positive() { @@ -514,10 +771,11 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { if crate::state::DockTab::from_id(panel).is_some() { crate::dock::panel_body(self.app, ui, panel, width, height); } else { - egui::ScrollArea::vertical() - .id_salt(("shared-panel-body", panel)) - .auto_shrink([false, false]) - .show(ui, |ui| crate::dock::panel_body(self.app, ui, panel, width, height)); + // The body is as wide as the scroll area leaves it beside its scroll bar. + crate::widgets::panel_scroll(ui, egui::ScrollArea::vertical().id_salt(("shared-panel-body", panel)).auto_shrink([false, false]), |ui| { + let width = ui.available_width(); + crate::dock::panel_body(self.app, ui, panel, width, height) + }); } } @@ -540,7 +798,9 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { self.commands.push(("window.panel.dock", json!({"panel": active, "group": true}))); } } else { - let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::drag()); + // Click as well as drag, so a double-click here collapses the group + // (`tab_area_double_clicked`) rather than floating it on the first press. + let response = ui.interact(grip, ui.id().with("dock-group-bar"), egui::Sense::click_and_drag()); if response.drag_started() && let Some(pos) = response.interact_pointer_pos() { @@ -551,6 +811,8 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { panel: active.clone(), layout: self.app.ui.docking.clone().unwrap_or_else(|| legacy(self.app)), hidden: self.app.ui.docking_hidden.clone(), + collapsed: self.app.ui.docking_collapsed.clone(), + open_panel: self.app.ui.open_panel.clone(), generation: self.app.ui.docking_generation, }, ) @@ -562,6 +824,107 @@ impl craft_ui::docking::DockContent for PanelContent<'_> { } } +/// The command that puts `panel` (with its group when `whole_group`) where a pointer drop on +/// `target` puts it. +fn drop_command(panel: &str, target: crate::floating::Drop, whole_group: bool) -> (&'static str, Value) { + use crate::floating::Drop; + match target { + Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})), + Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})), + Drop::Below(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "below": onto})), + Drop::Above(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "above": onto})), + Drop::Column(slot) => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "column": slot})), + Drop::Root(anchor, zone) => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "anchor": anchor, "zone": zone.id()})), + Drop::Icons => ("window.panel.dock", json!({"panel": panel, "group": whole_group, "icon": true})), + } +} + +/// The groups of the shared dock's tree as drawn in `content` (the rect its tree was laid out in), +/// from where its tabs were drawn: a group's tabs start at its top-left corner, and the groups of +/// a split meet where the second one's tabs start. +fn root_groups(ctx: &egui::Context, layout: &Layout, content: egui::Rect, tab_height: f32) -> Vec { + let area = egui::Id::new("vectorcraft-panel-docking"); + let tab = |panel: &String| ctx.read_response(area.with(("tab", panel))).map(|response| response.rect).filter(|r| r.is_positive()); + /// The top-left corner of a node's first group, from where its tabs were drawn. + fn corner(node: &Node, tab: &dyn Fn(&String) -> Option) -> Option { + match node { + Node::Tabs { panels, .. } => panels.iter().filter_map(tab).map(|r| r.min).reduce(|a, b| a.min(b)), + Node::Stack { entries } => entries.first().and_then(|entry| tab(&entry.panel)).map(|r| r.min), + Node::Split { first, .. } => corner(first, tab), + } + } + let mut out = Vec::new(); + let mut pending: Vec<(&Node, egui::Rect)> = layout.root.iter().map(|root| (root, content)).collect(); + while let Some((node, rect)) = pending.pop() { + if out.len() >= craft_ui::docking::MAX_PANELS || !rect.is_positive() { + break; + } + match node { + Node::Tabs { panels, active } => { + let ids: Vec<&'static str> = panels.iter().filter_map(|id| normalize(id)).collect(); + let Some(shown) = panels.get(*active).and_then(|id| normalize(id)).or_else(|| ids.first().copied()) else { continue }; + let strip = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), (rect.top() + tab_height).min(rect.bottom()))); + out.push(crate::floating::RootGroup { panels: ids, shown, rect, strip }); + } + Node::Stack { entries } => { + let ids: Vec<&'static str> = entries.iter().filter_map(|entry| normalize(&entry.panel)).collect(); + let Some(shown) = ids.first().copied() else { continue }; + let strip = egui::Rect::from_min_max(rect.min, egui::pos2(rect.right(), (rect.top() + tab_height).min(rect.bottom()))); + out.push(crate::floating::RootGroup { panels: ids, shown, rect, strip }); + } + Node::Split { axis, first, second, .. } => { + let Some(at) = corner(second, &tab) else { continue }; + let (a, b) = match axis { + craft_ui::layout::SplitAxis::Vertical => { + let y = at.y.clamp(rect.top(), rect.bottom()); + (egui::Rect::from_x_y_ranges(rect.x_range(), rect.top()..=y), egui::Rect::from_x_y_ranges(rect.x_range(), y..=rect.bottom())) + } + craft_ui::layout::SplitAxis::Horizontal => { + let x = at.x.clamp(rect.left(), rect.right()); + (egui::Rect::from_x_y_ranges(rect.left()..=x, rect.y_range()), egui::Rect::from_x_y_ranges(x..=rect.right(), rect.y_range())) + } + }; + pending.push((second, b)); + pending.push((first, a)); + } + } + } + out +} + +/// The panel shown by the dock group whose strip was double-clicked this frame right of its tabs +/// and left of its panel menu, from which Illustrator also collapses the group +/// (). Most of it has no +/// widget of its own, so the press is read from the input; `layer` is the dock's, so a press on a +/// floating group or a menu over the strip doesn't count. Accordions, and groups beside another +/// (which don't collapse), are left out. +fn tab_area_double_clicked( + ctx: &egui::Context, + layer: egui::LayerId, + layout: &Layout, + groups: &[crate::floating::RootGroup], +) -> Option<&'static str> { + let at = ctx.input(|i| if i.pointer.button_double_clicked(egui::PointerButton::Primary) { i.pointer.interact_pos() } else { None })?; + // No layer: the background, where the dock panel is. + if ctx.layer_id_at(at).is_some_and(|l| l != layer) { + return None; + } + let group = groups.iter().find(|g| g.strip.contains(at))?; + if tab_group_shortens(layout, group.shown) != Some(true) { + return None; + } + let area = egui::Id::new("vectorcraft-panel-docking"); + let right = |id: egui::Id| ctx.read_response(id).map(|r| r.rect.right()); + let tabs = group + .panels + .iter() + .filter_map(|p| right(area.with(("tab", &p.to_string())))) + .chain(right(area.with(("overflow", &group.shown.to_string())))) + .fold(group.strip.left(), f32::max); + // The panel menu is 16 wide, 12 in from the right, with 4 to spare (`group_header`). + (at.x >= tabs && at.x < group.strip.right() - 24.0).then_some(group.shown) +} + pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { let root_key = egui::Id::new("vectorcraft-root-group-drag"); if let Some(drag) = ui.ctx().data(|data| data.get_temp::(root_key)) { @@ -570,6 +933,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } else if ui.input(|input| !input.focused || input.key_pressed(egui::Key::Escape)) { app.ui.docking = Some(drag.layout); app.ui.docking_hidden = drag.hidden; + app.ui.docking_collapsed = drag.collapsed; + app.ui.open_panel = drag.open_panel; sync_legacy(app); ui.ctx().data_mut(|data| data.remove::(root_key)); } else if let Some(pos) = ui.input(|input| input.pointer.interact_pos()) { @@ -586,13 +951,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { if let Some(panel) = normalize(&drag.panel) && let Some((target, _)) = crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) { - let (id, params) = match target { - crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel":panel,"group":true})), - crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"onto":onto})), - crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"below":onto})), - crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":true,"above":onto})), - crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":true,"column":slot})), - }; + let (id, params) = drop_command(panel, target, true); if let Err(error) = app.run(id, params) { app.ui.status = error; } @@ -653,6 +1012,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { .collect(); crate::dock::apply_pending_dock_width(app, ui.ctx(), crate::dock::SHARED_DOCK_PANEL, crate::dock::clamp_shared_dock_width); let shared_dock_default = crate::dock::shared_dock_default_width(&app.ui); + // Splits sized to a collapsed group's tabs: their dividers don't resize the saved layout. + let mut fixed: Vec> = Vec::new(); let mut draw = |ui: &mut egui::Ui| { if layout.root.is_some() && crate::dock::collapse_header(ui, collapsed) { let _ = app.run("window.collapseDock", json!({"collapsed":!collapsed})); @@ -695,22 +1056,67 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { style.panels_label = tl!("Panels").to_string(); style.resize_label = tl!("Resize panels").to_string(); style.resize_window_label = tl!("Resize panel window").to_string(); - let mut content = PanelContent { app, commands: Vec::new() }; - let output = craft_ui::docking::DockArea::new(area).show_customized( - ui, - &display, - &style, - |id| { - crate::state::all_panels() - .find(|p| p.0 == id) - .map(|p| tl!(p.1).to_string()) - .or_else(|| library_label(id)) - .unwrap_or_else(|| id.clone()) - }, - |_| craft_ui::docking::Permissions::default(), - |_| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, 80.0), ..Default::default() }, - &mut content, - ); + // Panels set their own minimum height, so a collapsed group can be as short as its tabs. + style.min_pane = 0.0; + let mut tree = ui.available_rect_before_wrap(); + let mut shut = std::collections::BTreeSet::new(); + let mut drawn = display.clone(); + // The height of the tree when it is only collapsed groups: drawn that tall, so no group + // takes the rest of the dock as a blank pane. + let mut only_tabs = None; + if let Some(root) = &display.root { + let (node, height) = + collapse_node(root, &app.ui.docking_collapsed, (style.tab_height, style.gap), &mut Vec::new(), &mut fixed, &mut shut); + drawn.root = Some(node); + only_tabs = height.filter(|h| h.is_finite() && *h > 0.0); + } + let short = shut.clone(); + let mut content = PanelContent { app, commands: Vec::new(), shut }; + let mut dock = |ui: &mut egui::Ui| { + craft_ui::docking::DockArea::new(area).show_customized( + ui, + &drawn, + &style, + |id| { + crate::state::all_panels() + .find(|p| p.0 == id) + .map(|p| tl!(p.1).to_string()) + .or_else(|| library_label(id)) + .unwrap_or_else(|| id.clone()) + }, + |_| craft_ui::docking::Permissions::default(), + |id| craft_ui::docking::PanelLimits { min: egui::vec2(220.0, if short.contains(id) { 0.0 } else { 80.0 }), ..Default::default() }, + &mut content, + ) + }; + let output = match only_tabs { + Some(height) => { + let size = egui::vec2(ui.available_width(), height.min(ui.available_height())); + ui.allocate_ui(size, |ui| { + ui.set_max_height(size.y); + tree = ui.available_rect_before_wrap(); + dock(ui) + }) + .inner + } + None => dock(ui), + }; + // A double-click on a tab of the dock's tree collapses its group to its tabs, or expands it. + // A group beside another doesn't collapse, so its tabs ignore it rather than report an error. + if let Some(root) = &drawn.root { + let tabs = Layout { root: Some(root.clone()), floating: Vec::new() }; + if let Some(panel) = + tabs.panels().into_iter().find(|p| ui.ctx().read_response(area.with(("tab", *p))).is_some_and(|r| r.double_clicked())) + && tab_group_shortens(&tabs, panel) == Some(true) + { + content.commands.push(("window.panel.collapse", json!({"panel": panel}))); + } + } + let groups = if collapsed { Vec::new() } else { root_groups(ui.ctx(), &display, tree, style.tab_height) }; + if let Some(panel) = tab_area_double_clicked(ui.ctx(), ui.layer_id(), &display, &groups) { + content.commands.push(("window.panel.collapse", json!({"panel": panel}))); + } + ui.ctx().data_mut(|data| data.insert_temp(crate::floating::root_groups_id(), groups)); for (id, params) in content.commands { if let Err(error) = app.run(id, params) { app.ui.status = error; @@ -738,6 +1144,16 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { { crate::floating::preview(ui.ctx(), target, rect); } + // A tab dragged over the icon column becomes an icon there: light the column up (the shared + // dock shows the other drops). + if ui.input(|input| input.pointer.primary_down()) + && let Some(pos) = ui.input(|input| input.pointer.interact_pos()) + && let Some(panel) = dragged_tab(ui.ctx(), &layout).and_then(|panel| normalize(&panel)) + && let Some((target @ crate::floating::Drop::Icons, rect)) = + crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) + { + crate::floating::preview(ui.ctx(), target, rect); + } if let Some(error) = output.error { app.ui.status = error.to_string(); } @@ -751,7 +1167,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { _ => return None, }; crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos) - .filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock)) + .filter(|(target, _)| !matches!(target, crate::floating::Drop::Dock | crate::floating::Drop::Root(..))) .map(|(target, _)| (panel, target)) }) }); @@ -766,15 +1182,19 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { { return app.ui.docking.is_some(); } + craft_ui::docking::cancel_drag::(ui.ctx(), egui::Id::new("vectorcraft-panel-docking")); ui.ctx().data_mut(|data| data.remove::(egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"))); } for action in output.actions { + if matches!(&action, Action::ResizeSplit { path, .. } if fixed.contains(path)) { + continue; + } if tab_drop .is_some_and(|(panel, _)| matches!(&action, Action::Float { panel: moved, .. } | Action::Move { panel: moved, .. } if moved == panel)) { continue; } - if let Err(error) = app.run("window.panel.layout", json!({"action": action})) { + if let Err(error) = apply_action(app, action) { app.ui.status = error; } } @@ -783,13 +1203,7 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { crate::floating::drop_target(app, ui.ctx(), crate::floating::Moving::Panel(panel), pos).map(|(target, _)| (panel, target, true)) }); if let Some((panel, target, whole_group)) = group_drop.or_else(|| tab_drop.map(|(panel, target)| (panel, target, false))) { - let (command, params) = match target { - crate::floating::Drop::Dock => ("window.panel.dock", json!({"panel": panel, "group": whole_group})), - crate::floating::Drop::Stack(onto) => ("window.panel.float", json!({"panel": panel, "group": whole_group, "onto": onto})), - crate::floating::Drop::Below(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"below":onto})), - crate::floating::Drop::Above(onto) => ("window.panel.float", json!({"panel":panel,"group":whole_group,"above":onto})), - crate::floating::Drop::Column(slot) => ("window.panel.dock", json!({"panel":panel,"group":whole_group,"column":slot})), - }; + let (command, params) = drop_command(panel, target, whole_group); // Shared and native windows have already chosen their render ownership for this // frame. Switch representations only after both renderers finish, so a panel's // widgets never appear in two layers in the same frame. @@ -813,6 +1227,17 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { true } +/// The panel whose tab (of the shared dock, or of the legacy dock or icon column on the drag that +/// began the shared layout) is being dragged. +fn dragged_tab(ctx: &egui::Context, layout: &Layout) -> Option { + let area = egui::Id::new("vectorcraft-panel-docking"); + if let Some(drag) = ctx.data(|data| data.get_temp::(area.with("legacy-origin"))) { + return Some(drag.panel); + } + let dragged = ctx.dragged_id()?; + layout.panels().into_iter().find(|panel| area.with(("tab", *panel)) == dragged).cloned() +} + fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(ui.ctx()); let column = egui::Panel::right("shared-panel-icons") @@ -821,23 +1246,43 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE) .show(ui, |ui| { crate::theme::card(ui, &t, egui::Margin { left: 3, right: 3, top: 6, bottom: 6 }, |ui| { - egui::ScrollArea::vertical().id_salt("shared-panel-icons-scroll").show(ui, |ui| { + crate::widgets::strip_scroll(ui, "shared-panel-icons-scroll", |ui| { ui.spacing_mut().item_spacing.y = 2.0; - for &(id, label, icon) in crate::state::ICON_PANEL_GROUPS + // The tabbed group's panels dropped on the column head it, then the icon panels, + // then the other panels dropped on it (those the column has no place for). + let tabs: Vec<_> = crate::state::DockTab::ALL + .into_iter() + .map(crate::state::DockTab::info) + .filter(|info| app.ui.docking_icons.contains(info.0)) + .collect(); + let grouped = |id: &str| crate::state::ICON_PANEL_GROUPS.iter().any(|group| group.contains(&id)); + let dropped: Vec<_> = crate::state::ICON_PANELS .iter() - .flat_map(|group| group.iter()) - .filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id)) + .filter(|entry| !grouped(entry.0) && app.ui.docking_icons.contains(entry.0)) + .copied() + .collect(); + for (id, label, icon) in tabs + .into_iter() + .chain( + crate::state::ICON_PANEL_GROUPS + .iter() + .flat_map(|group| group.iter()) + .filter_map(|id| crate::state::ICON_PANELS.iter().find(|entry| entry.0 == *id).copied()), + ) + .chain(dropped) { if app.ui.docking.as_ref().is_some_and(|layout| layout.contains(&id.to_string())) { continue; } let (_, rect) = ui.allocate_space(egui::vec2(30.0, 30.0)); let response = ui.interact(rect, ui.id().with(("panel-icon", id)), egui::Sense::click_and_drag()); - let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), app.ui.open_panel.as_deref() == Some(id)); + let open = app.ui.open_panel.as_deref() == Some(id); + let response = crate::widgets::paint_icon_button(ui, response, icon, tl!(label), open); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), open, tl!(label))); if response.clicked() { app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(id) { None } else { Some(id.into()) }; } - legacy_tab(app, ui, id, &response); + legacy_tab(app, ui, id, &response, false); } }); }) @@ -856,7 +1301,21 @@ fn icon_rail(app: &mut VectorcraftApp, ui: &mut egui::Ui) { }); } -pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response) { +/// A tab of the dock's tabbed group (`closable`) or an icon of the icon column: its context menu +/// floats, groups or (a tab) closes its panel, and a drag moves it in the shared layout. +pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &str, response: &egui::Response, closable: bool) { + legacy_tab_in_strip(app, ui, panel, response, closable, None); +} + +/// [`legacy_tab`] for a tab of the tab strip `strip`, where a drag reorders the tabs. +pub(crate) fn legacy_tab_in_strip( + app: &mut VectorcraftApp, + ui: &mut egui::Ui, + panel: &str, + response: &egui::Response, + closable: bool, + strip: Option, +) { let Some(panel) = normalize(panel) else { return }; response.context_menu(|ui| { if ui.button(tl!("Float Panel")).clicked() { @@ -879,10 +1338,18 @@ pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &st } } }); + if closable && ui.button(tl!("Close Panel")).clicked() { + if let Err(error) = app.run("window.panel.close", json!({"panel": panel})) { + app.ui.status = error; + } + ui.close(); + } }); - if response.drag_started() - && let Some(id) = normalize(panel) - { + let inside_strip = strip.is_some_and(|strip| ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| strip.expand(4.0).contains(p))); + let area = egui::Id::new("vectorcraft-panel-docking"); + let begun = ui.ctx().data(|d| d.get_temp::(area.with("legacy-origin")).is_some()); + let begin = if strip.is_some() { !begun && (response.drag_started() || (!inside_strip && response.dragged())) } else { response.drag_started() }; + if begin && let Some(id) = normalize(panel) { let mut layout = app.ui.docking.clone().unwrap_or_else(|| legacy(app)); let bounds = ui.max_rect(); let source = egui::Rect::from_min_size( @@ -896,10 +1363,12 @@ pub(crate) fn legacy_tab(app: &mut VectorcraftApp, ui: &mut egui::Ui, panel: &st data.insert_temp( egui::Id::new("vectorcraft-panel-docking").with("legacy-origin"), LegacyDrag { + panel: id.to_string(), layout: app.ui.docking.clone(), open_panel: app.ui.open_panel.clone(), floating: app.ui.floating_panels.clone(), icons: app.ui.docking_icons.clone(), + collapsed: app.ui.docking_collapsed.clone(), }, ) }); @@ -1035,9 +1504,10 @@ mod tests; /// Commit a pointer drop after shared and native panel windows have finished rendering. pub(crate) fn finish_native_drop(app: &mut VectorcraftApp, ctx: &egui::Context) { - if let Some((command, params)) = + if let Some((id, params)) = ctx.data_mut(|data| data.remove_temp::<(&'static str, serde_json::Value)>(egui::Id::new("vectorcraft-native-panel-drop"))) - && let Err(error) = app.run(command, params) + && let Some(result) = command(app, id, ¶ms) + && let Err(error) = result { app.ui.status = error; } diff --git a/crates/ui-egui/src/panel_docking_tests.rs b/crates/ui-egui/src/panel_docking_tests.rs index dbd77ed33..e1b89c2b4 100644 --- a/crates/ui-egui/src/panel_docking_tests.rs +++ b/crates/ui-egui/src/panel_docking_tests.rs @@ -693,3 +693,530 @@ fn library_panels_float_with_their_names_and_a_persistent_menu_item() { let persistent: Vec<&str> = h.state().ui.library_panels.iter().filter(|p| p.persistent).map(|p| p.id.as_str()).collect(); assert_eq!(persistent, ["pastels"]); } + +/// The dock groups the last frame registered as drop targets. +fn root_groups(h: &Harness<'static, VectorcraftApp>) -> Vec { + h.ctx.data(|d| d.get_temp::>(crate::floating::root_groups_id())).unwrap_or_default() +} + +/// The dock's group holding `panel`, as panel ids. +fn root_group_of(app: &VectorcraftApp, panel: &str) -> Vec { + let layout = app.ui.docking.as_ref().unwrap(); + assert!(Layout { root: layout.root.clone(), floating: Vec::new() }.contains(&panel.to_string()), "{panel} is in the dock: {layout:?}"); + group_members(layout, panel) +} + +/// A legacy (native) floating group holding `panel`, as the Window menu or an icon tear leaves it +/// before the shared layout takes over, and a point on its title bar. +fn native_float(h: &mut Harness<'static, VectorcraftApp>, panel: &'static str) -> Pos2 { + crate::floating::float(&mut h.state_mut().ui, &[panel], panel, egui::pos2(300.0, 300.0)); + h.run_steps(3); + assert!(h.state().ui.docking.is_none(), "still the legacy dock"); + let r = h.ctx.memory(|m| m.area_rect(crate::floating::area_id(panel))).unwrap(); + egui::pos2(r.left() + 30.0, r.top() + 1.0 + crate::floating::TITLE / 2.0) +} + +/// A floating group dropped on the dock's tab strip joins the tabbed group as tabs. +#[test] +fn a_floating_group_dropped_on_the_dock_tabs_joins_the_tabbed_group() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let title = native_float(&mut h, "pathfinder"); + let groups = root_groups(&h); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].panels, ["properties", "layers", "libraries"]); + let onto = groups[0].strip.right_center() - vec2(60.0, 0.0); + drag(&mut h, title, onto); + assert!(h.state().ui.floating_panels.is_empty(), "{:?}", h.state().ui.floating_panels); + assert_eq!(root_group_of(h.state(), "pathfinder"), ["properties", "layers", "libraries", "pathfinder"]); + assert!(!h.state().ui.docking_icons.contains("pathfinder"), "its home is the tabbed group now"); + assert!(valid(h.state().ui.docking.as_ref().unwrap())); +} + +/// A floating group dropped on a dock group's lower half becomes a new group below it. +#[test] +fn a_floating_group_dropped_on_the_lower_half_of_the_dock_splits_it() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + let title = native_float(&mut h, "pathfinder"); + let group = root_groups(&h)[0].rect; + drag(&mut h, title, egui::pos2(group.center().x, group.bottom() - 80.0)); + assert!(h.state().ui.floating_panels.is_empty()); + let layout = h.state().ui.docking.clone().unwrap(); + let Some(Node::Split { axis: craft_ui::layout::SplitAxis::Vertical, second, .. }) = &layout.root else { + panic!("a top/bottom split: {layout:?}") + }; + assert_eq!(**second, Node::Tabs { panels: vec!["pathfinder".into()], active: 0 }); + assert_eq!(root_group_of(h.state(), "layers"), ["properties", "layers", "libraries"]); + // The two groups are drawn one above the other, and another group dropped on the upper half of + // the lower one goes between them. + h.run_steps(3); + let groups = root_groups(&h); + assert_eq!(groups.len(), 2, "{groups:?}"); + assert!(groups[0].rect.bottom() <= groups[1].rect.top() + 1.0, "{groups:?}"); + assert_eq!(groups[1].panels, ["pathfinder"]); + let lower = groups[1].rect; + h.state_mut().run("window.panel.float", json!({"panel":"align","x":300,"y":300})).unwrap(); + h.run_steps(3); + let title = + h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"align".to_string())))).unwrap().left_top() + + vec2(6.0, 16.0); + drag(&mut h, title, egui::pos2(lower.center().x, lower.top() + 50.0)); + assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty()); + h.run_steps(3); + let panels: Vec<_> = root_groups(&h).into_iter().map(|g| g.panels).collect(); + assert_eq!(panels, [vec!["properties", "layers", "libraries"], vec!["align"], vec!["pathfinder"]]); +} + +/// A floating group dropped on the icon column becomes icons there. +#[test] +fn a_floating_group_dropped_on_the_icon_column_becomes_icons() { + let mut h = harness(app(), vec2(1280.0, 1400.0)); + h.state_mut().run("window.panel.float", json!({"panel":"layers","x":300,"y":300})).unwrap(); + h.run_steps(3); + let title = + h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"layers".to_string())))).unwrap().left_top() + + vec2(6.0, 16.0); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + drag(&mut h, title, column.center()); + let ui = &h.state().ui; + assert!(ui.docking_icons.contains("layers")); + assert!(!ui.docking.as_ref().unwrap().contains(&"layers".to_string())); + assert_eq!(ui.open_panel.as_deref(), Some("layers"), "it pops out of its new icon"); +} + +/// A dock tab dragged onto the icon column becomes an icon whose flyout shows the panel. +#[test] +fn a_dock_tab_dragged_onto_the_icon_column_becomes_an_icon_and_its_flyout_shows() { + let mut h = harness(app(), vec2(1280.0, 1400.0)); + let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + drag(&mut h, tab, column.center()); + let ui = &h.state().ui; + assert!(ui.docking_icons.contains("layers"), "{:?}", ui.docking_icons); + assert_eq!(root_group_of(h.state(), "properties"), ["properties", "libraries"]); + // Its icon heads the column; a click puts the popped-out panel away and another shows it again. + h.state_mut().ui.open_panel = None; + h.run_steps(3); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + let icon = h.query_all_by_label("Layers").map(|node| node.rect()).find(|r| column.contains_rect(*r)).expect("a Layers icon in the column"); + h.event(Event::PointerMoved(icon.center())); + h.event(Event::PointerButton { pos: icon.center(), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: icon.center(), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert_eq!(h.state().ui.open_panel.as_deref(), Some("layers")); + // The preferences keep it in the column. + let saved = serde_json::to_value(&h.state().ui).unwrap(); + let restored: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert!(restored.sanitized().docking_icons.contains("layers")); + // Dragged from its icon back onto the tabbed group's tabs, it is a tab again. + let strip = root_groups(&h)[0].strip; + drag(&mut h, icon.center(), strip.right_center() - vec2(30.0, 0.0)); + assert_eq!(root_group_of(h.state(), "layers"), ["properties", "libraries", "layers"]); +} + +/// A dock tab's context menu closes its panel, and the Window menu brings it back. +#[test] +fn a_dock_tab_context_menu_closes_its_panel_and_the_window_menu_brings_it_back() { + let mut h = harness(app(), vec2(1280.0, 800.0)); + assert!(h.state().ui.docking.is_none()); + let tab = h.query_all_by_label("Libraries").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + h.event(Event::PointerMoved(tab)); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + h.get_by_label("Close Panel").click(); + h.run_steps(3); + let layout = h.state().ui.docking.clone().expect("closing starts the shared layout"); + assert!(!layout.contains(&"libraries".to_string())); + assert_eq!(root_group_of(h.state(), "layers"), ["properties", "layers"]); + // In the shared dock the tabs' context menu closes them too. + let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"layers".to_string()))).unwrap().rect.center(); + h.event(Event::PointerMoved(tab)); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + h.event(Event::PointerButton { pos: tab, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + h.get_by_label("Close Panel").click(); + h.run_steps(3); + assert_eq!(root_group_of(h.state(), "properties"), ["properties"]); + h.state_mut().run("window.panel", json!({"panel":"libraries"})).unwrap(); + h.state_mut().run("window.panel", json!({"panel":"layers"})).unwrap(); + let mut panels = root_group_of(h.state(), "properties"); + panels.sort(); + assert_eq!(panels, ["layers", "libraries", "properties"]); +} + +/// `window.panel.dock {icon}` and `{anchor, zone}` check their parameters and survive a workspace switch. +#[test] +fn icon_docking_commands_validate_and_round_trip_through_workspaces() { + let mut app = app(); + for params in [json!({"panel":"layers","icon":3}), json!({"panel":"layers","icon":true,"anchor":"properties"})] { + assert!(app.run("window.panel.dock", params).is_err()); + assert!(app.ui.docking.is_none()); + } + assert!(app.run("window.panel.move", json!({"panel":"layers","anchor":"properties","icon":true})).is_err()); + let r = app.run("window.panel.dock", json!({"panel":"layers","icon":true})).unwrap(); + assert!(r.get("layout").is_some() && r.get("column").is_none(), "the shared layout's result: {r}"); + assert!(app.ui.docking_icons.contains("layers")); + assert!(!app.ui.docking.as_ref().unwrap().contains(&"layers".to_string())); + let r = app.run("window.panel.dock", json!({"panel":"pathfinder","anchor":"properties","zone":"bottom"})).unwrap(); + assert!(r.get("layout").is_some() && r.get("column").is_none(), "the shared layout's result: {r}"); + assert!(matches!(&app.ui.docking.as_ref().unwrap().root, Some(Node::Split { axis: craft_ui::layout::SplitAxis::Vertical, .. }))); + app.run("window.workspace.new", json!({"name":"Icons"})).unwrap(); + app.run("window.workspace", json!({"name":"Essentials"})).unwrap(); + assert!(!app.ui.docking_icons.contains("layers")); + app.run("window.workspace", json!({"name":"Icons"})).unwrap(); + assert!(app.ui.docking_icons.contains("layers")); + assert_eq!(root_group_of(&app, "pathfinder"), ["pathfinder"]); + // From the Window menu it pops out of its icon. + app.run("window.panel", json!({"panel":"layers"})).unwrap(); + assert_eq!(app.ui.open_panel.as_deref(), Some("layers")); +} + +/// `column` goes with neither `anchor` nor `icon: true`, so it is never silently dropped; +/// `icon: false` is the same as no `icon`, so a dock column still takes the panel. +#[test] +fn a_dock_column_is_refused_with_an_anchor_or_icon_and_icon_false_is_no_icon() { + let mut app = app(); + for params in [json!({"panel":"color","column":1,"anchor":"properties"}), json!({"panel":"color","column":true,"icon":true})] { + assert!(app.run("window.panel.dock", params.clone()).is_err(), "{params}"); + assert!(app.ui.docking.is_none() && app.ui.floating_panels.is_empty(), "{params} changed nothing"); + } + let r = app.run("window.panel.dock", json!({"panel":"color","column":true,"icon":false})).unwrap(); + assert_eq!((r["column"].clone(), r["floating"].clone()), (json!(1), json!(false))); +} + +/// A group dropped on the icon column leaves what the column can't show (a library panel) in the +/// dock instead of losing it. +#[test] +fn a_library_panel_dropped_on_the_icon_column_with_its_group_stays_in_the_dock() { + let mut app = app(); + app.run("file.new", json!({})).unwrap(); + app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); + let library = "library:swatches:pastels".to_string(); + app.run("window.panel.float", json!({"panel": library, "x": 300, "y": 100})).unwrap(); + app.run("window.panel.move", json!({"panel":"color", "anchor": library})).unwrap(); + assert_eq!(group_members(app.ui.docking.as_ref().unwrap(), &library).len(), 2, "one floating group"); + app.run("window.panel.dock", json!({"panel": library, "group": true, "icon": true})).unwrap(); + assert!(app.ui.docking_icons.contains("color")); + assert!(!app.ui.docking_icons.contains(&library)); + assert!(Layout { root: app.ui.docking.as_ref().unwrap().root.clone(), floating: Vec::new() }.contains(&library), "in the dock"); +} + +/// A panel outside the icon groups still gets an icon once dropped on the column. +#[test] +fn a_panel_the_icon_column_has_no_place_for_still_shows_there_once_dropped_on_it() { + let mut h = harness(app(), vec2(1280.0, 1600.0)); + assert!(!crate::state::ICON_PANEL_GROUPS.iter().any(|group| group.contains(&"charStyles"))); + h.state_mut().run("window.panel.float", json!({"panel":"charStyles","x":300,"y":300})).unwrap(); + h.run_steps(3); + let title = + h.ctx.memory(|m| m.area_rect(egui::Id::new("vectorcraft-panel-docking").with(("floating", &"charStyles".to_string())))).unwrap().left_top() + + vec2(6.0, 16.0); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + drag(&mut h, title, column.center()); + assert!(h.state().ui.docking_icons.contains("charStyles")); + h.state_mut().ui.open_panel = None; + h.run_steps(3); + let column = h.ctx.data(|d| d.get_temp::(crate::floating::icons_rect_id())).unwrap(); + assert!(h.query_all_by_label("Character Styles").any(|node| column.contains_rect(node.rect())), "its icon is in the column"); +} + +/// Double-clicks at `at`, then waits out the double-click time. +fn double_click(h: &mut Harness<'static, VectorcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.run_steps(1); + for _ in 0..2 { + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(1); + } + // Past the double- and triple-click times, so the next press is a click of its own. + h.run_steps(45); +} + +/// The Layers panel's body is drawn (its search field). +fn layers_drawn(h: &Harness<'static, VectorcraftApp>) -> bool { + h.ctx.read_response(egui::Id::new("layers-search").with("edit")).is_some() +} + +/// Clicks at `at`. +fn click(h: &mut Harness<'static, VectorcraftApp>, at: Pos2) { + h.event(Event::PointerMoved(at)); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +/// The middle of `panel`'s tab in the shared dock. +/// Two stacked groups both collapsed are both as short as their tabs: the lower one doesn't take +/// the rest of the dock as a blank pane. +#[test] +fn two_stacked_groups_both_collapsed_are_both_as_short_as_their_tabs() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.collapse", json!({"panel":"swatches", "collapsed": true})).unwrap(); + app.run("window.panel.collapse", json!({"panel":"layers", "collapsed": true})).unwrap(); + let h = harness(app, vec2(1280.0, 800.0)); + let groups = root_groups(&h); + assert_eq!(groups.len(), 2, "{groups:?}"); + for group in &groups { + assert!(group.rect.height() < 40.0, "{groups:?}"); + } +} + +fn tab_center(h: &Harness<'static, VectorcraftApp>, panel: &str) -> Pos2 { + h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &panel.to_string()))).unwrap().rect.center() +} + +/// A double-click on a tab of the shared dock collapses its group to its tabs; another tab expands it. +#[test] +fn a_double_click_on_a_dock_tab_collapses_its_group_to_its_tabs_and_another_expands_it() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + let saved = app.ui.docking.clone(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let groups = root_groups(&h); + assert_eq!(groups.len(), 2, "{groups:?}"); + let (top, lower) = (groups[0].rect, groups[1].rect); + assert!(top.height() > 100.0, "{top:?}"); + + // The upper group shrinks to its tabs and the lower one takes the room; the saved layout keeps + // its sizes. + let at = tab_center(&h, "swatches"); + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.contains("swatches")); + let groups = root_groups(&h); + assert!(groups[0].rect.height() < 40.0, "{groups:?}"); + assert!(groups[1].rect.top() < lower.top() - 50.0, "{groups:?}"); + assert_eq!(h.state().ui.docking, saved); + // Again, and it is as tall as before. + let at = tab_center(&h, "swatches"); + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.is_empty()); + let groups = root_groups(&h); + assert!((groups[0].rect.height() - top.height()).abs() < 2.0, "{groups:?} {top:?}"); + + // The lower group, tabbed: its body goes, and a click on another of its tabs brings it back + // showing that panel. + let at = tab_center(&h, "layers"); + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.contains("layers")); + assert!(!layers_drawn(&h), "no Layers body"); + let at = tab_center(&h, "properties"); + click(&mut h, at); + assert!(h.state().ui.docking_collapsed.is_empty()); + assert_eq!(root_group_of(h.state(), "properties"), ["properties", "layers", "libraries"]); + assert!(valid(h.state().ui.docking.as_ref().unwrap())); +} + +/// A double-click on a tab of a group beside another does nothing, and leaves no error in the +/// status bar: that group doesn't collapse (`a_group_beside_another_does_not_collapse`). +#[test] +fn a_double_click_on_a_tab_of_a_group_beside_another_does_nothing() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"left"})).unwrap(); + let saved = app.ui.docking.clone(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let status = h.state().ui.status.clone(); + let shown: Vec<&str> = root_groups(&h).iter().map(|g| g.shown).collect(); + assert_eq!(shown.len(), 2, "{shown:?}"); + for panel in shown { + let at = tab_center(&h, panel); + double_click(&mut h, at); + assert_eq!(h.state().ui.status, status, "no error: {panel}"); + assert!(h.state().ui.docking_collapsed.is_empty(), "{panel}"); + assert_eq!(h.state().ui.docking, saved, "{panel}"); + } +} + +/// A double-click on a tab of the legacy dock collapses the tabbed group to its tabs. +#[test] +fn a_double_click_on_a_legacy_dock_tab_collapses_the_tabbed_group() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + click(&mut h, tab); + h.run_steps(45); + assert!(layers_drawn(&h), "Layers shows"); + double_click(&mut h, tab); + assert!(h.state().ui.docking.is_none(), "still the legacy dock"); + assert!(h.state().ui.docking_collapsed.contains("layers")); + assert!(!layers_drawn(&h), "no Layers body"); + double_click(&mut h, tab); + assert!(h.state().ui.docking_collapsed.is_empty()); + assert!(layers_drawn(&h), "Layers shows again"); +} + +/// Illustrator also collapses the group from the blank part of the strip, see +/// . The legacy dock's strip right of its tabs collapses the tabbed group, and floats nothing. +#[test] +fn a_double_click_on_the_legacy_dock_strip_right_of_the_tabs_collapses_the_tabbed_group() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let libraries = h.query_all_by_label("Libraries").find(|node| node.rect().height() <= 45.0).unwrap().rect(); + let dock = h.ctx.data(|d| d.get_temp::(crate::floating::dock_rect_id())).unwrap(); + let blank = pos2(libraries.right() + 12.0, libraries.center().y); + assert!(blank.x < dock.right() - 30.0, "left of the panel menu: {libraries:?} {dock:?}"); + let tab = h.query_all_by_label("Layers").find(|node| node.rect().height() <= 45.0).unwrap().rect().center(); + click(&mut h, tab); + h.run_steps(45); + assert!(layers_drawn(&h), "Layers shows"); + double_click(&mut h, blank); + assert!(h.state().ui.docking.is_none() && h.state().ui.floating_panels.is_empty(), "nothing floats"); + assert!(h.state().ui.docking_collapsed.contains("layers")); + assert!(!layers_drawn(&h), "no Layers body"); + double_click(&mut h, blank); + assert!(h.state().ui.docking_collapsed.is_empty()); + assert!(layers_drawn(&h), "Layers shows again"); +} + +/// The shared dock's strip right of a group's tabs, and the grip beside its panel menu, collapse +/// the group to its tabs and expand it, without floating it. +#[test] +fn a_double_click_on_the_shared_dock_strip_right_of_the_tabs_collapses_its_group() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + let saved = app.ui.docking.clone(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let strip = root_groups(&h)[0].strip; + let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"swatches".to_string()))).unwrap().rect; + // Between the tab and the group's header (80 wide), and on the header's grip. + let gap = pos2(tab.right() + 10.0, strip.center().y); + assert!(gap.x < strip.right() - 80.0, "{tab:?} {strip:?}"); + let grip = pos2(strip.right() - 50.0, strip.center().y); + for at in [gap, grip] { + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.contains("swatches"), "{at:?}"); + assert!(root_groups(&h)[0].rect.height() < 40.0, "{at:?}"); + double_click(&mut h, at); + assert!(h.state().ui.docking_collapsed.is_empty(), "{at:?}"); + assert_eq!(h.state().ui.docking, saved, "nothing floats: {at:?}"); + } +} + +/// `window.panel.collapse` checks its parameters, and a collapsed group survives a reload. +#[test] +fn the_collapse_command_validates_and_survives_a_reload() { + let mut app = app(); + assert_eq!(app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(), json!({"panel":"layers", "collapsed":true})); + assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers, "it shows the panel"); + let saved = serde_json::to_value(&app.ui).unwrap(); + let restored = serde_json::from_value::(saved).unwrap().sanitized(); + assert!(restored.docking_collapsed.contains("layers")); + assert_eq!(app.run("window.panel.collapse", json!({"panel":"layers", "collapsed":false})).unwrap()["collapsed"], false); + for params in [json!({"panel":"nope"}), json!({"panel":"layers", "collapsed":"yes"}), json!({"panel":"swatches"})] { + assert!(app.run("window.panel.collapse", params.clone()).is_err(), "{params}"); + } + app.run("window.panel.float", json!({"panel":"layers", "x":300, "y":300})).unwrap(); + assert!(app.run("window.panel.collapse", json!({"panel":"layers"})).is_err(), "floating groups collapse with float"); + assert!(app.ui.docking_collapsed.is_empty()); +} + +/// A panel moved out of a collapsed group doesn't take the collapse along. +#[test] +fn a_collapsed_groups_panel_moved_elsewhere_takes_no_collapse_along() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(); + // Layers, shown by the collapsed group, goes to the Swatches group and is shown there. + app.run("window.panel.move", json!({"panel":"layers", "anchor":"swatches"})).unwrap(); + app.run("window.panel.activate", json!({"panel":"layers"})).unwrap(); + crate::panel_docking::prune_collapsed(&mut app.ui); + assert!(app.ui.docking_collapsed.is_empty(), "neither group is collapsed"); + // The same through a serialized action (a drag). + app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(); + app.run("window.panel.layout", json!({"action": {"Move": {"panel":"layers", "anchor":"properties", "placement": {"Tab": {"before": null}}}}})) + .unwrap(); + assert!(app.ui.docking_collapsed.is_empty()); +} + +/// `window.panel.collapse` refuses a panel that is in an accordion, not a tab group. +#[test] +fn the_collapse_command_rejects_an_accordion_panel() { + let mut app = app(); + app.ui.docking = Some(Layout { + root: Some(Node::Stack { + entries: vec![ + craft_ui::docking::StackEntry { panel: "layers".into(), open: true, height: None }, + craft_ui::docking::StackEntry { panel: "swatches".into(), open: true, height: None }, + ], + }), + floating: Vec::new(), + }); + assert!(valid(app.ui.docking.as_ref().unwrap())); + assert!(app.run("window.panel.collapse", json!({"panel":"layers"})).is_err()); + assert!(app.ui.docking_collapsed.is_empty()); +} + +/// Escape on a collapsed group's drag puts it back collapsed. +#[test] +fn escape_on_a_collapsed_groups_drag_leaves_it_collapsed() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"top"})).unwrap(); + app.run("window.panel.collapse", json!({"panel":"layers"})).unwrap(); + let mut h = harness(app, vec2(1280.0, 800.0)); + // An icon panel's flyout is open too. + h.state_mut().ui.open_panel = Some("color".into()); + h.run_steps(2); + let groups = root_groups(&h); + let lower = groups.iter().find(|g| g.panels.contains(&"layers")).unwrap().strip; + // The grip right of the lower group's tabs, dragged out of the dock, then Escape. + let from = lower.right_center() - vec2(50.0, 0.0); + h.event(Event::PointerMoved(from)); + h.run_steps(1); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for step in 1..=6 { + h.event(Event::PointerMoved(from - vec2(80.0 * step as f32, 0.0))); + h.run_steps(1); + } + assert!(h.state().ui.docking.as_ref().unwrap().floating.iter().any(|g| g.panels.contains(&"layers".into())), "the group floats mid-drag"); + h.event(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + assert!(h.state().ui.docking.as_ref().unwrap().floating.is_empty(), "back in the dock"); + assert!(h.state().ui.docking_collapsed.contains("layers"), "and still collapsed"); + assert_eq!(h.state().ui.open_panel.as_deref(), Some("color"), "the flyout is back"); +} + +/// A group beside another (a left/right split) is refused: it can't be shorter than its neighbour. +#[test] +fn a_group_beside_another_does_not_collapse() { + let mut app = app(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"left"})).unwrap(); + let before = app.ui.docking.clone(); + assert!(app.run("window.panel.collapse", json!({"panel":"swatches"})).is_err()); + assert!(app.run("window.panel.collapse", json!({"panel":"properties"})).is_err()); + assert_eq!(app.ui.docking, before, "refused without a change"); + assert!(app.ui.docking_collapsed.is_empty()); + // One left collapsed by an earlier layout (a hand-edited file) is expanded. + app.ui.docking_collapsed.insert("swatches".into()); + crate::panel_docking::prune_collapsed(&mut app.ui); + assert!(app.ui.docking_collapsed.is_empty()); +} + +/// A double-click on the strip of a group beside another does nothing, and leaves no error in the +/// status bar. +#[test] +fn a_double_click_on_the_strip_of_a_group_beside_another_does_nothing() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("window.panel.move", json!({"panel":"swatches", "anchor":"layers", "zone":"left"})).unwrap(); + let saved = app.ui.docking.clone(); + let mut h = harness(app, vec2(1280.0, 800.0)); + let status = h.state().ui.status.clone(); + let strip = root_groups(&h).into_iter().find(|g| g.shown == "swatches").unwrap().strip; + let tab = h.ctx.read_response(egui::Id::new("vectorcraft-panel-docking").with(("tab", &"swatches".to_string()))).unwrap().rect; + let gap = pos2(tab.right() + 4.0, strip.center().y); + assert!(gap.x < strip.right() - 24.0, "{tab:?} {strip:?}"); + double_click(&mut h, gap); + assert_eq!(h.state().ui.status, status, "no error"); + assert!(h.state().ui.docking_collapsed.is_empty()); + assert_eq!(h.state().ui.docking, saved); +} diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 5eb641217..105d18f44 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -55,6 +55,15 @@ pub fn play(app: &mut VectorcraftApp, set: usize, idx: usize) -> Result = pstate(ui.ctx(), "actions-selected"); let has = selected.and_then(|(s, a)| app.ui.action_sets.get(s).and_then(|set| set.actions.get(a))).is_some(); + if widgets::menu_item(ui, tl!("New Set…"), true, false) { + crate::dialogs::action_set_options::open_new(app); + } + let current = app.ui.action_set.filter(|&s| s < app.ui.action_sets.len()); + if widgets::menu_item(ui, tl!("Set Options…"), current.is_some(), false) + && let Some(s) = current + { + crate::dialogs::action_set_options::open(app, s); + } if widgets::menu_item(ui, tl!("Batch…"), has, false) && let Some((s, a)) = selected && let Err(e) = crate::dialogs::action_batch::open(app, s, a) @@ -75,10 +84,26 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { // value per type, so reading the bare pair never found it and no action stayed selected (#1629). const SELECTED: &str = "actions-selected"; let mut selected: Option<(usize, usize)> = pstate(ui.ctx(), SELECTED); + // A set made with New Set, or whose name was clicked, is current with none of its actions + // selected. + if selected.is_some_and(|(s, _)| app.ui.action_set != Some(s)) { + selected = None; + } + let mut current = app.ui.action_set; let mut play_req = None; egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for (si, set) in app.ui.action_sets.iter().enumerate() { - ui.label(egui::RichText::new(format!("▾ {}", set.name)).color(t.text)); + let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click()); + if current == Some(si) && selected.is_none() { + ui.painter().rect_filled(r, 0.0, t.row_selected); + } else if resp.hovered() { + ui.painter().rect_filled(r, 0.0, t.hover); + } + ui.painter().text(r.left_center(), egui::Align2::LEFT_CENTER, format!("▾ {}", set.name), egui::FontId::proportional(12.5), t.text); + if resp.clicked() { + current = Some(si); + selected = None; + } for (ai, a) in set.actions.iter().enumerate() { let sel = selected == Some((si, ai)); let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 22.0), egui::Sense::click()); @@ -103,6 +128,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { ); if resp.clicked() { selected = Some((si, ai)); + current = Some(si); } if resp.double_clicked() { play_req = Some((si, ai)); @@ -111,13 +137,16 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } }); set_pstate(ui.ctx(), SELECTED, selected); + app.ui.action_set = current; widgets::divider(ui); - // Bottom bar: stop, record, play, new, delete. + // Bottom bar: stop, record, play, new set, delete. ui.horizontal(|ui| { let recording = app.ui.recording.is_some(); if widgets::icon_button(ui, "square", tl!("Stop Playing/Recording"), false, 24.0).clicked() && let Some((set, name, start)) = app.ui.recording.take() { + // A canceled undo group numbers the steps down when it drops entries made before them. + let start = app.session.journal.held().unwrap_or(start); let steps = app.session.journal_for_action(start); app.session.journal.hold(None); if let Some(s) = app.ui.action_sets.get_mut(set) { @@ -127,11 +156,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let (r, resp) = ui.allocate_exact_size(egui::vec2(24.0, 24.0), egui::Sense::click()); ui.painter().circle_filled(r.center(), 6.0, if recording { egui::Color32::from_rgb(0xe0, 0x30, 0x30) } else { t.icon }); if resp.on_hover_text(tl!("Begin Recording")).clicked() && !recording { - let n = app.ui.action_sets.first().map(|s| s.actions.len()).unwrap_or(0) + 1; if app.ui.action_sets.is_empty() { app.ui.action_sets.push(ActionSet { name: "Set 1".into(), actions: vec![] }); } - let set = selected.map(|s| s.0).unwrap_or(0); + let set = current.filter(|&s| s < app.ui.action_sets.len()).unwrap_or(0); + let n = app.ui.action_sets.get(set).map_or(0, |s| s.actions.len()) + 1; let start = app.session.journal.len(); // Keep every step from here on until recording stops, however long it runs. app.session.journal.hold(Some(start)); @@ -142,6 +171,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { { play_req = Some(s); } + if widgets::icon_button(ui, "dc-folder", tl!("Create New Set"), false, 24.0).clicked() { + crate::dialogs::action_set_options::open_new(app); + } if widgets::icon_button(ui, "trash-2", tl!("Delete Selection"), false, 24.0).clicked() && let Some((si, ai)) = selected && let Some(s) = app.ui.action_sets.get_mut(si) @@ -195,10 +227,10 @@ mod tests { click(&ctx, &mut app, text_rect(&texts, "Reflect Horizontal").center(), show); frame_events(&ctx, &mut app, vec![], show); assert_eq!(pstate::>(&ctx, "actions-selected"), Some((0, 5)), "selected on the frames after the click"); - // The bottom bar's 24-point buttons, left to right: stop, record, play, delete. + // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, delete. let mut bar = crate::toolbar::tests::widget_rects(&ctx, egui::vec2(24.0, 24.0)); bar.sort_by(|a, b| a.left().total_cmp(&b.left())); - let [_, _, play, delete] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + let [_, _, play, _, delete] = bar[..] else { panic!("the bottom bar: {bar:?}") }; click(&ctx, &mut app, text_rect(&texts, "Duplicate and Offset 10 pt").center(), show); click(&ctx, &mut app, play.center(), show); assert_eq!(app.session.active().unwrap().selection.len(), 1); @@ -211,6 +243,103 @@ mod tests { assert_eq!(names(&app), before[1..], "nothing selected after a delete: nothing more goes"); } + /// #3712: Create New Set asks for a name (Set 1 when free), the new set is the current one, so a + /// recording goes into it, and a click on another set's name makes that one current. The sets + /// are saved with the UI preferences. + #[test] + fn create_new_set_names_a_set_that_recordings_go_into() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + let ctx = egui::Context::default(); + // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, delete. A new + // set or action moves the bar down. + let bar = |ctx: &egui::Context, app: &mut VectorcraftApp| { + frame_events(ctx, app, vec![], show); + let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0)); + bar.sort_by(|a, b| a.left().total_cmp(&b.left())); + let [stop, record, _, new_set, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + (stop, record, new_set) + }; + let (_, _, new_set) = bar(&ctx, &mut app); + click(&ctx, &mut app, new_set.center(), show); + let d = app.ui.dialog.as_ref().expect("New Set asks for a name"); + assert_eq!((d.kind.as_str(), d.str("name")), ("actionSetOptions", "Set 1".to_string())); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Print")); + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let sets = |app: &VectorcraftApp| app.ui.action_sets.iter().map(|s| (s.name.clone(), s.actions.len())).collect::>(); + assert_eq!(sets(&app), [("Default Actions".to_string(), 6), ("Print".into(), 0)]); + let rect = json!({"x": 10, "y": 10, "width": 20, "height": 20}); + let (stop, record, _) = bar(&ctx, &mut app); + click(&ctx, &mut app, record.center(), show); + app.run("shape.rectangle", rect.clone()).unwrap(); + click(&ctx, &mut app, stop.center(), show); + assert_eq!(sets(&app), [("Default Actions".to_string(), 6), ("Print".into(), 1)], "recorded into the new set"); + assert_eq!(app.ui.action_sets[1].actions[0].name, "Action 1", "numbered within its set"); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "▾ Default Actions").center(), show); + let (stop, record, _) = bar(&ctx, &mut app); + click(&ctx, &mut app, record.center(), show); + app.run("shape.rectangle", rect).unwrap(); + click(&ctx, &mut app, stop.center(), show); + assert_eq!(sets(&app), [("Default Actions".to_string(), 7), ("Print".into(), 1)], "recorded into the set clicked"); + let saved = serde_json::to_value(&app.ui).unwrap(); + let restored = serde_json::from_value::(saved).unwrap(); + assert_eq!(restored.action_sets.iter().map(|s| s.name.as_str()).collect::>(), ["Default Actions", "Print"]); + } + + /// #3712: the panel menu's Set Options… renames the current set and keeps its actions. + #[test] + fn set_options_renames_the_current_set() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.ui.action_sets.push(ActionSet { name: "Mine".into(), actions: vec![] }); + let ctx = egui::Context::default(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "▾ Mine").center(), show); + let texts = frame_events(&ctx, &mut app, vec![], menu); + let item = texts.iter().find(|(s, _)| s.trim() == "Set Options…").map(|(_, r)| *r).expect("Set Options… in the menu"); + click(&ctx, &mut app, item.center(), menu); + let d = app.ui.dialog.as_ref().expect("Set Options opens"); + assert_eq!((d.kind.as_str(), d.str("name")), ("actionSetOptions", "Mine".to_string())); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Web")); + crate::dialogs::confirm(&mut app).unwrap(); + let names = app.ui.action_sets.iter().map(|s| (s.name.as_str(), s.actions.len())).collect::>(); + assert_eq!(names, [("Default Actions", 6), ("Web", 0)], "the clicked set is renamed, the other kept"); + } + + /// A recording keeps its steps when a canceled undo group drops journal entries made before the + /// recording began. + #[test] + fn a_recording_keeps_its_steps_when_a_canceled_undo_group_drops_earlier_entries() { + use super::super::tests_appearance::{click, frame_events}; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let rect = |x: i32| json!({"x": x, "y": 10, "width": 20, "height": 20}); + for (id, p) in [ + ("file.new", json!({"width": 200, "height": 100})), + ("file.new", json!({"width": 200, "height": 100})), + ("document.activate", json!({"index": 0})), + ("edit.undoGroup.begin", json!({})), + ("shape.rectangle", rect(10)), + ] { + app.run(id, p).unwrap(); + } + let ctx = egui::Context::default(); + frame_events(&ctx, &mut app, vec![], show); + // The bottom bar's 24-point buttons, left to right: stop, record, play, new set, delete. + let mut bar = crate::toolbar::tests::widget_rects(&ctx, egui::vec2(24.0, 24.0)); + bar.sort_by(|a, b| a.left().total_cmp(&b.left())); + let [stop, record, _, _, _] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + click(&ctx, &mut app, record.center(), show); + for (id, p) in [("document.activate", json!({"index": 1})), ("shape.rectangle", rect(50)), ("edit.undoGroup.end", json!({"cancel": true}))] { + app.run(id, p).unwrap(); + } + click(&ctx, &mut app, stop.center(), show); + let steps = app.ui.action_sets[0].actions.last().map(|a| a.steps.iter().map(|(c, _)| c.clone()).collect::>()); + assert_eq!(steps, Some(vec!["document.activate".to_string(), "shape.rectangle".into(), "edit.undoGroup.end".into()])); + } + /// Actions recorded with Align to Artboard and Paste in Place, and the default Center on /// Artboard, play on the artboard active when they play, not on the one active when they were /// recorded. diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 942171193..1f79030d2 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -1,5 +1,6 @@ //! Align panel: Align Objects, Distribute Objects and Distribute Spacing (both with the spacing -//! value when aligning to a key object) and Align To (selection / key object / artboard). +//! value when aligning to a key object or a key guide) and Align To (selection / key object / +//! artboard). use egui::Ui; use serde::Serialize; @@ -51,9 +52,9 @@ pub fn align_params(base: Value, to: AlignTo, n_selected: usize) -> Value { } /// `object.align` params `base` with the `to` that the align buttons of the Align panel, the Control -/// bar and the Properties panel send: the artboard for a single selected object, else the key object -/// while there is one, else the Align To choice. Object → Align sends it too while every selected -/// object is selected whole ([`crate::menus::invoke`]). +/// bar and the Properties panel send: the key while there is one (a key object, or a key guide: +/// #2143), else the artboard for a single selected object, else the Align To choice. Object → Align +/// sends it too while every selected object is selected whole ([`crate::menus::invoke`]). pub fn with_align_to(app: &VectorcraftApp, base: Value) -> Value { align_params(base, align_to(app), objects_aligned(app)) } @@ -88,6 +89,85 @@ pub const DISTRIBUTE: [(&str, &str, &str, &str); 6] = [ ("dc-dist-right", "Horizontal Distribute Right", "horizontal", "right"), ]; +/// What a button of the Align panel's Align Objects, Distribute Objects and Distribute Spacing +/// rows runs ([`run_button`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Button { + /// An Align Objects button: `object.align {axis: value}`. + Align(&'static str, &'static str), + /// Horizontal & Vertical Align Center: `object.align {horizontal: "center", vertical: "center"}`. + AlignCenters, + /// A Distribute Objects button: `object.distribute {axis: value}`. + Distribute(&'static str, &'static str), + /// A Distribute Spacing button: `object.distributeSpacing {axis}`. + Spacing(&'static str), +} + +impl Button { + /// The engine command the button runs. + pub(crate) fn command(self) -> &'static str { + match self { + Button::Align(..) | Button::AlignCenters => "object.align", + Button::Distribute(..) => "object.distribute", + Button::Spacing(_) => "object.distributeSpacing", + } + } +} + +/// The Align panel's buttons as UI commands ([`crate::menus::UI_COMMANDS`]), in the panel's order: +/// (command id, button). Edit › Keyboard Shortcuts lists each under Align with its button's label, +/// with no default shortcut, and a shortcut runs what a click on the button runs. +pub const COMMANDS: [(&str, Button); 15] = [ + ("align.horizontalLeft", Button::Align("horizontal", "left")), + ("align.horizontalCenter", Button::Align("horizontal", "center")), + ("align.horizontalRight", Button::Align("horizontal", "right")), + ("align.horizontalVerticalCenter", Button::AlignCenters), + ("align.verticalTop", Button::Align("vertical", "top")), + ("align.verticalCenter", Button::Align("vertical", "center")), + ("align.verticalBottom", Button::Align("vertical", "bottom")), + ("align.distributeVerticalTop", Button::Distribute("vertical", "top")), + ("align.distributeVerticalCenter", Button::Distribute("vertical", "center")), + ("align.distributeVerticalBottom", Button::Distribute("vertical", "bottom")), + ("align.distributeHorizontalLeft", Button::Distribute("horizontal", "left")), + ("align.distributeHorizontalCenter", Button::Distribute("horizontal", "center")), + ("align.distributeHorizontalRight", Button::Distribute("horizontal", "right")), + ("align.distributeVerticalSpace", Button::Spacing("vertical")), + ("align.distributeHorizontalSpace", Button::Spacing("horizontal")), +]; + +/// The button of Align panel command `id` ([`COMMANDS`]), which [`run_button`] runs and which is +/// enabled while the engine command it runs is. `None`: `id` is not one of them. +pub(crate) fn button(id: &str) -> Option