Merge main (433ddb59) into feat/native-save-precision

This commit is contained in:
dotCooCoo
2026-10-31 17:22:00 -07:00
676 changed files with 74763 additions and 5037 deletions
+2 -2
View File
@@ -27,8 +27,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# 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/windows-arm64.yml@main
with:
app-name: vectorcraft
app-dir: VectorCraft
+10 -8
View File
@@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
@@ -46,7 +46,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
@@ -59,7 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap and progress docs
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
The progress docs:
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
@@ -72,17 +72,19 @@ When a task lands, update in the same PR: the area row in `docs/target-app-parit
## Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See `docs/contributors.md`.
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+4 -1
View File
@@ -6,7 +6,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo
Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`).
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts) (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). The small Latin UI/document fonts below predate that rule and stay.
**Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay.
**Optional build input: craft-fonts.** Builds made with `CRAFT_FONTS_DIR=<craft-fonts checkout>` (all official releases) embed the Japanese fonts listed in its [`ATTRIBUTION.md`](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md): BIZ UDPGothic Regular and Bold, Shippori Mincho Regular and BIZ UDMincho Regular (web builds: BIZ UDPGothic Regular only), all under the SIL Open Font License 1.1. They are not files in this repository; release packages carry each one's `OFL-<family>.txt`. Builds without `CRAFT_FONTS_DIR` embed none of them.
@@ -345,6 +345,9 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `examples/ribbons.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/feature-sheet.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `examples/perspective-city.vectorcraft` | VectorCraft contributors | Original artwork built through the VectorCraft command API | MIT OR Apache-2.0 | |
| `crates/engine/tests/fixtures/jpeg2000/checker.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `3de9c2bb5b8aaec241d939b795002a68eb3d50e41d51c43368000804ad3de057` |
| `crates/engine/tests/fixtures/jpeg2000/checker-raw.j2k` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `76fa51ab8ccbc2534098b26b8e7fe16dd886e2b6298ba52d06a6b1bde9b61d9e` |
| `crates/engine/tests/fixtures/jpeg2000/checker-300ppi.jp2` | VectorCraft contributors | Original test image authored for VectorCraft's tests | MIT OR Apache-2.0 | a 2 × 2 checker encoded with FFmpeg's JPEG 2000 encoder; SHA-256 `1b1aeed8975d3cdc832da6ecf5855852d07d0c490105121e61a8aefb31af009b` |
## Assets embedded by the PDF backend
+8 -3
View File
@@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Creative Commons with redistribution allowed.
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
@@ -47,7 +47,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
@@ -60,7 +60,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
## Roadmap and progress docs
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
The progress docs:
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
@@ -69,3 +69,8 @@ The progress docs follow craftrules' [`standards/progress-docs.md`](https://gith
When a task lands, update in the same PR: the area row in `docs/target-app-parity.md` (score, missing items, hours) and its evidence list, the gap entry in `docs/gaps.md` if it closed or shrank, the checklist it belongs to, the milestone row in `docs/roadmap.md`, and the ROADMAP's tables and progress log when a headline number moves. Update each touched doc's status line and revision history.
- Grade by behaviour against the public documentation (`plan/illustrator/` notes), not by whether a menu item exists. Scores are self-assessed, so err low; mark each number measured or estimated.
- Keep the README's Status section in step with the ROADMAP headline.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+106 -72
View File
@@ -303,7 +303,7 @@ dependencies = [
"futures-lite",
"parking",
"polling",
"rustix 1.1.5",
"rustix 1.1.6",
"slab",
"windows-sys 0.61.2",
]
@@ -334,7 +334,7 @@ dependencies = [
"cfg-if",
"event-listener",
"futures-lite",
"rustix 1.1.5",
"rustix 1.1.6",
]
[[package]]
@@ -360,7 +360,7 @@ dependencies = [
"cfg-if",
"futures-core",
"futures-io",
"rustix 1.1.5",
"rustix 1.1.6",
"signal-hook-registry",
"slab",
"windows-sys 0.61.2",
@@ -665,7 +665,7 @@ checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"rustix 1.1.6",
"tracing",
]
@@ -688,7 +688,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-backend",
"wayland-client",
]
@@ -763,9 +763,9 @@ dependencies = [
[[package]]
name = "clipboard-win"
version = "5.4.1"
version = "5.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a"
dependencies = [
"error-code",
]
@@ -1017,7 +1017,7 @@ source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13af
dependencies = [
"craft-winprint-gdi",
"miniz_oxide 0.9.1",
"roxmltree 0.21.1",
"roxmltree 0.21.2",
"tempfile",
"winprint",
"winsafe",
@@ -1047,13 +1047,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
@@ -1887,7 +1888,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.5",
"rustix 1.1.6",
"windows-link",
]
@@ -1939,6 +1940,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl 0.1.12",
]
[[package]]
name = "gif"
version = "0.14.2"
@@ -2232,7 +2243,7 @@ dependencies = [
"rustc-hash 2.1.3",
"smallvec",
"stringprep",
"zune-jpeg",
"zune-jpeg 0.5.15",
]
[[package]]
@@ -2608,14 +2619,14 @@ dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"gif 0.14.2",
"image-webp",
"moxcms",
"num-traits",
"png 0.18.1",
"tiff",
"zune-core",
"zune-jpeg",
"zune-core 0.5.3",
"zune-jpeg 0.5.15",
]
[[package]]
@@ -2836,7 +2847,7 @@ dependencies = [
"bumpalo",
"flate2",
"float-cmp",
"gif",
"gif 0.14.2",
"image-webp",
"imagesize 0.14.0",
"indexmap",
@@ -2851,7 +2862,7 @@ dependencies = [
"tiny-skia-path 0.12.0",
"xmp-writer",
"yoke",
"zune-jpeg",
"zune-jpeg 0.5.15",
]
[[package]]
@@ -3899,7 +3910,7 @@ dependencies = [
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.5",
"rustix 1.1.6",
"windows-sys 0.61.2",
]
@@ -4379,12 +4390,15 @@ version = "0.45.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
dependencies = [
"gif 0.13.3",
"image-webp",
"log",
"pico-args",
"rgb",
"svgtypes 0.15.3",
"tiny-skia",
"usvg 0.45.1",
"zune-jpeg 0.4.21",
]
[[package]]
@@ -4445,9 +4459,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "roxmltree"
version = "0.21.1"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
dependencies = [
"memchr",
]
@@ -4488,9 +4502,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.5"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.2",
"errno",
@@ -5096,9 +5110,9 @@ dependencies = [
[[package]]
name = "smallvec"
version = "1.16.3"
version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
dependencies = [
"serde",
]
@@ -5141,7 +5155,7 @@ dependencies = [
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"rustix 1.1.6",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
@@ -5416,7 +5430,7 @@ dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"rustix 1.1.6",
"windows-sys 0.61.2",
]
@@ -5489,7 +5503,7 @@ dependencies = [
"half",
"quick-error 2.0.1",
"weezl 0.1.12",
"zune-jpeg",
"zune-jpeg 0.5.15",
]
[[package]]
@@ -5992,7 +6006,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"pico-args",
"roxmltree 0.21.1",
"roxmltree 0.21.2",
"simplecss",
"siphasher",
"skrifa 0.44.0",
@@ -6036,7 +6050,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -6061,8 +6075,10 @@ dependencies = [
"raw-window-handle",
"rfd",
"serde_json",
"smithay-clipboard",
"socket2",
"vectorcraft-engine",
"vectorcraft-render",
"vectorcraft-text",
"vectorcraft-ui-egui",
"wayland-backend",
@@ -6079,7 +6095,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"crc32fast",
"flate2",
@@ -6090,7 +6106,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -6102,7 +6118,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6118,7 +6134,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -6140,7 +6156,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -6152,7 +6168,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"chrono",
"hayro-ccitt",
@@ -6166,12 +6182,12 @@ dependencies = [
"tiff",
"vectorcraft-color",
"vectorcraft-geom",
"zune-jpeg",
"zune-jpeg 0.5.15",
]
[[package]]
name = "vectorcraft-effects"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6187,17 +6203,18 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"craft-docpath",
"crc32fast",
"flate2",
"hayro-jpeg2000",
"image",
"kurbo 0.13.1",
"log",
"proptest",
"roxmltree 0.21.1",
"rustix 1.1.5",
"roxmltree 0.21.2",
"rustix 1.1.6",
"ruzstd",
"serde",
"serde_json",
@@ -6226,7 +6243,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"fax",
"flate2",
@@ -6250,11 +6267,11 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"flate2",
"proptest",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -6265,7 +6282,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -6277,7 +6294,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"image",
"log",
@@ -6296,7 +6313,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6310,7 +6327,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -6322,7 +6339,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"aes",
"chrono",
@@ -6335,7 +6352,7 @@ dependencies = [
"kurbo 0.13.1",
"log",
"md-5",
"roxmltree 0.21.1",
"roxmltree 0.21.2",
"serde",
"serde_json",
"sha2",
@@ -6354,7 +6371,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"serde",
"serde_json",
@@ -6368,10 +6385,10 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"flate2",
"gif",
"gif 0.14.2",
"image",
"jpeg-encoder",
"log",
@@ -6380,6 +6397,7 @@ dependencies = [
"psd",
"serde_json",
"tiff",
"unicode-general-category",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
@@ -6397,14 +6415,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"craft-docpath",
"flate2",
@@ -6429,7 +6447,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"fax",
"image",
@@ -6447,7 +6465,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -6468,7 +6486,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -6476,12 +6494,13 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
"serde",
"serde_json",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
@@ -6492,7 +6511,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6504,7 +6523,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"bytemuck",
"craft-keymap",
@@ -6515,7 +6534,7 @@ dependencies = [
"egui_kittest",
"image",
"log",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_json",
"toml",
@@ -6542,7 +6561,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"eframe",
"egui",
@@ -6808,7 +6827,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix 1.1.5",
"rustix 1.1.6",
"scoped-tls",
"smallvec",
"wayland-sys",
@@ -6821,7 +6840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
dependencies = [
"bitflags 2.13.2",
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-backend",
"wayland-scanner",
]
@@ -6843,7 +6862,7 @@ version = "0.31.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
dependencies = [
"rustix 1.1.5",
"rustix 1.1.6",
"wayland-client",
"xcursor",
]
@@ -7518,7 +7537,7 @@ dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"rustix 1.1.6",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
@@ -7577,7 +7596,7 @@ dependencies = [
"libc",
"libloading",
"once_cell",
"rustix 1.1.5",
"rustix 1.1.6",
"x11rb-protocol",
]
@@ -7619,7 +7638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.5",
"rustix 1.1.6",
]
[[package]]
@@ -7673,7 +7692,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.18.0"
version = "0.19.0"
dependencies = [
"serde_json",
]
@@ -7723,7 +7742,7 @@ dependencies = [
"hex",
"libc",
"ordered-stream",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_repr",
"tracing",
@@ -7907,9 +7926,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.0"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
@@ -7946,19 +7965,34 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.4.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [
"zune-core 0.4.12",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
"zune-core 0.5.3",
]
[[package]]
+3 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.18.0"
version = "0.19.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -19,7 +19,8 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
# 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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
+4 -4
View File
@@ -205,10 +205,10 @@ drag-and-drop, set **Preferences › Performance › Linux Display Server** to *
the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayland, where drops work
(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 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
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft can't get a drawing tablet's pen
input on native Wayland yet (#491, #2028). When a pen is attached as VectorCraft starts, the window opens through
XWayland, where the pen works as a mouse and the app reads its pressure (#4967; `VECTORCRAFT_NATIVE_WAYLAND=1` keeps
Wayland). For a pen attached later, set the same preference to **X11 (XWayland)** 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
+22 -4
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -34,8 +34,9 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel",
# DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs,
# shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR
# Apache-2.0.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] }
# Apache-2.0. Win32_System_Threading has `GetProcessAffinityMask`, which the desktop app's
# src/windows_cores.rs, shared, uses to count the cores the process may run on.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading"] }
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT.
+7
View File
@@ -49,6 +49,10 @@ mod prefs_dir;
#[cfg(windows)]
#[path = "../../vectorcraft/src/system_fonts.rs"]
mod system_fonts;
/// The desktop app's affinity-mask reader, shared: renders start workers only for usable cores.
#[cfg(windows)]
#[path = "../../vectorcraft/src/windows_cores.rs"]
mod windows_cores;
use serde_json::{Value, json};
use vectorcraft_engine::file_access::{self, AutomationRoots};
@@ -161,6 +165,9 @@ fn help() -> Result<(), String> {
}
fn main() -> ExitCode {
// Before the first render: the cores the process may run on.
#[cfg(windows)]
windows_cores::install();
// Before any font lookup: the fonts font services load (#579).
#[cfg(windows)]
system_fonts::install();
+2 -2
View File
@@ -116,7 +116,7 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut drag = |down: Point| -> Result<f64, String> {
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), pressure: 1.0 };
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0 };
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;
s.select_tool("directSelection", view).map_err(err)?;
let undo = s.doc().map_err(err)?.history.undo.len();
@@ -215,7 +215,7 @@ fn deep(n: usize) -> Result<(), String> {
let first = s.doc().map_err(err)?.selection.objects.first().copied();
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
if let Some(down) = at {
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), pressure: 1.0 };
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), backquote: false, pressure: 1.0 };
s.select_tool("selection", view).map_err(err)?;
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
let mut dx = 0.0;
+12 -3
View File
@@ -54,7 +54,12 @@ pollster = "1"
# Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to
# read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has
# `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the
# cores the process may run on. Win32_UI_Input_KeyboardAndMouse has `GetKeyState`, which
# src/caps_lock.rs uses to read whether Caps Lock is on.
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
# The renderer's worker count follows those cores (src/windows_cores.rs).
vectorcraft-render = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
@@ -86,12 +91,16 @@ pollster = "1"
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"] }
# through winit with the `xinput` feature. XKB reads Caps Lock's locked modifier state (src/caps_lock.rs).
x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] }
# 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.
wayland-client = "0.31"
# Copy and Paste of text through the compositor's core data-device protocol on the window's
# connection (src/clipboard.rs), where arboard has neither data-control (GNOME) nor X11 (a Flatpak
# on a Wayland session). MIT, already in the build through egui-winit.
smithay-clipboard = "0.7"
wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
+18 -8
View File
@@ -1,6 +1,8 @@
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
use super::gpu;
use super::prefs::save_prefs;
use super::prefs::{save_prefs, save_prefs_soon};
#[cfg(not(target_os = "macos"))]
use super::single_instance;
#[cfg(all(windows, not(target_vendor = "win7")))]
use super::windows_stylus;
use super::{App, VectorcraftApp, file_access, window};
@@ -66,11 +68,17 @@ impl eframe::App for App {
// macOS converts the frame from sRGB to the display's profile (#2249).
#[cfg(all(target_os = "macos", feature = "wgpu"))]
super::mac_color_space::tag(ui);
if let Some(on) = self.host_caps_lock.read_transition() {
self.app.set_caps_lock(on);
}
file_access::unconfined(|| self.app.ui(ui));
// Preferences are written when confirmed, not only at exit: a session ended any other way
// (the system shutting down, the process ended or crashing) used to lose them (#1747).
if self.app.take_prefs_changed() {
file_access::unconfined(|| save_prefs(&self.app));
} else {
// The workspace, panels and other UI settings change without the dialog (#4625).
file_access::unconfined(|| save_prefs_soon(&self.app));
}
// Not while a file dialog's sheet has the keyboard: its name field's composition would go.
#[cfg(target_os = "macos")]
@@ -102,11 +110,13 @@ impl App {
vectorcraft_ui_egui::background::wait_all(&mut self.app);
return;
}
// 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.
#[cfg(target_os = "macos")]
// Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file
// dialog is open: what asked for it runs again on its document when it answers.
if !self.app.file_dialog_open() {
#[cfg(target_os = "macos")]
let files = open_documents::take();
#[cfg(not(target_os = "macos"))]
let files = single_instance::take();
if !files.is_empty() {
bring_window_forward(ctx);
}
@@ -144,15 +154,15 @@ 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))]
/// Show the window when files are opened from the Finder or the Dock, or handed over by a later
/// launch (#4892): a minimized window comes back and the app takes focus, as a document-based app
/// does (#3769).
pub(crate) 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
/// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that
/// can't be opened is reported to the user and on stderr; the others still open.
pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
for f in files {
+216
View File
@@ -0,0 +1,216 @@
//! Read the host's Caps Lock state, which gives the drawing tools the precise crosshair while it is on.
/// Only host transitions replace a value set by a script through `view.capsLock`.
#[derive(Default)]
pub struct HostCapsLock {
previous: Option<bool>,
#[cfg(target_os = "linux")]
x11: Option<X11CapsLock>,
}
#[cfg(target_os = "linux")]
struct X11CapsLock {
connection: x11rb::rust_connection::RustConnection,
locked: bool,
initial_sequence: u16,
}
impl HostCapsLock {
pub fn new(window: Option<&winit::window::Window>) -> Self {
#[cfg(target_os = "linux")]
let x11 = window.and_then(x11_connection);
#[cfg(not(target_os = "linux"))]
let _ = window;
Self {
previous: None,
#[cfg(target_os = "linux")]
x11,
}
}
pub fn read_transition(&mut self) -> Option<bool> {
#[cfg(target_os = "linux")]
let current = match self.x11.as_mut() {
Some(state) => match state.poll() {
Some(locked) => Some(locked),
None => {
self.x11 = None;
None
}
},
None => {
// Native Wayland does not expose the seat's XKB lock bit through winit or egui.
None
}
};
#[cfg(any(target_os = "macos", windows))]
let current = Some(read());
// This platform has no lock-state reader.
#[cfg(not(any(target_os = "macos", windows, target_os = "linux")))]
let current = None;
self.transition(current)
}
fn transition(&mut self, reading: Option<bool>) -> Option<bool> {
reading.and_then(|current| self.observe(current))
}
fn observe(&mut self, current: bool) -> Option<bool> {
if self.previous.replace(current) == Some(current) { None } else { Some(current) }
}
}
#[cfg(target_os = "linux")]
fn x11_connection(window: &winit::window::Window) -> Option<X11CapsLock> {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
use x11rb::protocol::xkb;
if !matches!(window.window_handle().ok()?.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)) {
return None;
}
let (connection, _) = x11rb::connect(None).ok()?;
let supported = x11rb::protocol::xkb::use_extension(&connection, 1, 0).ok()?.reply().ok()?.supported;
if !supported {
return None;
}
// Subscribe before the initial read so changes during setup remain queued.
xkb::select_events(
&connection,
xkb::ID::USE_CORE_KBD.into(),
xkb::EventType::default(),
xkb::EventType::default(),
xkb::MapPart::default(),
xkb::MapPart::default(),
&xkb::SelectEventsAux {
state_notify: Some(xkb::SelectEventsAuxStateNotify {
affect_state: xkb::StatePart::MODIFIER_LOCK,
state_details: xkb::StatePart::MODIFIER_LOCK,
}),
..Default::default()
},
)
.ok()?
.check()
.ok()?;
let (locked, initial_sequence) = read_x11(&connection)?;
Some(X11CapsLock { connection, locked, initial_sequence })
}
#[cfg(target_os = "linux")]
impl X11CapsLock {
fn poll(&mut self) -> Option<bool> {
use x11rb::connection::Connection as _;
use x11rb::protocol::Event;
while let Some(event) = self.connection.poll_for_event().ok()? {
if let Event::XkbStateNotify(state) = event
// Events queued before the initial GetState reply must not replace that newer snapshot.
&& state.sequence.wrapping_sub(self.initial_sequence) < 0x8000
{
self.locked = lock_modifier_is_set(state.locked_mods);
}
}
Some(self.locked)
}
}
#[cfg(target_os = "linux")]
fn read_x11(connection: &x11rb::rust_connection::RustConnection) -> Option<(bool, u16)> {
use x11rb::protocol::xkb;
let state = xkb::get_state(connection, xkb::ID::USE_CORE_KBD.into()).ok()?.reply().ok()?;
Some((lock_modifier_is_set(state.locked_mods), state.sequence))
}
#[cfg(target_os = "linux")]
fn lock_modifier_is_set(modifiers: x11rb::protocol::xproto::ModMask) -> bool {
use x11rb::protocol::xproto::ModMask;
u16::from(modifiers) & u16::from(ModMask::LOCK) != 0
}
#[cfg(target_os = "macos")]
const MAC_CAPS_LOCK: usize = 1 << 16;
#[cfg(target_os = "macos")]
fn mac_flags_have_caps_lock(flags: usize) -> bool {
flags & MAC_CAPS_LOCK != 0
}
#[cfg(target_os = "macos")]
pub fn read() -> bool {
mac_flags_have_caps_lock(objc2_app_kit::NSEvent::modifierFlags_class().bits())
}
#[cfg(windows)]
fn windows_state_has_caps_lock(state: i16) -> bool {
state & 1 != 0
}
#[cfg(windows)]
#[allow(unsafe_code)]
pub fn read() -> bool {
// GetKeyState reports the toggle bit for the calling UI thread's keyboard state.
use windows::Win32::UI::Input::KeyboardAndMouse::{GetKeyState, VK_CAPITAL};
// SAFETY: `GetKeyState` takes a virtual-key code by value and reads no caller memory.
windows_state_has_caps_lock(unsafe { GetKeyState(i32::from(VK_CAPITAL.0)) })
}
// Querying XWayland for a native Wayland window would read a different keyboard.
#[cfg(test)]
mod tests {
#[test]
fn no_host_reader_preserves_scripted_value_and_next_reading_transitions() {
let mut host = super::HostCapsLock::default();
let mut session = vectorcraft_engine::Session::new();
session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap();
if let Some(on) = host.transition(None) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
assert!(session.caps_lock);
assert_eq!(host.transition(Some(false)), Some(false));
}
#[cfg(target_os = "linux")]
#[test]
fn no_x11_reader_has_no_transition() {
assert_eq!(super::HostCapsLock::default().read_transition(), None);
}
#[test]
fn scripted_value_survives_unchanged_host_until_transition() {
let mut host = super::HostCapsLock::default();
let mut session = vectorcraft_engine::Session::new();
if let Some(on) = host.observe(false) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
session.execute("view.capsLock", &serde_json::json!({"on": true})).unwrap();
if let Some(on) = host.observe(false) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
assert!(session.caps_lock);
session.execute("view.capsLock", &serde_json::json!({"on": false})).unwrap();
if let Some(on) = host.observe(true) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
assert!(session.caps_lock);
if let Some(on) = host.observe(false) {
session.execute("view.capsLock", &serde_json::json!({"on": on})).unwrap();
}
assert!(!session.caps_lock);
}
#[cfg(target_os = "macos")]
#[test]
fn mac_modifier_flags_map_caps_lock() {
assert!(!super::mac_flags_have_caps_lock(0));
assert!(!super::mac_flags_have_caps_lock(1 << 17));
assert!(super::mac_flags_have_caps_lock(1 << 16));
assert!(super::mac_flags_have_caps_lock((1 << 16) | (1 << 17)));
}
#[cfg(windows)]
#[test]
fn windows_key_state_maps_toggle_bit() {
assert!(!super::windows_state_has_caps_lock(0));
assert!(!super::windows_state_has_caps_lock(i16::MIN));
assert!(super::windows_state_has_caps_lock(1));
}
}
+531 -21
View File
@@ -9,7 +9,10 @@
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol.
//! copied), falling back to X11 where the compositor lacks the protocol, and where there is no
//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection,
//! text only. A clipboard that can't be opened is tried again after a wait that grows to a
//! minute, not each time Paste asks.
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
@@ -19,12 +22,16 @@ use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
use vectorcraft_ui_egui::SystemClipboard;
/// This platform's system clipboard.
pub fn system_clipboard() -> Box<dyn SystemClipboard> {
/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where
/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
pub fn system_clipboard(window: Option<std::sync::Arc<winit::window::Window>>) -> Box<dyn SystemClipboard> {
#[cfg(windows)]
let cb = Box::<win::Clipboard>::default();
let cb = {
let _ = window;
Box::<win::Clipboard>::default()
};
#[cfg(not(windows))]
let cb = Box::<portable::Clipboard>::default();
let cb = Box::new(portable::Clipboard::new(window));
cb
}
@@ -306,10 +313,14 @@ mod win {
}
}
/// arboard: one format at a time. Built everywhere so it is checked everywhere.
/// arboard, or on a Wayland compositor without its data-control protocol the core protocol's text
/// clipboard: one format at a time. Built everywhere so it is checked everywhere.
#[cfg_attr(windows, allow(dead_code))]
mod portable {
use std::borrow::Cow;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
@@ -319,19 +330,216 @@ mod portable {
Image(usize, usize),
}
#[derive(Default)]
/// An open clipboard connection.
enum Backend {
Arboard(arboard::Clipboard),
/// The compositor's core data-device protocol on the window's connection (text only).
#[cfg(target_os = "linux")]
Wayland(super::wayland::TextClipboard),
}
/// What a write of a picture says where the clipboard carries text only.
#[cfg(target_os = "linux")]
const TEXT_ONLY: &str = "this clipboard carries text only";
impl Backend {
/// arboard (Wayland data-control, else X11), else the core protocol on `window`'s
/// Wayland connection. Err: why each failed.
fn open(window: Option<&Arc<winit::window::Window>>) -> Result<Self, String> {
first_open(
|| arboard::Clipboard::new().map(Self::Arboard).map_err(|e| e.to_string()),
|| {
#[cfg(target_os = "linux")]
return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window");
#[cfg(not(target_os = "linux"))]
{
let _ = window;
Err("not on Linux")
}
},
)
}
fn name(&self) -> &'static str {
match self {
Self::Arboard(_) => "arboard",
#[cfg(target_os = "linux")]
Self::Wayland(_) => "the Wayland core protocol (text only)",
}
}
fn get_text(&mut self) -> Result<String, String> {
match self {
Self::Arboard(cb) => cb.get_text().map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(cb) => cb.get_text(),
}
}
fn set_text(&mut self, text: &str) -> Result<(), String> {
match self {
Self::Arboard(cb) => cb.set_text(text).map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(cb) => {
cb.set_text(text);
Ok(())
}
}
}
fn get_image(&mut self) -> Result<arboard::ImageData<'static>, String> {
match self {
Self::Arboard(cb) => cb.get_image().map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(_) => Err(TEXT_ONLY.into()),
}
}
fn set_image(&mut self, img: arboard::ImageData<'_>) -> Result<(), String> {
match self {
Self::Arboard(cb) => cb.set_image(img).map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(_) => Err(TEXT_ONLY.into()),
}
}
fn clear(&mut self) -> Result<(), String> {
match self {
Self::Arboard(cb) => cb.clear().map_err(|e| e.to_string()),
#[cfg(target_os = "linux")]
Self::Wayland(cb) => {
cb.set_text("");
Ok(())
}
}
}
/// Files copied in a file manager (none where the backend can't read a file list).
fn file_list(&mut self) -> Vec<std::path::PathBuf> {
match self {
Self::Arboard(cb) => cb.get().file_list().unwrap_or_default(),
#[cfg(target_os = "linux")]
Self::Wayland(_) => vec![],
}
}
}
/// The first of `arboard` and `wayland` that opens, tried in that order. Err: why each failed.
fn first_open<B>(arboard: impl FnOnce() -> Result<B, String>, wayland: impl FnOnce() -> Result<B, &'static str>) -> Result<B, String> {
let arboard = match arboard() {
Ok(cb) => return Ok(cb),
Err(e) => e,
};
wayland().map_err(|wayland| format!("arboard: {arboard}; Wayland core protocol: {wayland}"))
}
/// Who asks for the clipboard: the background paste check waits out the whole wait after a
/// failure, a Copy or Paste of the user's [`USER_RETRY`] at most.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Asker {
Probe,
User,
}
/// The wait after the first failure to open the clipboard; it doubles with each failed retry.
const FIRST_WAIT: Duration = Duration::from_secs(1);
/// The longest wait between retries.
const MAX_WAIT: Duration = Duration::from_secs(60);
/// A Copy or Paste of the user's retries at most this often.
const USER_RETRY: Duration = Duration::from_secs(1);
/// Whether "unavailable" has been logged and not yet answered by "available again": the
/// clipboard and its background check hold a handle each, and their failures are one outage.
static UNAVAILABLE_LOGGED: AtomicBool = AtomicBool::new(false);
/// A failure to open, and when to try again.
struct Failed {
at: Instant,
wait: Duration,
}
/// A handle opened on first use and kept, or after a failure opened again only once its wait is
/// over: where neither backend opens, asking a few times a second must not reconnect each time.
pub(super) struct Reopen<B> {
handle: Option<B>,
failed: Option<Failed>,
/// The outage flag shared with the other handles ([`UNAVAILABLE_LOGGED`]).
logged: &'static AtomicBool,
}
impl<B> Reopen<B> {
pub(super) fn new(logged: &'static AtomicBool) -> Self {
Self { handle: None, failed: None, logged }
}
/// The open handle, opened with `open` when there is none and `asker` may try at `now`.
/// `name` names an opened handle in the log.
pub(super) fn get(
&mut self,
asker: Asker,
now: Instant,
open: impl FnOnce() -> Result<B, String>,
name: impl Fn(&B) -> &'static str,
) -> Option<&mut B> {
if self.handle.is_none() {
if let Some(f) = &self.failed {
let wait = match asker {
Asker::Probe => f.wait,
Asker::User => f.wait.min(USER_RETRY),
};
if now.saturating_duration_since(f.at) < wait {
return None;
}
}
match open() {
Ok(handle) => {
if self.logged.swap(false, Ordering::Relaxed) {
log::info!("the system clipboard is available again, through {}", name(&handle));
} else {
log::debug!("system clipboard opened through {}", name(&handle));
}
self.failed = None;
self.handle = Some(handle);
}
Err(why) => {
let wait = match &self.failed {
Some(f) => (f.wait * 2).min(MAX_WAIT),
None => FIRST_WAIT,
};
if self.logged.swap(true, Ordering::Relaxed) {
log::debug!("the system clipboard is still unavailable ({why}); next try in {wait:?}");
} else {
log::warn!("the system clipboard is unavailable ({why}): Copy and Paste stay inside VectorCraft");
}
self.failed = Some(Failed { at: now, wait });
}
}
}
self.handle.as_mut()
}
/// The handle if one is open, without trying to open one.
pub(super) fn opened(&mut self) -> Option<&mut B> {
self.handle.as_mut()
}
}
pub struct Clipboard {
/// Kept open: on Linux the copied data lives as long as the handle does.
cb: Option<arboard::Clipboard>,
cb: Reopen<Backend>,
/// The app window, for the Wayland core protocol (on its connection).
window: Option<Arc<winit::window::Window>>,
ours: Option<Ours>,
}
impl Clipboard {
fn cb(&mut self) -> Result<&mut arboard::Clipboard, String> {
if self.cb.is_none() {
self.cb = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
self.cb.as_mut().ok_or_else(|| "no system clipboard".to_string())
pub fn new(window: Option<Arc<winit::window::Window>>) -> Self {
Self { cb: Reopen::new(&UNAVAILABLE_LOGGED), window, ours: None }
}
fn cb(&mut self, asker: Asker) -> Result<&mut Backend, String> {
let window = self.window.as_ref();
self.cb.get(asker, Instant::now(), || Backend::open(window), Backend::name).ok_or_else(|| "no system clipboard".to_string())
}
}
@@ -342,19 +550,19 @@ mod portable {
Some(_) => None,
None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)),
};
let cb = self.cb()?;
let cb = self.cb(Asker::User)?;
let ours = match (text, image) {
(Some(t), _) => {
cb.set_text(t.as_str()).map_err(|e| e.to_string())?;
cb.set_text(t.as_str())?;
Some(Ours::Text(t))
}
(None, Some(img)) => {
let (width, height) = (img.width() as usize, img.height() as usize);
cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) }).map_err(|e| e.to_string())?;
cb.set_image(arboard::ImageData { width, height, bytes: Cow::Owned(img.into_raw()) })?;
Some(Ours::Image(width, height))
}
(None, None) => {
cb.clear().map_err(|e| e.to_string())?;
cb.clear()?;
None
}
};
@@ -363,7 +571,7 @@ mod portable {
}
fn holds_ours(&mut self) -> bool {
let Some(cb) = self.cb.as_mut() else { return false };
let Some(cb) = self.cb.opened() else { return false };
match &self.ours {
Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t),
Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)),
@@ -372,8 +580,8 @@ mod portable {
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
let cb = self.cb().ok()?;
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
let cb = self.cb(Asker::User).ok()?;
if let Some(f) = copied_file(cb.file_list(), mimes) {
return Some(f);
}
mimes.iter().find_map(|m| {
@@ -402,7 +610,309 @@ mod portable {
if super::mac::has(mimes) {
return true;
}
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
mimes.contains(&TEXT) && self.cb(Asker::Probe).is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
/// A clock that starts now and moves only when told.
struct Clock(Cell<Instant>);
impl Clock {
fn new() -> Self {
Self(Cell::new(Instant::now()))
}
fn now(&self) -> Instant {
self.0.get()
}
fn advance(&self, by: Duration) {
self.0.set(self.0.get() + by);
}
}
fn fail() -> Result<u8, String> {
Err("no compositor".into())
}
fn name(_: &u8) -> &'static str {
"fake"
}
#[test]
fn the_paste_check_retries_with_a_growing_wait() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
// Every 250 ms for a minute, as `clipboard_probe` asks.
for _ in 0..240 {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
assert!(cb.get(Asker::Probe, clock.now(), open, name).is_none());
clock.advance(Duration::from_millis(250));
}
// 0, 1, 3, 7, 15 and 31 s: not one connection a check.
assert_eq!(attempts.get(), 6);
assert!(LOGGED.load(Ordering::Relaxed), "the outage is logged");
}
#[test]
fn the_wait_stops_growing_at_a_minute() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
for _ in 0..(20 * 60) {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
let _ = cb.get(Asker::Probe, clock.now(), open, name);
clock.advance(Duration::from_secs(1));
}
// 0, 1, 3, 7, 15, 31, 63 s, then a minute apart until 20 minutes are up.
assert_eq!(attempts.get(), 7 + (20 * 60 - 63 - 1) / 60);
}
#[test]
fn a_copy_or_paste_retries_at_most_once_a_second() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
let mut ask = |asker| {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
let _ = cb.get(asker, clock.now(), open, name);
};
// Long after the paste check gave up for a minute, a held Ctrl+V for 3 s at 30 a second.
for _ in 0..8 {
ask(Asker::Probe);
clock.advance(Duration::from_secs(70));
}
let before = attempts.get();
for _ in 0..90 {
ask(Asker::User);
clock.advance(Duration::from_millis(33));
}
assert_eq!(attempts.get() - before, 3);
}
#[test]
fn a_success_is_kept_and_ends_the_outage() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let clock = Clock::new();
let mut cb = Reopen::new(&LOGGED);
assert!(cb.get(Asker::Probe, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
clock.advance(FIRST_WAIT);
assert_eq!(cb.get(Asker::Probe, clock.now(), || Ok(7), name).copied(), Some(7));
assert!(!LOGGED.load(Ordering::Relaxed), "the recovery is logged");
// Kept: not opened again, even where opening would now fail.
assert_eq!(cb.get(Asker::Probe, clock.now(), fail, name).copied(), Some(7));
assert_eq!(cb.opened().copied(), Some(7));
}
#[test]
fn arboard_is_tried_before_the_wayland_core_protocol() {
let wayland_tried = Cell::new(false);
let wayland = |ok| {
wayland_tried.set(true);
if ok { Ok("wayland") } else { Err("not a Wayland window") }
};
// Where arboard opens (data-control, X11) the core protocol isn't tried.
assert_eq!(first_open(|| Ok("arboard"), || wayland(true)), Ok("arboard"));
assert!(!wayland_tried.get());
// The Flatpak on GNOME: no data-control, no X11.
assert_eq!(first_open(|| Err("no X11".into()), || wayland(true)), Ok("wayland"));
assert!(wayland_tried.get());
// Neither: every reason is kept for the log.
assert_eq!(
first_open(|| Err("no X11".into()), || wayland(false)),
Err::<&str, _>("arboard: no X11; Wayland core protocol: not a Wayland window".into())
);
}
#[test]
fn one_outage_is_logged_once_across_handles() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let clock = Clock::new();
let (mut ui, mut probe) = (Reopen::new(&LOGGED), Reopen::new(&LOGGED));
assert!(probe.get(Asker::Probe, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
// The other handle's failure is the same outage: `swap` reports it already logged.
assert!(ui.get(Asker::User, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
assert!(probe.opened().is_none() && ui.opened().is_none());
}
}
}
/// The compositor's clipboard through the core data-device protocol (`wl_data_device`), on the
/// window's own Wayland connection: what Copy and Paste use where the compositor lacks the
/// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on
/// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the
/// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's
/// data with no limit, so reads run on a worker of their own.
#[cfg(target_os = "linux")]
mod wayland {
use std::sync::Arc;
use std::sync::mpsc;
use std::time::Duration;
/// The longest a read waits for the selection's owner to send its text.
const LOAD_WAIT: Duration = Duration::from_secs(2);
/// What a read says once its worker has stopped.
const GONE: &str = "the clipboard's reader stopped";
pub struct TextClipboard {
/// Writes: a store only hands the text to smithay-clipboard's thread.
cb: smithay_clipboard::Clipboard,
/// Reads, on a worker with a second clipboard on the same connection.
loads: Loads,
/// Held as long as the clipboards: their connection is the window's.
_window: Arc<winit::window::Window>,
}
/// The read worker's clipboard and the window whose connection it uses, dropped in that order.
struct Loader {
cb: smithay_clipboard::Clipboard,
_window: Arc<winit::window::Window>,
}
impl Loader {
fn load(&self) -> Result<String, String> {
self.cb.load().map_err(|e| e.to_string())
}
}
impl TextClipboard {
/// None when `window` isn't a Wayland window or the read worker can't start.
#[allow(unsafe_code)]
pub fn new(window: Arc<winit::window::Window>) -> Option<Self> {
use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle};
let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None };
let display = d.display.as_ptr();
// SAFETY: the display is winit's open `wl_display`, which outlives `window`; each
// clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard
// makes its own event queue on the display and doesn't close it.
let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) };
let loader = Loader { cb: reader, _window: window.clone() };
// `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names,
// and the worker must hold the window as long as its clipboard.
let loads = Loads::spawn(move || loader.load())?;
Some(Self { cb, loads, _window: window })
}
pub fn get_text(&mut self) -> Result<String, String> {
self.loads.get(LOAD_WAIT)
}
pub fn set_text(&self, text: &str) {
self.cb.store(text);
}
}
/// Reads made on a worker thread and waited for a bounded time: a selection owner that never
/// sends its data holds the worker, not the caller.
struct Loads {
requests: mpsc::Sender<()>,
answers: mpsc::Receiver<Result<String, String>>,
/// A read that timed out and hasn't answered yet: until it does, reads don't queue behind it.
pending: bool,
}
impl Loads {
fn spawn(mut load: impl FnMut() -> Result<String, String> + Send + 'static) -> Option<Self> {
let (requests, asked) = mpsc::channel::<()>();
let (answer, answers) = mpsc::channel();
let work = move || {
while asked.recv().is_ok() {
if answer.send(load()).is_err() {
break;
}
}
};
std::thread::Builder::new().name("wayland-clipboard-read".into()).spawn(work).ok()?;
Some(Self { requests, answers, pending: false })
}
/// The text, or why there is none, after `wait` at most.
fn get(&mut self, wait: Duration) -> Result<String, String> {
if self.pending {
match self.answers.try_recv() {
// The late answer of the read that timed out: stale by now.
Ok(_) => self.pending = false,
Err(mpsc::TryRecvError::Empty) => return Err("the clipboard's owner hasn't answered an earlier read".into()),
Err(mpsc::TryRecvError::Disconnected) => return Err(GONE.into()),
}
}
self.requests.send(()).map_err(|_| GONE.to_string())?;
match self.answers.recv_timeout(wait) {
Ok(text) => text,
Err(mpsc::RecvTimeoutError::Timeout) => {
self.pending = true;
Err(format!("the clipboard's owner didn't send its text within {wait:?}"))
}
Err(mpsc::RecvTimeoutError::Disconnected) => Err(GONE.into()),
}
}
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use super::*;
#[test]
fn a_read_that_answers_in_time_gives_the_text() {
let mut loads = Loads::spawn(|| Ok("<svg/>".into())).unwrap();
assert_eq!(loads.get(Duration::from_secs(5)), Ok("<svg/>".to_string()));
assert_eq!(loads.get(Duration::from_secs(5)), Ok("<svg/>".to_string()));
}
#[test]
fn an_owner_that_never_answers_holds_the_worker_not_the_caller() {
let (release, gate) = mpsc::channel::<()>();
let mut calls = 0;
let mut loads = Loads::spawn(move || {
calls += 1;
if calls == 1 {
// The first owner sends nothing until released.
let _ = gate.recv();
return Ok("stale".into());
}
Ok("fresh".into())
})
.unwrap();
let start = Instant::now();
assert!(loads.get(Duration::from_millis(50)).is_err(), "the first read times out");
// While it is unanswered, a read doesn't queue behind it or wait at all.
assert!(loads.get(Duration::from_secs(60)).is_err());
assert!(start.elapsed() < Duration::from_secs(30), "no read waited past its bound");
release.send(()).unwrap();
// Once the late answer is in, it is dropped and a new read is made.
let text = (0..200).find_map(|_| {
std::thread::sleep(Duration::from_millis(10));
loads.get(Duration::from_secs(5)).ok()
});
assert_eq!(text.as_deref(), Some("fresh"));
}
#[test]
fn a_read_after_the_worker_stops_says_so() {
let mut loads = Loads::spawn(|| Ok(String::new())).unwrap();
// The worker ends when its answers can't be sent: drop them by hand.
let (_, dead) = mpsc::channel();
drop(std::mem::replace(&mut loads.answers, dead));
assert_eq!(loads.get(Duration::from_millis(50)), Err(GONE.to_string()));
}
}
}
+8 -5
View File
@@ -182,11 +182,14 @@ fn any_hardware(kinds: &[DeviceType]) -> bool {
kinds.iter().any(|k| *k != DeviceType::Cpu)
}
/// The largest texture side to ask the device for, which bounds the window's surface: the
/// adapter's own limit when it is above eframe's `default` (8192 px), so a window larger than
/// that keeps its surface (#1459). Never below `default`, which is what was asked for before.
pub fn surface_limit(default: u32, adapter: u32) -> u32 {
default.max(adapter)
/// The limits to ask the device for: what eframe `asked` for, lowered to what the `adapter` offers
/// wherever it offers less, so a GPU short of one of them still gets a device (#2105). The texture
/// side, which bounds the window's surface, is the adapter's own, above eframe's 8192 px when the
/// adapter allows more, so a window larger than that keeps its surface (#1459).
pub fn device_limits(asked: wgpu::Limits, adapter: &wgpu::Limits) -> wgpu::Limits {
let mut limits = asked.or_worse_values_from(adapter);
limits.max_texture_dimension_2d = adapter.max_texture_dimension_2d;
limits
}
/// One adapter, as [`adapter_order`] sees it.
+26 -3
View File
@@ -5,11 +5,34 @@ fn gpu(name: &str, backend: Backend, device_type: DeviceType) -> Candidate {
}
/// #1459: a 9048 px wide window (two display scales' ratio past 8192) fits an adapter that
/// takes 16384 px textures; an adapter with less than eframe asks for changes nothing.
/// takes 16384 px textures.
#[test]
fn the_surface_may_be_as_large_as_the_adapter_allows() {
assert_eq!(surface_limit(8192, 16384), 16384);
assert_eq!(surface_limit(8192, 4096), 8192);
let adapter = wgpu::Limits { max_texture_dimension_2d: 16384, ..wgpu::Limits::default() };
let asked = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() };
assert_eq!(device_limits(asked, &adapter).max_texture_dimension_2d, 16384);
}
/// #2105: a GPU that offers less than eframe asks for on any limit (here a Raspberry Pi 5's V3D 7.1:
/// 15 inter-stage variables on Vulkan, 4096 px textures on OpenGL) gets a device with what it
/// offers, instead of failing and leaving the window to the software rasterizer. A GPU that offers
/// more is asked for no more than before, except for the texture side.
#[test]
fn the_device_asks_for_no_more_than_the_adapter_offers() {
let asked = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() };
let vulkan = wgpu::Limits { max_inter_stage_shader_variables: 15, max_texture_dimension_2d: 7680, ..wgpu::Limits::default() };
let limits = device_limits(asked.clone(), &vulkan);
assert_eq!((limits.max_inter_stage_shader_variables, limits.max_texture_dimension_2d), (15, 7680));
assert!(limits.check_limits(&vulkan), "{limits:?}");
let gl = wgpu::Limits { max_texture_dimension_2d: 4096, ..wgpu::Limits::downlevel_webgl2_defaults() };
let webgl2 = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::downlevel_webgl2_defaults() };
let limits = device_limits(webgl2.clone(), &gl);
assert_eq!(limits.max_texture_dimension_2d, 4096);
assert!(limits.check_limits(&gl), "{limits:?}");
let ample = wgpu::Limits { max_texture_dimension_2d: 8192, ..wgpu::Limits::default() };
assert_eq!(device_limits(asked.clone(), &ample), asked);
let better = wgpu::Limits { max_inter_stage_shader_variables: 31, max_texture_dimension_2d: 8192, ..wgpu::Limits::default() };
assert_eq!(device_limits(asked.clone(), &better), asked, "never more than eframe asked for");
}
fn names(c: &[Candidate], order: &[usize]) -> Vec<String> {
+4 -2
View File
@@ -255,6 +255,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
.as_ref()
.and_then(|w| w.window_handle().ok())
.is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
let (clip, clip_probe) = (parent.clone(), parent.clone());
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
@@ -286,11 +287,12 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
fileio::write_file_with(p, fill).map_err(|e| e.to_string())
})),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
system_clipboard: Some(clipboard::system_clipboard(clip)),
// Linux checks whether Paste has something to take on a background thread: an X11 clipboard
// owner that never answers holds a read for up to 4 s. Windows only asks which formats the
// clipboard holds and macOS asks the pasteboard server, so they check in line.
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(clipboard::system_clipboard) as ClipboardProbeFactory),
// The probe's handle is made on its own thread, from the window (`Send`), not its raw display.
clipboard_probe: cfg!(target_os = "linux").then(|| Box::new(move || clipboard::system_clipboard(clip_probe)) as ClipboardProbeFactory),
// Help → Discord / website / GitHub, the Discord button, About and Home links.
open_url: Some(Box::new(|url: &str| {
let _ = webbrowser::open(url);
+46 -12
View File
@@ -1,7 +1,10 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//!
//! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given
//! (Windows and Linux; `single_instance`).
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
//! macOS instead of the macOS menu bar (`mac_menu`).
@@ -16,6 +19,7 @@
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
mod automation_args;
mod caps_lock;
mod clipboard;
#[cfg(feature = "wgpu")]
mod compositor_gpu;
@@ -45,11 +49,15 @@ mod open_documents;
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(not(target_os = "macos"))]
mod single_instance;
#[cfg(windows)]
mod system_fonts;
mod tablet;
mod window;
mod window_frame;
#[cfg(windows)]
mod windows_cores;
mod windows_stylus;
mod xwayland;
@@ -87,6 +95,7 @@ use titlebar::{custom_titlebar, native_options};
struct App {
app: VectorcraftApp,
host_caps_lock: caps_lock::HostCapsLock,
/// Reported by wgpu when the window's graphics device is lost (a driver reset).
graphics_loss: GraphicsLoss,
/// The graphics device was lost and the unsaved changes are kept for Data Recovery.
@@ -118,7 +127,9 @@ Options:
(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
--new-instance Open the files in a new window even when VectorCraft
already runs (Windows and Linux)
--version Print the version and exit
-h, --help Print this help and exit
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
@@ -128,7 +139,7 @@ Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
fn main() -> std::process::ExitCode {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::VECTORCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&vectorcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// Before any thread starts: the pointer must find a cursor theme on Wayland (#2450).
@@ -142,6 +153,7 @@ fn main() -> std::process::ExitCode {
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
let mut files = Vec::new();
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
let mut new_instance = false;
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
@@ -154,6 +166,7 @@ fn main() -> std::process::ExitCode {
}
match a.as_str() {
"--in-window-menus" => in_window_menus = true,
"--new-instance" => new_instance = true,
"--version" => {
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
return std::process::ExitCode::SUCCESS;
@@ -183,7 +196,19 @@ fn main() -> std::process::ExitCode {
};
// Before desktop services, so every start-up warning is recorded (`logging`).
let logger = logging::install();
// Files opened from the file manager while VectorCraft runs open in its window (#4892). Before
// the log file is attached: a launch that hands its files over leaves the running app's log be.
#[cfg(not(target_os = "macos"))]
let instance = match single_instance::start(control_port.is_some(), new_instance, &files) {
single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS,
single_instance::Start::Running(primary) => primary,
};
#[cfg(target_os = "macos")]
let _ = new_instance;
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
// Before the first render: the cores the process may run on.
#[cfg(windows)]
windows_cores::install();
// Before the first font scan (the app's start): the fonts font services load (#579).
#[cfg(windows)]
system_fonts::install();
@@ -265,15 +290,15 @@ fn main() -> std::process::ExitCode {
let options = native_options(custom_titlebar);
// winit picks Wayland whenever `WAYLAND_DISPLAY` is set; Preferences › Performance › Linux
// Display Server › X11 opens the window through XWayland instead, where file drops work (#1151),
// and X11 (XWayland) When a Pen Is Attached does so when a pen is attached, where the pen draws (#2028).
// and the other choices do so when a pen is attached, where the pen draws (#2028, #4967).
#[cfg(target_os = "linux")]
let options = {
let mut options = options;
let pref = saved.as_ref().and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
if xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) {
if xwayland::x11_libraries_found() {
if pref == Some("x11WithPen") {
log::info!("pen found: opening the window through XWayland (Preferences › Performance › Linux Display Server)");
if pref != Some("x11") {
log::info!("pen found: opening the window through XWayland, where the pen works (VECTORCRAFT_NATIVE_WAYLAND=1 keeps Wayland)");
} else {
log::info!("opening the window through XWayland (Preferences › Performance › Linux Display Server)");
}
@@ -321,14 +346,15 @@ fn main() -> std::process::ExitCode {
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
create.instance_descriptor.backend_options.dx12.shader_compiler =
eframe::wgpu::Dx12Compiler::from_env().unwrap_or(eframe::wgpu::Dx12Compiler::Fxc);
// The window's surface may be as large as the adapter allows, not eframe's fixed
// 8192 px: a window stretched over several monitors, or grown by a display scale
// change, otherwise fails to configure its surface and the app closes (#1459).
// The device asks for no more than the adapter offers, so a GPU short of one of
// eframe's limits still gets a device (#2105). The window's surface may be as large as
// the adapter allows, not eframe's fixed 8192 px: a window stretched over several
// monitors, or grown by a display scale change, otherwise fails to configure its
// surface and the app closes (#1459).
let device = create.device_descriptor.clone();
create.device_descriptor = std::sync::Arc::new(move |adapter: &eframe::wgpu::Adapter| {
let mut descriptor = device(adapter);
let limit = &mut descriptor.required_limits.max_texture_dimension_2d;
*limit = gpu::surface_limit(*limit, adapter.limits().max_texture_dimension_2d);
descriptor.required_limits = gpu::device_limits(descriptor.required_limits, &adapter.limits());
descriptor
});
}
@@ -452,7 +478,10 @@ fn main() -> std::process::ExitCode {
}
}
#[cfg(not(target_os = "macos"))]
let _ = in_window_menus;
{
let _ = in_window_menus;
single_instance::set_ui(&cc.egui_ctx);
}
app.services.native_input_hook = native_input_hook(&app);
file_access::unconfined(|| open_files(&mut app, files));
// The first frame is due from now (`gpu::watch_first_frame`).
@@ -461,6 +490,7 @@ fn main() -> std::process::ExitCode {
// The content and the pointer stay aligned on Windows (#1758).
let window_frame = window_frame::service(cc, custom_titlebar);
Ok(Box::new(App {
host_caps_lock: caps_lock::HostCapsLock::new(winit_window.as_deref()),
app,
graphics_loss,
graphics_lost: false,
@@ -473,6 +503,10 @@ fn main() -> std::process::ExitCode {
}),
)
});
// The window is gone: later launches start their own app, including the one a graphics
// failure starts again below.
#[cfg(not(target_os = "macos"))]
drop(instance);
#[cfg(feature = "wgpu")]
let outcome = gpu::finish(outcome, &startup);
#[cfg(not(feature = "wgpu"))]
+183 -7
View File
@@ -84,24 +84,200 @@ pub(crate) fn load_prefs(app: &mut VectorcraftApp, saved: Option<vectorcraft_ui_
app.ui = ui.sanitized();
}
vectorcraft_ui_egui::prefs_dialog::restore(app);
// What this window started from: only what changes after it is this window's to save.
set_saved_base(prefs_value(app).ok());
}
/// Save what this window changed since it last read or saved the preferences, merged into the
/// file as it is now ([`merge_changes`]): another VectorCraft window open at the same time, which
/// read the file before this one saved, then keeps this window's changes when it saves or quits
/// (#4625). Nothing is written when nothing changed.
pub(crate) fn save_prefs(app: &VectorcraftApp) {
if !prefs_enabled() || KEEP_UI_PREFS.load(std::sync::atomic::Ordering::Relaxed) {
return;
}
if let Some(p) = prefs_path() {
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
if let Ok(bytes) = prefs_bytes(app) {
// Preferences are best effort: a failed write keeps the previous file.
let _ = fileio::write_atomic(&p, &bytes);
}
let Some(p) = prefs_path() else { return };
let Ok(mine) = prefs_value(app) else { return };
let base = saved_base();
if base.as_ref().is_some_and(|b| same_lasting(b, &mine)) {
return;
}
// The file as another window may have saved it since (none, or unreadable: this one's own).
let on_disk = std::fs::read(&p).ok().and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok());
let merged = match (base.as_ref(), on_disk) {
(Some(base), Some(on_disk)) => merge_changes(base, &mine, on_disk),
_ => mine.clone(),
};
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
let Ok(bytes) = serde_json::to_vec_pretty(&merged) else { return };
// Preferences are best effort: a failed write keeps the previous file, and the changes are
// tried again on the next save.
if fileio::write_atomic(&p, &bytes).is_ok() {
set_saved_base(Some(mine));
}
}
/// How often [`save_prefs_soon`] looks for changes to save.
const SAVE_EVERY: std::time::Duration = std::time::Duration::from_secs(2);
/// Save the preferences at most every [`SAVE_EVERY`] while the app runs, when something changed:
/// the workspace, panels, toolbars and the other UI settings kept only in the UI state were saved
/// only on quitting, so a session ended any other way (the window closed by the system, the
/// process ended or crashing) started the next one from the settings before it (#4625).
pub(crate) fn save_prefs_soon(app: &VectorcraftApp) {
static LAST: std::sync::Mutex<Option<std::time::Instant>> = std::sync::Mutex::new(None);
let Ok(mut last) = LAST.lock() else { return };
if last.is_some_and(|t| t.elapsed() < SAVE_EVERY) {
return;
}
*last = Some(std::time::Instant::now());
drop(last);
save_prefs(app);
}
/// The preferences as this window last read or saved them (`None`: not read yet).
static SAVED_BASE: std::sync::Mutex<Option<serde_json::Value>> = std::sync::Mutex::new(None);
fn saved_base() -> Option<serde_json::Value> {
SAVED_BASE.lock().ok().and_then(|b| b.clone())
}
fn set_saved_base(value: Option<serde_json::Value>) {
if let Ok(mut b) = SAVED_BASE.lock() {
*b = value;
}
}
/// Fields of the UI state that a start resets ([`vectorcraft_ui_egui::UiState::sanitized`]): a
/// change to them alone isn't saved, and they are never merged over another window's.
const PASSING: &[&str] = &["status", "dialog", "flyout", "palette_open", "palette_query", "about", "open_panel"];
/// Do `a` and `b` hold the same preferences, leaving out what a start resets ([`PASSING`])?
fn same_lasting(a: &serde_json::Value, b: &serde_json::Value) -> bool {
match (a.as_object(), b.as_object()) {
(Some(a), Some(b)) => {
let keys: std::collections::BTreeSet<&String> = a.keys().chain(b.keys()).filter(|k| !PASSING.contains(&k.as_str())).collect();
keys.into_iter().all(|k| a.get(k) == b.get(k))
}
_ => a == b,
}
}
/// `on_disk` with what changed from `base` to `mine` put over it: each top-level field of the UI
/// state, and each engine preference (`engine_prefs`) on its own, so two windows that change
/// different settings keep both. A field both changed takes this window's value (it saves last).
fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
let (Some(base), Some(mine), Value::Object(mut out)) = (base.as_object(), mine.as_object(), on_disk) else {
return mine.clone();
};
let keys: std::collections::BTreeSet<&String> = base.keys().chain(mine.keys()).collect();
for key in keys {
if PASSING.contains(&key.as_str()) {
if let Some(v) = mine.get(key) {
out.entry(key.clone()).or_insert_with(|| v.clone());
}
continue;
}
let (was, now) = (base.get(key), mine.get(key));
if was == now {
continue;
}
if key == "engine_prefs"
&& let (Some(Value::Object(was)), Some(Value::Object(now)), Some(Value::Object(theirs))) = (was, now, out.get_mut(key))
{
let names: std::collections::BTreeSet<&String> = was.keys().chain(now.keys()).collect();
for name in names {
match (was.get(name), now.get(name)) {
(a, b) if a == b => {}
(_, Some(v)) => {
theirs.insert(name.clone(), v.clone());
}
(_, None) => {
theirs.remove(name);
}
}
}
continue;
}
match now {
Some(v) => {
out.insert(key.clone(), v.clone());
}
None => {
out.remove(key);
}
}
}
Value::Object(out)
}
/// The preferences file's contents: the UI state with the engine preferences.
#[cfg(test)]
pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result<Vec<u8>> {
serde_json::to_vec_pretty(&prefs_value(app)?)
}
/// [`prefs_bytes`] as JSON.
fn prefs_value(app: &VectorcraftApp) -> serde_json::Result<serde_json::Value> {
let mut ui = app.ui.clone();
ui.engine_prefs = app.session.prefs.to_json();
serde_json::to_vec_pretty(&ui)
serde_json::to_value(&ui)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{merge_changes, same_lasting};
/// Two windows open at once (#4625): the one that saves last keeps the other's changes and
/// adds its own, instead of putting back the preferences it started from.
#[test]
fn a_window_saves_only_what_it_changed_over_another_windows_save() {
let base = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.0}});
// The other window switched the workspace and the accent, and saved.
let on_disk = json!({"workspace": "Painting", "toolbar": true, "engine_prefs": {"uiAccent": "violet", "uiScaling": 1.0}});
// This one changed only the UI scaling.
let mine = json!({"workspace": "Essentials", "toolbar": true, "engine_prefs": {"uiAccent": "blue", "uiScaling": 1.25}});
let merged = merge_changes(&base, &mine, on_disk);
assert_eq!(merged, json!({"workspace": "Painting", "toolbar": true, "engine_prefs": {"uiAccent": "violet", "uiScaling": 1.25}}));
}
#[test]
fn a_setting_both_windows_changed_takes_the_last_saved() {
let base = json!({"workspace": "Essentials", "engine_prefs": {"uiAccent": "blue"}});
let on_disk = json!({"workspace": "Painting", "engine_prefs": {"uiAccent": "violet"}});
let mine = json!({"workspace": "Typography", "engine_prefs": {"uiAccent": "green"}});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"workspace": "Typography", "engine_prefs": {"uiAccent": "green"}}));
}
#[test]
fn fields_added_or_removed_by_this_window_are_saved() {
let base = json!({"docking": {"root": 1}, "engine_prefs": {"old": 1}});
let on_disk = json!({"docking": {"root": 1}, "recent_files": ["a.svg"], "engine_prefs": {"old": 1}});
let mine = json!({"dock_width": 300.0, "engine_prefs": {"new": 2}});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"dock_width": 300.0, "recent_files": ["a.svg"], "engine_prefs": {"new": 2}}));
}
/// The status line and open dialogs reset at start: they don't make a save, and they don't
/// replace another window's.
#[test]
fn what_a_start_resets_is_left_out() {
let base = json!({"status": "", "dialog": null, "workspace": "Essentials"});
let mine = json!({"status": "Saved", "dialog": {"kind": "preferences"}, "workspace": "Essentials"});
assert!(same_lasting(&base, &mine));
let on_disk = json!({"status": "Opened", "dialog": null, "workspace": "Painting"});
let mine = json!({"status": "Saved", "dialog": {"kind": "preferences"}, "workspace": "Essentials", "toolbar": false});
let merged = merge_changes(&base, &mine, on_disk);
assert_eq!(merged, json!({"status": "Opened", "dialog": null, "workspace": "Painting", "toolbar": false}));
assert!(!same_lasting(&base, &mine));
}
#[test]
fn a_file_that_is_not_an_object_is_replaced() {
let base = json!({"workspace": "Essentials"});
let mine = json!({"workspace": "Painting"});
assert_eq!(merge_changes(&base, &mine, json!([1, 2])), mine);
}
}
+386
View File
@@ -0,0 +1,386 @@
//! One app per user (#4892): a file opened from the file manager (double-click, Open With, a drop
//! on the executable) while VectorCraft runs opens as a new document tab in the running app, which
//! comes to the front, instead of in a second app.
//!
//! The running app (the primary) listens on a loopback port and records the port and two random
//! tokens in `instance.json` next to the preferences (owner-only on Unix; on Windows the settings
//! folder is the user's own). A later launch with files connects; the primary greets it with
//! `hello`, which only a process that could read the file (or talk to the primary) knows; only then
//! does the launch send `token` and the absolute paths of its files, one JSON line. The primary
//! checks the token, keeps the paths that are existing files, acknowledges and opens them; the
//! launch exits. Anything else (no file, no listener, a wrong greeting, no answer) starts the launch
//! as its own app, which replaces a stale file. Nothing received is ever run.
//!
//! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward
//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files
//! opened from Finder to the running app itself (`open_documents`), so this is off there.
use std::fs::{self, OpenOptions};
use std::hash::{BuildHasher as _, RandomState};
use std::io::{self, BufRead as _, BufReader, Read as _, Write as _};
use std::net::{Ipv4Addr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock, PoisonError};
use std::time::Duration;
use serde_json::{Value, json};
use super::prefs::{prefs_enabled, prefs_path};
/// The endpoint file, next to the preferences.
const FILE_NAME: &str = "instance.json";
/// The longest message either side reads, and the longest endpoint file.
const MAX_MESSAGE: u64 = 64 * 1024;
/// The most files one launch hands over.
const MAX_FILES: usize = 256;
/// A live primary's kernel accepts at once, even while its window is busy; a stale port on Windows
/// would otherwise retry for about two seconds.
const CONNECT_TIMEOUT: Duration = Duration::from_millis(500);
/// How long the primary waits on one launch; it serves launches one at a time.
const SERVER_TIMEOUT: Duration = Duration::from_secs(2);
/// How long a launch waits on the primary: longer than a launch served before it can take.
const CLIENT_TIMEOUT: Duration = Duration::from_secs(5);
/// Paths handed over by later launches that the UI hasn't opened yet.
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
static UI: OnceLock<egui::Context> = OnceLock::new();
/// What this launch does about an app already running.
pub enum Start {
/// The running app took the files: this launch exits.
Forwarded,
/// This launch runs; `Some` while it is the app later launches hand their files to.
Running(Option<Primary>),
}
/// Hand `files` to the app already running, or become the app later launches hand theirs to.
/// `automation`: started with `--control`.
pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start {
if automation || !prefs_enabled() {
return Start::Running(None);
}
let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) };
// With nothing to hand over, the same exchange only asks whether an app is running.
let send: &[String] = if new_instance { &[] } else { files };
match forward(&path, send) {
Ok(()) if send.is_empty() => Start::Running(None),
Ok(()) => Start::Forwarded,
Err(e) => {
log::debug!("no running VectorCraft to hand files to ({e})");
Start::Running(listen(&path, queue))
}
}
}
/// Wake `ctx` when files arrive from later launches.
pub fn set_ui(ctx: &egui::Context) {
// Set once; a second call would hand over the same context.
let _ = UI.set(ctx.clone());
}
/// The paths later launches handed over since the last call.
pub fn take() -> Vec<String> {
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
}
fn queue(files: Vec<String>) {
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files);
if let Some(ctx) = UI.get() {
ctx.request_repaint();
}
}
/// This app is the one recorded in the endpoint file; dropping it removes the file, unless a later
/// app has replaced it since.
pub struct Primary {
path: PathBuf,
token: String,
}
impl Drop for Primary {
fn drop(&mut self) {
if read_endpoint(&self.path).is_some_and(|e| e.token == self.token) {
// Best effort: a file left behind is stale, and the next launch replaces it.
let _ = fs::remove_file(&self.path);
}
}
}
struct Endpoint {
port: u16,
token: String,
hello: String,
}
fn invalid(why: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, why)
}
fn read_endpoint(path: &Path) -> Option<Endpoint> {
let mut text = String::new();
fs::File::open(path).ok()?.take(MAX_MESSAGE).read_to_string(&mut text).ok()?;
let v: Value = serde_json::from_str(&text).ok()?;
let port = u16::try_from(v.get("port")?.as_u64()?).ok().filter(|p| *p != 0)?;
Some(Endpoint { port, token: v.get("token")?.as_str()?.to_owned(), hello: v.get("hello")?.as_str()?.to_owned() })
}
/// Hand `files` (made absolute against this launch's folder) to the app recorded at `path`; `Ok`
/// once it acknowledged them.
fn forward(path: &Path, files: &[String]) -> io::Result<()> {
let endpoint = read_endpoint(path).ok_or_else(|| invalid("no endpoint file"))?;
let files = files.iter().map(|f| std::path::absolute(f).map(|p| p.to_string_lossy().into_owned())).collect::<io::Result<Vec<_>>>()?;
let stream = TcpStream::connect_timeout(&(Ipv4Addr::LOCALHOST, endpoint.port).into(), CONNECT_TIMEOUT)?;
stream.set_read_timeout(Some(CLIENT_TIMEOUT))?;
stream.set_write_timeout(Some(CLIENT_TIMEOUT))?;
// The token and the paths go only to a listener that knows the greeting.
if !same(read_message(&stream)?.get("hello"), &endpoint.hello) {
return Err(invalid("the listener isn't VectorCraft"));
}
write_message(&stream, &json!({ "token": endpoint.token, "files": files }))?;
match read_message(&stream)?.get("ok") {
Some(Value::Bool(true)) => Ok(()),
_ => Err(invalid("refused")),
}
}
/// Listen for later launches and record this app at `path`; each launch's files go to `deliver`.
/// `None` when that fails (logged): the app then runs on its own, as before.
fn listen(path: &Path, deliver: impl Fn(Vec<String>) + Send + 'static) -> Option<Primary> {
try_listen(path, deliver).inspect_err(|e| log::warn!("files opened while VectorCraft runs will open in another window: {e}")).ok()
}
fn try_listen(path: &Path, deliver: impl Fn(Vec<String>) + Send + 'static) -> io::Result<Primary> {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let port = listener.local_addr()?.port();
let (token, hello) = random_tokens();
write_endpoint(path, &json!({ "port": port, "token": token, "hello": hello }))?;
// From here on a failure removes the file again.
let primary = Primary { path: path.to_path_buf(), token: token.clone() };
std::thread::Builder::new().name("open-files-listener".into()).spawn(move || {
for stream in listener.incoming() {
match stream.and_then(|s| receive(&s, &hello, &token)) {
Ok(files) if files.is_empty() => {}
Ok(files) => deliver(files),
Err(e) => {
log::warn!("refused files from another launch: {e}");
// An accept that keeps failing (out of handles) doesn't spin.
std::thread::sleep(Duration::from_millis(50));
}
}
}
})?;
Ok(primary)
}
/// Serve one launch: greet it, check its token, keep the paths that are existing files.
fn receive(stream: &TcpStream, hello: &str, token: &str) -> io::Result<Vec<String>> {
stream.set_read_timeout(Some(SERVER_TIMEOUT))?;
stream.set_write_timeout(Some(SERVER_TIMEOUT))?;
write_message(stream, &json!({ "hello": hello }))?;
let message = read_message(stream)?;
if !same(message.get("token"), token) {
return Err(invalid("wrong token"));
}
let files = message.get("files").and_then(Value::as_array).ok_or_else(|| invalid("no file list"))?;
if files.len() > MAX_FILES {
return Err(invalid("too many files"));
}
let files = files
.iter()
.filter_map(Value::as_str)
.filter(|f| {
let p = Path::new(f);
let ok = p.is_absolute() && p.is_file();
if !ok {
log::warn!("another launch named {f}, which isn't a file");
}
ok
})
.map(str::to_owned)
.collect();
write_message(stream, &json!({ "ok": true }))?;
Ok(files)
}
/// One JSON line of at most [`MAX_MESSAGE`] bytes. Each side sends one line and waits for the
/// other's, so nothing read past it is lost.
fn read_message(stream: &TcpStream) -> io::Result<Value> {
let mut line = Vec::new();
BufReader::new(stream.take(MAX_MESSAGE)).read_until(b'\n', &mut line)?;
if line.last() != Some(&b'\n') {
return Err(invalid("message too long or cut short"));
}
serde_json::from_slice(&line).map_err(|e| invalid(&e.to_string()))
}
fn write_message(mut stream: &TcpStream, message: &Value) -> io::Result<()> {
let mut line = serde_json::to_vec(message).map_err(io::Error::other)?;
line.push(b'\n');
stream.write_all(&line)
}
/// `given` is the string `expected`, compared in constant time.
fn same(given: Option<&Value>, expected: &str) -> bool {
given
.and_then(Value::as_str)
.is_some_and(|g| g.len() == expected.len() && g.bytes().zip(expected.bytes()).fold(0, |acc, (a, b)| acc | (a ^ b)) == 0)
}
/// Two unrelated 128-bit hex tokens. `RandomState` is keyed from the system's random numbers, and
/// its keyed hash of distinct values gives unpredictable, independent output (no crate needed).
fn random_tokens() -> (String, String) {
let keyed = RandomState::new();
let token = |a: u8, b: u8| format!("{:016x}{:016x}", keyed.hash_one(a), keyed.hash_one(b));
(token(1, 2), token(3, 4))
}
/// Replace `path` with `endpoint` at once (a temporary file renamed over it), readable only by the
/// user on Unix.
fn write_endpoint(path: &Path, endpoint: &Value) -> io::Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let tmp = path.with_extension(format!("{}.tmp", std::process::id()));
// A leftover of an earlier app with the same process id; `create_new` sets the mode below.
let _ = fs::remove_file(&tmp);
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let bytes = serde_json::to_vec(endpoint).map_err(io::Error::other)?;
let written = options.open(&tmp).and_then(|mut f| f.write_all(&bytes)).and_then(|()| fs::rename(&tmp, path));
if written.is_err() {
// Best effort: the temporary file is all there is to clean up.
let _ = fs::remove_file(&tmp);
}
written
}
#[cfg(test)]
mod tests {
use std::sync::mpsc;
use super::*;
/// A fresh folder for one test, with an existing file in it.
fn folder(name: &str) -> (PathBuf, PathBuf, String) {
let dir = std::env::temp_dir().join(format!("vc-single-instance-{}-{name}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let file = dir.join("a b.svg");
fs::write(&file, "<svg/>").unwrap();
let file = file.to_string_lossy().into_owned();
(dir.join(FILE_NAME), dir, file)
}
fn primary(path: &Path) -> (Primary, mpsc::Receiver<Vec<String>>) {
let (tx, rx) = mpsc::channel();
let primary = listen(path, move |files| tx.send(files).unwrap()).unwrap();
(primary, rx)
}
/// Connects as a launch would and sends `message` after the greeting; what came back.
fn send_raw(path: &Path, message: &[u8]) -> io::Result<Value> {
let endpoint = read_endpoint(path).unwrap();
let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, endpoint.port)).unwrap();
stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap();
assert!(same(read_message(&stream).unwrap().get("hello"), &endpoint.hello));
(&stream).write_all(message)?;
read_message(&stream)
}
#[test]
fn a_launch_hands_its_files_to_the_running_app() {
let (path, dir, file) = folder("forward");
let (primary, rx) = primary(&path);
let missing = dir.join("missing.svg").to_string_lossy().into_owned();
forward(&path, &[file.clone(), missing]).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file), "the missing file is left out");
// An empty list only asks whether the app runs: nothing is delivered.
forward(&path, &[]).unwrap();
forward(&path, std::slice::from_ref(&file)).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
drop(primary);
assert!(!path.exists(), "the endpoint file goes with the app");
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_wrong_token_garbage_and_oversized_messages_are_refused() {
let (path, dir, file) = folder("refused");
let (_primary, rx) = primary(&path);
let token = read_endpoint(&path).unwrap().token;
let wrong = format!("{}\n", json!({ "token": "0".repeat(token.len()), "files": [file] }));
assert!(send_raw(&path, wrong.as_bytes()).is_err());
assert!(send_raw(&path, b"not json\n").is_err());
assert!(send_raw(&path, &vec![b'a'; MAX_MESSAGE as usize + 10]).is_err());
let many = format!("{}\n", json!({ "token": token, "files": vec![file.clone(); MAX_FILES + 1] }));
assert!(send_raw(&path, many.as_bytes()).is_err());
let relative = format!("{}\n", json!({ "token": token, "files": ["a b.svg", 7] }));
assert_eq!(send_raw(&path, relative.as_bytes()).unwrap(), json!({ "ok": true }));
assert!(rx.try_recv().is_err(), "nothing was delivered");
// The listener still serves a launch with the right token.
forward(&path, std::slice::from_ref(&file)).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_stale_endpoint_file_is_ignored_and_replaced() {
let (path, dir, file) = folder("stale");
// No listener on the recorded port any more.
let port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap();
assert!(forward(&path, std::slice::from_ref(&file)).is_err());
// A listener that doesn't know the greeting gets neither the token nor the paths.
let impostor = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = impostor.local_addr().unwrap().port();
write_endpoint(&path, &json!({ "port": port, "token": "t", "hello": "h" })).unwrap();
let heard = std::thread::spawn(move || {
let (stream, _) = impostor.accept().unwrap();
write_message(&stream, &json!({ "hello": "x" })).unwrap();
stream.set_read_timeout(Some(CLIENT_TIMEOUT)).unwrap();
read_message(&stream).ok()
});
assert!(forward(&path, std::slice::from_ref(&file)).is_err());
assert!(heard.join().unwrap().is_none());
// Garbage in the file is no endpoint either.
fs::write(&path, b"\xff not json").unwrap();
assert!(forward(&path, std::slice::from_ref(&file)).is_err());
// A new primary replaces the stale file.
let (_primary, rx) = primary(&path);
forward(&path, std::slice::from_ref(&file)).unwrap();
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), [file]);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn a_replaced_endpoint_file_stays_when_the_earlier_app_ends() {
let (path, dir, _) = folder("replaced");
let (first, _rx1) = primary(&path);
let (_second, _rx2) = primary(&path);
let second_token = read_endpoint(&path).unwrap().token;
drop(first);
assert_eq!(read_endpoint(&path).map(|e| e.token), Some(second_token));
let _ = fs::remove_dir_all(dir);
}
#[test]
fn tokens_are_random_and_distinct() {
let (a, b) = random_tokens();
let (c, _) = random_tokens();
assert_eq!(a.len(), 32);
assert!(a != b && a != c);
}
#[cfg(unix)]
#[test]
fn the_endpoint_file_is_the_users_alone() {
use std::os::unix::fs::PermissionsExt as _;
let (path, dir, _) = folder("mode");
let _primary = primary(&path);
assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600);
let _ = fs::remove_dir_all(dir);
}
}
+40
View File
@@ -0,0 +1,40 @@
//! Windows: the cores the process may run on, from its affinity mask, for the renderer's worker
//! count (`vectorcraft_render::set_usable_cores`). `available_parallelism` counts every processor
//! in the machine, also when the process may run on fewer (started with `start /affinity`, or
//! pinned by a launcher). The desktop app and `vectorcraft-cli` (exports, MCP) both install it; the
//! CLI shares this file.
//!
//! `GetProcessAffinityMask` is a C API with no safe binding, hence the scoped `unsafe_code`
//! allowance.
#![allow(unsafe_code)]
use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessAffinityMask};
/// Tells the renderer how many cores the process may run on.
pub fn install() {
vectorcraft_render::set_usable_cores(usable_cores());
}
/// The number of cores in the process's affinity mask, or `None` when Windows reports none (a
/// process with threads in more than one processor group).
fn usable_cores() -> Option<usize> {
let (mut process, mut system) = (0usize, 0usize);
// SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no closing, and both pointers
// point at live `usize`s for the length of the call.
unsafe { GetProcessAffinityMask(GetCurrentProcess(), &mut process, &mut system) }.ok()?;
(process != 0).then(|| process.count_ones() as usize)
}
#[cfg(test)]
mod tests {
/// The mask Windows reports for this process gives at least one core and no more than the
/// machine has. (A child process started with `start /affinity` would test a one-core mask
/// end to end, but a process spawned while other tests hold sockets inherits them and resets
/// their connections; `automatic_threads` covers the one-core count.)
#[test]
fn the_affinity_mask_gives_the_cores_the_process_may_use() {
let cores = super::usable_cores().unwrap();
let all = std::thread::available_parallelism().unwrap().get();
assert!((1..=all).contains(&cores), "{cores} of {all}");
}
}
+32 -21
View File
@@ -1,8 +1,10 @@
//! Linux: Preferences › Performance › Linux Display Server › X11 opens the window through XWayland
//! on a Wayland session, where dropped files arrive (winit 0.30 has no Wayland drag and drop,
//! #1151). X11 (XWayland) When a Pen Is Attached does so only when a pen is attached as the app
//! starts: winit 0.30 doesn't bind the Wayland tablet protocol, and XWayland hands the pen to X11
//! windows as a pointer (#2028). Only when XWayland can run the window: an X display is set and
//! #1151). Automatic and X11 (XWayland) When a Pen Is Attached do so only when a pen is attached
//! as the app starts, as PhotoCraft does: winit 0.30 doesn't bind the Wayland tablet protocol, so
//! on native Wayland the pen moves only the compositor's cursor, and XWayland hands the pen to X11
//! windows as a pointer (#2028, #4967). `VECTORCRAFT_NATIVE_WAYLAND=1` keeps a pen's window on
//! Wayland. Only when XWayland can run the window: an X display is set and
//! the X11 libraries winit loads at runtime are installed. Otherwise the window opens on Wayland,
//! so a missing library never keeps the app from starting.
//!
@@ -21,16 +23,17 @@ const X11_LIBS: &[&str] =
&["libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libXcursor.so.1", "libXi.so.6", "libxkbcommon.so.0", "libxkbcommon-x11.so.0"];
/// Whether the saved `linuxDisplayServer` and the environment ask for XWayland: `x11` chosen, or
/// `x11WithPen` chosen and `pen` finding a pen; a Wayland session (winit picks Wayland whenever
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only for
/// `x11WithPen`, after the session checks.
/// any other choice (`automatic`, `x11WithPen`, none saved) with `pen` finding a pen and
/// `VECTORCRAFT_NATIVE_WAYLAND` unset (or `0`); a Wayland session (winit picks Wayland whenever
/// `WAYLAND_DISPLAY` or `WAYLAND_SOCKET` is set); and an X display to open. `pen` runs only after
/// the other checks.
pub fn wanted(pref: Option<&str>, var: impl Fn(&str) -> Option<OsString>, pen: impl FnOnce() -> bool) -> bool {
let set = |name: &str| var(name).is_some_and(|v| !v.is_empty());
let session = (set("WAYLAND_DISPLAY") || set("WAYLAND_SOCKET")) && set("DISPLAY");
let native_wayland = var("VECTORCRAFT_NATIVE_WAYLAND").is_some_and(|v| !v.is_empty() && v != "0");
match pref {
Some("x11") => session,
Some("x11WithPen") => session && pen(),
_ => false,
_ => session && !native_wayland && pen(),
}
}
@@ -149,27 +152,35 @@ mod tests {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11"), &wayland, no_probe));
assert!(wanted(Some("x11"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("automatic"), &wayland, no_probe));
assert!(!wanted(None, &wayland, no_probe));
assert!(!wanted(Some("wayland"), &wayland, no_probe));
assert!(!wanted(Some("automatic"), &wayland, || false));
assert!(!wanted(None, &wayland, || false));
// An X11 session already opens on X11; without an X display there is no XWayland to use.
assert!(!wanted(Some("x11"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
assert!(!wanted(Some("x11"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
}
/// X11 (XWayland) When a Pen Is Attached (#2028): XWayland on a Wayland session with an X
/// display when a pen is found, the session's own display server otherwise. The pen check
/// runs only on such a session.
/// Automatic and X11 (XWayland) When a Pen Is Attached (#2028, #4967): XWayland on a Wayland
/// session with an X display when a pen is found, where the pen reaches the window, the
/// session's own display server otherwise. The pen check runs only on such a session, and
/// `VECTORCRAFT_NATIVE_WAYLAND` keeps Wayland.
#[test]
fn the_pen_choice_opens_xwayland_only_with_a_pen() {
fn a_pen_opens_xwayland_unless_native_wayland_is_asked_for() {
let wayland = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0")]);
assert!(wanted(Some("x11WithPen"), &wayland, || true));
assert!(wanted(Some("x11WithPen"), env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true));
assert!(!wanted(Some("x11WithPen"), &wayland, || false));
assert!(!wanted(Some("x11WithPen"), env(&[("DISPLAY", ":0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe));
assert!(!wanted(Some("x11WithPen"), env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe));
let native = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("VECTORCRAFT_NATIVE_WAYLAND", "1")]);
let not_native = env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", ":0"), ("VECTORCRAFT_NATIVE_WAYLAND", "0")]);
for pref in [Some("automatic"), None, Some("x11WithPen")] {
assert!(wanted(pref, &wayland, || true), "{pref:?}");
assert!(wanted(pref, env(&[("WAYLAND_SOCKET", "3"), ("DISPLAY", ":0")]), || true), "{pref:?}");
assert!(!wanted(pref, &wayland, || false), "{pref:?}");
assert!(!wanted(pref, env(&[("DISPLAY", ":0")]), no_probe), "{pref:?}");
assert!(!wanted(pref, env(&[("WAYLAND_DISPLAY", "wayland-0")]), no_probe), "{pref:?}");
assert!(!wanted(pref, env(&[("WAYLAND_DISPLAY", "wayland-0"), ("DISPLAY", "")]), no_probe), "{pref:?}");
assert!(!wanted(pref, &native, no_probe), "{pref:?}");
assert!(wanted(pref, &not_native, || true), "{pref:?}");
}
// The X11 choice opens XWayland with or without a pen; the opt-out is for the pen only.
assert!(wanted(Some("x11"), &native, no_probe));
}
/// `/proc/bus/input/devices` of a laptop with a Wacom Intuos Pro attached (64-bit longs: the
+1 -3
View File
@@ -16,9 +16,7 @@ head-and-shoulders portrait on a full-bleed field, no frame or roundel, with the
**Tile:** `viewBox="0 0 512 512"`, a rounded square with `rx=112`, clipped. The macOS files add Apple's
transparent margin (tile = 824/1024 of the canvas); Windows and Linux use the tile edge to edge.
**Provenance:** the owner's original ArtCraft drawing (2880 px, keyed to the palette), vectorised with
craftrules `assets/logo-options/_tools/vectorize_tile.py`. The drawing is kept in craftrules at
`craftrules/assets/app-icons/vectorcraft/source.png`, not here. `vectorcraft.svg` is the canonical
**Provenance:** the owner's original ArtCraft drawing, vectorised. `vectorcraft.svg` is the canonical
artwork. Licence: see `LICENSE.txt`.
## Files
+17 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -177,3 +174,18 @@ sync_github_name = false
[people.krosk93]
sync_github_name = true
[people.ashokram-si]
real_name = "Ashok Ram"
[people.dylanmtaylor]
sync_github_name = true
[people.marshiyar]
display_name = "Arshiya"
real_name = "Arshiya Rahgozar"
sync_github_name = false
[people.WadahBadawi]
display_name = "Wadah Badawi"
sync_github_name = true
+1 -1
View File
@@ -17,7 +17,7 @@ Affinity files.
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
| Rectangles with corner radii (relative or absolute, locked or per corner) and round or straight corners, ellipses, polygons (smooth too), stars, square stars, pies, triangles, trapezoids | paths |
| Compound shapes (`Comp`, add and subtract) | one even-odd path |
| A shape or curve with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
| A shape, curve or text with children clips them | clip group whose clipping path keeps its fill (behind) and stroke (over) |
| Vector masks (`AdCh` curves and shapes) | clip group |
| 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) |
+19 -13
View File
@@ -98,20 +98,26 @@ pub fn utf8(text: &str) -> F {
}
pub fn document(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6]) -> Vec<u8> {
let text = F::Obj(
tag(if frame { b"TxtF" } else { b"TxtA" }),
vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])),
(
tag(b"TxtH"),
F::Obj(
tag(if frame { b"FrFr" } else { b"ArFr" }),
vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))],
),
document_with_children(blocks, frame, dpi, transform, vec![])
}
/// [`document`] whose text holds `children` (objects nested inside the text).
pub fn document_with_children(blocks: Vec<F>, frame: bool, dpi: f64, transform: [f64; 6], children: Vec<F>) -> Vec<u8> {
let mut fields = vec![
(tag(b"StSt"), F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(blocks))])),
(
tag(b"TxtH"),
F::Obj(
tag(if frame { b"FrFr" } else { b"ArFr" }),
vec![(tag(b"FrmB"), F::F64s(vec![10.0, 20.0, 210.0, 120.0])), (tag(b"ArtV"), F::F64(18.0))],
),
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
],
);
),
(tag(b"Xfrm"), F::F64s(transform.to_vec())),
];
if !children.is_empty() {
fields.push((tag(b"Chld"), F::Shared(children)));
}
let text = F::Obj(tag(if frame { b"TxtF" } else { b"TxtA" }), fields);
let mut root = vec![
(tag(b"UVCn"), F::Obj(tag(b"UVCn"), vec![(tag(b"UPPI"), F::F64(dpi))])),
(
+3 -2
View File
@@ -138,9 +138,10 @@ pub fn stain(inst: &Node) -> Option<(Color, f32)> {
Some((f.paint.color()?, f.opacity))
}
/// A symbol's art for instance `inst`, stained by the instance's fill ([`stain`]).
/// A symbol's art for instance `inst`: with the instance's own appearance changes to it (a dynamic
/// symbol's, [`vectorcraft_doc::symbol_overrides`]), stained by the instance's fill ([`stain`]).
pub fn instance_art(art: &Node, inst: &Node) -> Node {
let mut a = art.clone();
let mut a = vectorcraft_doc::symbol_overrides::dynamic_art(art, inst);
if let Some((c, amount)) = stain(inst) {
tint_node(&mut a, &c, amount);
}
+43
View File
@@ -386,10 +386,22 @@ impl CornerSource {
#[serde(default)]
pub struct PatternBrush {
pub side: Node,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub outer_corner: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outer_corner_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outer_corner_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_corner: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inner_corner_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inner_corner_swatch: Option<String>,
/// What the outer corners take when there is no `outer_corner` art. Without art or a source,
/// they take the `inner_corner` art.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -400,8 +412,16 @@ pub struct PatternBrush {
pub inner_source: Option<CornerSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_swatch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub end: Option<Node>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_frame: Option<Rect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_swatch: Option<String>,
/// Scale in percent.
pub scale: f64,
/// Space between tiles in percent of the tile width.
@@ -422,12 +442,22 @@ impl Default for PatternBrush {
fn default() -> Self {
Self {
side: defaults::stitch_tile(),
side_frame: None,
side_swatch: None,
outer_corner: None,
outer_corner_frame: None,
outer_corner_swatch: None,
inner_corner: None,
inner_corner_frame: None,
inner_corner_swatch: None,
outer_source: None,
inner_source: None,
start: None,
start_frame: None,
start_swatch: None,
end: None,
end_frame: None,
end_swatch: None,
scale: 100.0,
spacing: 0.0,
fit: PatternFit::Stretch,
@@ -500,6 +530,14 @@ fn within_pair((a, b): (f64, f64), range: (f64, f64), fallback: f64) -> (f64, f6
(within(a, range, fallback), within(b, range, fallback))
}
fn valid_tile_frame(frame: Rect) -> bool {
[frame.x0, frame.y0, frame.x1, frame.y1].into_iter().all(f64::is_finite)
&& frame.width().is_finite()
&& frame.height().is_finite()
&& frame.width() > 1e-9
&& frame.height() > 1e-9
}
impl Colorization {
/// A Hue Shift key colour that isn't a colour (components out of reach) becomes black.
fn sanitize(&mut self) {
@@ -600,6 +638,11 @@ impl Brush {
BrushKind::Pattern(p) => {
p.scale = within(p.scale, PatternBrush::SCALE_RANGE, 100.0);
p.spacing = within(p.spacing, PatternBrush::SPACING_RANGE, 0.0);
for frame in [&mut p.side_frame, &mut p.outer_corner_frame, &mut p.inner_corner_frame, &mut p.start_frame, &mut p.end_frame] {
if frame.is_some_and(|f| !valid_tile_frame(f)) {
*frame = None;
}
}
p.colorization.sanitize();
}
BrushKind::Bristle(b) => {
+38 -1
View File
@@ -561,6 +561,43 @@ fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() {
assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty");
}
#[test]
fn pattern_without_start_tile_runs_side_to_path_start() {
let path = line(0.0, 0.0, 100.0, 0.0);
let doc = Document::new(1.0, 1.0);
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
p.start = Some(rect_art(20.0, 4.0));
}
let with_start = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(with_start[0].geometric_bounds().unwrap().width(), 20.0);
if let BrushKind::Pattern(p) = &mut b.kind {
p.start = None;
}
let without_start = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(without_start.len(), 10);
assert!(near(without_start[0].geometric_bounds().unwrap().x0, 0.0, 1e-6));
assert!(near(without_start[0].geometric_bounds().unwrap().width(), 10.0, 1e-6));
}
#[test]
fn pattern_without_end_tile_runs_side_to_path_end() {
let path = line(0.0, 0.0, 100.0, 0.0);
let doc = Document::new(1.0, 1.0);
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
p.end = Some(rect_art(20.0, 4.0));
}
let with_end = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert!(with_end.iter().any(|n| near(n.geometric_bounds().unwrap().width(), 20.0, 1e-6)));
if let BrushKind::Pattern(p) = &mut b.kind {
p.end = None;
}
let without_end = stroke_pieces(&doc, &b, &path, &stroke(1.0));
assert_eq!(without_end.len(), 10);
assert!(near(without_end.last().unwrap().geometric_bounds().unwrap().x1, 100.0, 1e-6));
}
#[test]
fn pattern_corner_sources_round_trip_and_stay_out_of_brushes_without_them() {
let saved = serde_json::to_value(pattern_brush(PatternFit::Stretch, true)).unwrap();
@@ -1097,7 +1134,7 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
let mut brushed = rect_art(10.0, 4.0);
brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) }));
for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] {
doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) });
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: std::sync::Arc::new(art.clone()) });
doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)]));
}
let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY });
+85 -8
View File
@@ -41,7 +41,8 @@ const MASK_STEPS: usize = 32;
/// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than
/// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord
/// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`.
/// Text, images and symbol instances can't bend and are dropped.
/// Text, images and symbol instances can't bend and are dropped. The art of a linked opacity mask
/// bends with the object it masks.
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option<Node> {
let mut out = n.clone();
out.appearance.scale_strokes(width_scale);
@@ -63,9 +64,21 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max
}
_ => return None,
}
map_mask(&mut out, |art| warp_node(art, f, flat_tol, max_du, doc_tol, width_scale));
Some(out)
}
/// Replace the art of `n`'s linked opacity mask, if it has one, with what `map` makes of it. Where
/// `map` draws nothing, the mask's art becomes an empty group.
fn map_mask(n: &mut Node, map: impl FnOnce(&Node) -> Option<Node>) {
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut())
&& m.linked
{
let id = m.art.id;
m.art = Arc::new(map(&m.art).unwrap_or_else(|| Node::group(id, vec![])));
}
}
/// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords
/// would, else the chords. Points `f` cannot place (not finite) are left out of the chords.
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData {
@@ -234,6 +247,16 @@ fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) ->
(b.width() > 1e-9).then_some((a, b))
}
/// Orient a tile's repeat frame with its art. The frame can be smaller than the art.
fn oriented_tile(art: &Node, frame: Option<Rect>, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> {
let (node, bounds) = oriented(art, Direction::LeftToRight, flip_along, flip_across)?;
let Some(frame) = frame else { return Some((node, bounds)) };
let x = if flip_along { (-frame.x1, -frame.x0) } else { (frame.x0, frame.x1) };
let y = if flip_across { (-frame.y1, -frame.y0) } else { (frame.y0, frame.y1) };
let frame = Rect::new(x.0, y.0, x.1, y.1);
(frame.width() > 1e-9 && frame.height() > 1e-9).then_some((node, frame))
}
/// Piecewise-linear map from art x (offset from the art's left edge) to arc length.
#[derive(Clone, Copy, Debug)]
pub(crate) struct AlongMap {
@@ -338,7 +361,8 @@ fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option<Affine> {
/// Everything else matches [`warp_node`] deliberately, so curves are the only difference between
/// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every
/// path, an empty path or a group left with no children is dropped, and strokes are scaled by
/// `width_scale` at every level rather than by the transform's own factor.
/// `width_scale` at every level rather than by the transform's own factor, the art of linked
/// opacity masks included.
fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option<Node> {
let mut out = prepared(n, width_scale)?;
out.transform(a, false);
@@ -365,6 +389,7 @@ fn prepared(n: &Node, width_scale: f64) -> Option<Node> {
}
_ => return None,
}
map_mask(&mut out, |art| prepared(art, width_scale));
Some(out)
}
@@ -416,6 +441,7 @@ fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node {
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind {
*children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect();
}
map_mask(&mut out, |art| Some(widen_strokes(art, factor)));
out
}
@@ -590,6 +616,9 @@ fn anchors_of(n: &Node) -> usize {
if let Some(c) = n.children() {
stack.extend(c.iter().map(Arc::as_ref));
}
if let Some(m) = &n.extras().mask {
stack.push(&m.art);
}
}
total
}
@@ -781,13 +810,13 @@ fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option<Node> {
}
pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node> {
let orient = |n: &Node| oriented(n, Direction::LeftToRight, pb.flip_along, pb.flip_across);
let Some(side) = orient(&pb.side) else { return vec![] };
let orient = |n: &Node, frame| oriented_tile(n, frame, pb.flip_along, pb.flip_across);
let Some(side) = orient(&pb.side, pb.side_frame) else { return vec![] };
let k = weight * pb.scale.max(1.0) / 100.0;
let outer = pb.outer_corner.as_ref().and_then(orient);
let inner = pb.inner_corner.as_ref().and_then(orient);
let start = pb.start.as_ref().and_then(orient);
let end = pb.end.as_ref().and_then(orient);
let outer = pb.outer_corner.as_ref().and_then(|n| orient(n, pb.outer_corner_frame));
let inner = pb.inner_corner.as_ref().and_then(|n| orient(n, pb.inner_corner_frame));
let start = pb.start.as_ref().and_then(|n| orient(n, pb.start_frame));
let end = pb.end.as_ref().and_then(|n| orient(n, pb.end_frame));
let tw = side.1.width() * k;
let gap = tw * pb.spacing.max(0.0) / 100.0;
// Each kind of corner takes its own art, else its source, else the other kind's art.
@@ -1133,4 +1162,52 @@ mod tests {
}
}
}
/// A linked opacity mask on brush art bends and widens with the art, on a bent run and on a
/// straight one, so a mask drawn over the art still covers it. An unlinked mask stays put.
#[test]
fn a_linked_opacity_mask_bends_with_the_art() {
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
let stroked = |id: u64| {
Node::path(
NodeId(id),
vectorcraft_geom::shapes::ellipse(b),
Appearance::basic(
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
2.0,
),
)
};
let masked = |linked: bool| {
let mut n = stroked(0);
let mut mask = vectorcraft_doc::OpacityMask::new(stroked(1), true);
mask.linked = linked;
n.extras_mut().mask = Some(Box::new(mask));
n
};
let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let bent_track = tracks(&round, 0.05).remove(0);
let mut line = BezPath::new();
line.move_to((0.0, 0.0));
line.line_to((320.0, 0.0));
let straight_track = tracks(&line, 0.01).remove(0);
let along = AlongMap::linear(b.width(), 100.0, 140.0);
for (name, t) in [("bent", &bent_track), ("straight", &straight_track)] {
let out = bend(t, &masked(true), b, along, 2.0).expect("the art is drawn");
let mask = out.extras().mask.as_ref().expect("the mask is kept");
assert_eq!(mask.art.kind, out.kind, "{name} run: the mask's art bends with the art it was drawn over");
assert_eq!(mask.art.appearance, out.appearance, "{name} run: the mask's strokes widen with the art's");
let out = bend(t, &masked(false), b, along, 2.0).expect("the art is drawn");
let mask = out.extras().mask.as_ref().expect("the mask is kept");
assert_eq!(*mask.art, stroked(1), "{name} run: an unlinked mask stays where it was");
let out = bend_widened(t, &masked(true), b, along, 2.0, &|_| 0.5).expect("the art is drawn");
let mask = out.extras().mask.as_ref().expect("the mask is kept");
assert_eq!(mask.art.kind, out.kind, "{name} run, widened: the mask's art bends with the art");
assert_eq!(mask.art.appearance, out.appearance, "{name} run, widened: the mask's strokes narrow with the art's");
}
assert_eq!(anchors_of(&masked(true)), 8, "the anchor budget counts the mask's art");
}
}
+1 -1
View File
@@ -622,7 +622,7 @@ impl<'d, 'a> Build<'d, 'a> {
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
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) });
self.doc.symbols.push(Symbol { dynamic: false, name: symbol.clone(), art: Arc::new(art) });
symbol
});
self.symbols.insert(key, made.clone());
+28
View File
@@ -1049,6 +1049,34 @@ fn drawings_the_export_writes_read_back() {
}
}
#[test]
fn a_lone_segment_reads_back_from_the_line_the_export_writes() {
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let mut b = kurbo::BezPath::new();
b.move_to((20.0, 30.0));
b.line_to((120.0, 70.0));
let id = doc.alloc_id();
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
doc.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
for version in crate::DxfVersion::ALL.into_iter().filter(|v| v.r2000()) {
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
let out = crate::export(&doc, &opts).unwrap();
assert!(std::str::from_utf8(&out.bytes).unwrap().contains("\nLINE\n"), "{version:?}");
let back = open(&out.bytes, &points());
let art = leaves(&back.document);
assert_eq!(art.len(), 1, "{version:?}");
let path = art[0].path_data().unwrap();
assert_eq!(path.subpaths.len(), 1, "{version:?}");
assert!(!path.subpaths[0].closed, "{version:?}");
let shift = Affine::translate((0.0, 100.0 - height(&back.document)));
let points = path_points(&art[0]);
assert_eq!(points.len(), 17, "{version:?}: one segment");
let ends = [shift * points[0], shift * points[16]];
assert!(ends[0].distance(Point::new(20.0, 30.0)) < 1e-6 && ends[1].distance(Point::new(120.0, 70.0)) < 1e-6, "{version:?}: {ends:?}");
}
}
#[test]
fn closed_control_point_splines_stay_closed_through_export_and_import() {
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
+5 -2
View File
@@ -233,7 +233,8 @@ impl RasterFormat {
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Preserve {
/// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or
/// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush
/// outside, width profiles, arrowheads, fitted dashes, dashes offset into their pattern)
/// become their filled outlines; brush
/// strokes become their brush art.
#[default]
Appearance,
@@ -277,7 +278,9 @@ pub struct DxfOptions {
pub colors: ColorDepth,
pub raster: RasterFormat,
pub preserve: Preserve,
/// Alter Paths for Appearance: every stroke is written as the filled area it paints.
/// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`]
/// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have
/// no lineweights) is written as the filled area it paints.
pub alter_paths: bool,
/// Type is written as glyph outlines (always with [`Preserve::Appearance`]).
pub outline_text: bool,
+143 -10
View File
@@ -10,7 +10,7 @@ use kurbo::PathEl;
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
use crate::aci::nearest_aci;
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
@@ -184,6 +184,46 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
Some(CircularSub { centre, radius, samples })
}
/// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle
/// scaled out of proportion. `u` and `v` are the conjugate semi-diameters through its first two
/// anchors.
struct EllipticalSub {
centre: Point,
u: Vec2,
v: Vec2,
}
/// Fit a closed subpath of four cubics to one ellipse. Its anchors give the center and two
/// conjugate semi-diameters; the subpath mapped by their inverse must pass [`circular_subpath`] as
/// the unit circle, so the same tolerance and turn checks apply.
fn elliptical_subpath(sub: &Sub) -> Option<EllipticalSub> {
if !sub.closed || sub.segs.len() != 4 {
return None;
}
let (a0, a1, a2) = (sub.start, sub.segs.first()?.end(), sub.segs.get(1)?.end());
let centre = a0.midpoint(a2);
let (u, v) = (a0 - centre, a1 - centre);
let det = u.cross(v);
let scale2 = u.hypot2().max(v.hypot2());
if !det.is_finite() || !scale2.is_finite() || det.abs() <= scale2 * 1e-9 {
return None;
}
let unit = |p: Point| {
let d = p - centre;
Point::new(d.cross(v) / det, u.cross(d) / det)
};
let mut segs = Vec::with_capacity(4);
for seg in &sub.segs {
let Seg::Cubic(a, b, to) = *seg else { return None };
segs.push(Seg::Cubic(unit(a), unit(b), unit(to)));
}
let circle = circular_subpath(&Sub { start: unit(sub.start), segs, closed: true })?;
if circle.centre.to_vec2().hypot() > 5e-4 || (circle.radius - 1.0).abs() > 5e-4 {
return None;
}
Some(EllipticalSub { centre, u, v })
}
fn angle_deg(p: Point, centre: Point) -> f64 {
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
}
@@ -233,6 +273,17 @@ const MAX_ENTITIES: u32 = 10_000_000;
const MAX_COORD: f64 = 1e9;
const MAX_POINTS: usize = 4_000_000;
/// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of
/// odd length repeats twice in its linetype.
fn starts_with_pattern(d: &Dash) -> bool {
if !d.is_dashed() || !d.offset.is_finite() {
return true;
}
let period = d.pattern.iter().sum::<f64>() * if d.pattern.len() % 2 == 1 { 2.0 } else { 1.0 };
let phase = d.offset.rem_euclid(period);
phase.min(period - phase) <= period * 1e-9
}
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
// Far outside any drawing, a curve flattens to millions of points at this tolerance.
@@ -494,7 +545,7 @@ impl<'a> Scene<'a> {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
{
let mut art = (*sym.art).clone();
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
art.transform(*xf, false);
self.nest += 1;
self.node(&art, true);
@@ -612,8 +663,7 @@ impl<'a> Scene<'a> {
/// The DXF lineweight nearest to `width` points (scaled with the drawing when asked).
fn lineweight(&mut self, width: f64) -> i32 {
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
let hundredths = width * 2540.0 / 72.0 * scale;
let hundredths = self.hundredths(width);
let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1];
if hundredths > f64::from(max) + 5.0 {
self.warn(WIDE_LINES);
@@ -621,6 +671,21 @@ impl<'a> Scene<'a> {
LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max)
}
/// `width` points in hundredths of a millimetre, the unit of DXF lineweights (scaled with
/// the drawing when asked).
fn hundredths(&self, width: f64) -> f64 {
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
width * 2540.0 / 72.0 * scale
}
/// Can a CAD line draw a plain stroke `width` points wide in this version? R12 polylines
/// carry their width, R13 and R14 have no lineweights, and from 2000 a lineweight holds up
/// to 2.11 mm, to the hundredth.
fn line_keeps_width(&self, width: f64) -> bool {
let v = self.o.version;
!v.handles() || (v.r2000() && self.hundredths(width).round() <= f64::from(LINEWEIGHTS[LINEWEIGHTS.len() - 1]))
}
/// A linetype for dash pattern `d` (one per distinct pattern).
fn linetype(&mut self, d: &Dash) -> String {
let mut lengths: Vec<f64> = d.pattern.iter().map(|v| v * self.k).collect();
@@ -673,7 +738,11 @@ impl<'a> Scene<'a> {
}
let Some(rgb) = self.paint_rgb(&st.paint) else { return };
let plain = stroke::is_plain(st);
if self.o.alter_paths || (appearance && !plain) {
// A linetype has no phase: dashes that start part way into their pattern need outlines.
let lined = plain && st.dash.as_ref().is_none_or(starts_with_pattern);
// Alter Paths for Appearance outlines a stroke only when a line can't draw it.
let altered = self.o.alter_paths && !(lined && self.line_keeps_width(st.width));
if altered || (appearance && !lined) {
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st);
let style = self.style(rgb, st.opacity, None, None);
self.area(&region.to_bezpath(), FillRule::NonZero, &style, true);
@@ -740,9 +809,9 @@ impl<'a> Scene<'a> {
self.e.int(98, 0);
}
/// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened),
/// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives
/// polylines as a width.
/// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for
/// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones.
/// `width`: the stroke weight, which DXF before 2000 gives polylines as a width.
fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) {
let v = self.o.version;
let width = (!v.r2000() && width > 0.0).then_some(width * self.k);
@@ -755,11 +824,17 @@ impl<'a> Scene<'a> {
if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
pts.pop();
}
self.polyline(&pts, sub.closed, style, width);
// A LINE has no width, so a stroke before 2000 keeps its polyline width.
match (sub.closed, width, pts.as_slice()) {
(false, None, &[a, b]) => self.line(a, b, style),
_ => self.polyline(&pts, sub.closed, style, width),
}
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
// its width. Non-circular curves continue through the existing fallback.
} else if v.handles() && self.elliptical(&sub, style) {
// R13 and later write an ellipse as a native ELLIPSE.
} else if v.handles() {
self.spline(&sub, style);
} else {
@@ -778,6 +853,16 @@ impl<'a> Scene<'a> {
}
}
/// A line from `a` to `b` (document space).
fn line(&mut self, a: Point, b: Point, style: &EntityStyle) {
let (a, b) = (self.pt(a), self.pt(b));
let layer = self.layer.clone();
let h = self.handle();
self.e.entity("LINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbLine");
self.e.xyz(10, a.x, a.y);
self.e.xyz(11, b.x, b.y);
}
/// A polyline through `pts` (document space): lightweight from R14, else a polyline with a
/// vertex entity per point.
fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option<f64>) {
@@ -787,7 +872,8 @@ impl<'a> Scene<'a> {
let pts: Vec<Point> = pts.iter().map(|p| self.pt(*p)).collect();
let layer = self.layer.clone();
let h = self.handle();
let flags = i64::from(closed);
// Bit 128: a linetype runs on through the vertices instead of restarting at each one.
let flags = i64::from(closed) | if style.linetype.is_some() { 128 } else { 0 };
if self.o.version.lwpolyline() {
self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline");
self.e.int(90, pts.len() as i64);
@@ -887,6 +973,53 @@ impl<'a> Scene<'a> {
true
}
/// Write a recognized elliptical subpath as a native DXF ELLIPSE (R13 and later): its center,
/// the end of its major axis relative to the center, and the minor to major ratio. Returns
/// false when the curve is not an ellipse, leaving the caller to write it as a spline.
fn elliptical(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
if !sub.closed || last.distance(sub.start) > 1e-9 {
return false;
}
let Some(ellipse) = elliptical_subpath(sub) else { return false };
let centre = self.to_dxf * ellipse.centre;
let u = self.to_dxf * (ellipse.centre + ellipse.u) - centre;
let v = self.to_dxf * (ellipse.centre + ellipse.v) - centre;
// The semi-axes are the square roots of the eigenvalues of [[a, b], [b, d]] = u u^T + v v^T.
let (a, b, d) = (u.x * u.x + v.x * v.x, u.x * u.y + v.x * v.y, u.y * u.y + v.y * v.y);
let mean = (a + d) / 2.0;
let spread = ((a - d) / 2.0).hypot(b);
let (major, minor) = ((mean + spread).sqrt(), (mean - spread).max(0.0).sqrt());
let angle = 0.5 * (2.0 * b).atan2(a - d);
let axis = Vec2::new(major * angle.cos(), major * angle.sin());
let ratio = (minor / major).min(1.0);
// A thinner ellipse stays a spline: DXF readers such as ezdxf need a ratio of 1e-6 or more.
if !centre.x.is_finite() || !centre.y.is_finite() || !axis.x.is_finite() || !axis.y.is_finite() || !ratio.is_finite() || ratio < 1e-6 {
return false;
}
// The drawing extents take the ellipse's bounding box: half-widths hypot(u.x, v.x) and
// hypot(u.y, v.y) about the center.
let (c, eu, ev) = (ellipse.centre, ellipse.u, ellipse.v);
let (hx, hy) = (eu.x.hypot(ev.x), eu.y.hypot(ev.y));
for p in [Point::new(c.x - hx, c.y), Point::new(c.x + hx, c.y), Point::new(c.x, c.y - hy), Point::new(c.x, c.y + hy)] {
self.pt(p);
}
let layer = self.layer.clone();
let h = self.handle();
self.e.entity("ELLIPSE", h, fixed::MODEL_RECORD, &layer, style, "AcDbEllipse");
self.e.xyz(10, centre.x, centre.y);
self.e.xyz(11, axis.x, axis.y);
self.e.normal();
// The ratio and the end parameter are written in full: rounded to six decimals, a thin
// ellipse's minor axis would change size, and a whole turn would read back as an open curve.
self.e.raw(40, &ratio.to_string());
self.e.num(41, 0.0);
self.e.raw(42, &std::f64::consts::TAU.to_string());
true
}
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
/// passes through every anchor (straight segments become straight cubics).
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
+320 -8
View File
@@ -4,7 +4,7 @@ use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, Shape, shapes};
use crate::*;
@@ -125,6 +125,71 @@ fn every_version_declares_its_acadver_and_is_well_formed() {
assert_eq!(DxfVersion::from_id("2019"), None);
}
/// The structure strict readers (Autodesk Fusion, #3085) need: the required sections and table
/// entries, handles that every reference resolves to, and LAYOUT objects with only the group codes
/// the DXF Reference defines for PLOTSETTINGS and LAYOUT (AutoCAD DXF Reference, OBJECTS section:
/// https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-1113675E-AB07-4567-BD47-B7FBE7290D1C).
#[test]
fn r2000_and_later_files_have_the_structure_cad_apps_require() {
use std::collections::HashSet;
// Common object codes, then AcDbPlotSettings, then AcDbLayout (76 is the shade plot mode in
// the first and the UCS type in the second).
let layout_codes: HashSet<i32> = [0, 5, 102, 330, 360, 100]
.into_iter()
.chain([1, 2, 4, 6, 7, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 140, 141, 142, 143, 70, 72, 73, 74, 75, 76, 77, 78, 147, 148, 149, 333])
.chain([71, 10, 20, 11, 21, 12, 22, 32, 14, 24, 34, 15, 25, 35, 146, 13, 23, 33, 16, 26, 36, 17, 27, 37, 331, 345, 346])
.collect();
let d = shapes_doc();
for v in DxfVersion::ALL.into_iter().filter(|v| *v >= DxfVersion::R2000) {
let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap();
let p = pairs(&out.bytes);
for name in ["HEADER", "CLASSES", "TABLES", "BLOCKS", "ENTITIES", "OBJECTS"] {
assert!(p.windows(2).any(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())), "{v:?}: {name} section");
}
let tables = section(&p, "TABLES");
let names = |k: &str| -> Vec<String> { tables.iter().filter(|o| kind(o) == k).filter_map(|o| get(o, 2).map(str::to_string)).collect() };
for (k, want) in [
("LTYPE", "ByBlock"),
("LTYPE", "ByLayer"),
("LTYPE", "Continuous"),
("LAYER", "0"),
("STYLE", "Standard"),
("APPID", "ACAD"),
("DIMSTYLE", "Standard"),
("BLOCK_RECORD", "*Model_Space"),
("BLOCK_RECORD", "*Paper_Space"),
] {
assert!(names(k).iter().any(|n| n == want), "{v:?}: {k} {want}");
}
// Every object after the header has a handle below $HANDSEED, and every reference to
// another object (owner 330, soft/hard pointers 340/350/390) names one of them.
let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..];
let handles: HashSet<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect();
let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap();
assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed");
for (code, value) in body.iter().filter(|x| matches!(x.0, 330 | 340 | 350 | 390)) {
let h = u32::from_str_radix(value, 16).unwrap();
assert!(h == 0 || handles.contains(&h), "{v:?}: group {code} points at missing handle {value}");
}
let objects = section(&p, "OBJECTS");
let layouts: Vec<&Pairs> = objects.iter().filter(|o| kind(o) == "LAYOUT").collect();
assert_eq!(layouts.len(), 2, "{v:?}: Model and Layout1");
for l in &layouts {
let bad: Vec<i32> = l.iter().map(|x| x.0).filter(|c| !layout_codes.contains(c)).collect();
assert!(bad.is_empty(), "{v:?}: LAYOUT has group codes the DXF Reference doesn't define: {bad:?}");
// The paper image origin is 148 (x) and 149 (y).
assert!(get(l, 148).is_some() && get(l, 149).is_some(), "{v:?}: paper image origin");
// Flag 1024 marks the model space layout, and only it.
let name = l.iter().skip_while(|x| x.1 != "AcDbLayout").find(|x| x.0 == 1).map(|x| x.1.as_str());
let flags: i64 = get(l, 70).unwrap().parse().unwrap();
assert_eq!(flags & 1024 != 0, name == Some("Model"), "{v:?}: model type flag of {name:?}");
}
// The plot style placeholder carries only the common object codes.
let holder = objects.iter().find(|o| kind(o) == "ACDBPLACEHOLDER").expect("placeholder");
assert!(holder.iter().all(|x| matches!(x.0, 0 | 5 | 102 | 330)), "{v:?}: placeholder {holder:?}");
}
}
#[test]
fn r12_viewport_has_no_duplicate_group_codes() {
let d = shapes_doc();
@@ -208,10 +273,168 @@ fn circles_and_circular_arcs_are_native_dxf_entities() {
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
}
}
// An actual ellipse is not close enough to a circle and remains a spline.
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
/// The path `p` with `xf` applied.
fn transformed(p: &PathData, xf: Affine) -> PathData {
PathData::from_bezpath(&(xf * p.to_bezpath()))
}
fn close_to(a: &[f64], b: &[f64], tol: f64) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
}
#[test]
fn ellipses_and_unevenly_scaled_circles_are_native_ellipses() {
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
let d = doc_with(|d| {
let stroke = || Paint::solid(Color::BLACK);
vec![
// 80 × 40 pt about (60, 40): DXF center (60, 60), major axis (40, 0), ratio 0.5.
path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, stroke(), 1.0),
// A 60 pt circle scaled 150% across about (50, 50): 90 × 60 pt, ratio 2/3.
path(
d,
transformed(&circle, Affine::translate((50.0, 50.0)) * Affine::scale_non_uniform(1.5, 1.0) * Affine::translate((-50.0, -50.0))),
Paint::None,
stroke(),
1.0,
),
// The first ellipse turned 30° about its center, filled: the outline is the ellipse too.
path(
d,
transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0))),
Paint::solid(Color::BLACK),
Paint::None,
0.0,
),
]
});
for version in DxfVersion::ALL.into_iter().filter(|version| *version >= DxfVersion::R13) {
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["ELLIPSE", "ELLIPSE", "ELLIPSE", "HATCH"], "{version:?}");
let ellipse = |x: &Pairs| (all(x, 10), all(x, 20), all(x, 11), all(x, 21), all(x, 40), all(x, 41), all(x, 42));
let (cx, cy, mx, my, ratio, start, end) = ellipse(&e[0]);
assert_eq!((cx, cy, mx, my, ratio), (vec![60.0], vec![60.0], vec![40.0], vec![0.0], vec![0.5]), "{version:?}");
assert!(close_to(&start, &[0.0], 1e-9) && close_to(&end, &[std::f64::consts::TAU], 1e-12), "{version:?}: a whole turn");
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[1]);
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[50.0, 50.0, 45.0, 0.0, 2.0 / 3.0], 1e-6), "{version:?}: {:?}", e[1]);
// Turned 30° counterclockwise on screen, so clockwise with DXF's y up.
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[2]);
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[60.0, 60.0, 40.0 * cos, -40.0 * sin, 0.5], 1e-6), "{version:?}: {:?}", e[2]);
assert!(e[0].contains(&(100, "AcDbEllipse".into())), "{version:?}");
}
// R12 has no ELLIPSE: the curves are flattened polylines.
let r12 = kinds(&export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap());
assert!(!r12.iter().any(|k| k == "ELLIPSE") && r12.iter().any(|k| k == "POLYLINE"), "{r12:?}");
// A thin ellipse keeps its ratio in full (six decimals would write 0.000002).
let thin = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(0.0, 50.0, 200.0, 50.0003)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
let e = entities(&export(&thin, &opts(&thin)).unwrap());
assert_eq!(kind(&e[0]), "ELLIPSE");
assert!(close_to(&all(&e[0], 40), &[1.5e-6], 1e-10), "{:?}", e[0]);
}
#[test]
fn exported_ellipses_read_back_as_the_same_ellipse() {
let xf = Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0));
let d = doc_with(|d| {
vec![path(d, transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), xf), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let out = export(&d, &opts(&d)).unwrap();
assert_eq!(kinds(&out), ["ELLIPSE"]);
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() };
let back = import(&out.bytes, &o).unwrap().document;
let mut paths = vec![];
back.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
paths.push(path.to_bezpath());
}
});
assert_eq!(paths.len(), 1);
assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "the ellipse reads back closed");
// Every point of the curve read back lies on the 80 × 40 pt ellipse turned 30°, once both are centered.
let bp = &paths[0];
let found = bp.bounding_box().center();
let inverse = xf.inverse();
let original = Point::new(60.0, 40.0);
for seg in bp.segments() {
for i in 0..=16 {
let p = seg.eval(f64::from(i) / 16.0) - found.to_vec2() + original.to_vec2();
let q = inverse * p;
let r = ((q.x - 60.0) / 40.0).hypot((q.y - 40.0) / 20.0);
assert!((r - 1.0).abs() < 1e-3, "{p:?} is off the ellipse ({r})");
}
}
}
#[test]
fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() {
// A rounded square: four cubics through the ellipse's anchors with longer handles.
let (c, r, k) = (Point::new(50.0, 50.0), 30.0, 30.0 * 0.8);
let mut squircle = vectorcraft_geom::BezPath::new();
squircle.move_to(Point::new(c.x + r, c.y));
squircle.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
squircle.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
squircle.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
squircle.curve_to(Point::new(c.x + k, c.y + r), Point::new(c.x + r, c.y + k), Point::new(c.x + r, c.y));
squircle.close_path();
// An egg: one half wider than the other.
let egg = shapes::ellipse(Rect::new(110.0, 20.0, 190.0, 60.0)).to_bezpath();
let mut els: Vec<kurbo::PathEl> = egg.elements().to_vec();
if let Some(kurbo::PathEl::CurveTo(a, b, _)) = els.get_mut(1) {
a.x -= 10.0;
b.x -= 10.0;
}
let d = doc_with(|d| {
vec![
path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0),
path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0),
// An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6).
path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0),
]
});
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]);
}
#[test]
fn a_lone_straight_segment_is_a_line() {
let segment = |closed: bool, extra: Option<Point>| {
let mut b = vectorcraft_geom::BezPath::new();
b.move_to(Point::new(20.0, 30.0));
b.line_to(Point::new(120.0, 70.0));
if let Some(p) = extra {
b.line_to(p);
}
if closed {
b.close_path();
}
PathData::from_bezpath(&b)
};
let d = doc_with(|d| vec![path(d, segment(false, None), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
for version in DxfVersion::ALL.into_iter().filter(|v| v.r2000()) {
let e = entities(&export(&d, &DxfOptions { version, ..opts(&d) }).unwrap());
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["LINE"], "{version:?}");
assert_eq!(e[0].iter().rfind(|p| p.0 == 100).map(|p| p.1.as_str()), Some("AcDbLine"), "{version:?}");
assert_eq!((all(&e[0], 10), all(&e[0], 20)), (vec![20.0], vec![70.0]), "{version:?}: y up from the artboard's bottom");
assert_eq!((all(&e[0], 11), all(&e[0], 21)), (vec![120.0], vec![30.0]), "{version:?}");
}
// Without a width (R12 writes a fill as its outline), R12 writes a LINE too; a stroke weight
// before 2000 keeps its polyline width.
let filled = doc_with(|d| vec![path(d, segment(false, None), Paint::solid(Color::BLACK), Paint::None, 0.0)]);
let r12 = |d: &Document| kinds(&export(d, &DxfOptions { version: DxfVersion::R12, ..opts(d) }).unwrap());
assert_eq!(r12(&filled), ["LINE"]);
assert_eq!(r12(&d), ["POLYLINE", "VERTEX", "VERTEX", "SEQEND"]);
let r14 = entities(&export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap());
assert_eq!((kind(&r14[0]), get(&r14[0], 43)), ("LWPOLYLINE", Some("1")));
// A closed segment and two segments stay polylines.
for p in [segment(true, None), segment(false, Some(Point::new(150.0, 20.0)))] {
let d = doc_with(|d| vec![path(d, p, Paint::None, Paint::solid(Color::BLACK), 1.0)]);
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["LWPOLYLINE"]);
}
}
#[test]
@@ -453,14 +676,44 @@ fn dashes_become_linetypes_and_opacity_transparency() {
#[test]
fn alter_paths_writes_strokes_as_filled_outlines() {
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
// Each fill and stroke: its outlines, then its hatch.
let fill = ["LWPOLYLINE", "HATCH"];
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
// Strokes a lineweight draws stay lines: the rectangle, the circle and an open arc.
let mut d = shapes_doc();
let c = Point::new(150.0, 95.0);
let k = 20.0 * shapes::KAPPA;
let mut arc = vectorcraft_geom::BezPath::new();
arc.move_to(Point::new(c.x + 20.0, c.y));
arc.curve_to(Point::new(c.x + 20.0, c.y - k), Point::new(c.x + k, c.y - 20.0), Point::new(c.x, c.y - 20.0));
let arc = path(&mut d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0);
let layer = d.layers[0].id;
d.insert(Some(layer), usize::MAX, arc).unwrap();
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), [&fill[..], &["LWPOLYLINE", "CIRCLE", "ARC"]].concat());
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
// R12 polylines carry any width, so its strokes stay lines too.
let r12 = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R12, ..opts(&d) }).unwrap();
assert!(!kinds(&r12).iter().any(|k| k == "HATCH"));
assert!(entities(&r12).iter().any(|e| kind(e) == "POLYLINE" && get(e, 40) == Some("2")));
// R13 and R14 have no lineweights: every stroke becomes its outline, then its hatch.
let d = shapes_doc();
let out = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R14, ..opts(&d) }).unwrap();
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
// The rectangle's stroke: an outer and an inner boundary.
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
// A stroke wider than the widest lineweight (2.11 mm) is outlined at its true width.
let wide = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 8.0)]);
let out = export(&wide, &DxfOptions { alter_paths: true, ..opts(&wide) }).unwrap();
assert_eq!(kinds(&out), rect_stroke);
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
let lines = export(&wide, &opts(&wide)).unwrap();
assert_eq!(kinds(&lines), ["LWPOLYLINE"]);
assert!(lines.warnings.iter().any(|w| w.contains("2.11 mm")));
// To the hundredth of a millimetre: 6 pt (2.117 mm) is outlined, 5.98 pt (2.110 mm) is a line.
for (width, expected) in [(6.0, &rect_stroke[..]), (5.98, &["LWPOLYLINE"][..])] {
let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), width)]);
assert_eq!(kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()), expected, "{width} pt");
}
// Preserve Appearance outlines only the strokes a line can't draw.
let mut d = shapes_doc();
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
@@ -471,6 +724,65 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
// With Maximize Editability, Alter Paths outlines that stroke and keeps the circle a line.
let altered = export(&d, &DxfOptions { preserve: Preserve::Editability, alter_paths: true, ..opts(&d) }).unwrap();
assert_eq!(kinds(&altered), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
assert!(altered.warnings.is_empty(), "{:?}", altered.warnings);
}
#[test]
fn dashes_that_start_part_way_into_their_pattern_are_outlined() {
let dashed = |offset: f64| {
doc_with(|d| {
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = n.appearance.stroke_mut() {
st.dash = Some(Dash { pattern: vec![6.0, 3.0, 2.0], offset, align_corners: false });
}
vec![n]
})
};
// A linetype starts at the start of its pattern: an offset of a whole period (the odd
// pattern twice) keeps the line, any other offset needs the dashes' outlines and a hatch.
for (offset, outlined) in [(0.0, false), (22.0, false), (4.0, true), (11.0, true)] {
let d = dashed(offset);
let k = kinds(&export(&d, &opts(&d)).unwrap());
if outlined {
assert!(k.len() > 2 && k.last().is_some_and(|k| k == "HATCH"), "offset {offset}: {k:?}");
} else {
assert_eq!(k, ["LWPOLYLINE"], "offset {offset}");
}
let altered = kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap());
assert_eq!(altered, k, "offset {offset}, Alter Paths");
}
// Maximize Editability keeps the line and its linetype.
let d = dashed(4.0);
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
assert_eq!(kinds(&editable), ["LWPOLYLINE"]);
assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1"));
}
#[test]
fn dashed_polylines_continue_their_pattern_around_corners() {
let rect = |dash: Option<Dash>| {
doc_with(|d| {
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
if let Some(st) = n.appearance.stroke_mut() {
st.dash = dash.clone();
}
vec![n]
})
};
let flags = |out: &DxfOutput| entities(out).first().and_then(|e| get(e, 70).map(str::to_string));
// Group 70 bit 128 makes the linetype run on through the vertices instead of restarting
// at each one; a solid polyline keeps only its closed bit.
let dashed = rect(Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }));
for o in [opts(&dashed), DxfOptions { alter_paths: true, ..opts(&dashed) }, DxfOptions { preserve: Preserve::Editability, ..opts(&dashed) }] {
assert_eq!(flags(&export(&dashed, &o).unwrap()).as_deref(), Some("129"));
}
let r12 = export(&dashed, &DxfOptions { version: DxfVersion::R12, ..opts(&dashed) }).unwrap();
assert_eq!((kinds(&r12).first().map(String::as_str), flags(&r12).as_deref()), (Some("POLYLINE"), Some("129")));
let solid = rect(None);
assert_eq!(flags(&export(&solid, &opts(&solid)).unwrap()).as_deref(), Some("1"));
}
fn text_doc(text: &str, size: f64) -> Document {
@@ -674,7 +986,7 @@ fn crop_keeps_an_instance_whose_art_reaches_the_region() {
// while the 20 × 20 square around the origin, moved to (-60, -60) to (-40, -40), is off it.
let mut d = doc_with(|d| vec![Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Far".into(), xf: Affine::translate((-50.0, -50.0)) })]);
let art = path(&mut d, shapes::rectangle(Rect::new(100.0, 50.0, 140.0, 90.0)), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
d.symbols.push(vectorcraft_doc::Symbol { name: "Far".into(), art: Arc::new(art) });
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
let o = DxfOptions { crop: true, ..opts(&d) };
assert!(!kinds(&export(&d, &o).unwrap()).is_empty());
}
+15 -3
View File
@@ -564,7 +564,10 @@ impl Drawing {
c.handle(350, fixed::PLOT_NORMAL);
c.raw(100, "AcDbDictionaryWithDefault");
c.handle(340, fixed::PLOT_NORMAL);
c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder");
// A placeholder has no data and no subclass marker of its own.
c.raw(0, "ACDBPLACEHOLDER");
c.handle(5, fixed::PLOT_NORMAL);
c.handle(330, fixed::PLOT_STYLES);
self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD);
self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD);
}
@@ -618,14 +621,23 @@ impl Drawing {
c.num(141, 0.0);
c.num(142, 1.0);
c.num(143, 1.0);
c.int(70, 688);
// Plot flags; 1024 marks the model space layout.
c.int(70, if record == fixed::MODEL_RECORD { 688 | 1024 } else { 688 });
c.int(72, 1);
c.int(73, 0);
c.int(74, 5);
c.raw(7, "");
c.int(75, 16);
if self.version.true_color() {
// Shade plot mode (as displayed), resolution level (normal) and custom DPI.
c.int(76, 0);
c.int(77, 2);
c.int(78, 300);
}
c.num(147, 1.0);
c.xy(148, 0.0, 0.0);
// The paper image origin is 148 (x) and 149 (y), not a point at 148 and 158.
c.num(148, 0.0);
c.num(149, 0.0);
c.raw(100, "AcDbLayout");
c.raw(1, name);
c.int(70, 1);
+12 -3
View File
@@ -149,11 +149,20 @@ impl IccProfile {
/// Device values (RGB / CMYK / Gray, 0..1) → sRGB.
pub fn to_srgb(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> {
let mut src = [0.0f32; 4];
let channels = self.channels();
src.get_mut(..channels)?.iter_mut().zip(v.iter()).for_each(|(dst, value)| *dst = *value);
self.to_srgb_into(src.get(..channels)?, intent)
}
/// Convert an exact-width device pixel using the cached transform and stack storage.
pub fn to_srgb_into(&self, v: &[f32], intent: Intent) -> Option<[f32; 3]> {
if v.len() != self.channels() {
return None;
}
let xf = self.to_xf(intent)?;
let mut src = v.to_vec();
src.resize(self.channels(), 0.0);
let mut dst = [0.0f32; 3];
xf.transform(&src, &mut dst).ok()?;
xf.transform(v, &mut dst).ok()?;
Some(dst.map(|x| x.clamp(0.0, 1.0)))
}
+9
View File
@@ -53,6 +53,15 @@ impl WidthProfile {
// Two swells between narrow necks.
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
];
/// The widest the stroke gets, as a multiple of its weight: the largest total of the left and
/// right factors at any of the points (between them the profile is straight), 1 for a profile
/// without points.
pub fn widest(&self) -> f64 {
if self.points.is_empty() {
return 1.0;
}
self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max)
}
/// The built-in profile with this id.
pub fn preset(id: &str) -> Option<Self> {
Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() })
+33 -2
View File
@@ -35,8 +35,8 @@ pub struct Artboard {
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.
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print draw
/// it behind the art ([`super::Document::with_artboard_fills`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<vectorcraft_color::Color>,
}
@@ -77,6 +77,37 @@ impl Artboard {
}
}
impl super::Document {
/// A copy for output with the background colors ([`Artboard::color`]) of the artboards at
/// `indices` drawn behind all the art: each a rectangle of its artboard filled with its color
/// (in the color's own model), on a layer of their own (Artboard Fill) below the
/// others. `None` when none of them has a color.
pub fn with_artboard_fills(&self, indices: impl IntoIterator<Item = usize>) -> Option<Self> {
let fills: Vec<(Rect, vectorcraft_color::Color)> = indices
.into_iter()
.filter_map(|i| {
let a = self.artboards.get(i)?;
Some((a.rect, a.color?))
})
.collect();
if fills.is_empty() {
return None;
}
let mut d = self.clone();
let mut layer = Node::layer(d.alloc_id(), "Artboard Fill", super::LayerColor::Preset(0));
let paths: Vec<Arc<Node>> = fills
.into_iter()
.map(|(r, c)| {
let look = super::Appearance::basic(vectorcraft_color::Paint::solid(c), vectorcraft_color::Paint::None, 0.0);
Arc::new(Node::path(d.alloc_id(), vectorcraft_geom::shapes::rectangle(r), look))
})
.collect();
*layer.children_mut()? = paths;
d.layers.insert(0, Arc::new(layer));
Some(d)
}
}
/// A document's locked artboards ([`Document::locked_artboards`]).
pub struct LockedArtboards<'a> {
pub(crate) rects: Vec<Rect>,
+11 -1
View File
@@ -196,7 +196,11 @@ pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
/// so dashes open out of a solid line; patterns of different lengths repeat to a common one.
fn lerp_dash(x: Option<&Dash>, y: Option<&Dash>, t: f64) -> Option<Dash> {
let solid_like = |d: &Dash| Dash {
pattern: d.pattern.chunks(2).flat_map(|c| [c.iter().sum::<f64>(), 0.0]).take(d.pattern.len().max(2)).collect(),
// An odd pattern repeats twice per period, so pair up the doubled one to keep every gap closed.
pattern: if d.pattern.len() % 2 == 1 { d.pattern.repeat(2) } else { d.pattern.clone() }
.chunks(2)
.flat_map(|c| [c.iter().sum::<f64>(), 0.0])
.collect(),
offset: d.offset,
align_corners: d.align_corners,
};
@@ -1423,6 +1427,12 @@ mod tests {
assert_eq!(p.at(0.5), (1.0, 1.0));
}
#[test]
fn odd_dash_blends_to_solid_with_closed_gaps() {
let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap();
assert!(d.pattern.iter().skip(1).step_by(2).all(|g| *g == 0.0), "{:?}", d.pattern);
}
#[test]
fn copied_steps_get_new_members() {
let rect = |x: f64| Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default()));
+3 -3
View File
@@ -485,7 +485,7 @@ mod tests {
#[test]
fn symbols_and_patterns_are_limited_too() {
let mut d = Document::new(10.0, 10.0);
d.symbols.push(crate::Symbol { name: "s".into(), art: nested(MAX_DEPTH + 3) });
d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: nested(MAX_DEPTH + 3) });
assert!(d.too_deep(None));
assert!(d.limit_depth());
assert!(!d.too_deep(None));
@@ -504,7 +504,7 @@ mod tests {
for _ in 0..levels {
art = Arc::new(Node::group(NodeId(3), vec![art]));
}
d.symbols.push(crate::Symbol { name: format!("s{i}"), art });
d.symbols.push(crate::Symbol { dynamic: false, name: format!("s{i}"), art });
}
d
}
@@ -573,7 +573,7 @@ mod tests {
};
let through_a_symbol = |d: &mut Document| {
d.patterns.push(crate::PatternDef::new("p", vec![instance(2, "s")]));
d.symbols.push(crate::Symbol { name: "s".into(), art: painted(3, "p") });
d.symbols.push(crate::Symbol { dynamic: false, name: "s".into(), art: painted(3, "p") });
};
for make in [&itself as &dyn Fn(&mut Document), &each_other, &through_a_symbol] {
let mut d = doc(painted(1, "p"));
+14 -13
View File
@@ -110,6 +110,12 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
hit_test_skipping(doc, p, opt, &|_| false, false)
}
/// [`hit_test_visible`] passing over the nodes `skip` names (and their contents): what lies below
/// them at `p`. A skipped clipping path still clips.
pub fn hit_test_visible_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool) -> Option<Hit> {
hit_test_skipping(doc, p, opt, skip, false)
}
/// The object of `selected` (topmost first) whose own art lies under `p` where a clipping mask
/// hides it: a clip group (or a layer with a clipping mask) holding it doesn't clip to `p`. A
/// click there hits nothing ([`hit_test`]), so the selection tools grab the selection by that
@@ -538,20 +544,15 @@ fn hit_children(
}
/// The order a click at `p` tries the children of `parent` in, front first: their stacking order
/// from the top, except that in an Intertwine group the members drawn over the others at `p`
/// come first (from the top among them too).
/// from the top, except that in an Intertwine group the members are tried in the order they are
/// drawn at `p`, from the top ([`crate::intertwine::tier_at`]).
fn hit_order<'a>(parent: &Node, children: &'a [std::sync::Arc<Node>], p: Point) -> impl Iterator<Item = usize> + 'a {
let over: Vec<usize> = if matches!(parent.kind, NodeKind::Intertwine { .. }) {
let contains = |a: &vectorcraft_geom::PathData| fill_contains(&a.to_bezpath(), vectorcraft_geom::FillRule::NonZero, p);
(0..children.len()).rev().filter(|i| children.get(*i).and_then(|c| crate::intertwine::over_area(c)).is_some_and(contains)).collect()
} else {
vec![]
};
let rest = (0..children.len()).rev().filter({
let over = over.clone();
move |i| !over.contains(i)
});
over.into_iter().chain(rest)
let mut order: Vec<usize> = (0..children.len()).rev().collect();
if matches!(parent.kind, NodeKind::Intertwine { .. }) {
let tier = |i: usize| children.get(i).map_or(0, |c| crate::intertwine::tier_at(c, p));
order.sort_by_cached_key(|i| std::cmp::Reverse((tier(*i), *i)));
}
order.into_iter()
}
/// A click on the strokes of a path or compound path `n` along `bp`: what any visible stroke
+1 -1
View File
@@ -841,7 +841,7 @@ fn objects_at_matches_repeated_hits_on_random_documents() {
fn objects_at_finds_symbol_instances_by_their_art() {
let mut d = Document::new(200.0, 200.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 60.0, 140.0, 80.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Far".into(), art: Arc::new(art) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Far".into(), art: Arc::new(art) });
let (layer, top, inner, group) = (d.layers[0].id, d.alloc_id(), d.alloc_id(), d.alloc_id());
let instance = |id, dy: f64| Node::new(id, NodeKind::SymbolInstance { symbol: "Far".into(), xf: vectorcraft_geom::Affine::translate((0.0, dy)) });
d.insert(Some(layer), 0, instance(top, 0.0)).unwrap();
+59 -20
View File
@@ -1,36 +1,51 @@
//! Object › Intertwine: objects that pass over and under each other.
//!
//! An Intertwine group ([`NodeKind::Intertwine`]) keeps its members as they are, in their own
//! stacking order. A member can be drawn over the other members in part of the group: that area
//! is the member's [`crate::node::NodeExtras::intertwine`], in document coordinates. The group
//! draws (and exports, and is hit) as its live expansion ([`expand`]): the members, then, for
//! each member with an area, a copy of it clipped to that area. Where several members are drawn
//! over the others in one place, the upper one in the stacking order ends on top.
//! stacking order. A member can be drawn over other members in part of the group, at a tier: the
//! area of tier 1 is the member's [`crate::node::NodeExtras::intertwine`], and the areas of tiers 2
//! and up are its [`crate::node::NodeExtras::intertwine_tiers`], in document coordinates. In each
//! place the members are drawn by their tier there (0 outside their areas), and members of one
//! tier in their stacking order, so any order of the members can be set for each overlap area.
//! The group draws (and exports, and is hit) as its live expansion ([`expand`]): the members,
//! then, tier by tier, a copy of each member with an area of that tier clipped to it.
//!
//! The members stay editable, and Release gives them back as they were (the areas let go).
//! Behaviour: <https://helpx.adobe.com/illustrator/using/intertwine-objects.html>.
use std::sync::Arc;
use vectorcraft_geom::{FillRule, PathData};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{FillRule, PathData, Point};
use crate::{Node, NodeId, NodeKind};
/// The area where `n` is drawn over the other members of its Intertwine group (empty: nowhere).
pub fn over_area(n: &Node) -> Option<&PathData> {
n.extras().intertwine.as_deref().filter(|a| !a.is_empty())
/// The areas where `n` is drawn over other members of its Intertwine group, each with its tier
/// (1 and up); empty ones are left out.
pub fn tier_areas(n: &Node) -> impl Iterator<Item = (usize, &PathData)> {
let e = n.extras();
e.intertwine
.as_deref()
.map(|a| (1, a))
.into_iter()
.chain(e.intertwine_tiers.iter().enumerate().map(|(k, a)| (k + 2, a)))
.filter(|(_, a)| !a.is_empty())
}
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then each
/// member with an area drawn over the others, as a clip group of that area around a copy of it
/// (ids 0: generated art).
/// The tier `n` is drawn at at `p`: the highest tier whose area holds `p` (0: none does).
pub fn tier_at(n: &Node, p: Point) -> usize {
tier_areas(n).filter(|(_, a)| fill_contains(&a.to_bezpath(), FillRule::NonZero, p)).map(|(t, _)| t).max().unwrap_or(0)
}
/// The members of an Intertwine group evaluated as plain art: the members (ids kept), then, for
/// each tier from 1 up, each member with an area of that tier, as a clip group of that area
/// around a copy of it (ids 0: generated art).
pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
let mut out: Vec<Node> = children.iter().map(|c| plain(c)).collect();
for c in children {
let Some(area) = over_area(c) else { continue };
if !c.visible {
continue;
}
let mut over: Vec<(usize, usize, &PathData)> =
children.iter().enumerate().filter(|(_, c)| c.visible).flat_map(|(i, c)| tier_areas(c).map(move |(t, a)| (t, i, a))).collect();
over.sort_by_key(|(t, i, _)| (*t, *i));
for (_, i, area) in over {
let Some(c) = children.get(i) else { continue };
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area.clone(), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
clip.appearance = crate::Appearance::default().into();
let mut copy = plain(c);
@@ -40,11 +55,13 @@ pub fn expand(children: &[Arc<Node>]) -> Vec<Node> {
out
}
/// `n` without its Intertwine area (the expansion is plain art).
/// `n` without its Intertwine areas (the expansion is plain art).
fn plain(n: &Node) -> Node {
let mut n = n.clone();
if n.extras().intertwine.is_some() {
n.extras_mut().intertwine = None;
if n.extras().intertwine.is_some() || !n.extras().intertwine_tiers.is_empty() {
let mut e = n.extras_mut();
e.intertwine = None;
e.intertwine_tiers = Vec::new();
}
n
}
@@ -88,6 +105,28 @@ mod tests {
assert_eq!((out[2].id, children[1].id), (NodeId(0), NodeId(0)), "generated art has no ids of its own");
}
#[test]
fn copies_are_drawn_tier_by_tier() {
let over = || shapes::rectangle(Rect::new(5.0, 0.0, 10.0, 10.0));
// The lower member at tier 2, the upper one at tier 1: the lower one ends on top.
let mut a = (*square(1, 0.0)).clone();
a.extras_mut().intertwine_tiers = vec![over()];
let mut b = (*square(2, 5.0)).clone();
b.extras_mut().intertwine = Some(Box::new(over()));
let kids = vec![Arc::new(a), Arc::new(b)];
assert_eq!((tier_at(&kids[0], Point::new(7.0, 5.0)), tier_at(&kids[1], Point::new(7.0, 5.0))), (2, 1));
assert_eq!(tier_at(&kids[0], Point::new(2.0, 5.0)), 0);
let out = expand(&kids);
assert_eq!(out.len(), 4, "both members, then the copy of tier 1, then the one of tier 2");
assert!(out.iter().all(|n| n.extras().intertwine.is_none() && n.extras().intertwine_tiers.is_empty()), "the expansion is plain art");
let copied = |n: &Node| match &n.kind {
NodeKind::Group { children, clip: true } => children[1].path_data().cloned(),
k => panic!("a clip group: {k:?}"),
};
assert_eq!(copied(&out[2]).as_ref(), kids[1].path_data());
assert_eq!(copied(&out[3]).as_ref(), kids[0].path_data());
}
#[test]
fn a_hidden_member_is_not_drawn_over_the_others() {
let mut a = (*square(1, 0.0)).clone();
+4 -1
View File
@@ -24,11 +24,13 @@ pub mod intertwine;
pub mod shared;
mod symbol_export_type;
mod symbol_nine_slice;
pub mod symbol_overrides;
mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
pub use symbol_overrides::SymbolOverride;
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
pub mod live;
@@ -107,7 +109,8 @@ pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign,
WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
+109 -2
View File
@@ -63,7 +63,7 @@ pub use expand::{
};
#[cfg(test)]
use mesh::grid_eval;
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
#[cfg(test)]
@@ -181,6 +181,44 @@ mod tests {
assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}");
}
/// #3592: a mesh read from a file can have any number of patches, and the view any zoom, so the
/// quads one tessellation makes are budgeted: the subdivision per patch gives way before memory
/// does, and each patch stays at least one quad.
#[test]
fn a_large_mesh_tessellates_within_the_quad_budget() {
let mesh = |n: u32| GradientMesh::from_surface(n, n, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
// 3,600 patches at 32 x 32 would be 3.7 million quads.
let m = mesh(60);
let quads = m.quads(32);
assert!(quads.len() <= GradientMesh::MAX_QUADS, "{} quads", quads.len());
assert!(quads.len() >= 3600 * 4, "still finer than a quad per patch: {}", quads.len());
// A small mesh keeps the subdivision asked for.
assert_eq!(mesh(3).quads(8).len(), 9 * 64);
// The finest the view asks for, on a mesh of 22,500 patches, is a few quads per patch.
let big = mesh(150).quads(64);
assert!(big.len() <= GradientMesh::MAX_QUADS && big.len() >= 22_500, "{} quads", big.len());
// More patches than the budget has quads is malformed, as a point count that doesn't fit is.
let point = MeshPoint { p: Point::ZERO, color: Color::rgb(0.0, 0.0, 0.0), opacity: 1.0, handles: [Vec2::ZERO; 4] };
let huge = GradientMesh { rows: 2000, cols: 2000, points: vec![point; 2001 * 2001] };
assert!(!huge.is_valid());
assert!(huge.quads(1).is_empty());
}
/// Flat pieces of a mesh over the quad budget follow the coarser subdivision [`GradientMesh::quads`]
/// gives way to: each patch's pieces are its own quads', so together they cover the mesh once.
#[test]
fn a_large_meshs_pieces_follow_the_quad_budget() {
let m = GradientMesh::from_surface(60, 60, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
let area = |pts: &[Point]| {
let n = pts.len();
(0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum::<f64>().abs() / 2.0
};
let pieces = m.pieces(32);
assert!(pieces.len() >= 3600, "{} pieces", pieces.len());
let total: f64 = pieces.iter().map(|p| area(&p.pts)).sum();
assert!((total - 90_000.0).abs() < 90.0, "covered {total}");
}
/// A file's rows and columns can be anything: a point count too large to compute is no match
/// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points).
#[test]
@@ -195,17 +233,86 @@ mod tests {
assert_eq!(envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 100.0))(p), p);
}
/// How far the border of `m` strays from the ellipse in `r`, in points (measured across the
/// ellipse's short radius), checked at 20 places along every border edge.
fn mesh_border_off_ellipse(m: &GradientMesh, r: Rect) -> f64 {
let (rows, cols) = (m.rows as usize, m.cols as usize);
let edges = (0..cols).flat_map(|c| [m.edge_u(0, c), m.edge_u(rows, c)]).chain((0..rows).flat_map(|k| [m.edge_v(k, 0), m.edge_v(k, cols)]));
let (c, a, b) = (r.center(), r.width() / 2.0, r.height() / 2.0);
edges
.flat_map(|e| (0..=20).map(move |i| e.eval(f64::from(i) / 20.0)))
.map(|p| ((p.x - c.x) / a).hypot((p.y - c.y) / b) - 1.0)
.fold(0.0f64, |w, d| w.max(d.abs() * a.min(b)))
}
/// #3187: a gradient mesh made from an ellipse keeps the ellipse's shape: a Mesh tool click
/// (a 1×1 mesh with lines through the point) and Create Gradient Mesh's 4×4 grid both lay
/// their border on the outline, within about twice the 0.35 pt the outline is flattened to.
/// The 1×1 mesh was up to 7 pt off this 400 × 300 pt ellipse.
#[test]
fn a_mesh_from_an_ellipse_keeps_its_outline() {
const OFF: f64 = 0.75;
let r = Rect::new(100.0, 100.0, 500.0, 400.0);
let e = shapes::ellipse(r);
for (rows, cols) in [(1, 1), (4, 4), (2, 5)] {
let m = GradientMesh::for_path(&e, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let off = mesh_border_off_ellipse(&m, r);
assert!(off < OFF, "{rows}×{cols} mesh: border {off} pt off the ellipse");
}
let made = GradientMesh::for_path(&e, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let mut m = made.clone();
// Off centre, so each line splits its edges unevenly.
let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap();
let off = mesh_border_off_ellipse(&m, r);
assert!(off < OFF, "after a Mesh tool click: {off} pt");
// Deleting the lines again (Alt-click) gives the border back as it was.
assert!(m.remove_point_lines(i));
assert_eq!((m.rows, m.cols), (1, 1));
for (k, (now, was)) in m.points.iter().zip(&made.points).enumerate() {
let moved = now.handles.iter().zip(&was.handles).map(|(a, b)| (*a - *b).hypot()).fold(0.0, f64::max);
assert!(now.p.distance(was.p) < 1e-6 && moved < 1e-3, "point {k}: {now:?}, made as {was:?}");
}
// With a handle at the new border point shortened since, the two edges no longer make one
// curve: the merged edge is straight rather than grown past its ends.
let mut m = made.clone();
let i = m.add_lines_at(Point::new(200.0, 330.0), 0.0).unwrap();
let top = m.idx(0, 1);
m.points[top].handles[H_LEFT] *= 1e-4;
assert!(m.remove_point_lines(i));
let (a, b) = (m.points[0].p, m.points[1].p);
assert!((m.points[0].handles[H_RIGHT] - (b - a) / 3.0).hypot() < 1e-9, "{:?}", m.points[0].handles);
// A rectangle's mesh keeps straight edges with handles a third of the way along.
let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 90.0, 60.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let e = m.edge_u(0, 0);
assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}");
}
#[test]
fn mesh_insert_and_remove_lines() {
let mut m =
GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let i = m.add_lines_at(Point::new(30.0, 60.0)).unwrap();
let i = m.add_lines_at(Point::new(30.0, 60.0), 0.0).unwrap();
assert_eq!((m.rows, m.cols), (2, 2));
assert!(m.points[i].p.distance(Point::new(30.0, 60.0)) < 0.5, "{:?}", m.points[i].p);
assert!(m.remove_point_lines(i));
assert_eq!((m.rows, m.cols), (1, 1));
}
/// A point outside the mesh within the tolerance locates the nearest point of its edge.
#[test]
fn mesh_locate_within_reaches_the_edge() {
let m = GradientMesh::for_path(&shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
let p = Point::new(102.0, 30.0);
assert_eq!(m.locate(p), None);
assert_eq!(m.locate_within(p, 1.0), None);
let (r, c, u, v) = m.locate_within(p, 3.0).unwrap();
assert_eq!((r, c), (0, 1));
assert_eq!(u, 1.0, "on the right edge");
let q = m.eval(r, c, u, v);
assert!(q.distance(Point::new(100.0, 30.0)) < 1e-6, "{q:?}");
assert_eq!(m.locate_within(Point::new(30.0, 60.0), 3.0), m.locate(Point::new(30.0, 60.0)));
}
/// Corners all in CMYK mix in CMYK (the inks halfway), not in RGB.
#[test]
fn a_cmyk_mesh_mixes_its_inks() {
+3 -2
View File
@@ -293,9 +293,10 @@ pub fn is_live(n: &Node) -> bool {
)
}
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
/// Mesh tessellation as flat-filled paths (no stroke): each patch cut `n`×`n`, its quads joined
/// where the colour hardly changes ([`GradientMesh::pieces`]).
pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec<Node> {
m.quads(n)
m.pieces(n)
.into_iter()
.map(|q| {
let mut ap = Appearance::default();
+291 -16
View File
@@ -35,6 +35,15 @@ pub struct MeshQuad {
pub corners: [(Color, f32); 4],
}
/// One flat-coloured piece of a mesh ([`GradientMesh::pieces`]): a closed outline through
/// tessellation points, its colour and opacity.
#[derive(Clone, Debug, PartialEq)]
pub struct MeshPiece {
pub pts: Vec<Point>,
pub color: Color,
pub opacity: f32,
}
/// Create Gradient Mesh → Appearance.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MeshAppearance {
@@ -157,6 +166,76 @@ impl Coons {
// Gradient meshes
// =====================================================================================
/// The handles, as offsets from `ends`, of the cubic from `ends.0` to `ends.1` that comes closest
/// (least squares) to `side` between `span.0` and `span.1`; `None` when they can't be computed.
/// Each point of the side is matched to the curve's nearest point, refined over a few rounds.
fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(Vec2, Vec2)> {
const N: usize = 32;
const ROUNDS: usize = 6;
let (p0, p3) = (ends.0, ends.1);
let qs: Vec<Point> = (1..N).map(|i| Coons::sample(side, span.0 + (span.1 - span.0) * i as f64 / N as f64)).collect();
let mut ts: Vec<f64> = (1..N).map(|i| i as f64 / N as f64).collect();
let mut fit = None;
for _ in 0..ROUNDS {
let (mut c11, mut c12, mut c22) = (0.0, 0.0, 0.0);
let (mut x1, mut x2) = (Vec2::ZERO, Vec2::ZERO);
for (q, &t) in qs.iter().zip(&ts) {
let s = 1.0 - t;
let (b0, b1, b2, b3) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t);
let r = q.to_vec2() - b0 * p0.to_vec2() - b3 * p3.to_vec2();
c11 += b1 * b1;
c12 += b1 * b2;
c22 += b2 * b2;
x1 += b1 * r;
x2 += b2 * r;
}
let det = c11 * c22 - c12 * c12;
if det.abs() < 1e-12 {
break;
}
let (p1, p2) = (((c22 * x1 - c12 * x2) / det).to_point(), ((c11 * x2 - c12 * x1) / det).to_point());
if !(p1.is_finite() && p2.is_finite()) {
break;
}
fit = Some((p1 - p0, p2 - p3));
// One Newton step towards the nearest point of the curve for each point of the side.
let c = CubicBez::new(p0, p1, p2, p3);
let (a, b, k) = (p1 - p0, p2 - p1, p3 - p2);
for (q, t) in qs.iter().zip(ts.iter_mut()) {
let s = 1.0 - *t;
let v = 3.0 * (s * s * a + 2.0 * s * *t * b + *t * *t * k);
let acc = 6.0 * (s * (b - a) + *t * (k - b));
let e = c.eval(*t) - *q;
let den = v.hypot2() + e.dot(acc);
if den.abs() > 1e-12 {
*t = (*t - e.dot(v) / den).clamp(0.0, 1.0);
}
}
}
fit
}
/// The handles of one cubic standing for two edges that meet at point `mid.0`, whose handles
/// towards them are `mid.1` (towards `a`) and `mid.2` (towards `b`), as offsets from their outer
/// ends: each outer handle grown back by the share of the curve its edge had, which undoes the
/// split [`GradientMesh::insert_row`] and [`GradientMesh::insert_col`] make, so deleting a mesh
/// line keeps the curve of the border it crossed (#3187). The share is the split parameter, read
/// from the lengths of the middle handles. When the edges no longer fit one cubic (a point or a
/// handle was moved since the split), the cubic grown back would not pass through the middle
/// point, and the merged edge is straight.
fn merged_handles(a: (Point, Vec2), mid: (Point, Vec2, Vec2), b: (Point, Vec2)) -> (Vec2, Vec2) {
let straight = ((b.0 - a.0) / 3.0, (a.0 - b.0) / 3.0);
let (la, lb) = (mid.1.hypot(), mid.2.hypot());
let t = la / (la + lb);
if !(0.01..=0.99).contains(&t) {
return straight;
}
let (ha, hb) = (a.1 / t, b.1 / (1.0 - t));
let through = CubicBez::new(a.0, a.0 + ha, b.0 + hb, b.0).eval(t);
let tol = 1e-3 * (a.0.distance(mid.0) + mid.0.distance(b.0)).max(1e-9);
if ha.is_finite() && hb.is_finite() && through.distance(mid.0) <= tol { (ha, hb) } else { straight }
}
/// `0, 1/n, …, 1` for `n` (clamped to 1..=200) even steps.
fn even_steps(n: u32) -> Vec<f64> {
let n = n.clamp(1, 200);
@@ -170,11 +249,22 @@ pub(super) fn grid_len(rows: u32, cols: u32) -> Option<usize> {
}
impl GradientMesh {
/// The most quads one tessellation ([`Self::quads`]) makes, and so the most patches a mesh can
/// have: about 170 bytes a quad, so about 170 MB. A file's mesh can have any number of patches
/// and the view any zoom; the subdivision per patch gives way before memory does, and a mesh
/// with more patches than that is malformed, like a point count that doesn't fit (#3592).
pub const MAX_QUADS: usize = 1 << 20;
pub fn idx(&self, r: usize, c: usize) -> usize {
r * (self.cols as usize + 1) + c
}
pub fn is_valid(&self) -> bool {
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len())
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) && self.patches() <= Self::MAX_QUADS
}
/// The number of patches (rows times columns), saturating.
fn patches(&self) -> usize {
(self.rows as usize).saturating_mul(self.cols as usize)
}
/// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v
@@ -263,7 +353,49 @@ impl GradientMesh {
/// colour and opacity `color_at` gives at its position (a gradient fill's, say).
pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<Self> {
let coons = Coons::from_path(path)?;
Some(Self::from_grid(&even_steps(cols), &even_steps(rows), &|u, v| coons.eval(u, v), &|_, _, p| color_at(p)))
let (us, vs) = (even_steps(cols), even_steps(rows));
let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p));
m.fit_border(&coons, &us, &vs);
Some(m)
}
/// Give each edge on the mesh's border the handles that make it follow the outline it stands
/// for, in place of the handles [`Self::from_grid`] estimates from the surface's derivatives
/// (#3187). Where two border edges meet along a side, their handles are made collinear, so the
/// border stays smooth there; the four corners keep the handles their own edges were fitted with.
fn fit_border(&mut self, coons: &Coons, us: &[f64], vs: &[f64]) {
let (rows, cols) = (self.rows as usize, self.cols as usize);
if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 {
return;
}
let edges = [(0, &coons.top), (rows, &coons.bottom)]
.map(|(r, side)| (r, side, true))
.into_iter()
.chain([(0, &coons.left), (cols, &coons.right)].map(|(c, side)| (c, side, false)));
for (line, side, along_u) in edges {
let (n, steps) = if along_u { (cols, us) } else { (rows, vs) };
for k in 0..n {
let (a, b, out, back) = if along_u {
(self.idx(line, k), self.idx(line, k + 1), H_RIGHT, H_LEFT)
} else {
(self.idx(k, line), self.idx(k + 1, line), H_DOWN, H_UP)
};
if let Some((h1, h2)) = fit_edge(side, (steps[k], steps[k + 1]), (self.points[a].p, self.points[b].p)) {
self.points[a].handles[out] = h1;
self.points[b].handles[back] = h2;
}
}
for k in 1..n {
let (i, out, back) = if along_u { (self.idx(line, k), H_RIGHT, H_LEFT) } else { (self.idx(k, line), H_DOWN, H_UP) };
let h = &mut self.points[i].handles;
let (fwd, bwd) = (h[out], h[back]);
let dir = (fwd.normalize() - bwd.normalize()).normalize();
if fwd.hypot2() > 0.0 && bwd.hypot2() > 0.0 && dir.is_finite() {
h[out] = dir * fwd.hypot();
h[back] = -dir * bwd.hypot();
}
}
}
}
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
@@ -347,13 +479,20 @@ impl GradientMesh {
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
}
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
/// The quads per patch side [`Self::quads`] cuts at when `n` is asked: 1 to 64, and coarser
/// where the patches at `n` would pass [`Self::MAX_QUADS`] (a quad per patch is the least).
fn subdivision(&self, n: usize) -> usize {
n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches().max(1)) as f64).sqrt() as usize).max(1)
}
/// Tessellate into `n`×`n` quads per patch ([`Self::subdivision`]), each coloured at its centre
/// and its corners.
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
if !self.is_valid() {
return vec![];
}
let n = n.clamp(1, 64);
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
let n = self.subdivision(n);
let mut out = Vec::with_capacity(self.patches() * n * n);
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
for r in 0..self.rows as usize {
@@ -383,6 +522,105 @@ impl GradientMesh {
out
}
/// Flat-coloured pieces covering the mesh, for outputs that can't shade a mesh smoothly (SVG,
/// Expand): each patch cut into `n`×`n` quads as by [`Self::quads`], then neighbouring quads
/// joined into square blocks as large as keeps the colour across each block within about one
/// 8-bit step. A patch of one colour is one piece; a patch whose colour changes a lot keeps
/// all its quads. A piece's outline runs through its quads' outer corners, so it covers what
/// they covered, and it takes the colour at its centre.
pub fn pieces(&self, n: usize) -> Vec<MeshPiece> {
let n = self.subdivision(n);
let quads = self.quads(n);
let cols = (self.cols as usize).max(1);
let mut out = Vec::with_capacity(quads.len());
for (k, patch) in quads.chunks_exact(n * n).enumerate() {
let (r, c) = (k / cols, k % cols);
let side = self.block_side(r, c, n, patch);
if side == 1 {
out.extend(patch.iter().map(|q| MeshPiece { pts: q.pts.to_vec(), color: q.color, opacity: q.opacity }));
continue;
}
// Tessellation point (i, j) of the patch (0..=n each), from a quad it is a corner of.
let at = |i: usize, j: usize| -> Option<Point> {
let (qi, qj) = (i.min(n - 1), j.min(n - 1));
let q = patch.get(qj * n + qi)?;
q.pts
.get(match (i > qi, j > qj) {
(false, false) => 0,
(true, false) => 1,
(true, true) => 2,
(false, true) => 3,
})
.copied()
};
for j0 in (0..n).step_by(side) {
for i0 in (0..n).step_by(side) {
let (i1, j1) = (i0 + side, j0 + side);
// Around the block, in the quads' order: along its top, down its right side,
// back along its bottom and up its left side.
let ring = (i0..i1)
.map(|i| (i, j0))
.chain((j0..j1).map(|j| (i1, j)))
.chain((i0 + 1..=i1).rev().map(|i| (i, j1)))
.chain((j0 + 1..=j1).rev().map(|j| (i0, j)));
let Some(pts) = ring.map(|(i, j)| at(i, j)).collect::<Option<Vec<Point>>>() else { continue };
let half = side as f64 / 2.0;
let (color, opacity) = self.color_at(r, c, (i0 as f64 + half) / n as f64, (j0 as f64 + half) / n as f64);
out.push(MeshPiece { pts, color, opacity });
}
}
}
out
}
/// The side, in quads, of the blocks [`Self::pieces`] joins patch (r, c) into, cut into
/// `n`×`n` `quads`: the largest divisor of `n` that keeps the colour changing by at most one
/// 8-bit step across a block. 1 (no joining) where the patch folds over itself, as the outline
/// of a block across the fold wouldn't fill it as its quads do.
fn block_side(&self, r: usize, c: usize, n: usize, quads: &[MeshQuad]) -> usize {
let corner = |dr: usize, dc: usize| self.points.get(self.idx(r + dr, c + dc));
let (Some(p00), Some(p01), Some(p10), Some(p11)) = (corner(0, 0), corner(0, 1), corner(1, 0), corner(1, 1)) else {
return 1;
};
// Corners all in CMYK mix in their inks ([`Self::color_at`]); the RGB they show is checked too.
let inks = [p00, p01, p10, p11].iter().all(|p| matches!(p.color, Color::Cmyk { .. }));
let channels = |p: &MeshPoint| -> Vec<f32> {
let mut v = p.color.to_rgb().to_vec();
if inks && let Color::Cmyk { c, m, y, k } = p.color {
v.extend([c, m, y, k]);
}
v.push(p.opacity);
v
};
let [a, b, cc, d] = [p00, p01, p10, p11].map(channels);
// A bilinear patch changes across a block of side h (0..1 of the patch) by at most
// h × (du + dv) in each channel, du and dv its largest change along each parameter.
let mut steps = 0.0f32;
for k in 0..a.len() {
let ch = |v: &[f32]| v.get(k).copied().unwrap_or_default();
let du = (ch(&a) - ch(&b)).abs().max((ch(&cc) - ch(&d)).abs());
let dv = (ch(&a) - ch(&cc)).abs().max((ch(&b) - ch(&d)).abs());
steps = steps.max((du + dv) * 255.0);
}
if !steps.is_finite() {
return 1;
}
let side = (1..=n).rev().find(|&s| n.is_multiple_of(s) && (n / s) as f32 >= steps).unwrap_or(1);
if side > 1 {
// Every quad turning the same way: the patch doesn't fold, so a block's outline
// encloses just what its quads cover.
let turn = |q: &MeshQuad| {
let [a, b, c, d] = q.pts;
(b - a).cross(c - a) + (c - a).cross(d - a)
};
let first = quads.first().map(turn).unwrap_or_default();
if !quads.iter().all(|q| turn(q) * first > 0.0) {
return 1;
}
}
side
}
/// The mesh's outer boundary as a closed path.
pub fn outline(&self) -> PathData {
if !self.is_valid() {
@@ -466,12 +704,21 @@ impl GradientMesh {
/// Which patch and local (u, v) contains `p` (sampled search; `None` outside the mesh).
pub fn locate(&self, p: Point) -> Option<(usize, usize, f64, f64)> {
self.locate_within(p, 0.0)
}
/// As [`Self::locate`], and for a point outside the mesh within `tol` of its outline, the
/// patch and local (u, v) of the nearest point of the mesh's edge.
pub fn locate_within(&self, p: Point, tol: f64) -> Option<(usize, usize, f64, f64)> {
if !self.is_valid() {
return None;
}
let bp = self.outline().to_bezpath();
if bp.winding(p) == 0 {
return None;
if tol.is_nan() || tol <= 0.0 {
return None;
}
return self.nearest_edge(p).filter(|e| e.0 <= tol * tol).map(|(_, r, c, u, v)| (r, c, u, v));
}
const N: usize = 16;
let mut best = (f64::INFINITY, 0, 0, 0.0, 0.0);
@@ -508,6 +755,30 @@ impl GradientMesh {
Some((r, c, u, v))
}
/// The point of the mesh's outer edge nearest `p`: its squared distance from `p`, and its
/// boundary patch and local (u, v).
fn nearest_edge(&self, p: Point) -> Option<(f64, usize, usize, f64, f64)> {
use vectorcraft_geom::kurbo::ParamCurveNearest;
let (rows, cols) = (self.rows as usize, self.cols as usize);
let mut best: Option<(f64, usize, usize, f64, f64)> = None;
let mut take = |e: CubicBez, at: (usize, usize, Option<f64>, Option<f64>)| {
let n = e.nearest(p, 1e-9);
if best.is_none_or(|b| n.distance_sq < b.0) {
let (r, c, u, v) = at;
best = Some((n.distance_sq, r, c, u.unwrap_or(n.t), v.unwrap_or(n.t)));
}
};
for c in 0..cols {
take(self.edge_u(0, c), (0, c, None, Some(0.0)));
take(self.edge_u(rows, c), (rows - 1, c, None, Some(1.0)));
}
for r in 0..rows {
take(self.edge_v(r, 0), (r, 0, Some(0.0), None));
take(self.edge_v(r, cols), (r, cols - 1, Some(1.0), None));
}
best
}
/// Index of the mesh point within `tol` of `p`.
pub fn point_near(&self, p: Point, tol: f64) -> Option<usize> {
self.points
@@ -595,9 +866,10 @@ impl GradientMesh {
self.cols += 1;
}
/// Mesh tool click: add a row and a column through `p` (inside the mesh). Returns the new point index.
pub fn add_lines_at(&mut self, p: Point) -> Option<usize> {
let (r, c, u, v) = self.locate(p)?;
/// Mesh tool click: add a row and a column through `p` (inside the mesh, or outside within
/// `tol` of its edge, through the nearest point of the edge). Returns the new point index.
pub fn add_lines_at(&mut self, p: Point, tol: f64) -> Option<usize> {
let (r, c, u, v) = self.locate_within(p, tol)?;
let mut rr = r;
let mut cc = c;
let row_new = v > 0.02 && v < 0.98;
@@ -632,10 +904,11 @@ impl GradientMesh {
let above = self.idx(r - 1, cc);
let below = self.idx(r + 1, cc);
let mid = self.idx(r, cc);
let (pa, pm, pb) = (self.points[above].p, self.points[mid].p, self.points[below].p);
let _ = pm;
self.points[above].handles[H_DOWN] = (pb - pa) / 3.0;
self.points[below].handles[H_UP] = (pa - pb) / 3.0;
let (pa, pb, m) = (self.points[above].p, self.points[below].p, &self.points[mid]);
let hm = (m.p, m.handles[H_UP], m.handles[H_DOWN]);
let (down, up) = merged_handles((pa, self.points[above].handles[H_DOWN]), hm, (pb, self.points[below].handles[H_UP]));
self.points[above].handles[H_DOWN] = down;
self.points[below].handles[H_UP] = up;
}
self.points.drain(r * w..(r + 1) * w);
self.rows -= 1;
@@ -646,9 +919,11 @@ impl GradientMesh {
let rows = self.rows as usize;
for rr in 0..=rows {
let (l, rt) = (rr * w + c - 1, rr * w + c + 1);
let (pl, pr) = (self.points[l].p, self.points[rt].p);
self.points[l].handles[H_RIGHT] = (pr - pl) / 3.0;
self.points[rt].handles[H_LEFT] = (pl - pr) / 3.0;
let (pl, pr, m) = (self.points[l].p, self.points[rt].p, &self.points[rr * w + c]);
let hm = (m.p, m.handles[H_LEFT], m.handles[H_RIGHT]);
let (right, left) = merged_handles((pl, self.points[l].handles[H_RIGHT]), hm, (pr, self.points[rt].handles[H_LEFT]));
self.points[l].handles[H_RIGHT] = right;
self.points[rt].handles[H_LEFT] = left;
}
let mut k = 0;
self.points.retain(|_| {
+4
View File
@@ -253,6 +253,10 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps) + boxed(&e.colorize);
b += e.trace.as_ref().map_or(0, |t| ALLOC + size_of::<Value>() + json_bytes(t));
b += e.intertwine.as_deref().map_or(0, |a| ALLOC + size_of::<PathData>() + path_bytes(a));
if e.intertwine_tiers.capacity() > 0 {
b += ALLOC + e.intertwine_tiers.capacity() * size_of::<PathData>() + e.intertwine_tiers.iter().map(path_bytes).sum::<usize>();
}
b += e.symbol_overrides.capacity() * size_of::<crate::SymbolOverride>();
b += e.objects_on_path.as_deref().map_or(0, |o| {
ALLOC + size_of::<crate::objects_on_path::ObjectsOnPath>() + o.members.capacity() * size_of::<crate::objects_on_path::Member>()
});
+10
View File
@@ -44,6 +44,11 @@ pub struct NodeExtras {
/// Object › Intertwine: as a member of an Intertwine group, the area (document coordinates,
/// filled non-zero) where this object is drawn over the group's other members.
pub intertwine: Option<Box<PathData>>,
/// Object › Intertwine: as a member of an Intertwine group, the areas (document coordinates,
/// filled non-zero) where this object is drawn over members that are themselves drawn over
/// others there. Item `k` is the area of tier `k + 2`; [`NodeExtras::intertwine`] is tier 1
/// (see [`crate::intertwine`]).
pub intertwine_tiers: Vec<PathData>,
/// Live Paint group: its Gap Options (Object › Live Paint › Gap Options; `None`: the defaults).
pub live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
/// Object › Objects on Path: the group's path and the objects attached to it (see
@@ -58,6 +63,9 @@ pub struct NodeExtras {
pub compound_shape: bool,
/// As a member of a compound shape, how this object combines with the members below it.
pub shape_mode: crate::ShapeMode,
/// As an instance of a dynamic symbol, its own appearance changes to objects of the symbol's
/// art (see [`crate::symbol_overrides`]); empty for none.
pub symbol_overrides: Vec<crate::SymbolOverride>,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -76,11 +84,13 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
perspective: None,
dimension: None,
intertwine: None,
intertwine_tiers: Vec::new(),
live_paint_gaps: None,
objects_on_path: None,
colorize: None,
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
symbol_overrides: Vec::new(),
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+17 -5
View File
@@ -496,8 +496,13 @@ impl Node {
Arc::make_mut(&mut m.art).transform_scaled(a, sc);
}
// The area where an Intertwine member is drawn over the others moves with it.
if let Some(area) = self.extras_if_any().and_then(|e| e.intertwine.as_mut()) {
area.transform(a);
if let Some(e) = self.extras_if_any() {
if let Some(area) = e.intertwine.as_mut() {
area.transform(a);
}
for area in &mut e.intertwine_tiers {
area.transform(a);
}
}
}
@@ -683,9 +688,15 @@ impl Node {
impl Node {
/// Whether this object shows any transparency, itself or anything inside it: object, fill or
/// stroke opacity below 100%, a blend mode, an opacity mask, effects or transparent gradient
/// stops. Hidden objects show none.
/// stroke opacity below 100%, a blend mode, an opacity mask, effects, transparent gradient
/// stops or a colorized image. Hidden objects show none.
pub fn shows_transparency(&self) -> bool {
self.shows_transparency_with(&|_| true)
}
/// [`Self::shows_transparency`], counting a colorized image by itself only when `colorize`
/// accepts it, for files that write some colorized images in their colorize paint (EPS).
pub fn shows_transparency_with(&self, colorize: &dyn Fn(&Node) -> bool) -> bool {
use vectorcraft_color::Paint;
let see_through = |p: &Paint| matches!(p, Paint::Gradient(g) if g.gradient.stops.iter().any(|s| s.opacity < 1.0));
let effects = |fx: &[crate::Effect]| fx.iter().any(|e| e.visible);
@@ -693,13 +704,14 @@ impl Node {
&& (self.opacity < 1.0
|| self.blend != BlendMode::Normal
|| self.extras().mask.as_ref().is_some_and(|m| !m.disabled)
|| (matches!(self.kind, NodeKind::Image(_)) && self.extras().colorize.is_some() && colorize(self))
|| effects(&self.appearance.effects)
|| self
.appearance
.items
.iter()
.any(|i| i.visible() && (i.opacity() < 1.0 || i.blend() != BlendMode::Normal || see_through(i.paint()) || effects(i.effects())))
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency())))
|| self.children().is_some_and(|ch| ch.iter().any(|c| c.shows_transparency_with(colorize))))
}
}
+46
View File
@@ -339,6 +339,35 @@ fn transform_group_recurses() {
assert_eq!(child.appearance.stroke_width(), 2.0);
}
/// A symbol instance in perspective keeps its picture moved by a transform, and loses its
/// projection when no invertible map of finite numbers draws the moved picture.
#[test]
fn a_projection_follows_the_transform_or_is_removed() {
use vectorcraft_geom::{Homography, Point};
let instance = |h: Homography| {
let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY });
n.extras_mut().perspective =
Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) }));
n
};
let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection);
// A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down.
let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]);
let mut n = instance(big);
n.transform(Affine::scale(10.0), false);
let p = Point::new(3.0, 7.0);
assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9);
// A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0).
let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
let mut n = instance(far);
n.transform(Affine::scale(1e150), false);
assert_eq!(projection(&n), None);
// A transform that is not invertible leaves the projection as it is.
let mut n = instance(far);
n.transform(Affine::scale(0.0), false);
assert_eq!(projection(&n), Some(far.to_array()));
}
#[test]
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
@@ -455,6 +484,23 @@ fn objects_are_read_within_the_node_budget() {
assert!(with_node_budget(3, read).is_ok());
}
/// An Intertwine member's areas of tier 2 and up are written and read back, and each one counts
/// against [`with_node_budget`] as an object does, so a file of many empty areas is refused.
#[test]
fn intertwine_tiers_are_read_within_the_node_budget() {
let mut n = Node::group(NodeId(1), vec![]);
let area = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
n.extras_mut().intertwine_tiers = vec![PathData::default(), area];
let json = serde_json::to_string(&n).unwrap();
assert!(json.contains("\"intertwine_tiers\""), "{json}");
let read = || serde_json::from_str::<Node>(&json);
assert_eq!(with_node_budget(3, read).unwrap(), n, "the object and its two areas fit a budget of three");
let why = with_node_budget(2, read).unwrap_err().to_string();
assert!(why.contains("more than 2 objects"), "{why}");
let plain = serde_json::to_string(&Node::group(NodeId(1), vec![])).unwrap();
assert!(!plain.contains("intertwine_tiers"), "an object without tiers writes none: {plain}");
}
/// #3587: fields read before an object's `type` are buffered value by value within
/// [`with_buffered_value_budget`], each counted once: a group read back from the buffer buffers
/// its own fields again without counting them twice. VectorCraft writes `type` first, which
+40 -2
View File
@@ -46,11 +46,13 @@ impl Serialize for Node {
e.perspective.is_none(),
e.dimension.is_none(),
e.intertwine.is_none(),
e.intertwine_tiers.is_empty(),
e.live_paint_gaps.is_none(),
e.objects_on_path.is_none(),
e.colorize.is_none(),
is_default(&e.compound_shape),
is_default(&e.shape_mode),
e.symbol_overrides.is_empty(),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
@@ -85,11 +87,13 @@ impl Serialize for Node {
field!("perspective", &e.perspective);
field!("dimension", &e.dimension);
field!("intertwine", &e.intertwine);
field!("intertwine_tiers", &e.intertwine_tiers);
field!("live_paint_gaps", &e.live_paint_gaps);
field!("objects_on_path", &e.objects_on_path);
field!("colorize", &e.colorize);
field!("compound_shape", &e.compound_shape);
field!("shape_mode", &e.shape_mode);
field!("symbol_overrides", &e.symbol_overrides);
st.end()
}
}
@@ -123,11 +127,39 @@ pub fn with_node_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
/// each counts once, and the id usually comes first, before the node's children are read.
fn counted_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<NodeId, D::Error> {
let id = NodeId::deserialize(d)?;
count_one()?;
Ok(id)
}
/// Count one object against [`with_node_budget`].
fn count_one<E: serde::de::Error>() -> Result<(), E> {
if let Some((left, max)) = NODES_LEFT.get() {
let left = left.checked_sub(1).ok_or_else(|| serde::de::Error::custom(format!("the document has more than {max} objects")))?;
let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("the document has more than {max} objects")))?;
NODES_LEFT.set(Some((left, max)));
}
Ok(id)
Ok(())
}
/// A list read from a file (a member's Intertwine areas of tier 2 and up, an instance's symbol
/// overrides), each item counting against [`with_node_budget`] as an object does: a compressed
/// file of repeated empty items stays small while they take memory once read.
fn counted_list<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result<Vec<T>, D::Error> {
struct Items<T>(std::marker::PhantomData<T>);
impl<'de, T: Deserialize<'de>> serde::de::Visitor<'de> for Items<T> {
type Value = Vec<T>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<T>, A::Error> {
let mut out = Vec::new();
while let Some(item) = seq.next_element::<T>()? {
count_one()?;
out.push(item);
}
Ok(out)
}
}
d.deserialize_seq(Items(std::marker::PhantomData))
}
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
@@ -186,6 +218,8 @@ pub(super) struct NodeWire {
dimension: Option<Box<crate::dimension::Dimension>>,
#[serde(default)]
intertwine: Option<Box<PathData>>,
#[serde(default, deserialize_with = "counted_list")]
intertwine_tiers: Vec<PathData>,
#[serde(default)]
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
@@ -196,6 +230,8 @@ pub(super) struct NodeWire {
compound_shape: bool,
#[serde(default)]
shape_mode: crate::ShapeMode,
#[serde(default, deserialize_with = "counted_list")]
symbol_overrides: Vec<crate::SymbolOverride>,
}
impl From<NodeWire> for Node {
@@ -215,11 +251,13 @@ impl From<NodeWire> for Node {
perspective: w.perspective,
dimension: w.dimension,
intertwine: w.intertwine,
intertwine_tiers: w.intertwine_tiers,
live_paint_gaps: w.live_paint_gaps,
objects_on_path: w.objects_on_path,
colorize: w.colorize,
compound_shape: w.compound_shape,
shape_mode: w.shape_mode,
symbol_overrides: w.symbol_overrides,
};
Node {
id: w.id,
+1 -1
View File
@@ -337,7 +337,7 @@ mod tests {
// The art is a 40 × 10 box to the right of and below the origin, not the 20 × 20 square
// around it that instances are measured by without the symbols.
let mut d = crate::Document::new(400.0, 400.0);
d.symbols.push(crate::Symbol { name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: Arc::new(rect(9, Rect::new(0.0, 0.0, 40.0, 10.0))) });
let line = Node::path(NodeId(1), shapes::line(Point::new(100.0, 300.0), Point::new(300.0, 300.0)), Default::default());
let instance = Node::new(NodeId(2), NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::IDENTITY });
let other = rect(3, Rect::new(0.0, 0.0, 10.0, 10.0));
+1 -1
View File
@@ -200,7 +200,7 @@ mod tests {
fn oriented_bounds_hug_a_rotated_symbol_instances_art() {
let mut d = Document::new(500.0, 500.0);
let bar = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 10.0)), Appearance::default_art());
d.symbols.push(crate::Symbol { name: "Bar".into(), art: std::sync::Arc::new(bar) });
d.symbols.push(crate::Symbol { dynamic: false, name: "Bar".into(), art: std::sync::Arc::new(bar) });
let (l, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(l), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Bar".into(), xf: Affine::translate((100.0, 100.0)) })).unwrap();
let c = Point::new(120.0, 105.0);
+5 -3
View File
@@ -58,12 +58,14 @@ impl Node {
}
}
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). An
/// invertible `a` that the projection cannot follow removes it, and the art is drawn flat.
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut())
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
&& let Some(h) = p.homography()
&& a.determinant().abs() >= 1e-15
{
p.projection = Some(h.to_array());
p.projection = h.conjugated(a).map(|h| h.to_array());
}
}
}
+6 -2
View File
@@ -667,7 +667,7 @@ mod tests {
pixels: None,
})),
);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Star".into(), art: Arc::new(art) });
d.images.insert("pic".into(), ImageBlob::new("image/png", vec![1, 2, 3]));
let mut children = vec![Arc::new(Node::new(NodeId(51), NodeKind::SymbolInstance { symbol: "Star".into(), xf: Affine::IDENTITY }))];
if bytes.starts_with(b"nest") {
@@ -702,7 +702,11 @@ mod tests {
fn the_art_and_its_resources_join_only_a_prepared_copy_under_their_own_names() {
set_loader(stub_loader);
let mut d = Document::new(200.0, 200.0);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })) });
d.symbols.push(Symbol {
dynamic: false,
name: "Star".into(),
art: Arc::new(Node::new(NodeId(60), NodeKind::Group { children: vec![], clip: false })),
});
d.images.insert("doc1-1".into(), ImageBlob::new("application/json", b"nest".to_vec()));
let p = placed("doc1-1");
let layer = d.layers[0].id;
+13 -6
View File
@@ -18,8 +18,8 @@ pub trait ColorVisitor {
fn image(&mut self, key: &str) -> Option<String>;
}
/// What a recolouring pass reaches: fills (with gradient-mesh points), strokes and the pixels of
/// embedded images.
/// What a recolouring pass reaches: fills (including colorized image fills and gradient-mesh
/// points), strokes and the pixels of embedded images without a colorize paint.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Reach {
pub fill: bool,
@@ -33,11 +33,18 @@ impl Reach {
}
/// Recolour `n` and everything inside it through `v`: the paints of its fills and strokes and of
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked images stay
/// as they are). Members that change are replaced by copies; the others stay shared. Returns the
/// number of paints, meshes and images that changed.
/// its text runs, its gradient-mesh points and an embedded image's pixels (linked and colorized
/// images keep their pixels). Members that change are replaced by copies; the others stay shared.
/// Returns the number of paints, meshes and images that changed.
pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usize {
let mut changed = 0;
let colorized = matches!(&n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some();
if colorized
&& reach.fill
&& let Some(paint) = n.extras_mut().colorize.as_deref_mut()
{
changed += v.paint(paint) as usize;
}
for it in &mut n.appearance.items {
match it {
AppearanceItem::Fill(l) if reach.fill => changed += v.paint(&mut l.paint) as usize,
@@ -67,7 +74,7 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi
}
// 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() => {
NodeKind::Image(im) if reach.images && !colorized && im.link.is_none() && im.pixels.is_none() => {
if let Some(k) = v.image(&im.key) {
im.key = k;
changed += 1;
+4
View File
@@ -108,6 +108,10 @@ impl SavedSelection {
pub struct Symbol {
pub name: String,
pub art: Arc<Node>,
/// Symbol Options › Symbol Type › Dynamic Symbol: each instance may change the appearance of
/// objects in the art and stay linked (see [`crate::symbol_overrides`]). Static by default.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub dynamic: bool,
}
/// Encoded image bytes (PNG/JPEG/WebP…) shared by image objects.
+126 -32
View File
@@ -107,14 +107,17 @@ impl Quotes {
/// Single-quote pairs offered by Document Setup.
pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']];
/// Is `c` one of these opening quotes?
/// Is `c` one of these opening quotes? A typographic glyph that is also a closing quote does not
/// count; a straight quote has no direction, so it still opens.
fn is_open(&self, c: char) -> bool {
c == self.double[0] || c == self.single[0]
(c == self.double[0] || c == self.single[0]) && (c.is_ascii() || (c != self.double[1] && c != self.single[1]))
}
/// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote.
fn opens(&self, prev: Option<char>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p))
/// `quote_opened` says whether `prev` is a quote this conversion just produced, and if so whether
/// it opened; that settles glyphs which serve as both opening and closing quote.
fn opens(&self, prev: Option<char>, quote_opened: Option<bool>) -> bool {
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
}
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
@@ -122,11 +125,16 @@ impl Quotes {
/// and is updated to its last character, so text can be converted in pieces.
pub fn apply(&self, text: &str, prev: &mut Option<char>) -> String {
let mut out = String::with_capacity(text.len());
let mut quote_opened = None;
for c in text.chars() {
let r = match c {
'"' => self.double[usize::from(!self.opens(*prev))],
'\'' => self.single[usize::from(!self.opens(*prev))],
c => c,
quote_opened = match c {
'"' | '\'' => Some(self.opens(*prev, quote_opened)),
_ => None,
};
let r = match (c, quote_opened) {
('"', Some(open)) => self.double[usize::from(!open)],
('\'', Some(open)) => self.single[usize::from(!open)],
(c, _) => c,
};
out.push(r);
*prev = Some(r);
@@ -135,34 +143,66 @@ impl Quotes {
}
}
/// Document languages and the quote characters each uses (choosing a language in Document Setup
/// also picks its quotes).
pub const LANGUAGES: &[(&str, Quotes)] = &[
("English: USA", Quotes::ENGLISH),
("English: UK", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("German", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("German: Swiss", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("French", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("Italian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Spanish", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Catalan", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Portuguese", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Dutch", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Danish", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
("Swedish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Finnish", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Norwegian", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
/// Document and character languages: the name, the BCP 47 tag text is shaped in (so that fonts use
/// their language's forms, OpenType `locl`) and the quote characters each uses (choosing a
/// language in Document Setup also picks its quotes). The first is the default.
pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
("English: USA", "en-US", Quotes::ENGLISH),
("English: UK", "en-GB", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("German", "de", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("German: Swiss", "de-CH", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("French", "fr", Quotes { double: ['«', '»'], single: ['‹', '›'] }),
("Italian", "it", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Spanish", "es", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Catalan", "ca", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Portuguese", "pt", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Dutch", "nl", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Danish", "da", Quotes { double: ['»', '«'], single: ['›', '‹'] }),
("Swedish", "sv", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Finnish", "fi", Quotes { double: ['”', '”'], single: ['’', '’'] }),
("Norwegian", "nb", Quotes { double: ['«', '»'], single: ['‘', '’'] }),
("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Turkish", "tr", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Japanese", "ja", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", "zh", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Hindi", "hi", Quotes::ENGLISH),
("Marathi", "mr", Quotes::ENGLISH),
("Gujarati", "gu", Quotes::ENGLISH),
("Bengali", "bn", Quotes::ENGLISH),
("Punjabi", "pa", Quotes::ENGLISH),
("Tamil", "ta", Quotes::ENGLISH),
("Telugu", "te", Quotes::ENGLISH),
("Kannada", "kn", Quotes::ENGLISH),
("Malayalam", "ml", Quotes::ENGLISH),
("Odia", "or", Quotes::ENGLISH),
("Urdu", "ur", Quotes::ENGLISH),
];
/// The quotes of a [`LANGUAGES`] entry (case-insensitive).
pub fn language_quotes(language: &str) -> Option<Quotes> {
LANGUAGES.iter().find(|(l, _)| l.eq_ignore_ascii_case(language)).map(|(_, q)| *q)
LANGUAGES.iter().find(|(l, _, _)| l.eq_ignore_ascii_case(language)).map(|(_, _, q)| *q)
}
/// The [`LANGUAGES`] entry named or tagged `language` (case-insensitive) as a character's
/// language ([`crate::CharStyle::language`]) stores it: its tag, or `None` for the default (the
/// first entry); `Err` for a language not in the list.
pub fn char_language(language: &str) -> Result<Option<&'static str>, String> {
let i = LANGUAGES
.iter()
.position(|(n, t, _)| n.eq_ignore_ascii_case(language) || t.eq_ignore_ascii_case(language))
.ok_or_else(|| format!("unknown language `{language}` (one of {})", LANGUAGES.iter().map(|(n, _, _)| *n).collect::<Vec<_>>().join(", ")))?;
Ok(LANGUAGES.get(i).filter(|_| i > 0).map(|(_, t, _)| *t))
}
/// The name of the language a character's stored `language` tag stands for: the default's for
/// none, `None` for a tag not in [`LANGUAGES`] (from a file).
pub fn char_language_name(language: Option<&str>) -> Option<&'static str> {
match language {
None => LANGUAGES.first().map(|(n, _, _)| *n),
Some(tag) => LANGUAGES.iter().find(|(_, t, _)| t.eq_ignore_ascii_case(tag)).map(|(n, _, _)| *n),
}
}
/// The transparency flattener preset Document Setup uses unless another is chosen.
@@ -300,9 +340,19 @@ impl DocSetup {
pub fn keep_in_range(&mut self) {
self.bleed = bleed_in_range(self.bleed);
for m in [&mut self.superscript, &mut self.subscript] {
// `clamp` passes NaN through, so a non-finite value falls back to the default first.
if !m.size.is_finite() {
m.size = ScriptMetrics::DEFAULT.size;
}
if !m.position.is_finite() {
m.position = ScriptMetrics::DEFAULT.position;
}
m.size = m.size.clamp(1.0, MAX_SCRIPT);
m.position = m.position.clamp(-MAX_SCRIPT, MAX_SCRIPT);
}
if !self.small_caps_size.is_finite() {
self.small_caps_size = DocSetup::default().small_caps_size;
}
self.small_caps_size = self.small_caps_size.clamp(1.0, MAX_SMALL_CAPS);
}
}
@@ -311,6 +361,24 @@ impl DocSetup {
mod tests {
use super::*;
/// Characters store a language of the list by its tag, the default as none.
#[test]
fn char_languages_are_stored_by_tag() {
assert_eq!(char_language("Turkish"), Ok(Some("tr")));
assert_eq!(char_language("MR"), Ok(Some("mr")));
assert_eq!(char_language("english: usa"), Ok(None));
assert_eq!(char_language("en-US"), Ok(None));
assert!(char_language("Klingon").is_err() && char_language("").is_err());
assert_eq!(
(char_language_name(None), char_language_name(Some("gu")), char_language_name(Some("x-unknown"))),
(Some("English: USA"), Some("Gujarati"), None)
);
// Every entry has a tag of its own, and a name of its own.
for (i, (n, t, _)) in LANGUAGES.iter().enumerate() {
assert!(LANGUAGES.iter().skip(i + 1).all(|(n2, t2, _)| n2 != n && t2 != t), "{n} {t}");
}
}
#[test]
fn quotes_open_and_close_by_context() {
let mut prev = None;
@@ -326,6 +394,18 @@ mod tests {
assert_eq!((a.as_str(), b.as_str()), ("“", "“"));
}
#[test]
fn closing_quote_equal_to_other_kinds_opening_quote_still_closes() {
let q = Quotes { double: ['”', '”'], single: ['‘', '’'] };
let mut prev = None;
assert_eq!(q.apply("'\"a\"'", &mut prev), "‘”a”’");
let q = Quotes { double: ['"', '"'], single: ['‘', '’'] };
let mut prev = None;
assert_eq!(q.apply("'\"a\"'", &mut prev), "‘\"a\"’");
let mut prev = None;
assert_eq!(q.apply("\"'a'\"", &mut prev), "\"‘a’\"");
}
#[test]
fn bleed_grows_the_artboard() {
let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() };
@@ -333,6 +413,20 @@ mod tests {
assert!(s.has_bleed() && !DocSetup::default().has_bleed());
}
#[test]
fn keep_in_range_replaces_non_finite_type_values() {
let mut s = DocSetup::default();
s.superscript.size = f64::NAN;
s.superscript.position = f64::INFINITY;
s.subscript.size = f64::NEG_INFINITY;
s.subscript.position = f64::NAN;
s.small_caps_size = f64::NAN;
s.keep_in_range();
for v in [s.superscript.size, s.superscript.position, s.subscript.size, s.subscript.position, s.small_caps_size] {
assert!(v.is_finite(), "{v}");
}
}
#[test]
fn presets_and_defaults() {
let d = DocSetup::default();
+190
View File
@@ -0,0 +1,190 @@
//! Dynamic symbols (Symbol Options › Symbol Type › Dynamic Symbol): an instance of a dynamic
//! symbol keeps appearance changes of its own to objects in the symbol's art, while the art stays
//! the symbol's. Editing the symbol reshapes every instance and each keeps its changes; Reset drops
//! them. The changes are stored on the instance ([`crate::node::NodeExtras::symbol_overrides`]) and
//! applied wherever an instance is drawn or exported ([`dynamic_art`]).
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::{BlendMode, Paint};
use crate::{Node, NodeKind};
/// Deepest object an override may name: groups nested this deep in the symbol's art.
pub const MAX_PATH_DEPTH: usize = 64;
/// One instance's appearance changes to one object of its dynamic symbol's art.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct SymbolOverride {
/// The object: child indices (paint order) from the art's root down through its groups; empty
/// for the root itself.
#[serde(deserialize_with = "short_path")]
pub path: Vec<u32>,
/// The object's (top) fill paint; type takes it as its characters' fill.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fill: Option<Paint>,
/// The object's (top) stroke paint; type takes it as its characters' stroke.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stroke: Option<Paint>,
/// The object's opacity, 0..1.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opacity: Option<f32>,
/// The object's blending mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blend: Option<BlendMode>,
}
impl SymbolOverride {
/// Does this override change nothing?
pub fn is_empty(&self) -> bool {
self.fill.is_none() && self.stroke.is_none() && self.opacity.is_none() && self.blend.is_none()
}
/// Put this override's changes on object `n`.
fn apply(&self, n: &mut Node) {
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if let Some(p) = &self.fill {
r.style.fill = p.clone();
}
if let Some(p) = &self.stroke {
r.style.stroke = p.clone();
}
}
} else {
if let Some(p) = &self.fill {
n.appearance.set_fill(p.clone());
}
if let Some(p) = &self.stroke {
n.appearance.set_stroke(p.clone());
}
}
if let Some(o) = self.opacity.filter(|o| o.is_finite()) {
n.opacity = o.clamp(0.0, 1.0);
}
if let Some(b) = self.blend {
n.blend = b;
}
}
}
/// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file
/// of one endless path stays small once read.
fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
struct Path;
impl<'de> serde::de::Visitor<'de> for Path {
type Value = Vec<u32>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a list of child indices")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u32>, A::Error> {
let mut out = Vec::new();
while let Some(i) = seq.next_element::<u32>()? {
if out.len() == MAX_PATH_DEPTH {
return Err(serde::de::Error::custom(format!("a symbol override path is deeper than {MAX_PATH_DEPTH}")));
}
out.push(i);
}
Ok(out)
}
}
d.deserialize_seq(Path)
}
/// The children a path steps through: a group's (clip groups included) or a layer's.
fn groups_children(n: &Node) -> Option<&Vec<Arc<Node>>> {
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }).then(|| n.children()).flatten()
}
/// The object `path` names in symbol art `art`, if there is one.
pub fn part<'a>(art: &'a Node, path: &[u32]) -> Option<&'a Node> {
if path.len() > MAX_PATH_DEPTH {
return None;
}
path.iter().try_fold(art, |n, &i| groups_children(n)?.get(i as usize).map(|c| &**c))
}
/// [`part`], to change (the nodes on the way are copied when shared).
fn part_mut<'a>(art: &'a mut Node, path: &[u32]) -> Option<&'a mut Node> {
let Some((&i, rest)) = path.split_first() else { return Some(art) };
if !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) {
return None;
}
let c = art.children_mut()?.get_mut(i as usize)?;
part_mut(Arc::make_mut(c), rest)
}
/// Put `overrides` on symbol art `art`; an override naming no object of it is left out.
pub fn apply(art: &mut Node, overrides: &[SymbolOverride]) {
for o in overrides.iter().filter(|o| o.path.len() <= MAX_PATH_DEPTH) {
if let Some(n) = part_mut(art, &o.path) {
o.apply(n);
}
}
}
/// Symbol art `art` as instance `inst` shows it: a copy with the instance's overrides applied.
pub fn dynamic_art(art: &Node, inst: &Node) -> Node {
let mut a = art.clone();
apply(&mut a, &inst.extras().symbol_overrides);
a
}
/// Keep the overrides that name an object of `art` (a symbol's new art), dropping the rest.
pub fn retain_parts(overrides: &mut Vec<SymbolOverride>, art: &Node) {
overrides.retain(|o| part(art, &o.path).is_some());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Appearance, NodeId};
use vectorcraft_color::Color;
use vectorcraft_geom::{Rect, shapes};
fn square(id: u64) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()))
}
fn art() -> Node {
let inner = Node::group(NodeId(3), vec![square(4), square(5)]);
Node::group(NodeId(1), vec![square(2), Arc::new(inner)])
}
#[test]
fn paths_name_objects_through_groups_only() {
let a = art();
assert_eq!(part(&a, &[]).map(|n| n.id), Some(NodeId(1)));
assert_eq!(part(&a, &[1, 0]).map(|n| n.id), Some(NodeId(4)));
assert!(part(&a, &[2]).is_none());
assert!(part(&a, &[0, 0]).is_none(), "a path has no children");
assert!(part(&a, &[0; MAX_PATH_DEPTH + 1]).is_none());
}
#[test]
fn overrides_paint_their_object_and_skip_missing_ones() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let o = vec![
SymbolOverride { path: vec![1, 1], fill: Some(red.clone()), opacity: Some(0.5), ..Default::default() },
SymbolOverride { path: vec![9], fill: Some(red.clone()), ..Default::default() },
];
let mut a = art();
apply(&mut a, &o);
let n = part(&a, &[1, 1]).unwrap();
assert_eq!(n.appearance.fill_paint(), red);
assert_eq!(n.opacity, 0.5);
assert_eq!(part(&a, &[1, 0]).unwrap().appearance.fill_paint(), Paint::solid(Color::WHITE), "its sibling keeps the symbol's look");
let mut kept = o;
retain_parts(&mut kept, &a);
assert_eq!(kept.len(), 1);
}
#[test]
fn a_path_deeper_than_the_cap_does_not_read() {
let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]);
assert!(serde_json::from_str::<SymbolOverride>(&deep).is_err());
let ok: SymbolOverride = serde_json::from_str("{\"path\":[1,0],\"opacity\":0.25}").unwrap();
assert_eq!(ok, SymbolOverride { path: vec![1, 0], opacity: Some(0.25), ..Default::default() });
}
}
+1 -1
View File
@@ -410,7 +410,7 @@ mod tests {
/// Symbol `name` holding `art` in a group.
fn symbol(d: &mut Document, name: &str, art: Vec<Node>) {
let group = Node::group(d.alloc_id(), art.into_iter().map(Arc::new).collect());
d.symbols.push(Symbol { name: name.into(), art: Arc::new(group) });
d.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(group) });
}
#[test]
+36 -1
View File
@@ -55,6 +55,29 @@ fn doc_with_rects() -> (Document, NodeId, NodeId) {
(d, a, b)
}
/// Output draws the background colors of the artboards asked for, and only theirs, as rectangles
/// of those artboards on a layer below the art; without a color there is nothing to draw.
#[test]
fn artboard_fills_go_below_the_art() {
let (mut d, a, _) = doc_with_rects();
assert!(d.with_artboard_fills([0]).is_none(), "no color, no fill");
let second = Rect::new(700.0, 0.0, 800.0, 100.0);
d.artboards.push(Artboard { id: 2, rect: second, ..d.artboards[0].clone() });
let blue = vectorcraft_color::Color::cmyk(1.0, 0.5, 0.0, 0.0);
d.artboards[1].color = Some(blue);
assert!(d.with_artboard_fills([0]).is_none(), "only the artboards asked for");
let out = d.with_artboard_fills([0, 1]).unwrap();
assert_eq!(out.layers.len(), d.layers.len() + 1);
assert_eq!(out.layers[1..], d.layers[..], "the art stays as it was, above the fills");
let fills = out.layers[0].children().unwrap();
assert_eq!(out.layers[0].display_name(), "Artboard Fill");
assert_eq!(fills.len(), 1);
assert_eq!(fills[0].geometric_bounds(), Some(second));
assert_eq!(fills[0].appearance.fill_paint(), vectorcraft_color::Paint::solid(blue), "in the color's own model");
assert!(out.node(a).is_some() && out.node(fills[0].id).is_some() && fills[0].id != a);
assert_eq!(d.artboards, out.artboards);
}
#[test]
fn new_document_has_layer_and_artboard() {
let d = Document::new(612.0, 792.0);
@@ -202,7 +225,7 @@ fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
let art = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
d.symbols.push(Symbol { name: "Box".into(), art: Arc::new(art) });
d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(art) });
let (layer, id) = (d.layers[0].id, d.alloc_id());
d.insert(Some(layer), 0, Node::new(id, NodeKind::SymbolInstance { symbol: "Box".into(), xf: Affine::translate((2.0, 2.0)) })).unwrap();
// The art, (2, 2) to (12, 12), lies inside; the 20 × 20 square, (-8, -8) to (12, 12), doesn't.
@@ -388,6 +411,18 @@ fn char_position_round_trips() {
assert!((scale - 0.583).abs() < 1e-9 && (shift + 3.33).abs() < 1e-9);
}
#[test]
fn optical_kerning_round_trips_and_old_style_defaults_to_auto() {
let st = CharStyle { optical_kerning: true, kerning: Some(120.0), ..CharStyle::default() };
let value = serde_json::to_value(&st).unwrap();
assert_eq!(value["opticalKerning"], true);
assert_eq!(serde_json::from_value::<CharStyle>(value.clone()).unwrap(), st);
let mut old = value;
old.as_object_mut().unwrap().remove("opticalKerning");
assert!(!serde_json::from_value::<CharStyle>(old).unwrap().optical_kerning);
assert!(serde_json::to_value(CharStyle::default()).unwrap().get("opticalKerning").is_none());
}
#[test]
fn units() {
assert_eq!(Unit::Inches.parse("1"), Some(72.0));
+7
View File
@@ -144,3 +144,10 @@ fn mixed_mul_div() {
assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0));
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
}
#[test]
fn readout_has_no_negative_zero() {
assert_eq!(Unit::Points.readout(-0.001), "0.00 pt");
assert_eq!(Unit::Points.readout(-0.0), "0.00 pt");
assert_eq!(Unit::Points.readout(-12.5), "-12.50 pt");
}
+1 -1
View File
@@ -13,7 +13,7 @@ mod para_style;
mod runs;
mod tabs_lists;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextWrap, VerticalAlign, WrapShape};
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics};
pub use para_style::{
+17
View File
@@ -29,6 +29,19 @@ impl VerticalAlign {
}
}
/// Area Type Options › Text Flow: the order text fills the cells of area type with several rows
/// and columns.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum TextFlow {
/// Down each column's rows, then on to the next column (the default, and what a document
/// from before the option reads as).
#[default]
Columns,
/// Across each row's columns, then on to the next row.
Rows,
}
/// Text Wrap Options of a wrap object (Object → Text Wrap).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -135,6 +148,9 @@ pub struct AreaOptions {
pub first_baseline_min: f64,
/// Vertical alignment of the lines in each row/column.
pub vertical_align: VerticalAlign,
/// Text Flow: by columns (the default, left out when saved) or by rows.
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub flow: TextFlow,
/// How the frame and its text fit each other (Auto Size, Shrink Text to Fit).
#[serde(skip_serializing_if = "crate::skip::is_default")]
pub fit: AreaFit,
@@ -150,6 +166,7 @@ impl Default for AreaOptions {
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
vertical_align: VerticalAlign::Top,
flow: TextFlow::Columns,
fit: AreaFit::None,
}
}
+83 -3
View File
@@ -25,9 +25,12 @@ pub struct CharStyle {
/// Tracking in 1/1000 em.
#[serde(default)]
pub tracking: f64,
/// Kerning: None = Auto (metrics), Some(v) = manual in 1/1000 em.
/// Manual pair spacing in 1/1000 em; None uses the automatic mode.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kerning: Option<f64>,
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
#[serde(default, rename = "opticalKerning", skip_serializing_if = "crate::skip::is_default")]
pub optical_kerning: bool,
#[serde(default)]
pub baseline_shift: f64,
#[serde(default = "hundred")]
@@ -111,11 +114,18 @@ pub struct CharStyle {
/// <https://helpx.adobe.com/illustrator/using/hyphenation-line-breaks.html>
#[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")]
pub no_break: bool,
/// Language (Character panel): the BCP 47 tag of one of [`crate::setup::LANGUAGES`] these
/// characters are shaped in, so that the font uses that language's forms (OpenType `locl`).
/// `None`: the default language, English: USA, shaped without a language. New type takes the
/// document's language (Document Setup). Saved only when set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
}
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them. The text keeps
/// the digits that were typed; only the glyphs change (like All Caps).
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them or in the digits
/// of an Indic script. The text keeps the digits that were typed; only the glyphs change (like
/// All Caps).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Digits {
@@ -128,9 +138,68 @@ pub enum Digits {
Hindi,
/// Eastern Arabic-Indic digits ۰–۹ (U+06F0–U+06F9), used in Persian and Urdu.
Farsi,
/// Devanagari digits ०–९ (U+0966–U+096F), used in Hindi, Marathi, Nepali and Sanskrit.
Devanagari,
/// Bengali digits ০–৯ (U+09E6–U+09EF), used in Bengali and Assamese.
Bengali,
/// Gurmukhi digits ੦–੯ (U+0A66–U+0A6F), used in Punjabi.
Gurmukhi,
/// Gujarati digits ૦–૯ (U+0AE6–U+0AEF).
Gujarati,
/// Odia digits ୦–୯ (U+0B66–U+0B6F).
Odia,
/// Tamil digits ௦–௯ (U+0BE6–U+0BEF).
Tamil,
/// Telugu digits ౦–౯ (U+0C66–U+0C6F).
Telugu,
/// Kannada digits ೦–೯ (U+0CE6–U+0CEF).
Kannada,
/// Malayalam digits ൦–൯ (U+0D66–U+0D6F).
Malayalam,
}
impl Digits {
/// Every numeral system, in menu order.
pub const ALL: [Digits; 13] = [
Digits::Default,
Digits::Arabic,
Digits::Hindi,
Digits::Farsi,
Digits::Devanagari,
Digits::Bengali,
Digits::Gurmukhi,
Digits::Gujarati,
Digits::Odia,
Digits::Tamil,
Digits::Telugu,
Digits::Kannada,
Digits::Malayalam,
];
/// The name files and commands give this numeral system (as it is saved).
pub fn id(self) -> &'static str {
match self {
Digits::Default => "default",
Digits::Arabic => "arabic",
Digits::Hindi => "hindi",
Digits::Farsi => "farsi",
Digits::Devanagari => "devanagari",
Digits::Bengali => "bengali",
Digits::Gurmukhi => "gurmukhi",
Digits::Gujarati => "gujarati",
Digits::Odia => "odia",
Digits::Tamil => "tamil",
Digits::Telugu => "telugu",
Digits::Kannada => "kannada",
Digits::Malayalam => "malayalam",
}
}
/// The numeral system [`Digits::id`] names; `None` for any other name.
pub fn from_id(id: &str) -> Option<Digits> {
Digits::ALL.into_iter().find(|d| d.id() == id)
}
/// The digit `c` is drawn as in this numeral system: the same digit in the chosen set, or `c`
/// itself when `c` isn't a digit of the Western, Arabic-Indic or Eastern Arabic-Indic set.
pub fn map(self, c: char) -> char {
@@ -145,6 +214,15 @@ impl Digits {
Digits::Arabic => '0' as u32,
Digits::Hindi => 0x660,
Digits::Farsi => 0x6F0,
Digits::Devanagari => 0x966,
Digits::Bengali => 0x9E6,
Digits::Gurmukhi => 0xA66,
Digits::Gujarati => 0xAE6,
Digits::Odia => 0xB66,
Digits::Tamil => 0xBE6,
Digits::Telugu => 0xC66,
Digits::Kannada => 0xCE6,
Digits::Malayalam => 0xD66,
};
char::from_u32(base + value).unwrap_or(c)
}
@@ -266,6 +344,7 @@ impl Default for CharStyle {
leading: None,
tracking: 0.0,
kerning: None,
optical_kerning: false,
baseline_shift: 0.0,
h_scale: 100.0,
v_scale: 100.0,
@@ -293,6 +372,7 @@ impl Default for CharStyle {
small_caps: None,
digits: Digits::Default,
no_break: false,
language: None,
}
}
}
+1
View File
@@ -223,6 +223,7 @@ impl ParaList {
position: CharPosition::Normal,
tracking: 0.0,
kerning: None,
optical_kerning: first.optical_kerning,
tate_chu_yoko: false,
underline: false,
strikethrough: false,
+58
View File
@@ -60,6 +60,51 @@ fn digits_map_between_the_numeral_systems() {
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
fn indic_digits_map_to_their_scripts_block() {
let map = |d: Digits, s: &str| -> String { s.chars().map(|c| d.map(c)).collect() };
for (d, zero) in [
(Digits::Devanagari, 0x966),
(Digits::Bengali, 0x9E6),
(Digits::Gurmukhi, 0xA66),
(Digits::Gujarati, 0xAE6),
(Digits::Odia, 0xB66),
(Digits::Tamil, 0xBE6),
(Digits::Telugu, 0xC66),
(Digits::Kannada, 0xCE6),
(Digits::Malayalam, 0xD66),
] {
let block: String = (zero..zero + 10).map(|u| char::from_u32(u).unwrap()).collect();
assert!(block.chars().all(char::is_numeric), "{d:?}: {block}");
for s in ["0123456789", "٠١٢٣٤٥٦٧٨٩", "۰۱۲۳۴۵۶۷۸۹"] {
assert_eq!(map(d, s), block, "{d:?}");
}
assert_eq!(map(d, "a-Z ٪"), "a-Z ٪");
}
assert_eq!(map(Digits::Devanagari, "page 12"), "page १२");
for d in Digits::ALL {
assert_eq!(serde_json::to_value(d).unwrap(), d.id());
assert_eq!(serde_json::from_value::<Digits>(d.id().into()).unwrap(), d);
assert_eq!(Digits::from_id(d.id()), Some(d));
}
assert_eq!(Digits::from_id("roman"), None);
assert!(serde_json::from_value::<Digits>("roman".into()).is_err());
}
/// Language is saved only when set; styles written before it existed read as the default.
#[test]
fn language_is_saved_only_when_set() {
let st = CharStyle::default();
let old = serde_json::to_value(&st).unwrap();
assert!(old.get("language").is_none());
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().language, None);
let turkish = CharStyle { language: Some("tr".into()), ..CharStyle::default() };
assert_eq!(serde_json::to_value(&turkish).unwrap()["language"], "tr");
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&turkish).unwrap()).unwrap(), turkish);
}
/// Auto leading follows the paragraph's Auto Leading; a set leading doesn't.
#[test]
fn paragraph_effective_leading_follows_auto_leading() {
@@ -248,6 +293,19 @@ fn paragraph_styles_split_merge_and_normalize() {
assert_eq!(back.para_at(3).space_before, 4.0);
}
/// Text Flow is saved only when by rows: area type by columns (the default) saves as it did before
/// the option, and files from before it read by columns; other values don't read.
#[test]
fn text_flow_is_saved_only_when_by_rows() {
let by_columns = serde_json::to_value(AreaOptions::default()).unwrap();
assert!(by_columns.get("flow").is_none());
assert_eq!(serde_json::from_value::<AreaOptions>(by_columns).unwrap().flow, TextFlow::Columns);
let by_rows = serde_json::to_value(AreaOptions { flow: TextFlow::Rows, ..AreaOptions::default() }).unwrap();
assert_eq!(by_rows["flow"], "rows");
assert_eq!(serde_json::from_value::<AreaOptions>(by_rows).unwrap().flow, TextFlow::Rows);
assert!(serde_json::from_value::<AreaOptions>(serde_json::json!({"flow": "diagonal"})).is_err());
}
/// 120 × 40 area type at (40, 40), its text drawn at twice its size.
fn area_type() -> TextObject {
let mut t = TextObject::point(Point::ZERO, "Some text", CharStyle::default());
+4 -1
View File
@@ -161,7 +161,10 @@ impl Unit {
/// A point value as the canvas measurement labels show it: two decimals and the suffix
/// (`12.50 mm`).
pub fn readout(self, pt: f64) -> String {
format!("{:.2} {}", self.from_pt(pt), self.suffix())
let s = format!("{:.2}", self.from_pt(pt));
// A small negative value rounds to `-0.00`; show it as `0.00`.
let s = if s == "-0.00" { "0.00" } else { &s };
format!("{} {}", s, self.suffix())
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Effects of VectorCraft's built-in add-ons, one module per add-on.
//!
//! - [`color`]: Color Effects' live Exposure, Desaturate and Invert Colors, colour adjustments
//! evaluated with the others ([`crate::adjust`]).
//! - [`duotone`]: Color Effects' live Duotone, a colour adjustment that prints each colour's tone
//! with one to four inks.
//! - [`halftone`]: Color Effects' live Vector Halftone, and the screens Object › Vector Halftone
//! builds its dots with.
//! - [`path`]: Path Precision Tools' live Path Effects, geometry effects built on the add-on's path
//! kernels.
pub mod color;
pub mod duotone;
pub mod halftone;
pub mod path;
+138
View File
@@ -0,0 +1,138 @@
//! Color Effects (a built-in add-on): live Exposure, Desaturate, Duotone and Invert Colors, in
//! Effect › Color Effects. They are colour adjustments ([`crate::is_adjustment`]): they recolour what
//! an object paints as the others do, in stack order with them.
use serde_json::{Value, json};
use crate::catalog::{EffectInfo, Lengths};
use crate::util::num;
/// Exposure: brighten or darken in stops of light, with an offset and a gamma.
pub const EXPOSURE: &str = "addon.color.exposure";
/// Desaturate: every colour to the grey of its lightness.
pub const DESATURATE: &str = "addon.color.desaturate";
/// Invert Colors: every colour to its RGB inverse.
pub const INVERT: &str = "addon.color.invert";
pub use super::duotone::DUOTONE;
/// The add-on's effects, in menu order.
pub const EFFECTS: [&str; 4] = [DESATURATE, DUOTONE, EXPOSURE, INVERT];
/// Is `id` one of the add-on's effects?
pub fn is_color_effect(id: &str) -> bool {
EFFECTS.contains(&id)
}
/// The add-on's catalogue entries.
pub(crate) fn catalog() -> Vec<EffectInfo> {
const MENU: &[&str] = &["Effect", "Color Effects"];
let e = |id, label, params, defaults| EffectInfo { id, label, menu: MENU, params, defaults, raster: false, lengths: Lengths::default() };
vec![
e(DESATURATE, "Desaturate", "{} turns every colour the object paints to the grey of its lightness (each colour keeps its model)", json!({})),
e(DUOTONE, "Duotone…", super::duotone::PARAMS, super::duotone::defaults()),
e(
EXPOSURE,
"Exposure…",
"{exposure: stops -20..20 (0; mostly the highlights: each stop doubles or halves the light), offset: -0.5..0.5 (0; lifts or sinks the shadows and midtones), gamma: 0.1..10 (1; above 1 lightens the midtones)} recolours as Brightness/Contrast does, in linear light",
json!({"exposure": 0.0, "offset": 0.0, "gamma": 1.0}),
),
e(INVERT, "Invert Colors", "{} turns every colour the object paints to its RGB inverse (each colour keeps its model)", json!({})),
]
}
/// One of the add-on's adjustments, its parameters normalised.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Adjust {
/// Linear light times `gain`, plus `offset`, to the power 1 / `gamma`.
Exposure {
gain: f32,
offset: f32,
gamma: f32,
},
Desaturate,
Invert,
/// Duotone ([`super::duotone`]).
Duotone(super::duotone::Duotone),
}
impl Adjust {
pub(crate) fn parse(id: &str, p: &Value) -> Option<Self> {
Some(match id {
EXPOSURE => Adjust::Exposure {
gain: 2f64.powf(num(p, "exposure", 0.0).clamp(-20.0, 20.0)) as f32,
offset: num(p, "offset", 0.0).clamp(-0.5, 0.5) as f32,
gamma: num(p, "gamma", 1.0).clamp(0.1, 10.0) as f32,
},
DESATURATE => Adjust::Desaturate,
INVERT => Adjust::Invert,
DUOTONE => Adjust::Duotone(super::duotone::Duotone::parse(p)?),
_ => return None,
})
}
pub(crate) fn apply(&self, rgb: [f32; 3]) -> [f32; 3] {
match self {
Adjust::Exposure { gain, offset, gamma } => rgb.map(|v| to_srgb((to_linear(v) * gain + offset).max(0.0).powf(1.0 / gamma))),
Adjust::Desaturate => {
let [r, g, b] = rgb;
[(r.max(g).max(b) + r.min(g).min(b)) / 2.0; 3]
}
Adjust::Invert => rgb.map(|v| 1.0 - v),
Adjust::Duotone(d) => d.apply_rgb(rgb),
}
}
}
fn to_linear(v: f32) -> f32 {
let v = v.clamp(0.0, 1.0);
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
fn to_srgb(v: f32) -> f32 {
let v = v.clamp(0.0, 1.0);
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color_map;
use vectorcraft_doc::Effect;
fn map(id: &str, params: Value) -> crate::ColorMap {
color_map(&[Effect { id: id.into(), params, visible: true }]).unwrap()
}
fn close(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.01)
}
#[test]
fn the_effects_are_colour_adjustments_in_their_own_menu() {
for id in EFFECTS {
assert!(crate::is_adjustment(id), "{id}");
assert_eq!(crate::effect_info(id).unwrap().menu, ["Effect", "Color Effects"]);
}
}
#[test]
fn exposure_works_in_stops_of_linear_light() {
let grey = [0.5; 3];
assert!(close(map(EXPOSURE, json!({})).apply_rgb(grey), grey), "neutral by default");
// One stop doubles the light: sRGB 0.5 (linear 0.214) becomes linear 0.428, sRGB 0.686.
assert!(close(map(EXPOSURE, json!({"exposure": 1})).apply_rgb(grey), [0.686; 3]));
assert!(close(map(EXPOSURE, json!({"exposure": -20})).apply_rgb([1.0; 3]), [0.0; 3]));
assert!(map(EXPOSURE, json!({"offset": 0.1})).apply_rgb([0.0; 3])[0] > 0.3, "the offset lifts the shadows");
assert!(map(EXPOSURE, json!({"gamma": 2})).apply_rgb(grey)[0] > 0.6);
assert!(close(map(EXPOSURE, json!({"exposure": 1e300, "gamma": 0})).apply_rgb(grey), [1.0; 3]), "out of range values are clamped");
}
#[test]
fn desaturate_and_invert() {
assert!(close(map(DESATURATE, json!({})).apply_rgb([1.0, 0.0, 0.0]), [0.5; 3]));
assert!(close(map(DESATURATE, json!({})).apply_rgb([1.0, 1.0, 0.0]), [0.5; 3]), "lightness is kept, not luminance");
assert!(close(map(INVERT, json!({})).apply_rgb([1.0, 0.25, 0.0]), [0.0, 0.75, 1.0]));
let both = map(INVERT, json!({})).then(&map(INVERT, json!({})));
assert!(close(both.apply_rgb([0.2, 0.4, 0.6]), [0.2, 0.4, 0.6]));
}
}
+178
View File
@@ -0,0 +1,178 @@
//! Color Effects' Duotone (Effect › Color Effects › Duotone…), a colour adjustment: the
//! grey-equivalent tone of each colour an object paints is printed with one to four inks (a
//! monotone, duotone, tritone or quadtone). Each ink, a tone, is a colour, typically a spot swatch,
//! with its own curve from the tone's tint to the ink's. The inks overprint, so their tints
//! multiply.
//!
//! With one tone of a swatch, a colour becomes a tint of that swatch, linked to it, so a spot tone
//! separates onto its plate. With several tones it becomes the CMYK mix of their tints: a colour
//! links to one swatch at one tint, and a mix of several spot inks has no swatch to link to.
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_doc::color::{Color, cms::Model};
use crate::adjust::{LUT, curve_at, curve_points_on, lookup, sample};
use crate::raster::color_param;
use crate::util::flag;
/// Duotone (Effect › Color Effects › Duotone…).
pub const DUOTONE: &str = "addon.color.duotone";
/// Most tones Duotone reads (a quadtone): any past them are left out.
pub const MAX_TONES: usize = 4;
/// Most points a tone's curve reads: any past them are left out.
pub const MAX_CURVE_POINTS: usize = 64;
/// The parameters' description (`effect.apply`).
pub(crate) const PARAMS: &str = "{tones: [{color: \"#rrggbb\" or a colour object, swatch?: a global or spot swatch's name (with the swatch's colour as `color`), curve?: \"tint,ink tint,ink …\" or [[tint, ink], …] (0..100, at most 64 points; \"0,0 100,100\"), active?: true}] (up to four inks, overprinting; any past four are left out), skipSpot?: false (colours linked to a spot swatch keep their colour)} prints the grey-equivalent tone of each colour the object paints with the inks, each at the tint its curve gives for the tone's: with one tone of a swatch, as a tint of that swatch (a spot tone prints on its own plate); with several, as the CMYK mix of their tints";
/// The dialog's defaults: one black tone, its curve linear.
pub(crate) fn defaults() -> Value {
json!({"tones": [{"color": "#000000", "curve": "0,0 100,100", "active": true}], "skipSpot": false})
}
/// Whether swatch `name` of the document is a spot colour: `Some(false)` for a global process
/// swatch, `None` when the document has no global swatch of that name.
pub type SwatchKinds<'a> = dyn Fn(&str) -> Option<bool> + 'a;
/// [`SwatchKinds`] of `d`'s swatch `name`.
pub fn swatch_kind(d: &Document, name: &str) -> Option<bool> {
d.swatches_iter().find(|w| w.global && w.name == name).map(|w| w.spot)
}
/// [`swatch_kind`] of each of `d`'s global swatches.
pub fn swatch_kinds(d: &Document) -> HashMap<String, bool> {
d.swatches_iter().filter(|w| w.global).map(|w| (w.name.clone(), w.spot)).collect()
}
/// A tone's curve points (0..1 each) from its `curve` (0..100).
pub fn tone_curve_points(v: Option<&Value>) -> Vec<(f32, f32)> {
curve_points_on(v, 100.0, MAX_CURVE_POINTS)
}
/// One ink.
#[derive(Clone, Debug, PartialEq)]
struct Tone {
color: Color,
/// `color` as CMYK inks, which several tones mix in.
cmyk: [f32; 4],
/// The swatch the colour is of, if any.
swatch: Option<String>,
/// Its curve, sampled: the tone's tint → the ink's.
curve: Arc<[f32]>,
}
/// Duotone's tones and options, its parameters read.
#[derive(Clone, Debug, PartialEq)]
pub struct Duotone {
tones: Vec<Tone>,
skip_spot: bool,
/// The display colour at each sampled tint, for pixels.
rgb: Arc<[[f32; 3]]>,
}
impl Duotone {
/// `None` without an active tone (the effect then leaves the colours as they are).
pub(crate) fn parse(p: &Value) -> Option<Self> {
let tones: Vec<Tone> = p
.get("tones")
.and_then(Value::as_array)
.into_iter()
.flatten()
.take(MAX_TONES)
.filter(|t| t.is_object() && flag(t, "active", true))
.map(|t| {
let color = finite(color_param(t, Color::BLACK));
let cmyk = match color.in_model(Model::Cmyk) {
Color::Cmyk { c, m, y, k } => [c, m, y, k].map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }),
_ => [0.0, 0.0, 0.0, 1.0],
};
let pts = tone_curve_points(t.get("curve"));
let swatch = t.get("swatch").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string);
Tone { color, cmyk, swatch, curve: sample(|x| curve_at(&pts, x)) }
})
.collect();
if tones.is_empty() {
return None;
}
let mut d = Duotone { tones, skip_spot: flag(p, "skipSpot", false), rgb: Arc::new([]) };
d.rgb = (0..LUT).map(|i| d.mix(i as f32 / (LUT - 1) as f32).to_rgb()).collect();
Some(d)
}
/// The inks' colour for tone `t` (0 = white, 1 = black): one tone's colour at its tint, or
/// several tones' CMYK tints overprinted (each ink lets through what the others leave).
fn mix(&self, t: f32) -> Color {
if let [one] = self.tones.as_slice() {
return one.color.tinted(lookup(&one.curve, t));
}
let mut paper = [1.0f32; 4];
for tone in &self.tones {
let k = lookup(&tone.curve, t);
for (p, ink) in paper.iter_mut().zip(tone.cmyk) {
*p *= 1.0 - ink * k;
}
}
let [c, m, y, k] = paper.map(|p| 1.0 - p);
Color::cmyk(c, m, y, k)
}
/// Display components 0..1 through the effect.
pub(crate) fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] {
let x = tone(rgb) * (LUT - 1) as f32;
let i = (x.floor() as usize).min(LUT - 2);
let f = x - i as f32;
match (self.rgb.get(i), self.rgb.get(i + 1)) {
(Some(a), Some(b)) => std::array::from_fn(|k| a[k] + (b[k] - a[k]) * f),
_ => rgb,
}
}
/// A colour with its swatch link and tint through the effect ([`crate::ColorMap::apply_link`]):
/// true when it changed. With `skipSpot`, a colour linked to a spot swatch stays.
pub(crate) fn map_link(&self, c: &mut Color, link: &mut Option<String>, tint: &mut f32, swatches: &SwatchKinds) -> bool {
if self.skip_spot && link.as_deref().and_then(swatches) == Some(true) {
return false;
}
let t = tone(c.to_rgb());
let new = self.mix(t);
let (new_link, new_tint) = match self.tones.as_slice() {
[one] => match one.swatch.as_ref().filter(|n| swatches(n).is_some()) {
Some(name) => (Some(name.clone()), lookup(&one.curve, t)),
None => (None, 1.0),
},
_ => (None, 1.0),
};
if new == *c && new_link == *link && new_tint == *tint {
return false;
}
(*c, *link, *tint) = (new, new_link, new_tint);
true
}
}
/// The grey-equivalent tone of display colour `rgb`: 0 for white, 1 for black (its luma, as
/// Convert to Grayscale reads it, so a grey's tone is its ink).
fn tone([r, g, b]: [f32; 3]) -> f32 {
let t = 1.0 - (0.299 * r + 0.587 * g + 0.114 * b);
if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }
}
/// `c` with its components finite and in range.
fn finite(c: Color) -> Color {
let v = |x: f32, lo: f32, hi: f32| if x.is_finite() { x.clamp(lo, hi) } else { lo };
match c {
Color::Rgb { r, g, b } => Color::rgb(v(r, 0.0, 1.0), v(g, 0.0, 1.0), v(b, 0.0, 1.0)),
Color::Cmyk { c, m, y, k } => Color::cmyk(v(c, 0.0, 1.0), v(m, 0.0, 1.0), v(y, 0.0, 1.0), v(k, 0.0, 1.0)),
Color::Gray { k } => Color::gray(v(k, 0.0, 1.0)),
Color::Lab { l, a, b } => Color::lab(v(l, 0.0, 100.0), v(a, -128.0, 127.0), v(b, -128.0, 127.0)),
}
}
#[cfg(test)]
mod tests;
+128
View File
@@ -0,0 +1,128 @@
use serde_json::{Value, json};
use vectorcraft_doc::Effect;
use vectorcraft_doc::color::Color;
use super::*;
use crate::{ColorMap, color_map};
fn map(params: Value) -> ColorMap {
color_map(&[Effect { id: DUOTONE.into(), params, visible: true }]).unwrap()
}
/// `c` with link `link` through `m`, the document having spot swatch "Ink" and process swatch
/// "Blue": (colour, link, tint, changed).
fn linked(m: &ColorMap, c: Color, link: Option<&str>) -> (Color, Option<String>, f32, bool) {
let (mut c, mut link, mut tint) = (c, link.map(str::to_string), 1.0);
let kinds = |n: &str| match n {
"Ink" => Some(true),
"Blue" => Some(false),
_ => None,
};
let changed = m.apply_link(&mut c, &mut link, &mut tint, &kinds);
(c, link, tint, changed)
}
fn close(a: f32, b: f32) -> bool {
(a - b).abs() < 0.01
}
fn looks(c: Color, rgb: [f32; 3]) -> bool {
c.to_rgb().iter().zip(rgb).all(|(a, b)| close(*a, b))
}
const INK: Color = Color::Cmyk { c: 0.0, m: 0.5, y: 1.0, k: 0.0 };
fn ink_tone(curve: &str) -> Value {
json!({"tones": [{"color": INK, "swatch": "Ink", "curve": curve}]})
}
#[test]
fn a_spot_tone_turns_greys_into_tints_of_its_swatch() {
let m = map(ink_tone("0,0 100,100"));
for k in [0.0, 0.25, 0.6, 1.0] {
let (c, link, tint, _) = linked(&m, Color::gray(k), None);
assert_eq!(link.as_deref(), Some("Ink"), "linked at {k}");
assert!(close(tint, k), "the tint is the grey's ink: {k} → {tint}");
assert_eq!(c, INK.tinted(tint), "its colour is the swatch's at that tint");
}
// A colour reads as its grey equivalent: white is no ink.
let (_, _, tint, _) = linked(&m, Color::rgb(1.0, 1.0, 1.0), None);
assert!(close(tint, 0.0));
// A swatch the document hasn't: an unlinked tint of the tone's colour.
let (c, link, ..) = linked(&map(json!({"tones": [{"color": INK, "swatch": "Gone"}]})), Color::gray(0.5), None);
assert!(link.is_none() && looks(c, INK.tinted(0.5).to_rgb()));
// Without the links, colours (mesh points) take the tone's colour too.
assert!(looks(m.apply(Color::gray(0.5)), INK.tinted(0.5).to_rgb()));
}
#[test]
fn the_curve_changes_the_mapping() {
// Inverse: dark greys get little ink.
let (_, _, tint, _) = linked(&map(ink_tone("0,100 100,0")), Color::gray(0.8), None);
assert!(close(tint, 0.2), "{tint}");
// Through its points exactly, flat past its ends.
let lifted = map(ink_tone("0,0 50,80 100,100"));
assert!(close(linked(&lifted, Color::gray(0.5), None).2, 0.8));
assert!(close(linked(&map(ink_tone("20,0 80,100")), Color::gray(0.1), None).2, 0.0));
// Unreadable or missing curves are linear; points past the cap are left out.
for curve in [json!("junk"), json!(null), json!([[0, 0]])] {
let p = json!({"tones": [{"color": INK, "swatch": "Ink", "curve": curve}]});
assert!(close(linked(&map(p), Color::gray(0.3), None).2, 0.3), "{curve}");
}
let many: Vec<[f64; 2]> = (0..200).map(|i| [f64::from(i) / 2.0, 100.0 - f64::from(i) / 2.0]).collect();
assert_eq!(tone_curve_points(Some(&json!(many))).len(), MAX_CURVE_POINTS);
}
#[test]
fn several_tones_multiply_as_cmyk_inks() {
let cyan = json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}, "swatch": "Ink"});
let half_black = json!({"color": {"model": "cmyk", "c": 0.6, "m": 0.0, "y": 0.0, "k": 1.0}, "curve": "0,0 100,50"});
let m = map(json!({"tones": [cyan, half_black]}));
let (c, link, tint, changed) = linked(&m, Color::gray(1.0), Some("Ink"));
assert!(changed && link.is_none() && tint == 1.0, "a process mix, unlinked");
// Cyan 100% over the second ink at 50% (60% cyan, 100% black at half): cyan 1-(1-1)(1-0.3).
let Color::Cmyk { c, m: mg, y, k } = c else { panic!("CMYK: {c:?}") };
assert!(close(c, 1.0) && close(mg, 0.0) && close(y, 0.0) && close(k, 0.5), "{c} {mg} {y} {k}");
let (mid, ..) = linked(&m, Color::gray(0.5), None);
let Color::Cmyk { c, k, .. } = mid else { panic!("CMYK") };
// Cyan 50% over the second ink at 25% (15% cyan, 25% black): 1 - 0.5 * 0.85 = 0.575.
assert!(close(c, 0.575) && close(k, 0.25), "{c} {k}");
// Inactive tones and tones past the fourth don't count; none active leaves the colours.
let one = json!({"color": "#000000"});
let off = json!({"color": "#ff0000", "active": false});
let only_black = map(json!({"tones": [one, off, off, off, {"color": "#ff0000"}]}));
assert!(looks(only_black.apply(Color::gray(0.4)), [0.6; 3]));
assert!(color_map(&[Effect { id: DUOTONE.into(), params: json!({"tones": [off]}), visible: true }]).is_none());
assert!(color_map(&[Effect { id: DUOTONE.into(), params: json!({"tones": "x"}), visible: true }]).is_none());
}
#[test]
fn skip_spot_keeps_spot_colours() {
let mut p = json!({"tones": [{"color": "#000000"}], "skipSpot": true});
let m = map(p.clone());
let spot = Color::cmyk(0.0, 1.0, 0.0, 0.0);
assert_eq!(linked(&m, spot, Some("Ink")), (spot, Some("Ink".to_string()), 1.0, false));
// A process swatch's colour, and an unlinked one, still change.
assert!(linked(&m, spot, Some("Blue")).3);
assert!(linked(&m, spot, None).3);
p["skipSpot"] = json!(false);
assert!(linked(&map(p), spot, Some("Ink")).3);
}
#[test]
fn pixels_and_other_adjustments_compose() {
let m = map(json!({"tones": [{"color": "#ff0000"}]}));
// A grey pixel half way: half way to red from white.
let px = m.apply_rgb([0.5, 0.5, 0.5]);
assert!(close(px[0], 1.0) && close(px[1], 0.5) && close(px[2], 0.5), "{px:?}");
// Invert, then Duotone: white becomes full red.
let both = color_map(&[
Effect { id: crate::addons::color::INVERT.into(), params: json!({}), visible: true },
Effect { id: DUOTONE.into(), params: json!({"tones": [{"color": "#ff0000"}]}), visible: true },
])
.unwrap();
assert!(looks(both.apply(Color::WHITE), [1.0, 0.0, 0.0]));
// Out of range colours are brought into range.
let wild = map(json!({"tones": [{"color": [1e30, -5, 0.5]}]}));
assert!(looks(wild.apply(Color::gray(1.0)), [1.0, 0.0, 0.5]));
}
+489
View File
@@ -0,0 +1,489 @@
//! Vector Halftone (Color Effects): the art as vector dots, lines or shapes sized by its tone, as a
//! live effect (Effect › Color Effects › Vector Halftone…, [`HALFTONE`]) and for Object › Vector
//! Halftone, which builds its paths with the same screens ([`Halftone::layers`]).
//!
//! The art is sampled from pixels ([`Samples`], drawn by the renderer) once per halftone cell on a
//! grid turned by the screen angle; each cell gets a shape whose area follows the tone there. Mono
//! screens follow lightness in one colour (or, with `fromArt`, each dot takes the art's colour under
//! it); CMYK screens follow the art's ink amounts (as CMYK output separates it), one screen per ink at
//! the classic angles, multiplied over each other. The dots can be clipped to the art's outline as
//! real geometry.
//!
//! The renderer, the exporters ([`crate::bake_document`]) and Expand Appearance draw an object with
//! the effect as [`halftone_art`]: the renderer samples the art, so it registers how ([`set_art`]).
use std::collections::BTreeMap;
use std::sync::{Arc, OnceLock};
use serde_json::{Value, json};
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId};
use vectorcraft_geom::hit::fill_contains;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes};
use vectorcraft_pathops::{self as po, BoolOp};
use crate::catalog::{EffectInfo, Lengths};
use crate::util::{flag, num, text};
/// Vector Halftone, the live effect.
pub const HALFTONE: &str = "addon.color.halftone";
/// Most halftone cells per screen.
pub const MAX_CELLS: f64 = 100_000.0;
/// Most pixels the tone is sampled from.
pub const MAX_SAMPLE_PIXELS: f64 = 16.0e6;
/// Cells lighter than this get no dot.
const MIN_TONE: f64 = 0.01;
/// Levels per channel the art's colours are rounded to with `fromArt` (one path per colour).
const COLOR_LEVELS: f64 = 15.0;
/// The effect's catalogue entry.
pub(crate) fn catalog() -> Vec<EffectInfo> {
vec![EffectInfo {
id: HALFTONE,
label: "Vector Halftone…",
menu: &["Effect", "Color Effects"],
params: "{shape: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency: lines per inch 1..300 (20), angle: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode: \"mono\"|\"cmyk\" (mono), color: mono dot colour (\"#000000\"), fromArt: bool (false; mono: each dot takes the art's colour under it), invert: bool (false; dots for light tones), clip: bool (true; the dots clipped to the art's outline)} draws the object as vector halftone dots sized by its tone, as object.vectorHalftone makes them (CMYK screens multiply); the object is sampled as drawn with its other effects; at most 100000 cells per screen, past that the object draws as it is",
defaults: json!({"shape": "circle", "frequency": 20.0, "angle": 45.0, "mode": "mono", "color": "#000000", "fromArt": false, "invert": false, "clip": true}),
raster: false,
lengths: Lengths::default(),
}]
}
/// Does `n` carry a visible Vector Halftone effect?
pub fn has_halftone(n: &Node) -> bool {
n.appearance.effects.iter().any(|e| e.visible && e.id == HALFTONE)
}
/// The shape of a halftone dot.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DotShape {
Circle,
Ellipse,
Square,
Diamond,
Line,
}
impl DotShape {
pub fn parse(s: &str) -> Option<Self> {
Some(match s.to_ascii_lowercase().as_str() {
"circle" | "round" | "dot" => DotShape::Circle,
"ellipse" => DotShape::Ellipse,
"square" => DotShape::Square,
"diamond" => DotShape::Diamond,
"line" | "lines" => DotShape::Line,
_ => return None,
})
}
/// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen
/// running along x.
fn path(self, t: f64, s: f64) -> PathData {
let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side)));
match self {
DotShape::Circle => {
let r = s * (t / std::f64::consts::PI).sqrt();
shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r)))
}
DotShape::Ellipse => {
let r = s * (t / std::f64::consts::PI).sqrt();
shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3)))
}
DotShape::Square => square(s * t.sqrt()),
DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)),
// Cells along a row join into a line as thick as the tone.
DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))),
}
}
}
/// One screen: its ink and angle, and how to read its tone from a sampled pixel.
struct Screen {
paint: Color,
angle: f64,
/// Index into the sampled pixel (unused for mono, CMYK inks for cmyk).
channel: usize,
}
/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt.
pub struct Samples {
px: Vec<u8>,
w: usize,
h: usize,
region: Rect,
scale: f64,
/// Mono: tones are 1 − lightness of RGBA pixels drawn on white; CMYK: ink amounts.
mono: bool,
invert: bool,
}
impl Samples {
/// Pixels `px` (`w`×`h`, 4 bytes each) over `region` at `scale` px/pt; `None` when there are
/// fewer bytes than that.
pub fn new(px: Vec<u8>, (w, h): (usize, usize), region: Rect, scale: f64, mono: bool, invert: bool) -> Option<Self> {
(px.len() >= w.checked_mul(h)?.checked_mul(4)?).then_some(Self { px, w, h, region, scale, mono, invert })
}
/// The index of the pixel at document point `p`, `None` outside the samples.
fn index(&self, p: Point) -> Option<usize> {
let x = ((p.x - self.region.x0) * self.scale).floor();
let y = ((p.y - self.region.y0) * self.scale).floor();
(x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h).then(|| (y as usize * self.w + x as usize) * 4)
}
fn byte(&self, i: usize, k: usize) -> f64 {
self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0
}
/// The tone (0..1) of `channel` at document point `p`.
fn tone(&self, p: Point, channel: usize) -> f64 {
let Some(i) = self.index(p) else { return if self.invert { 1.0 } else { 0.0 } };
let t =
if self.mono { 1.0 - (0.2126 * self.byte(i, 0) + 0.7152 * self.byte(i, 1) + 0.0722 * self.byte(i, 2)) } else { self.byte(i, channel) };
if self.invert { 1.0 - t } else { t }
}
/// The 3 × 3 sample points of a cell of side `s` at `c` turned by `rot`.
fn cell_points(c: Point, s: f64, rot: Affine) -> impl Iterator<Item = Point> {
(0..9).map(move |k| c + (rot * Point::new((f64::from(k / 3) - 1.0) * s / 3.0, (f64::from(k % 3) - 1.0) * s / 3.0)).to_vec2())
}
/// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples).
fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 {
Self::cell_points(c, s, rot).map(|p| self.tone(p, channel)).sum::<f64>() / 9.0
}
/// The mean colour over a cell (as [`Self::cell_tone`]; white outside the samples), each channel
/// rounded to [`COLOR_LEVELS`] steps.
fn cell_color(&self, c: Point, s: f64, rot: Affine) -> [u8; 3] {
let mut sum = [0.0; 3];
for p in Self::cell_points(c, s, rot) {
for (k, v) in sum.iter_mut().enumerate() {
*v += self.index(p).map_or(1.0, |i| self.byte(i, k));
}
}
sum.map(|v| (v / 9.0 * COLOR_LEVELS).round().clamp(0.0, COLOR_LEVELS) as u8)
}
}
/// A halftone's settings.
#[derive(Clone, Debug, PartialEq)]
pub struct Halftone {
pub shape: DotShape,
/// Lines per inch.
pub frequency: f64,
/// Degrees.
pub angle: f64,
/// One screen in `color` (else four CMYK screens).
pub mono: bool,
pub color: Color,
/// Mono: each dot in the art's colour under it instead of `color`.
pub from_art: bool,
/// Dots for light tones.
pub invert: bool,
/// The dots clipped to the art's outline.
pub clip: bool,
}
impl Halftone {
/// The settings of `n`'s last visible Vector Halftone effect, its values brought into range.
pub fn of(n: &Node) -> Option<Self> {
let e = n.appearance.effects.iter().rev().find(|e| e.visible && e.id == HALFTONE)?;
Some(Self::from_params(&e.params))
}
fn from_params(p: &Value) -> Self {
Self {
shape: DotShape::parse(text(p, "shape", "circle")).unwrap_or(DotShape::Circle),
frequency: num(p, "frequency", 20.0).clamp(1.0, 300.0),
angle: num(p, "angle", 45.0),
mono: !text(p, "mode", "mono").eq_ignore_ascii_case("cmyk"),
color: crate::raster::color_param(p, Color::BLACK),
from_art: flag(p, "fromArt", false),
invert: flag(p, "invert", false),
clip: flag(p, "clip", true),
}
}
/// The side of a cell in points.
pub fn cell(&self) -> f64 {
72.0 / self.frequency
}
/// Where the art over `bounds` is sampled and at how many pixels per point: about four pixels per
/// cell, at most [`MAX_SAMPLE_PIXELS`]. `None` when `bounds` have no area or the grid turned by
/// the screen angle would have more than [`MAX_CELLS`] cells.
pub fn plan(&self, bounds: Rect) -> Option<(Rect, f64)> {
let cell = self.cell();
if !(bounds.width() > 0.0 && bounds.height() > 0.0 && bounds.area().is_finite()) {
return None;
}
// The grid turned by the screen angle covers at most (w + h)² / 2.
if (bounds.width() + bounds.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS {
return None;
}
let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / bounds.area()).sqrt()).clamp(0.01, 16.0);
Some((bounds.inflate(cell, cell), scale))
}
/// The screens, bottom to top.
fn screens(&self) -> Vec<Screen> {
let angle = self.angle;
if self.mono {
return vec![Screen { paint: self.color, angle, channel: 0 }];
}
// Bottom to top: yellow, magenta, cyan, black.
vec![
Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 },
Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 },
Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 },
Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 },
]
}
/// The dots of the art `samples` show over `bounds`, one path per ink (per colour with
/// `fromArt`) in paint order, each clipped to `outline` when given, and how many dots there are.
pub fn layers(&self, samples: &Samples, bounds: Rect, outline: Option<&(BezPath, FillRule)>) -> (Vec<(PathData, Color)>, usize) {
let cell = self.cell();
let mut layers = vec![];
let mut count = 0;
for sc in self.screens() {
let dots = screen_dots(samples, bounds, cell, &sc, self.shape);
count += dots.len();
if self.mono && self.from_art {
let rot = Affine::rotate(sc.angle.to_radians());
let mut by_color: BTreeMap<[u8; 3], Vec<PathData>> = BTreeMap::new();
for (dot, c) in dots {
by_color.entry(samples.cell_color(c, cell, rot)).or_default().push(dot);
}
for (rgb, dots) in by_color {
let [r, g, b] = rgb.map(|v| (f64::from(v) / COLOR_LEVELS) as f32);
layers.push((merged(dots, outline), Color::rgb(r, g, b)));
}
} else {
layers.push((merged(dots.into_iter().map(|d| d.0).collect(), outline), sc.paint));
}
}
layers.retain(|(path, _)| !path.is_empty());
(layers, count)
}
}
/// The dots of one screen over `bounds`, each with its cell's centre.
fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape) -> Vec<(PathData, Point)> {
let rot = Affine::rotate(sc.angle.to_radians());
let inv = rot.inverse();
// The grid covers the bounds in screen space.
let corners =
[bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q);
let (mut lo, mut hi) = (corners[0], corners[0]);
for q in &corners[1..] {
lo = Point::new(lo.x.min(q.x), lo.y.min(q.y));
hi = Point::new(hi.x.max(q.x), hi.y.max(q.y));
}
let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1);
let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1);
let near = bounds.inflate(cell, cell);
let mut dots = vec![];
for j in j0..=j1 {
for i in i0..=i1 {
let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell);
if !near.contains(c) {
continue;
}
let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0);
if t < MIN_TONE {
continue;
}
let place = Affine::translate(c.to_vec2()) * rot;
dots.push((shape.path(t, cell).transformed(place), c));
}
}
dots
}
/// The outline Clip to Art clips the dots of `nodes` to: their clip outline taken together.
pub fn art_outline(nodes: &[Node]) -> Option<(BezPath, FillRule)> {
crate::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect()))
}
/// `dots` as one path, clipped to `outline` when given (dots wholly inside it need no clipping).
fn merged(dots: Vec<PathData>, outline: Option<&(BezPath, FillRule)>) -> PathData {
let (mut inside, mut edge) = (vec![], vec![]);
for dot in dots {
let whole = match (outline, dot.control_bounds()) {
(Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)]
.iter()
.all(|q| fill_contains(bp, *rule, *q)),
_ => true,
};
if whole { inside.push(dot) } else { edge.push(dot) }
}
let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect();
if let Some((bp, rule)) = outline
&& !edge.is_empty()
{
let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect());
let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect);
subs.extend(clipped.subpaths);
} else {
subs.extend(edge.into_iter().flat_map(|p| p.subpaths));
}
PathData::new(subs)
}
/// The paths of `layers` ([`Halftone::layers`]), each filled with its ink and ids from `id`; CMYK
/// screens (`mono` false) multiply over each other.
pub fn dot_nodes(layers: Vec<(PathData, Color)>, mono: bool, id: &mut dyn FnMut() -> NodeId) -> Vec<Arc<Node>> {
layers
.into_iter()
.map(|(path, ink)| {
let mut n = Node::path(id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0));
if !mono {
n.blend = BlendMode::Multiply;
}
Arc::new(n)
})
.collect()
}
/// `n` split for its halftone: the object the effect samples (`n` visible, without its Vector
/// Halftone effects, transparency, mask and link) and an empty group that takes over its identity,
/// transparency, mask and link, for the dots.
pub fn split(n: &Node) -> (Node, Node) {
let mut object = n.clone();
object.appearance.effects.retain(|e| e.id != HALFTONE);
object.visible = true;
let mut g = Node::group(n.id, vec![]);
(g.visible, g.locked) = (n.visible, n.locked);
g.name = object.name.take();
g.opacity = std::mem::replace(&mut object.opacity, 1.0);
g.blend = std::mem::take(&mut object.blend);
g.isolate = std::mem::take(&mut object.isolate);
g.extras_mut().mask = object.extras_mut().mask.take();
g.extras_mut().attrs = object.extras_mut().attrs.take();
(object, g)
}
/// How far the dots of `n`'s halftone reach past the bounds of the object it samples: nothing when
/// they are clipped to its outline, else up to two cells (a dot's centre lies within a cell of the
/// bounds, and the dot within another).
pub fn reach(n: &Node) -> f64 {
Halftone::of(n).filter(|h| !h.clip).map_or(0.0, |h| 2.0 * h.cell())
}
/// Makes the art of an object with a visible Vector Halftone effect ([`halftone_art`]).
pub type ArtHook = fn(&Document, &Node) -> Option<Node>;
static ART: OnceLock<ArtHook> = OnceLock::new();
/// Register how the art of an object with Vector Halftone is made (the renderer samples it, above
/// this crate; the first registration stays).
pub fn set_art(f: ArtHook) {
// Already set: the same renderer, registered by another session.
let _ = ART.set(f);
}
/// The art of `n` with a visible Vector Halftone effect in `d`: the group [`split`] makes, holding
/// the dot paths ([`dot_nodes`], ids `n`'s). `None` without the effect, while nothing is registered
/// ([`set_art`]), and when the art has no area or would take too many cells ([`Halftone::plan`]).
pub fn halftone_art(d: &Document, n: &Node) -> Option<Node> {
if !has_halftone(n) {
return None;
}
ART.get()?(d, n)
}
#[cfg(test)]
mod tests {
use super::*;
use vectorcraft_doc::Effect;
/// A black square's samples: `side` points of black from (10, 10), white around it.
fn black_square(side: f64, h: &Halftone) -> (Samples, Rect) {
let b = Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side);
let (region, scale) = h.plan(b).unwrap();
let (w, hh) = ((region.width() * scale).round() as usize, (region.height() * scale).round() as usize);
let mut px = vec![255u8; w * hh * 4];
for y in 0..hh {
for x in 0..w {
let p = Point::new(region.x0 + (x as f64 + 0.5) / scale, region.y0 + (y as f64 + 0.5) / scale);
if b.contains(p) {
px[(y * w + x) * 4..][..3].copy_from_slice(&[200, 0, 0]);
}
}
}
(Samples::new(px, (w, hh), region, scale, true, h.invert).unwrap(), b)
}
fn effect(params: Value) -> Node {
let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default());
n.appearance.effects.push(Effect { id: HALFTONE.into(), params, visible: true });
n
}
#[test]
fn the_effect_is_in_color_effects_with_its_defaults() {
let info = crate::effect_info(HALFTONE).unwrap();
assert_eq!(info.menu, ["Effect", "Color Effects"]);
assert!(!crate::is_geometry(HALFTONE) && !crate::is_raster(HALFTONE) && !crate::is_adjustment(HALFTONE));
let h = Halftone::of(&effect(info.defaults)).unwrap();
assert_eq!((h.shape, h.frequency, h.angle, h.mono, h.from_art, h.invert, h.clip), (DotShape::Circle, 20.0, 45.0, true, false, false, true));
assert_eq!(h.color, Color::BLACK);
}
#[test]
fn values_out_of_range_are_brought_in() {
let h = Halftone::of(&effect(json!({"frequency": 1e308, "angle": "NaN", "shape": 7, "mode": "CMYK", "color": "nope"}))).unwrap();
assert_eq!((h.frequency, h.angle, h.shape, h.mono, h.color), (300.0, 45.0, DotShape::Circle, false, Color::BLACK));
assert_eq!(Halftone::of(&effect(json!({"frequency": -5}))).unwrap().frequency, 1.0);
let mut hidden = effect(json!({}));
hidden.appearance.effects[0].visible = false;
assert!(Halftone::of(&hidden).is_none() && !has_halftone(&hidden));
}
#[test]
fn dots_follow_the_tone_and_the_settings() {
let h = Halftone::of(&effect(json!({"frequency": 18}))).unwrap();
let (s, b) = black_square(60.0, &h);
let (layers, dots) = h.layers(&s, b, None);
assert_eq!(layers.len(), 1);
assert!((150..=320).contains(&dots), "{dots}");
// Inverted, the dark square gets small dots and the white around it full ones.
let inv = Halftone { invert: true, ..h.clone() };
let (s2, _) = black_square(60.0, &inv);
assert!(inv.layers(&s2, b, None).1 > dots);
// The colour of the art under each dot.
let art = Halftone { from_art: true, ..h.clone() };
let (layers, _) = art.layers(&s, b, None);
assert!(layers.iter().any(|(_, c)| c.to_hex() == "#cc0000"), "{:?}", layers.iter().map(|l| l.1.to_hex()).collect::<Vec<_>>());
// CMYK screens: one layer per ink with dots (red: magenta and yellow).
let cmyk = Halftone { mono: false, ..h };
let one = Samples::new([0u8, 255, 255, 0].repeat(400), (20, 20), Rect::new(0.0, 0.0, 20.0, 20.0), 1.0, false, false).unwrap();
assert_eq!(cmyk.layers(&one, Rect::new(2.0, 2.0, 18.0, 18.0), None).0.len(), 2);
}
#[test]
fn the_caps_refuse_too_many_cells() {
let h = Halftone::of(&effect(json!({"frequency": 300}))).unwrap();
assert!(h.plan(Rect::new(0.0, 0.0, 10_000.0, 10_000.0)).is_none());
assert!(h.plan(Rect::new(0.0, 0.0, f64::INFINITY, 10.0)).is_none());
assert!(h.plan(Rect::new(0.0, 0.0, 0.0, 10.0)).is_none());
assert!(h.plan(Rect::new(0.0, 0.0, 40.0, 40.0)).is_some());
assert!(Samples::new(vec![0; 3], (1, 1), Rect::new(0.0, 0.0, 1.0, 1.0), 1.0, true, false).is_none());
assert!(Samples::new(vec![], (usize::MAX, 2), Rect::new(0.0, 0.0, 1.0, 1.0), 1.0, true, false).is_none());
}
#[test]
fn the_group_takes_the_objects_identity() {
let mut n = effect(json!({"clip": false, "frequency": 36}));
n.opacity = 0.5;
n.name = Some("art".into());
let (object, g) = split(&n);
assert!(!has_halftone(&object));
assert_eq!((object.opacity, g.opacity, g.id, g.name.as_deref()), (1.0, 0.5, n.id, Some("art")));
assert_eq!(reach(&n), 4.0);
assert_eq!(reach(&effect(json!({}))), 0.0);
}
}
+243
View File
@@ -0,0 +1,243 @@
//! Path Effects (Path Precision Tools): live geometry effects that rewrite an object's path with the
//! add-on's path kernels (Effect › Path Effects). Each is evaluated with the other geometry effects
//! ([`crate::apply_geometry`]), so it draws, measures, exports and expands as they do; the geometry
//! is written only by Expand Appearance.
//!
//! Parameters come from documents and scripts, so each is clamped to its documented range, and a
//! path with a coordinate past [`MAX_COORDINATE`] or not finite is left as it is.
use serde_json::{Value, json};
use vectorcraft_doc::Dash;
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_pathops::addons::pathtools::{self as tools, AnchorSpacing, CloseMode, ExtendMode, SMOOTH_RATIO, SegmentKind};
use vectorcraft_pathops::addons::{PT_PER_TOLERANCE, retract_tiny_handles, simplify_keep_shape};
use crate::catalog::{EffectInfo, Lengths};
use crate::stroke;
use crate::util::{flag, num, text};
/// What every Path Effect's id starts with.
pub const PREFIX: &str = "addon.path.";
pub const DASHES: &str = "addon.path.dashes";
pub const ADD_ANCHORS: &str = "addon.path.addAnchors";
pub const EXTEND: &str = "addon.path.extend";
pub const CLOSE: &str = "addon.path.close";
pub const SMOOTH: &str = "addon.path.smooth";
pub const RETRACT: &str = "addon.path.retract";
pub const REVERSE: &str = "addon.path.reverse";
pub const SIMPLIFY: &str = "addon.path.simplify";
pub const SPLIT: &str = "addon.path.split";
const MENU: &[&str] = &["Effect", "Path Effects"];
/// The dash and gap parameters of [`DASHES`], pair by pair.
const DASH_PAIRS: [(&str, &str); 5] = [("dash1", "gap1"), ("dash2", "gap2"), ("dash3", "gap3"), ("dash4", "gap4"), ("dash5", "gap5")];
/// Longest dash or gap, and the farthest offset (pt).
const MAX_DASH: f64 = 10_000.0;
/// Longest stretch [`EXTEND`] adds or takes off an end, in points and in percent of the path.
const MAX_EXTEND: f64 = 100_000.0;
const MAX_EXTEND_PERCENT: f64 = 1000.0;
/// Most anchors [`ADD_ANCHORS`] adds to one segment.
const MAX_PER_SEGMENT: f64 = 1000.0;
/// Closest and farthest apart [`ADD_ANCHORS`]' anchors are (pt).
const DISTANCE_RANGE: (f64, f64) = (0.01, 100_000.0);
/// The range of [`SMOOTH`]'s ratio.
const RATIO_RANGE: (f64, f64) = (0.01, 0.5);
/// Longest handle length [`RETRACT`] takes (pt).
const MAX_HANDLE: f64 = 100_000.0;
/// Highest tolerance of [`SIMPLIFY`].
const MAX_TOLERANCE: f64 = 200.0;
// shortcut: simplifying measures every anchor on every draw, so a larger path draws as it is; lift
// this if the kernel gets an incremental pass.
/// Most anchors [`SIMPLIFY`] takes in one path.
const MAX_SIMPLIFY_ANCHORS: usize = 50_000;
/// A path with a coordinate past this, or one that isn't finite, is left as it is: lengths measured
/// on it would overflow.
const MAX_COORDINATE: f64 = 1e12;
/// The effects' catalogue entries.
pub(crate) fn catalog() -> Vec<EffectInfo> {
let entry = |id, label, params, defaults, always| EffectInfo {
id,
label,
menu: MENU,
params,
defaults,
raster: false,
lengths: Lengths { always, absolute: &[] },
};
vec![
entry(
DASHES,
"Dashes to Geometry…",
"{dash1: pt 0..10000 (12), gap1: pt 0..10000 (6), dash2: pt 0..10000 (0), gap2: pt 0..10000 (0), dash3: pt 0..10000 (0), gap3: pt 0..10000 (0), dash4: pt 0..10000 (0), gap4: pt 0..10000 (0), dash5: pt 0..10000 (0), gap5: pt 0..10000 (0), offset: pt -10000..10000 (0; how far into the pattern the path starts; not with alignCorners), alignCorners: bool (false; every run between corners and path ends holds whole periods, stretched or squeezed to fit, with a dash centred on each corner and end)} cuts the path into its dashes, open subpaths along it that the stroke paints as it paints any path. A dash of 0 is a point its stroke's round or projecting cap paints; a pair of two zeros is left out, and the pattern repeats. A pattern of nothing, or of more than 200000 dashes, leaves the path as it is; a dash longer than a closed subpath keeps it closed",
json!({"dash1": 12.0, "gap1": 6.0, "dash2": 0.0, "gap2": 0.0, "dash3": 0.0, "gap3": 0.0, "dash4": 0.0, "gap4": 0.0, "dash5": 0.0, "gap5": 0.0, "offset": 0.0, "alignCorners": false}),
&["dash1", "gap1", "dash2", "gap2", "dash3", "gap3", "dash4", "gap4", "dash5", "gap5", "offset"],
),
entry(
ADD_ANCHORS,
"Add Anchors…",
"{target: \"all\"|\"curved\"|\"straight\" (all; the segments that get anchors), spacing: \"count\"|\"distance\" (count), count: 1..1000 (1; anchors on each segment, evenly spaced by length), distance: pt 0.01..100000 (20; an anchor every so far along each segment from its start), maxCount: 1..1000 (100; most anchors added to one segment)} adds anchors without changing the shape; past 2 million anchors in all the path stays as it is",
json!({"target": "all", "spacing": "count", "count": 1, "distance": 20.0, "maxCount": 100}),
&["distance"],
),
entry(
EXTEND,
"Extend Path…",
"{startLength: pt -100000..100000 (10), startPercent: % of the path's length -1000..1000 (0), startShape: \"straight\"|\"arc\" (straight), endLength: pt -100000..100000 (10), endPercent: % of the path's length -1000..1000 (0), endShape: \"straight\"|\"arc\" (straight)} lengthens each end of an open subpath by its length and percent together, or shortens it back along itself when they come to less than 0. Straight runs on along the end's direction; arc follows the circle of the end's curvature, at most half way round. Closed subpaths stay as they are, and so does an end that would be shortened to nothing",
json!({"startLength": 10.0, "startPercent": 0.0, "startShape": "straight", "endLength": 10.0, "endPercent": 0.0, "endShape": "straight"}),
&["startLength", "endLength"],
),
entry(
CLOSE,
"Close Path…",
"{mode: \"straight\"|\"smooth\" (straight; smooth: the closing segment leaves both ends smoothly)} closes every open subpath of two anchors or more",
json!({"mode": "straight"}),
&[],
),
entry(
SMOOTH,
"Smooth Anchors…",
"{ratio: 0.01..0.5 (0.3905; a handle's length as a share of the distance to the nearer neighbour; 0.3905 makes a square's corners a circle)} makes every anchor smooth: a lone handle is mirrored, two are brought into line keeping their lengths, and an anchor without handles gets them at right angles to the bisector of its neighbours",
json!({"ratio": SMOOTH_RATIO}),
&[],
),
entry(
RETRACT,
"Retract Handles…",
"{handles: \"all\"|\"shorter\" (all; shorter: only those shorter than length), length: pt 0..100000 (5)} takes the handles back to their anchors; an anchor that loses one becomes a corner",
json!({"handles": "all", "length": 5.0}),
&["length"],
),
entry(
REVERSE,
"Reverse Direction",
"{} runs every subpath the other way: the shape stays, and arrowheads, dashes and the effects after it swap ends",
json!({}),
&[],
),
entry(
SIMPLIFY,
"Simplify Anchors…",
"{tolerance: 0..200 (10; higher removes more anchors and lets the outline move further, up to tolerance × 0.02 pt), protectCorners: bool (true; anchors where the path turns by more than 30° stay)} removes the anchors a path can do without, refitting the handles beside each; open ends stay; a path of more than 50000 anchors stays as it is",
json!({"tolerance": 10.0, "protectCorners": true}),
&[],
),
entry(
SPLIT,
"Split at Anchors…",
"{anchors: \"corner\"|\"smooth\"|\"both\" (both; smooth: where the handles stand in line, corner: the others)} cuts subpaths into open pieces at those anchors; the shape stays, and an open path's ends aren't cuts",
json!({"anchors": "both"}),
&[],
),
]
}
/// Path Effect `id` with parameters `p` on `path`, which keeps to `max` anchors where anchors are
/// added one by one. `path` as it is when `id` is no such effect or the path can't be measured.
pub(crate) fn apply(id: &str, p: &Value, path: &PathData, max: usize) -> PathData {
if !measurable(path) {
return path.clone();
}
let edit = |change: &dyn Fn(&mut SubPath)| {
let mut out = path.clone();
out.subpaths.iter_mut().for_each(change);
out
};
match id {
DASHES => dashes(path, p),
ADD_ANCHORS => add_anchors(path, p, max).unwrap_or_else(|| path.clone()),
EXTEND => PathData::new(path.subpaths.iter().map(|sp| extend(sp, p)).collect()),
CLOSE => {
let mode = if text(p, "mode", "straight") == "smooth" { CloseMode::Smooth } else { CloseMode::Straight };
edit(&|sp| {
tools::close(sp, mode, SMOOTH_RATIO);
})
}
SMOOTH => {
let ratio = num(p, "ratio", SMOOTH_RATIO).clamp(RATIO_RANGE.0, RATIO_RANGE.1);
edit(&|sp| tools::smooth_anchors(sp, ratio))
}
RETRACT if text(p, "handles", "all") == "shorter" => {
let length = num(p, "length", 5.0).clamp(0.0, MAX_HANDLE);
edit(&|sp| {
retract_tiny_handles(sp, length);
})
}
RETRACT => edit(&|sp| sp.anchors.iter_mut().for_each(|a| a.retract())),
REVERSE => edit(&SubPath::reverse),
SIMPLIFY if path.anchor_count() <= MAX_SIMPLIFY_ANCHORS => {
let tolerance = num(p, "tolerance", 10.0).clamp(0.0, MAX_TOLERANCE) * PT_PER_TOLERANCE;
simplify_keep_shape(path, tolerance, flag(p, "protectCorners", true))
}
SPLIT => {
let at = text(p, "anchors", "both");
let (corners, smooth) = (at != "smooth", at != "corner");
PathData::new(path.subpaths.iter().flat_map(|sp| tools::split_at_anchors(sp, corners, smooth)).collect())
}
_ => path.clone(),
}
}
/// Is every coordinate of `path` within [`MAX_COORDINATE`]?
fn measurable(path: &PathData) -> bool {
let near = |q: Point| q.x.abs() <= MAX_COORDINATE && q.y.abs() <= MAX_COORDINATE;
path.anchors().all(|(_, _, a)| near(a.p) && near(a.h_in) && near(a.h_out))
}
/// [`DASHES`]: the dashes of `path` as open subpaths, those of no length as subpaths that stay on
/// their point; `path` as it is when the pattern draws no dashes.
fn dashes(path: &PathData, p: &Value) -> PathData {
let length = |key| num(p, key, 0.0).clamp(0.0, MAX_DASH);
let pairs = DASH_PAIRS.iter().map(|(dash, gap)| (length(dash), length(gap)));
let pattern = pairs.filter(|(d, g)| *d > 0.0 || *g > 0.0).flat_map(|(d, g)| [d, g]).collect();
let dash = Dash { pattern, offset: num(p, "offset", 0.0).clamp(-MAX_DASH, MAX_DASH), align_corners: flag(p, "alignCorners", false) };
let Some(dashed) = stroke::dash(&path.to_bezpath(), &dash) else { return path.clone() };
let mut out = PathData::from_bezpath(&dashed.path);
out.subpaths.extend(dashed.dots.iter().map(|d| SubPath::polyline(&[d.at, d.at], false)));
out
}
/// [`ADD_ANCHORS`] within `max` anchors in all; `None` past them.
fn add_anchors(path: &PathData, p: &Value, max: usize) -> Option<PathData> {
let which = match text(p, "target", "all") {
"curved" => SegmentKind::Curved,
"straight" => SegmentKind::Straight,
_ => SegmentKind::All,
};
let spacing = match text(p, "spacing", "count") {
"distance" => AnchorSpacing::Distance(num(p, "distance", 20.0).clamp(DISTANCE_RANGE.0, DISTANCE_RANGE.1)),
_ => AnchorSpacing::Count(num(p, "count", 1.0).clamp(1.0, MAX_PER_SEGMENT) as usize),
};
let cap = num(p, "maxCount", 100.0).clamp(1.0, MAX_PER_SEGMENT) as usize;
let mut room = max.saturating_sub(path.anchor_count());
let mut out = Vec::with_capacity(path.subpaths.len());
for sp in &path.subpaths {
let added = tools::add_anchors(sp, which, spacing, cap, room)?;
room = room.saturating_sub(added.anchors.len().saturating_sub(sp.anchors.len()));
out.push(added);
}
Some(PathData::new(out))
}
/// [`EXTEND`] on one subpath, the start then the end; what can't be done stays as it is.
fn extend(sp: &SubPath, p: &Value) -> SubPath {
let total = sp.anchor_arc_positions().last().copied().unwrap_or(0.0);
let by = |length, percent| {
num(p, length, 0.0).clamp(-MAX_EXTEND, MAX_EXTEND) + num(p, percent, 0.0).clamp(-MAX_EXTEND_PERCENT, MAX_EXTEND_PERCENT) / 100.0 * total
};
let shape = |key| if text(p, key, "straight") == "arc" { ExtendMode::Arc } else { ExtendMode::Straight };
let ends = [(true, by("startLength", "startPercent"), shape("startShape")), (false, by("endLength", "endPercent"), shape("endShape"))];
let mut out = sp.clone();
for (at_start, by, shape) in ends {
if by != 0.0
&& let Some(changed) = tools::extend_open(&out, at_start, by, shape)
{
out = changed;
}
}
out
}
#[cfg(test)]
mod tests;
+278
View File
@@ -0,0 +1,278 @@
use serde_json::{Value, json};
use vectorcraft_doc::Effect;
use vectorcraft_geom::{Anchor, PathData, Point, Rect, SubPath};
use super::*;
use crate::{apply_geometry, effect_info, is_geometry, is_length, is_raster, length_range};
fn pt(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn line(pts: &[(f64, f64)], closed: bool) -> PathData {
PathData::single(SubPath::polyline(&pts.iter().map(|&(x, y)| pt(x, y)).collect::<Vec<_>>(), closed))
}
fn square() -> PathData {
line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true)
}
/// A curve: a smooth anchor between two ends.
fn curve() -> PathData {
let mid = Anchor::smooth(pt(50.0, -30.0), pt(70.0, -30.0));
PathData::single(SubPath::new(
vec![
Anchor { h_out: pt(10.0, -20.0), ..Anchor::corner(pt(0.0, 0.0)) },
mid,
Anchor { h_in: pt(90.0, -20.0), ..Anchor::corner(pt(100.0, 0.0)) },
],
false,
))
}
fn run(id: &str, params: Value, path: &PathData) -> PathData {
let e = Effect { id: id.into(), params, visible: true };
apply_geometry(&[e], path, path.bounds().unwrap_or(Rect::ZERO))
}
fn near(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-3
}
const ALL: [&str; 9] = [DASHES, ADD_ANCHORS, EXTEND, CLOSE, SMOOTH, RETRACT, REVERSE, SIMPLIFY, SPLIT];
#[test]
fn the_effects_are_geometry_effects_of_the_path_effects_menu() {
for id in ALL {
let info = effect_info(id).unwrap();
assert!(is_geometry(id) && !is_raster(id), "{id}");
assert_eq!(info.menu, ["Effect", "Path Effects"], "{id}");
assert!(id.starts_with(PREFIX));
}
assert!(is_length(DASHES, "gap5", false) && is_length(EXTEND, "endLength", false) && !is_length(EXTEND, "endPercent", false));
assert_eq!(length_range(DASHES, "offset"), Some(-10_000.0..=10_000.0));
}
#[test]
fn dashes_become_open_subpaths_along_the_path() {
let out = run(DASHES, json!({"dash1": 10, "gap1": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false));
assert_eq!(out.subpaths.len(), 7, "periods of 15 on 100");
assert!(out.subpaths.iter().all(|s| !s.closed));
assert!(near(out.subpaths[1].anchors[0].p.x, 15.0) && near(out.subpaths[1].anchors[1].p.x, 25.0));
// Two pairs, and a pair of zeros between them left out.
let out = run(DASHES, json!({"dash1": 10, "gap1": 5, "dash3": 20, "gap3": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false));
assert!(near(out.subpaths[1].anchors[0].p.x, 15.0) && near(out.subpaths[1].anchors[1].p.x, 35.0));
assert!(near(out.subpaths[2].anchors[0].p.x, 40.0));
// The offset starts part way into the pattern.
let out = run(DASHES, json!({"dash1": 10, "gap1": 5, "offset": 5}), &line(&[(0.0, 0.0), (100.0, 0.0)], false));
assert!(near(out.subpaths[0].anchors[1].p.x, 5.0));
}
#[test]
fn aligned_dashes_centre_on_corners_and_ends() {
let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
let out = run(DASHES, json!({"dash1": 10, "gap1": 10, "alignCorners": true}), &open);
let on = |p: Point| out.subpaths.iter().any(|s| (0..s.segment_count()).any(|i| s.anchors[i].p == p || s.anchors[i + 1].p == p));
assert!(on(pt(0.0, 0.0)) && on(pt(100.0, 100.0)), "a dash on each end");
let corner = out.subpaths.iter().find(|s| s.anchors.iter().any(|a| a.p == pt(100.0, 0.0))).expect("a dash over the corner");
assert!(near(corner.anchors[0].p.distance(pt(100.0, 0.0)), corner.anchors[2].p.distance(pt(100.0, 0.0))), "centred: {corner:?}");
}
#[test]
fn zero_dashes_are_points_and_patterns_of_nothing_leave_the_path() {
let path = line(&[(0.0, 0.0), (100.0, 0.0)], false);
let dots = run(DASHES, json!({"dash1": 0, "gap1": 25}), &path);
assert_eq!(dots.subpaths.len(), 5);
assert!(dots.subpaths.iter().all(|s| s.anchors.len() == 2 && s.anchors[0].p == s.anchors[1].p));
for params in [
json!({"dash1": 0, "gap1": 0}),
json!({"dash1": -5, "gap1": -5}),
json!({"dash1": 0.0001, "gap1": 0.0001}),
json!({"dash1": "x", "gap1": null}),
] {
assert_eq!(run(DASHES, params.clone(), &path), path, "{params}");
}
}
#[test]
fn add_anchors_spaces_them_and_keeps_to_the_caps() {
let square = square();
assert_eq!(run(ADD_ANCHORS, json!({}), &square).anchor_count(), 8, "one on each segment");
assert_eq!(run(ADD_ANCHORS, json!({"count": 3}), &square).anchor_count(), 16);
assert_eq!(run(ADD_ANCHORS, json!({"count": 3, "maxCount": 2}), &square).anchor_count(), 12);
let out = run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 30}), &square);
assert_eq!(out.anchor_count(), 16, "at 30, 60 and 90 on each side");
assert!(near(out.subpaths[0].anchors[2].p.x, 60.0));
assert_eq!(run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 25}), &square).anchor_count(), 16, "none on the segment's end");
assert_eq!(run(ADD_ANCHORS, json!({"spacing": "distance", "distance": 1e-9, "maxCount": 2}), &square).anchor_count(), 12);
// Only the segments asked for.
let mixed = PathData::new(vec![curve().subpaths[0].clone(), square.subpaths[0].clone()]);
assert_eq!(run(ADD_ANCHORS, json!({"target": "curved"}), &mixed).anchor_count(), 7 + 2);
assert_eq!(run(ADD_ANCHORS, json!({"target": "straight"}), &mixed).anchor_count(), 7 + 4);
// The shape stays.
let out = run(ADD_ANCHORS, json!({"count": 4}), &curve());
assert_eq!(out.anchor_count(), 3 + 8);
assert!(near(out.length(), curve().length()));
assert_eq!(out.bounds().map(|b| (b.y0 * 100.0).round()), curve().bounds().map(|b| (b.y0 * 100.0).round()));
}
#[test]
fn add_anchors_past_the_budget_leaves_the_path() {
let path = square();
let most = json!({"count": 1000, "maxCount": 1000});
assert_eq!(super::apply(ADD_ANCHORS, &most, &path, 4003), path);
assert_eq!(super::apply(ADD_ANCHORS, &most, &path, 4004).anchor_count(), 4004);
// Many segments, each with the most anchors: refused before they are built.
let long = line(&(0..4000).map(|i| (i as f64, 0.0)).collect::<Vec<_>>(), false);
assert_eq!(run(ADD_ANCHORS, json!({"count": 1000, "maxCount": 1000}), &long).anchor_count(), 4000, "4 million would pass the budget");
}
#[test]
fn extend_lengthens_and_shortens_each_end() {
let path = line(&[(0.0, 0.0), (100.0, 0.0)], false);
let out = run(EXTEND, json!({"startLength": 20, "endLength": 10}), &path);
let xs: Vec<f64> = out.subpaths[0].anchors.iter().map(|a| a.p.x).collect();
assert_eq!(xs, [-20.0, 0.0, 100.0, 110.0]);
// Percent of the path's length, adding to the length; negative shortens.
let out = run(EXTEND, json!({"startLength": 0, "endLength": 5, "endPercent": 10}), &path);
assert!(out.subpaths[0].anchors.last().is_some_and(|a| near(a.p.x, 115.0)));
let out = run(EXTEND, json!({"startLength": -10, "endLength": 0, "endPercent": -50}), &path);
let xs: Vec<f64> = out.subpaths[0].anchors.iter().map(|a| a.p.x).collect();
assert!(near(xs[0], 10.0) && near(xs[1], 50.0), "{xs:?}");
// An arc carries on round the end's curve.
let arc = run(EXTEND, json!({"startLength": 0, "endLength": 40, "endShape": "arc"}), &curve());
let straight = run(EXTEND, json!({"startLength": 0, "endLength": 40}), &curve());
assert!(arc.anchor_count() >= straight.anchor_count());
assert!((arc.length() - straight.length()).abs() < 0.1);
assert_ne!(arc.subpaths[0].anchors.last().map(|a| a.p), straight.subpaths[0].anchors.last().map(|a| a.p));
}
#[test]
fn extend_leaves_what_it_cannot_change() {
let closed = square();
assert_eq!(run(EXTEND, json!({"endLength": 50}), &closed), closed);
let path = line(&[(0.0, 0.0), (100.0, 0.0)], false);
assert_eq!(run(EXTEND, json!({"startLength": 0, "endLength": -500}), &path), path, "nothing would be left");
assert_eq!(run(EXTEND, json!({"startLength": 0, "endLength": 0, "endPercent": -100}), &path), path);
let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], false));
assert_eq!(run(EXTEND, json!({}), &one), one);
}
#[test]
fn close_joins_open_subpaths_straight_or_smooth() {
let open = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
let out = run(CLOSE, json!({}), &open);
assert!(out.subpaths[0].closed && out.subpaths[0].segment_is_line(2));
let out = run(CLOSE, json!({"mode": "smooth"}), &open);
assert!(out.subpaths[0].closed && !out.subpaths[0].segment_is_line(2));
assert_eq!(run(CLOSE, json!({}), &square()), square());
let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], false));
assert_eq!(run(CLOSE, json!({}), &one), one);
}
#[test]
fn smooth_makes_a_square_round_and_clamps_its_ratio() {
let out = run(SMOOTH, json!({}), &square());
assert!(out.subpaths[0].anchors.iter().all(|a| near(a.h_out.distance(a.p), 39.05) && a.has_in()));
let at = |ratio: f64| run(SMOOTH, json!({ "ratio": ratio }), &square()).subpaths[0].anchors[0];
assert!(near(at(9.0).h_out.distance(pt(0.0, 0.0)), 50.0));
assert!(near(at(-9.0).h_out.distance(pt(0.0, 0.0)), 1.0));
}
#[test]
fn retract_takes_all_handles_or_the_short_ones() {
let out = run(RETRACT, json!({}), &curve());
assert!(out.subpaths[0].anchors.iter().all(|a| !a.has_in() && !a.has_out()));
// The ends' handles are about 22 pt long and the middle anchor's 20.
let out = run(RETRACT, json!({"handles": "shorter", "length": 21}), &curve());
let a = &out.subpaths[0].anchors;
assert!(!a[1].has_in() && !a[1].has_out() && a[0].has_out() && a[2].has_in());
assert_eq!(run(RETRACT, json!({"handles": "shorter", "length": 1}), &curve()), curve());
}
#[test]
fn reverse_runs_the_other_way_and_twice_is_as_it_was() {
let out = run(REVERSE, json!({}), &curve());
assert_eq!(out.subpaths[0].anchors[0].p, pt(100.0, 0.0));
assert_eq!(run(REVERSE, json!({}), &out), curve());
}
#[test]
fn simplify_takes_anchors_within_the_tolerance_and_stops_at_its_cap() {
let pts: Vec<(f64, f64)> = (0..=40).map(|i| (i as f64, (i as f64 / 40.0 * std::f64::consts::PI).sin() * 3.0)).collect();
let path = line(&pts, false);
let out = run(SIMPLIFY, json!({"tolerance": 200}), &path);
assert!(out.anchor_count() < 20, "{}", out.anchor_count());
assert_eq!(run(SIMPLIFY, json!({"tolerance": 0}), &path), path);
// Corners stay unless told otherwise.
let corner = line(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)], false);
assert_eq!(run(SIMPLIFY, json!({"tolerance": 200}), &corner), corner);
assert_eq!(run(SIMPLIFY, json!({"tolerance": 200, "protectCorners": false}), &corner).anchor_count(), 2);
let long = line(&(0..=MAX_SIMPLIFY_ANCHORS).map(|i| (i as f64, 0.0)).collect::<Vec<_>>(), false);
assert_eq!(run(SIMPLIFY, json!({}), &long).anchor_count(), long.anchor_count());
}
#[test]
fn split_cuts_at_the_anchors_asked_for() {
let mixed = PathData::single(SubPath::new(
vec![
Anchor::corner(pt(0.0, 0.0)),
Anchor::smooth(pt(50.0, 0.0), pt(60.0, 0.0)),
Anchor::corner(pt(100.0, 0.0)),
Anchor::corner(pt(100.0, 50.0)),
],
false,
));
assert_eq!(run(SPLIT, json!({}), &mixed).subpaths.len(), 3);
assert_eq!(run(SPLIT, json!({"anchors": "smooth"}), &mixed).subpaths.len(), 2);
assert_eq!(run(SPLIT, json!({"anchors": "corner"}), &mixed).subpaths.len(), 2);
let out = run(SPLIT, json!({}), &square());
assert_eq!(out.subpaths.len(), 4);
assert!(out.subpaths.iter().all(|s| !s.closed && s.anchors.len() == 2));
assert!(near(out.length(), 400.0));
assert_eq!(run(SPLIT, json!({"anchors": "smooth"}), &square()), square(), "no smooth anchors");
}
/// Whatever a file holds: every effect takes wrong types, huge numbers and paths of nothing, of one
/// anchor or past measuring, and keeps to the budget.
#[test]
fn junk_params_and_paths_are_harmless() {
let junk = [
json!({}),
json!(null),
json!([1, 2]),
json!("x"),
json!({"dash1": 1e308, "gap1": 1e308, "dash2": -1e308, "gap2": "x", "offset": 1e308, "alignCorners": "yes"}),
json!({"dash1": 1e-300, "gap1": 1e-300, "offset": -1e308, "alignCorners": true}),
json!({"target": 3, "spacing": ["distance"], "count": 1e308, "distance": 1e-308, "maxCount": -1e308}),
json!({"spacing": "distance", "distance": -1e308, "maxCount": 1e308}),
json!({"startLength": 1e308, "startPercent": 1e308, "startShape": 7, "endLength": -1e308, "endPercent": -1e308, "endShape": "arc"}),
json!({"mode": {}, "ratio": 1e308, "handles": "shorter", "length": 1e308, "tolerance": 1e308, "protectCorners": "no", "anchors": 9}),
json!({"ratio": -1e308, "length": -1e308, "tolerance": -1e308, "anchors": "none"}),
];
let far = line(&[(1e308, -1e308), (-1e308, 1e308), (1e300, 0.0)], false);
let nan = line(&[(f64::NAN, 0.0), (f64::INFINITY, 1.0)], false);
let tiny = line(&[(0.0, 0.0), (1e-300, 0.0), (1e-300, 1e-300)], true);
let same = line(&[(5.0, 5.0), (5.0, 5.0), (5.0, 5.0)], true);
let empty = PathData::single(SubPath::default());
let one = PathData::single(SubPath::polyline(&[pt(1.0, 1.0)], true));
let paths = [square(), curve(), far.clone(), nan.clone(), tiny, same, empty, one, PathData::default()];
for id in ALL {
for params in &junk {
for path in &paths {
let out = super::apply(id, &crate::merged_params(id, params), path, 100_000);
assert!(out.anchor_count() <= 100_000.max(path.anchor_count()) + 8 * path.subpaths.len(), "{id} {params}");
}
}
// Compared as text: NaN never equals itself.
assert_eq!(format!("{:?}", super::apply(id, &json!({}), &nan, 100)), format!("{nan:?}"), "{id}");
assert_eq!(super::apply(id, &json!({}), &far, 100), far, "{id}");
}
}
/// The dash generator's limit holds: a pattern too fine for the path leaves it solid.
#[test]
fn too_many_dashes_leave_the_path() {
let long = line(&[(0.0, 0.0), (1e9, 0.0)], false);
assert_eq!(run(DASHES, json!({"dash1": 1, "gap1": 1}), &long), long);
}
+90 -30
View File
@@ -14,9 +14,10 @@ use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint, keep_model};
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_paint, recolor_node};
use vectorcraft_doc::recolor::{ColorVisitor, Reach, map_links, recolor_node};
use vectorcraft_doc::{Document, Effect, Node, NodeKind};
use crate::addons::duotone::{Duotone, SwatchKinds};
use crate::merged_params;
use crate::raster::color_param;
use crate::util::{flag, num, text};
@@ -27,7 +28,7 @@ pub const ADJUSTMENTS: [&str; 6] =
/// Is `id` a colour adjustment effect?
pub fn is_adjustment(id: &str) -> bool {
ADJUSTMENTS.contains(&id)
ADJUSTMENTS.contains(&id) || crate::addons::color::is_color_effect(id)
}
fn visible_adjustment(e: &Effect) -> bool {
@@ -72,7 +73,7 @@ pub fn is_channel(s: &str) -> bool {
}
/// Entries of a sampled tone curve.
const LUT: usize = 256;
pub(crate) const LUT: usize = 256;
/// One adjustment, its parameters normalised.
#[derive(Clone, Debug, PartialEq)]
@@ -88,6 +89,18 @@ enum Adjust {
ShiftToColor { target: [f32; 3], amount: f32, preserve: bool },
/// Warm/cool and magenta/green shifts, -1..1.
TemperatureTint { temperature: f32, tint: f32 },
/// One of the Color Effects add-on's.
Addon(crate::addons::color::Adjust),
}
impl Adjust {
/// Duotone, which maps a colour with its swatch link ([`ColorMap::apply_link`]).
fn duotone(&self) -> Option<&Duotone> {
match self {
Adjust::Addon(crate::addons::color::Adjust::Duotone(d)) => Some(d),
_ => None,
}
}
}
/// The colour adjustments of an effect list, composed in order.
@@ -102,17 +115,44 @@ impl ColorMap {
/// Adjust display (sRGB) components 0..1.
pub fn apply_rgb(&self, rgb: [f32; 3]) -> [f32; 3] {
self.0.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }))
apply_rgb(&self.0, rgb)
}
/// Adjust a colour, keeping its colour model (unchanged when the adjustments leave it as is).
/// Duotone gives its inks' colour instead ([`Duotone`]).
pub fn apply(&self, c: Color) -> Color {
let rgb = c.to_rgb();
let out = self.apply_rgb(rgb);
if out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) {
return c;
let (mut c, mut link, mut tint) = (c, None, 1.0);
self.apply_link(&mut c, &mut link, &mut tint, &|_| None);
c
}
/// Adjust a colour with its swatch link and tint (`link`, `tint`), as a paint holds them;
/// true when it changed. A colour that changes loses its link, except that Duotone with one
/// tone of a swatch the document has (`swatches`, [`SwatchKinds`]) links it to that swatch at
/// the tone's tint. The adjustments between Duotones work in display RGB, each run keeping
/// the colour's model.
pub fn apply_link(&self, c: &mut Color, link: &mut Option<String>, tint: &mut f32, swatches: &SwatchKinds) -> bool {
let mut changed = false;
let mut rest: &[Adjust] = &self.0;
while !rest.is_empty() {
let (run, after) = rest.split_at(rest.iter().position(|a| a.duotone().is_some()).unwrap_or(rest.len()));
if !run.is_empty() {
let rgb = c.to_rgb();
let out = apply_rgb(run, rgb);
if !out.iter().zip(rgb).all(|(a, b)| (a - b).abs() < 1e-6) {
*c = keep_model(*c, Color::rgb(out[0], out[1], out[2]));
*link = None;
*tint = 1.0;
changed = true;
}
}
let Some((duotone, tail)) = after.split_first() else { break };
if let Some(d) = duotone.duotone() {
changed |= d.map_link(c, link, tint, swatches);
}
rest = tail;
}
keep_model(c, Color::rgb(out[0], out[1], out[2]))
changed
}
/// Adjust an 8-bit sRGB pixel.
@@ -122,6 +162,11 @@ impl ColorMap {
}
}
/// Display (sRGB) components 0..1 through `adjustments`, in order.
fn apply_rgb(adjustments: &[Adjust], rgb: [f32; 3]) -> [f32; 3] {
adjustments.iter().fold(rgb, |c, a| a.apply(c).map(|v| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }))
}
/// The visible colour adjustments of `effects` as one map, `None` when there are none.
pub fn color_map(effects: &[Effect]) -> Option<ColorMap> {
let v: Vec<Adjust> =
@@ -175,7 +220,7 @@ impl Adjust {
preserve: flag(p, "preserveLightness", true),
},
"adjust.temperatureTint" => Adjust::TemperatureTint { temperature: amount(p, "temperature"), tint: amount(p, "tint") },
_ => return None,
_ => Adjust::Addon(crate::addons::color::Adjust::parse(id, p)?),
})
}
@@ -225,17 +270,18 @@ impl Adjust {
let (t, m) = (temperature * 0.3, tint * 0.3);
[r * (1.0 + t), g * (1.0 - m), b * (1.0 - t)]
}
Adjust::Addon(a) => a.apply([r, g, b]),
}
}
}
/// `f` sampled at [`LUT`] inputs 0..1.
fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> {
pub(crate) fn sample(f: impl Fn(f32) -> f32) -> Arc<[f32]> {
(0..LUT).map(|i| f(i as f32 / (LUT - 1) as f32).clamp(0.0, 1.0)).collect()
}
/// The tone curve `lut` at `v` (linear between samples).
fn lookup(lut: &[f32], v: f32) -> f32 {
pub(crate) fn lookup(lut: &[f32], v: f32) -> f32 {
let x = v.clamp(0.0, 1.0) * (LUT - 1) as f32;
let i = (x.floor() as usize).min(LUT - 2);
let (a, b) = (lut.get(i).copied().unwrap_or(v), lut.get(i + 1).copied().unwrap_or(v));
@@ -245,6 +291,12 @@ fn lookup(lut: &[f32], v: f32) -> f32 {
/// Curves' points (0..1 each, sorted by input, one per input) from its `points` parameter:
/// `"x,y x,y …"` or `[[x, y], …]` in 0..255. Missing or unreadable points give the identity line.
pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> {
// At most a point per level is meaningful.
curve_points_on(v, 255.0, 256)
}
/// [`curve_points`] on a scale of 0..`max`, reading at most `cap` points.
pub fn curve_points_on(v: Option<&Value>, max: f64, cap: usize) -> Vec<(f32, f32)> {
let mut pts: Vec<(f64, f64)> = match v {
Some(Value::String(s)) => s
.split(|c: char| c.is_whitespace() || c == ';')
@@ -257,10 +309,9 @@ pub fn curve_points(v: Option<&Value>) -> Vec<(f32, f32)> {
_ => vec![],
};
pts.retain(|(x, y)| x.is_finite() && y.is_finite());
// At most a point per level is meaningful.
pts.truncate(256);
let mut pts: Vec<(f32, f32)> =
pts.into_iter().map(|(x, y)| ((x.clamp(0.0, 255.0) / 255.0) as f32, (y.clamp(0.0, 255.0) / 255.0) as f32)).collect();
pts.truncate(cap);
let unit = |v: f64| (v.clamp(0.0, max) / max) as f32;
let mut pts: Vec<(f32, f32)> = pts.into_iter().map(|(x, y)| (unit(x), unit(y))).collect();
pts.sort_by(|a, b| a.0.total_cmp(&b.0));
pts.dedup_by(|a, b| (a.0 - b.0).abs() < 1e-6);
if pts.len() < 2 {
@@ -354,15 +405,22 @@ fn from_hsl([h, s, l]: [f32; 3]) -> [f32; 3] {
/// Gives the key of an embedded image recoloured by a map (`None`: leave it as it is).
pub type ImageHook<'a> = dyn FnMut(&str, &ColorMap) -> Option<String> + 'a;
/// The live visitor: every colour through `map`, embedded images through the hook.
/// The live visitor: every colour through `map` (paints with their swatch links, the document's
/// swatches being `swatches`), embedded images through the hook.
struct Live<'a, 'b> {
map: &'a ColorMap,
swatches: &'a SwatchKinds<'b>,
images: &'a mut ImageHook<'b>,
}
/// `p`'s colours through `map` with their swatch links ([`ColorMap::apply_link`]).
fn map_paint(p: &mut Paint, map: &ColorMap, swatches: &SwatchKinds) -> bool {
map_links(p, &|c, link, tint| map.apply_link(c, link, tint, swatches))
}
impl ColorVisitor for Live<'_, '_> {
fn paint(&mut self, p: &mut Paint) -> bool {
map_paint(p, &|c| self.map.apply(c))
map_paint(p, self.map, self.swatches)
}
fn color(&mut self, c: Color) -> Color {
self.map.apply(c)
@@ -375,12 +433,13 @@ impl ColorVisitor for Live<'_, '_> {
/// `n` with the colour adjustments in it applied, `None` when it has none: its members' first,
/// then those of its fills and strokes on their paints, then its own followed by `outer` (the
/// adjustments of an object `n` is the art of) on everything it paints. The adjustment effects
/// are gone from the result; embedded images take the keys `images` gives.
pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) -> Option<Node> {
/// are gone from the result; embedded images take the keys `images` gives. `swatches` tells the
/// document's global and spot swatches apart ([`crate::addons::duotone::swatch_kind`]).
pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images: &mut ImageHook<'_>) -> Option<Node> {
let mut m: Option<Node> = None;
if let Some(ch) = n.children() {
for (i, c) in ch.iter().enumerate() {
if let Some(new) = adjust(c, None, images)
if let Some(new) = adjust(c, None, swatches, images)
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
{
*slot = Arc::new(new);
@@ -391,7 +450,7 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) ->
if let Some(map) = color_map(item.effects())
&& let Some(it) = m.get_or_insert_with(|| n.clone()).appearance.items.get_mut(i)
{
map_paint(it.paint_mut(), &|c| map.apply(c));
map_paint(it.paint_mut(), &map, swatches);
it.effects_mut().retain(|e| !is_adjustment(&e.id));
}
}
@@ -403,7 +462,7 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, images: &mut ImageHook<'_>) ->
if let Some(map) = map {
let mm = m.get_or_insert_with(|| n.clone());
mm.appearance.effects.retain(|e| !is_adjustment(&e.id));
recolor_node(mm, Reach::ALL, &mut Live { map: &map, images });
recolor_node(mm, Reach::ALL, &mut Live { map: &map, swatches, images });
}
m
}
@@ -414,19 +473,20 @@ pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option<Node> {
let art;
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(Some(d), n, symbol.as_deref())?;
let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n));
art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?;
&art
}
_ => n,
};
let kinds = crate::addons::duotone::swatch_kinds(d);
let mut hook = |key: &str, map: &ColorMap| -> Option<String> {
let blob = d.images.get(key)?.map_rgb(|rgb| map.apply_rgb8(rgb))?;
let k = blob.content_key();
d.images.entry(k.clone()).or_insert(blob);
Some(k)
};
adjust(src, None, &mut hook)
adjust(src, None, &|name| kinds.get(name).copied(), &mut hook)
}
#[cfg(test)]
@@ -489,17 +549,17 @@ mod tests {
let mut g = Node::group(NodeId(1), vec![leaf(2), leaf(3)]);
g.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
assert!(has_adjustment(&g));
let out = adjust(&g, None, &mut |_, _| None).unwrap();
let out = adjust(&g, None, &|_| None, &mut |_, _| None).unwrap();
assert!(out.appearance.effects.is_empty());
for c in out.children().unwrap() {
assert_eq!(c.appearance.fill_paint().color().unwrap().to_hex(), "#00ff00");
}
// A hidden effect does nothing; a stroke's own effect recolours just that stroke.
g.appearance.effects[0].visible = false;
assert!(!has_adjustment(&g) && adjust(&g, None, &mut |_, _| None).is_none());
assert!(!has_adjustment(&g) && adjust(&g, None, &|_| None, &mut |_, _| None).is_none());
let mut p = Node::path(NodeId(5), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
p.appearance.items[1].effects_mut().push(Effect { id: "adjust.levels".into(), params: json!({"outputBlack": 255}), visible: true });
let out = adjust(&p, None, &mut |_, _| None).unwrap();
let out = adjust(&p, None, &|_| None, &mut |_, _| None).unwrap();
assert_eq!(out.appearance.items[1].paint().color().unwrap().to_hex(), "#ffffff");
assert_eq!(out.appearance.items[0].paint(), p.appearance.items[0].paint());
assert!(out.appearance.items[1].effects().is_empty());
@@ -514,7 +574,7 @@ mod tests {
let fill = Appearance::basic(Paint::Gradient(Box::new(gp)), Paint::None, 0.0);
let mut p = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), fill);
p.appearance.effects.push(Effect { id: "adjust.hueSaturation".into(), params: json!({"hue": 120}), visible: true });
let out = adjust(&p, None, &mut |_, _| None).unwrap();
let out = adjust(&p, None, &|_| None, &mut |_, _| None).unwrap();
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient fill") };
// The points are what a freeform gradient draws from, not its stops.
let hexes: Vec<String> = g.freeform.as_ref().unwrap().points.iter().map(|p| p.color.to_hex()).collect();
+9 -2
View File
@@ -52,6 +52,7 @@ pub fn needs_bake(n: &Node) -> bool {
|| crate::has_solid(n)
|| has_adjustment(n)
|| has_crop_marks(n)
|| crate::addons::halftone::has_halftone(n)
|| has_pathfinder(n)
|| has_own_paint(n)
|| has_geometry(&n.appearance.effects)
@@ -104,6 +105,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if let Some(m) = crop_marks_art(n) {
return Some(bake_pieces(d, m));
}
// Vector Halftone: the object as its dots.
if let Some(m) = crate::addons::halftone::halftone_art(d, n) {
return Some(bake_pieces(d, m));
}
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
let m = evaluate_container(Some(d), &m).unwrap_or(m);
return Some(bake_pieces(d, m));
@@ -117,10 +122,12 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
return Some(bake_pieces(d, m));
}
let symbol = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()),
NodeKind::SymbolInstance { symbol, .. } => {
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n))
}
_ => None,
};
let m = reshape(Some(d), n, symbol.as_deref())?;
let m = reshape(Some(d), n, symbol.as_ref())?;
return Some(bake_node(d, &m).unwrap_or(m));
}
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
+17
View File
@@ -42,6 +42,20 @@ pub fn is_length(id: &str, key: &str, relative: bool) -> bool {
catalog_index().get(id).is_some_and(|info| info.lengths.keys(relative).any(|k| k == key))
}
/// The range in points that distance parameter `key` of effect `id` is kept within, as its
/// parameter documentation gives it (`{maxRadius: pt 4..127 (8), …}`); `None` when it gives none.
pub fn length_range(id: &str, key: &str) -> Option<std::ops::RangeInclusive<f64>> {
let doc = catalog_index().get(id)?.params;
let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' ');
let rest = doc.match_indices(key).map(|(at, _)| at).filter(|at| starts(*at)).find_map(|at| {
let rest = doc.get(at + key.len()..)?;
rest.strip_prefix(": pt ").or_else(|| rest.strip_prefix("?: pt "))
})?;
let (lo, hi) = rest.split_whitespace().next()?.split_once("..")?;
let (lo, hi) = (lo.parse::<f64>().ok()?, hi.trim_end_matches(',').parse::<f64>().ok()?);
(lo.is_finite() && hi.is_finite() && lo <= hi).then_some(lo..=hi)
}
/// The distance parameters of effect `id` (none for unknown effects).
fn lengths_of(id: &str) -> Lengths {
let always = |always| Lengths { always, absolute: &[] };
@@ -777,6 +791,9 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
v.push(g(id, label, PATHFINDER, "{} (groups and layers: live Pathfinder over the members)", json!({})));
}
}
v.extend(crate::addons::color::catalog());
v.extend(crate::addons::halftone::catalog());
v.extend(crate::addons::path::catalog());
v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults()));
v.push(g(
CROP_MARKS,
+1
View File
@@ -265,6 +265,7 @@ fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomCont
"convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p),
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),
"stylize.scribble" => stylize::scribble(path, b, p),
_ if id.starts_with(crate::addons::path::PREFIX) => crate::addons::path::apply(id, p, path, max),
_ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) {
Some(style) => warp::warp(path, b, style, p, max),
None => path.clone(),
+2 -1
View File
@@ -34,6 +34,7 @@
//! anchored at the object's centre.
#![forbid(unsafe_code)]
pub mod addons;
mod adjust;
mod bake;
mod clip;
@@ -94,7 +95,7 @@ mod catalog;
mod geometry;
mod params;
pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, plugin_effects};
pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects};
pub(crate) use geometry::{Budget, apply_one};
pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with};
#[cfg(test)]
+1 -1
View File
@@ -24,7 +24,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?;
let mut art = (*sym.art).clone();
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
art.transform(*xf, false);
Some(Node::group(n.id, vec![Arc::new(art)]))
}
+4 -2
View File
@@ -118,14 +118,16 @@ pub fn is_raster(id: &str) -> bool {
|| PIXEL_EFFECTS.contains(&id)
}
/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour
/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in
/// Does `id` change geometry? (Crop Marks and Vector Halftone add art of their own instead,
/// [`crop_marks_art`] and [`crate::addons::halftone::halftone_art`]; colour adjustments recolour,
/// [`adjust`].) Plug-in effects do, installed or not (a missing plug-in
/// leaves the geometry as it is).
pub fn is_geometry(id: &str) -> bool {
!is_raster(id)
&& !is_pathfinder(id)
&& !is_adjustment(id)
&& id != CROP_MARKS
&& id != crate::addons::halftone::HALFTONE
&& !is_three_d(id)
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
}
+26 -1
View File
@@ -114,10 +114,34 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
}
// Type extrudes as its glyph outlines and stays type: editing it reshapes the solid (#4715).
NodeKind::Text(_) => {
let mut subpaths = vec![];
crate::outline_text(n)?.walk(&mut |c| subpaths.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().cloned())));
(!subpaths.is_empty()).then(|| (PathData::new(subpaths), FillRule::NonZero))
}
_ => None,
}
}
/// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the
/// fill and stroke of its first painted run (its characters' colours).
fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
let own: Vec<AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) {
return own;
}
let mut runs = vec![];
if let Some(outlined) = crate::outline_text(n) {
outlined.walk(&mut |c| {
if runs.is_empty() && c.path_data().is_some() {
runs = c.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
}
});
}
runs
}
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
let view = view_options(p);
match id {
@@ -320,7 +344,8 @@ pub fn solid_art(n: &Node) -> Option<Node> {
}
let (path, rule) = profile(n)?;
let ctx = GeomContext::of(n);
let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
let paints = solid_paints(n);
let painted: Vec<&AppearanceItem> = paints.iter().collect();
let mut children = vec![];
let remaining: Vec<Effect>;
if let Some((index, fx)) = solid(&n.appearance.effects) {
+18 -4
View File
@@ -1191,10 +1191,10 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() {
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)) });
d.symbols.push(Symbol { dynamic: false, name: "Grid".into(), art: repeat(grid(unit(), 500)) });
d.symbols.push(Symbol { dynamic: false, name: "Grouped".into(), art: Arc::new(Node::group(NodeId(0), vec![repeat(grid(unit(), 500))])) });
d.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(Node::group(NodeId(0), vec![unit(), instance("Loop")])) });
d.symbols.push(Symbol { dynamic: false, 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)));
@@ -1207,3 +1207,17 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() {
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);
}
#[test]
fn a_distance_parameters_range_comes_from_its_documentation() {
assert_eq!(length_range("pixelate.colorHalftone", "maxRadius"), Some(4.0..=127.0));
// The effect applies the range's floor to a radius below it.
assert_eq!(
crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 1})),
crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 4}))
);
// A distance documented without a range, and unknown effects and parameters, have none.
assert_eq!(length_range("path.offsetPath", "offset"), None);
assert_eq!(length_range("pixelate.colorHalftone", "channel1"), None);
assert_eq!(length_range("no.such", "maxRadius"), None);
}
+1
View File
@@ -36,6 +36,7 @@ vectorcraft-plugins = { workspace = true }
vectorcraft-affinity = { workspace = true }
vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] }
flate2 = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
roxmltree = "0.21"
+23 -13
View File
@@ -1,5 +1,6 @@
//! The command registry. Ids follow Illustrator's menu structure.
mod addons;
pub(crate) mod appearance;
mod assets;
mod attributes;
@@ -32,6 +33,7 @@ mod fonts;
pub(crate) mod freeform;
pub mod globaledit;
pub(crate) mod gradient;
pub mod gradpresets;
pub(crate) mod graph;
mod halftone;
pub mod help;
@@ -85,6 +87,7 @@ pub mod swatchlib;
pub mod symbollib;
pub(crate) mod tabs;
mod textedit;
pub use textedit::effective_kerning;
pub mod textstyles;
pub(crate) mod textwrap;
pub(crate) mod threads;
@@ -180,20 +183,22 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
}
/// A document is open and it is not in isolation mode: Object › Lock's and Object › Hide's items,
/// Unlock All and Show All are off in isolation mode. Pattern editing mode and opacity mask
/// editing isolate their art too, and do not count as isolation mode here while their own layer
/// is the isolated one. An object isolated inside them counts.
pub fn outside_isolation(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
let editing_layer = |l| st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == l) || st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == l);
if st.isolation.is_some_and(|l| !editing_layer(l)) { Err("exit isolation mode first".into()) } else { Ok(()) }
/// The isolated group, layer or object in isolation mode. Pattern editing mode and opacity mask
/// editing isolate their own layer, which does not count here; an object isolated inside them
/// counts.
pub(crate) fn ordinary_isolation(st: &crate::DocState) -> Option<vectorcraft_doc::NodeId> {
st.isolation.filter(|l| !st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l) && !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l))
}
/// Objects are selected ([`has_selection`]) and the document is not in isolation mode
/// ([`outside_isolation`]).
pub fn has_selection_outside_isolation(s: &Session) -> std::result::Result<(), String> {
outside_isolation(s)?;
has_selection(s)
/// Objects are selected ([`has_selection`]), and in isolation mode ([`ordinary_isolation`]) at
/// least one of them is inside the isolated object: Object › Lock's and Object › Hide's items.
pub fn has_selected_member(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
let Some(root) = ordinary_isolation(st) else { return has_selection(s) };
if st.selection.objects.iter().any(|id| inside_isolation(&st.doc, root, *id)) { Ok(()) } else { Err("nothing selected".into()) }
}
/// Whether `id` lies inside the isolated object `root` (the root itself does not).
pub(crate) fn inside_isolation(d: &vectorcraft_doc::Document, root: vectorcraft_doc::NodeId, id: vectorcraft_doc::NodeId) -> bool {
id != root && d.ancestry(id).is_some_and(|a| a.contains(&root))
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
@@ -243,6 +248,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(style::specs());
v.extend(swatch::specs());
v.extend(gradient::specs());
v.extend(gradpresets::specs());
v.extend(opacitymask::specs());
v.extend(layer::specs());
v.extend(layerpanel::specs());
@@ -322,6 +328,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(perspgrid::specs());
v.extend(shortcutset::specs());
v.extend(importmatches::specs());
v.extend(addons::specs());
v
})
}
@@ -611,6 +618,9 @@ pub use flatten::FlattenOptions;
/// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]).
pub use flatten::FlattenerPreset;
/// A saved Gradient panel preset ([`crate::Prefs::gradient_presets`]) and a row of the Presets list.
pub use gradpresets::{GradientPreset, GradientPresetEntry};
/// A saved Image Trace preset ([`crate::Prefs::trace_presets`]), and a change to the saved presets.
pub use buildcmds::{TracePreset, TracePresetChange};
+22
View File
@@ -0,0 +1,22 @@
//! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own
//! with commands under `addon.<name>.*` (see `docs/mcp.md`).
mod arrange;
mod cleanup;
mod color;
mod document;
mod pathtools;
mod pen;
use super::CommandSpec;
/// The commands of every built-in add-on.
pub fn specs() -> Vec<CommandSpec> {
let mut v = arrange::specs();
v.extend(cleanup::specs());
v.extend(color::specs());
v.extend(document::specs());
v.extend(pathtools::specs());
v.extend(pen::specs());
v
}
+358
View File
@@ -0,0 +1,358 @@
//! Arrange Tools (`addon.arrange.*`), a built-in add-on: Arrange in Grid lays the selected objects
//! out in rows and columns, a row or a column; Swap Objects swaps the places (and sizes) of two;
//! Replace with Top Object puts a copy of the topmost selected object in place of each of the
//! others; and Set Size of Each gives every selected object an exact width and height. Places and
//! sizes are measured and set as `object.setBounds` does. Group Overlapping Objects groups the
//! objects whose visual bounds overlap. Its submenu (Object › Plug-ins › Arrange
//! Tools), its dialogs and Zoom to Selection (a view command) are the UI's.
#[cfg(test)]
mod tests;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_doc::{Node, NodeId, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_render::effects;
use super::super::edit::selected_roots;
use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns};
use super::super::typecmd::refresh_bounds_of;
use super::super::{CommandSpec, bad, cmd, has_doc, has_multi, has_selection, number_or, switch_or};
use crate::{EngineError, Result, Session};
/// The largest gap, width or height (pt) the commands take.
const LIMIT: f64 = 1e6;
/// The smallest width or height (pt) Set Size of Each takes.
const LEAST: f64 = 0.001;
/// The most columns Arrange in Grid takes.
const MAX_COLUMNS: u64 = 10_000;
/// The most objects Group Overlapping Objects looks at.
const MAX_OBJECTS: usize = 20_000;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.arrange.grid",
"Arrange in Grid",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{layout?: \"grid\" (rows and columns) | \"row\" (one row) | \"column\" (one column), columns?: 1..10000 (grid; default ⌈√count⌉; never more than the count), gapX?: pt −1000000..1000000 (10), gapY?: pt −1000000..1000000 (gapX), centerOnArtboard?: false (true: the grid is centred on the artboard under the selection's centre, else the first; false: it starts at the selection's top-left)} lay the selected objects (the top-level ones) out in their stacking order from the back, left to right and top to bottom, as one undo step. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, each object is centred in its cell, and everything is measured by visual bounds (strokes and effects as drawn) → {count}",
has_selection,
grid
),
cmd!(
"addon.arrange.swap",
"Swap Objects",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{size?: false (true: each also takes the other's width and height), scaleStrokes?: the Scale Strokes & Effects preference} swap the places (bounds centres) of the two selected objects, measured as object.setBounds measures them (Use Preview Bounds), as one undo step. Off unless exactly two objects are selected",
exactly_two,
swap
),
cmd!(
"addon.arrange.replace",
"Replace with Top Object",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{fit?: \"keep\" (the copy keeps its size) | \"bounds\" (scaled to the replaced object's width and height) | \"proportional\" (scaled to fit inside them, in proportion) (\"keep\"), scaleStrokes?: the Scale Strokes & Effects preference, keep?: false (true: the replaced objects stay, under their copies)} put a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order, as one undo step; sizes and centres are measured as object.setBounds measures them, and the copies are selected → {ids}",
has_multi,
replace
),
cmd!(
"addon.arrange.size",
"Set Size of Each",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{width?: pt, height?: pt (at least one; each 0.001..1000000), proportional?: false (with only one of them given, the other follows in proportion; else it stays), reference?: 0..8 (the 9-point grid row by row from the top-left; 4, the centre: the point of each object that stays put), scaleStrokes?: the Scale Strokes & Effects preference} give each selected object (the top-level ones) that width and height, measured as object.setBounds measures them (Use Preview Bounds), as one undo step → {count}",
has_selection,
size
),
cmd!(
"addon.arrange.groupOverlapping",
"Group Overlapping Objects",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{} group the selected objects (the top-level ones, at most 20000) wherever their visual bounds overlap or touch, chains included (when A overlaps B and B overlaps C, all three go in one group), as one undo step. Only objects in the same layer or group are grouped together, objects that overlap nothing stay as they are, and each new group goes where its frontmost member was in the stacking order; the new groups and the objects left alone stay selected → {groups: [id…], grouped (how many objects went into groups)}",
has_multi,
group_overlapping
),
]
}
/// Exactly two objects are selected (Swap Objects).
fn exactly_two(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
let n = selected_roots(s).map_err(|e| e.to_string())?.len();
if n == 2 { Ok(()) } else { Err("select exactly two objects".into()) }
}
/// Number param `key` (`default` when left out) within `lo..=hi`.
fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str) -> Result<f64> {
let v = number_or(p, key, default, cmd)?;
if (lo..=hi).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
}
/// Whole-number param `key` within `lo..=hi`, `None` when left out.
fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result<Option<u64>> {
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?;
if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
}
/// A width or height param of Set Size of Each, `None` when left out.
fn size_param(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
p.get(key).filter(|v| !v.is_null()).map(|_| number_in(p, key, 0.0, (LEAST, LIMIT), cmd)).transpose()
}
/// `object.setBounds` params that centre an object on `at`, with Scale Strokes & Effects `strokes`
/// and, when given, a width and height.
fn place(at: Point, size: Option<(f64, f64)>, strokes: bool) -> Value {
let mut q = json!({ "x": at.x, "y": at.y, "reference": 4, "strokes": strokes });
if let Some((w, h)) = size {
q["width"] = json!(w);
q["height"] = json!(h);
}
q
}
/// Transform each object of `xfs` by its own transform as undo step `label`, strokes and effects
/// scaling with Scale Strokes & Effects `strokes`, pattern tiles as Transform Pattern Tiles says.
/// When every transform is (within rounding) the identity, nothing changes.
fn transform_each(s: &mut Session, label: &str, strokes: bool, xfs: &[(NodeId, Affine)]) -> Result<()> {
let still = |xf: &Affine| xf.as_coeffs().iter().zip(Affine::IDENTITY.as_coeffs()).all(|(a, b)| (a - b).abs() <= 1e-9);
if xfs.iter().all(|(_, xf)| still(xf)) {
return Ok(());
}
let p = json!({ "strokes": strokes });
let ids: Vec<NodeId> = xfs.iter().map(|(id, _)| *id).collect();
let mut sc = scaling(s, &p);
sc.patterns = transform_patterns(s, &p, &ids)?;
s.edit(label, |d, _| {
for (id, xf) in xfs {
if let Some(n) = d.node_mut(*id) {
transform_node(n, *xf, sc, true);
}
}
Ok(())
})
}
/// The box of object `id` as `object.setBounds` measures it.
fn box_of(s: &Session, id: NodeId) -> Result<OrientedBox> {
s.transform_box(&[id]).ok_or_else(|| EngineError::Other("no bounds".into()))
}
fn grid(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.grid";
let layout = choice(p, C, "layout", &["grid", "row", "column"])?.unwrap_or("grid");
let columns = whole_in(p, "columns", (1, MAX_COLUMNS), C)?;
let gap_x = number_in(p, "gapX", 10.0, (-LIMIT, LIMIT), C)?;
let gap_y = number_in(p, "gapY", gap_x, (-LIMIT, LIMIT), C)?;
let center = switch_or(p, "centerOnArtboard", false, C)?;
let roots = selected_roots(s)?;
let d = &s.doc()?.doc;
let items: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], true)?))).collect();
let Some(all) = items.iter().map(|(_, b)| *b).reduce(|a, b| a.union(b)) else { return Ok(json!({ "count": 0 })) };
let n = items.len();
let cols = match layout {
"row" => n,
"column" => 1,
_ => columns.map_or_else(|| (n as f64).sqrt().ceil() as usize, |c| usize::try_from(c).unwrap_or(n)),
}
.clamp(1, n);
let mut widths = vec![0.0_f64; cols];
let mut heights = vec![0.0_f64; n.div_ceil(cols)];
for (i, (_, b)) in items.iter().enumerate() {
if let Some(w) = widths.get_mut(i % cols) {
*w = w.max(b.width());
}
if let Some(h) = heights.get_mut(i / cols) {
*h = h.max(b.height());
}
}
let total = |sizes: &[f64], gap: f64| sizes.iter().sum::<f64>() + gap * sizes.len().saturating_sub(1) as f64;
let origin = if center {
let board = d.artboards.get(d.artboard_at(all.center()).unwrap_or(0)).map_or(all, |a| a.rect);
Point::new(board.center().x - total(&widths, gap_x) / 2.0, board.center().y - total(&heights, gap_y) / 2.0)
} else {
Point::new(all.x0, all.y0)
};
// The middle of each column (row): the sizes and gaps before it, and half its own size.
let middles = |sizes: &[f64], gap: f64, from: f64| -> Vec<f64> {
sizes
.iter()
.scan(from, |at, size| {
let mid = *at + size / 2.0;
*at += size + gap;
Some(mid)
})
.collect()
};
let (xs, ys) = (middles(&widths, gap_x, origin.x), middles(&heights, gap_y, origin.y));
let xfs: Vec<(NodeId, Affine)> = items
.iter()
.enumerate()
.filter_map(|(i, (id, b))| Some((*id, Affine::translate(Point::new(*xs.get(i % cols)?, *ys.get(i / cols)?) - b.center()))))
.collect();
transform_each(s, "Arrange in Grid", false, &xfs)?;
Ok(json!({ "count": n }))
}
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.swap";
let size = switch_or(p, "size", false, C)?;
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
let roots = selected_roots(s)?;
let &[a, b] = roots.as_slice() else { return Err(bad(C, "select exactly two objects")) };
let (ba, bb) = (box_of(s, a)?, box_of(s, b)?);
let to = |o: &OrientedBox| place(o.center(), size.then(|| (o.rect.width(), o.rect.height())), strokes);
let xfs = [(a, object_bounds_transform(s, &to(&bb), a)?), (b, object_bounds_transform(s, &to(&ba), b)?)];
transform_each(s, "Swap Objects", strokes, &xfs)?;
Ok(json!({}))
}
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.replace";
let fit = choice(p, C, "fit", &["keep", "bounds", "proportional"])?.unwrap_or("keep");
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
let keep = switch_or(p, "keep", false, C)?;
let roots = selected_roots(s)?;
let Some((&top, others)) = roots.split_last().filter(|(_, others)| !others.is_empty()) else {
return Err(bad(C, "select at least two objects"));
};
let tb = box_of(s, top)?;
let (tw, th) = (tb.rect.width(), tb.rect.height());
let mut xfs = Vec::with_capacity(others.len());
for &id in others {
let Some(b) = s.transform_box(&[id]) else { continue };
let (w, h) = (b.rect.width(), b.rect.height());
let size = match fit {
"bounds" => Some((w, h)),
// The smaller of the two scales that fit; a side the copy doesn't have doesn't count.
"proportional" => {
[(w, tw), (h, th)].into_iter().filter(|(_, t)| *t > 1e-9).map(|(v, t)| v / t).reduce(f64::min).map(|k| (tw * k, th * k))
}
_ => None,
};
xfs.push((id, object_bounds_transform(s, &place(b.center(), size, strokes), top)?));
}
let p = json!({ "strokes": strokes });
let mut sc = scaling(s, &p);
sc.patterns = transform_patterns(s, &p, &[top])?;
let ids = s.edit("Replace with Top Object", |d, sel| {
let Some(n) = d.node(top).cloned() else { return Ok(vec![]) };
let mut items = Vec::with_capacity(xfs.len());
for (id, xf) in &xfs {
let mut c = d.reid(&n);
transform_node(&mut c, *xf, sc, true);
items.push((*id, vec![c]));
}
let out: Vec<NodeId> = items.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
d.insert_beside(items, !keep)?;
refresh_bounds_of(d, &out);
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn size(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.size";
let width = size_param(p, "width", C)?;
let height = size_param(p, "height", C)?;
if width.is_none() && height.is_none() {
return Err(bad(C, "give `width` or `height`"));
}
let proportional = switch_or(p, "proportional", false, C)?;
let reference = whole_in(p, "reference", (0, 8), C)?.unwrap_or(4);
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
let mut q = json!({ "reference": reference, "strokes": strokes, "proportional": proportional && (width.is_none() || height.is_none()) });
if let Some(w) = width {
q["width"] = json!(w);
}
if let Some(h) = height {
q["height"] = json!(h);
}
let mut xfs = vec![];
for id in selected_roots(s)? {
if s.transform_box(&[id]).is_some() {
xfs.push((id, object_bounds_transform(s, &q, id)?));
}
}
transform_each(s, "Set Size of Each", strokes, &xfs)?;
Ok(json!({ "count": xfs.len() }))
}
/// The sets of two or more of `items` (each in the order given) whose rects overlap or touch,
/// chains included, among items of the same key (parent). A sweep along x: only the rects still
/// open where each one starts are compared with it.
fn overlapping<K: PartialEq>(items: &[(K, Rect)]) -> Vec<Vec<usize>> {
fn root(up: &mut [usize], mut i: usize) -> usize {
while up[i] != i {
up[i] = up[up[i]];
i = up[i];
}
i
}
let mut up: Vec<usize> = (0..items.len()).collect();
let mut order: Vec<usize> = (0..items.len()).collect();
order.sort_by(|a, b| items[*a].1.x0.total_cmp(&items[*b].1.x0));
let mut open: Vec<usize> = vec![];
for i in order {
let (key, r) = (&items[i].0, items[i].1);
open.retain(|&j| items[j].1.x1 >= r.x0);
for &j in &open {
let (k, o) = (&items[j].0, items[j].1);
if k == key && o.y0 <= r.y1 && r.y0 <= o.y1 {
let (a, b) = (root(&mut up, i), root(&mut up, j));
up[a] = b;
}
}
open.push(i);
}
let mut sets: Vec<Vec<usize>> = vec![vec![]; items.len()];
for i in 0..items.len() {
let r = root(&mut up, i);
sets[r].push(i);
}
sets.retain(|s| s.len() > 1);
sets.sort_by_key(|s| s.first().copied());
sets
}
fn group_overlapping(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "addon.arrange.groupOverlapping";
let roots = selected_roots(s)?;
if roots.len() > MAX_OBJECTS {
return Err(bad(C, "too many objects selected (at most 20000)"));
}
let d = &s.doc()?.doc;
let items: Vec<(NodeId, (Option<NodeId>, Rect))> =
roots.iter().filter_map(|id| Some((*id, (d.parent_of(*id), effects::preview::bounds_of(d, &[*id], true)?)))).collect();
let keyed: Vec<(Option<NodeId>, Rect)> = items.iter().map(|(_, k)| *k).collect();
let sets: Vec<Vec<NodeId>> = overlapping(&keyed).into_iter().map(|set| set.iter().filter_map(|i| Some(items.get(*i)?.0)).collect()).collect();
if sets.is_empty() {
return Ok(json!({ "groups": [], "grouped": 0 }));
}
let grouped: std::collections::HashSet<NodeId> = sets.iter().flatten().copied().collect();
let groups = s.edit("Group Overlapping Objects", |d, sel| {
// Every group at once: the members behind each front one go, then each front one is
// replaced by its group (one pass over the tree each, however many groups).
let (mut places, mut behind, mut groups) = (vec![], vec![], vec![]);
for set in &sets {
let Some((&front, rest)) = set.split_last() else { continue };
let children: Vec<Arc<Node>> = set.iter().filter_map(|id| d.node(*id).cloned().map(Arc::new)).collect();
let gid = d.alloc_id();
places.push((front, vec![Node::group(gid, children)]));
behind.extend_from_slice(rest);
groups.push(gid);
}
d.remove_many(&behind)?;
d.insert_beside(places, true)?;
sel.set(groups.iter().copied().chain(roots.iter().copied().filter(|id| !grouped.contains(id))));
Ok(groups)
})?;
Ok(json!({ "groups": groups.iter().map(|i| i.0).collect::<Vec<_>>(), "grouped": grouped.len() }))
}
@@ -0,0 +1,338 @@
//! Arrange Tools' commands: the geometry each one leaves, one undo step each, when they are off,
//! and bad params refused without a change.
use serde_json::{Value, json};
use vectorcraft_doc::NodeId;
use vectorcraft_geom::Rect;
use crate::Session;
/// A new document whose new art has no stroke, so visual and geometric bounds agree.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn near(a: Rect, b: (f64, f64, f64, f64)) -> bool {
[(a.x0, b.0), (a.y0, b.1), (a.width(), b.2), (a.height(), b.3)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
}
fn steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
/// Each of `bad` is refused and leaves the document as it was.
fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) {
let (before, n) = (s.doc().unwrap().doc.clone(), steps(s));
for p in bad {
assert!(s.execute(cmd, p).is_err(), "{cmd} {p}");
}
assert_eq!(steps(s), n, "{cmd}");
assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document");
}
fn enabled(s: &Session, cmd: &str) -> bool {
s.commands().into_iter().find(|c| c.id == cmd).unwrap().enabled
}
#[test]
fn grid_lays_objects_out_in_rows_and_columns_from_the_back() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 20.0, 10.0);
let b = rect(&mut s, 300.0, 50.0, 40.0, 30.0);
let c = rect(&mut s, 10.0, 200.0, 10.0, 10.0);
let d = rect(&mut s, 50.0, 5.0, 30.0, 20.0);
select(&mut s, &[d, c, b, a]);
let n = steps(&s);
// Four objects: two columns. The grid starts at the selection's top-left (10, 5).
let r = s.execute("addon.arrange.grid", &json!({"gapX": 5, "gapY": 2})).unwrap();
assert_eq!(r["count"], json!(4));
assert_eq!(steps(&s), n + 1, "one undo step");
// Columns 20 and 40 wide (the widest in each), rows 30 and 20 tall; each object centred.
assert!(near(bounds(&s, a), (10.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (35.0, 5.0, 40.0, 30.0)), "{:?}", bounds(&s, b));
assert!(near(bounds(&s, c), (15.0, 42.0, 10.0, 10.0)), "{:?}", bounds(&s, c));
assert!(near(bounds(&s, d), (40.0, 37.0, 30.0, 20.0)), "{:?}", bounds(&s, d));
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(bounds(&s, a), (100.0, 100.0, 20.0, 10.0)));
}
#[test]
fn grid_lays_out_a_row_or_a_column_and_centres_on_the_artboard() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 100.0, 100.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("addon.arrange.grid", &json!({"layout": "row"})).unwrap();
assert!(near(bounds(&s, b), (30.0, 0.0, 20.0, 20.0)), "gapX defaults to 10");
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapX": 4})).unwrap();
assert!(near(bounds(&s, b), (0.0, 24.0, 20.0, 20.0)), "gapY follows gapX");
// 20 × 44 centred on the 400 × 300 artboard.
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap();
assert!(near(bounds(&s, a), (190.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, a));
// In place already: no step.
let n = steps(&s);
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap();
assert_eq!(steps(&s), n);
// More columns than objects: one row.
s.execute("addon.arrange.grid", &json!({"columns": 50, "gapX": 0})).unwrap();
assert!(near(bounds(&s, b), (210.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, b));
}
#[test]
fn grid_measures_visual_bounds() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
s.execute("addon.arrange.grid", &json!({"layout": "row", "gapX": 0})).unwrap();
// Visual boxes 24 wide, touching.
assert!(near(bounds(&s, b), (24.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, b));
}
#[test]
fn swap_trades_places_and_optionally_sizes() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
let c = rect(&mut s, 200.0, 200.0, 5.0, 5.0);
select(&mut s, &[a]);
assert!(!enabled(&s, "addon.arrange.swap"));
select(&mut s, &[a, b, c]);
assert!(!enabled(&s, "addon.arrange.swap"));
refuses(&mut s, "addon.arrange.swap", &[json!({})]);
select(&mut s, &[a, b]);
assert!(enabled(&s, "addon.arrange.swap"));
let n = steps(&s);
s.execute("addon.arrange.swap", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1);
// Centres (10, 5) and (120, 120) swap; sizes stay.
assert!(near(bounds(&s, a), (110.0, 115.0, 20.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (-10.0, -15.0, 40.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.swap", &json!({"size": true})).unwrap();
assert!(near(bounds(&s, a), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (0.0, 0.0, 20.0, 10.0)), "{:?}", bounds(&s, b));
}
#[test]
fn swap_with_sizes_scales_strokes_as_asked() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
select(&mut s, &[a, b]);
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
let weight = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width();
s.execute("addon.arrange.swap", &json!({"size": true})).unwrap();
assert_eq!((weight(&s, a), weight(&s, b)), (2.0, 2.0), "off by default (the preference)");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.swap", &json!({"size": true, "scaleStrokes": true})).unwrap();
assert!((weight(&s, a) - 4.0).abs() < 1e-9 && (weight(&s, b) - 1.0).abs() < 1e-9, "{} {}", weight(&s, a), weight(&s, b));
}
#[test]
fn replace_puts_a_copy_of_the_top_object_in_each_place() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0);
select(&mut s, &[top]);
assert!(!enabled(&s, "addon.arrange.replace"));
refuses(&mut s, "addon.arrange.replace", &[json!({})]);
select(&mut s, &[a, b, top]);
let n = steps(&s);
let r = s.execute("addon.arrange.replace", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1);
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 2);
let st = s.doc().unwrap();
assert!(st.doc.node(a).is_none() && st.doc.node(b).is_none(), "replaced objects deleted");
assert!(st.doc.node(top).is_some(), "the top object stays");
assert_eq!(st.selection.objects, ids, "the copies are selected");
// Kept size, centred on (10, 20) and (120, 120); each in its object's place (bottom, then middle).
assert!(near(bounds(&s, ids[0]), (5.0, 17.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[0]));
assert!(near(bounds(&s, ids[1]), (115.0, 117.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[1]));
let layer = &s.doc().unwrap().doc.layers[0];
let order: Vec<NodeId> = layer.children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(order, [ids[0], ids[1], top]);
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(a).is_some());
}
#[test]
fn replace_fits_the_copies_and_can_keep_the_originals() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0);
let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0);
select(&mut s, &[a, top]);
let r = s.execute("addon.arrange.replace", &json!({"fit": "bounds", "keep": true})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
assert!(near(bounds(&s, copy), (0.0, 0.0, 20.0, 40.0)), "{:?}", bounds(&s, copy));
let order: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(order, [a, copy, top], "kept: the copy goes right above it");
s.execute("edit.undo", &json!({})).unwrap();
select(&mut s, &[a, top]);
let r = s.execute("addon.arrange.replace", &json!({"fit": "proportional"})).unwrap();
let copy = NodeId(r["ids"][0].as_u64().unwrap());
// 10 × 5 into 20 × 40: twice as large, centred on (10, 20).
assert!(near(bounds(&s, copy), (0.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, copy));
}
#[test]
fn size_gives_each_object_a_width_and_height() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
select(&mut s, &[a, b]);
let n = steps(&s);
s.execute("addon.arrange.size", &json!({"width": 30})).unwrap();
assert_eq!(steps(&s), n + 1);
// About each centre; the height stays.
assert!(near(bounds(&s, a), (-5.0, 0.0, 30.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (105.0, 100.0, 30.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.size", &json!({"width": 40, "proportional": true, "reference": 0})).unwrap();
assert!(near(bounds(&s, a), (0.0, 0.0, 40.0, 20.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, b));
s.execute("addon.arrange.size", &json!({"width": 10, "height": 12, "proportional": true, "reference": 8})).unwrap();
assert!(near(bounds(&s, a), (30.0, 8.0, 10.0, 12.0)), "both given: both set, {:?}", bounds(&s, a));
}
#[test]
fn bad_params_are_refused() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
select(&mut s, &[a, b]);
refuses(
&mut s,
"addon.arrange.grid",
&[
json!({"layout": "spiral"}),
json!({"columns": 0}),
json!({"columns": 1.5}),
json!({"columns": 1e12}),
json!({"gapX": 1e308}),
json!({"gapY": "10"}),
json!({"centerOnArtboard": "yes"}),
],
);
refuses(&mut s, "addon.arrange.swap", &[json!({"size": 1}), json!({"scaleStrokes": "no"})]);
refuses(&mut s, "addon.arrange.replace", &[json!({"fit": "stretch"}), json!({"keep": 0})]);
refuses(
&mut s,
"addon.arrange.size",
&[
json!({}),
json!({"width": 0}),
json!({"height": -5}),
json!({"width": 1e308}),
json!({"width": "10"}),
json!({"width": 10, "reference": 9}),
json!({"width": 10, "proportional": "yes"}),
],
);
}
#[test]
fn off_without_a_selection() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 20.0, 10.0);
s.execute("select.none", &json!({})).unwrap();
for c in ["addon.arrange.grid", "addon.arrange.swap", "addon.arrange.replace", "addon.arrange.size", "addon.arrange.groupOverlapping"] {
assert!(!enabled(&s, c), "{c}");
}
assert!(!enabled(&Session::new(), "addon.arrange.swap"));
}
fn ids_of(v: &Value) -> Vec<NodeId> {
v.as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
fn children(s: &Session, id: NodeId) -> Vec<NodeId> {
s.doc().unwrap().doc.node(id).unwrap().children().unwrap().iter().map(|n| n.id).collect()
}
#[test]
fn group_overlapping_groups_chains_and_leaves_loners() {
let mut s = session();
// a–b–c a chain (c only touches b), d alone, e over f.
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let d = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 15.0, 15.0, 20.0, 20.0);
let e = rect(&mut s, 100.0, 100.0, 30.0, 30.0);
let c = rect(&mut s, 35.0, 0.0, 10.0, 20.0);
let f = rect(&mut s, 110.0, 110.0, 5.0, 5.0);
select(&mut s, &[a, b, c, d, e, f]);
let n = steps(&s);
let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert_eq!(r["grouped"], json!(5));
let groups = ids_of(&r["groups"]);
assert_eq!(groups.len(), 2);
assert_eq!(children(&s, groups[0]), [a, b, c], "members keep their stacking order");
assert_eq!(children(&s, groups[1]), [e, f]);
// Each group where its frontmost member was: a d [abc] [ef] → d, group(abc) above it, then (ef).
let layer: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(layer, [d, groups[0], groups[1]]);
let sel = &s.doc().unwrap().selection.objects;
assert!(sel.contains(&groups[0]) && sel.contains(&groups[1]) && sel.contains(&d) && sel.len() == 3, "{sel:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 6);
}
#[test]
fn group_overlapping_keeps_layers_apart_and_measures_visual_bounds() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
let gap = rect(&mut s, 22.0, 0.0, 20.0, 20.0);
s.execute("layer.new", &json!({})).unwrap();
let other = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
select(&mut s, &[a, gap, other]);
// 2 pt apart: nothing overlaps within a layer, and a and `other` are in different layers.
let n = steps(&s);
let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap();
assert_eq!((r["grouped"].clone(), steps(&s)), (json!(0), n), "no groups, no step");
// A 4 pt stroke reaches 2 pt out: the two in the first layer now touch.
select(&mut s, &[a, gap]);
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
select(&mut s, &[a, gap, other]);
let r = s.execute("addon.arrange.groupOverlapping", &json!({})).unwrap();
let groups = ids_of(&r["groups"]);
assert_eq!(groups.len(), 1);
assert_eq!(children(&s, groups[0]), [a, gap]);
let doc = &s.doc().unwrap().doc;
assert_ne!(doc.layer_of(other), doc.layer_of(a), "left in its own layer");
}
#[test]
fn group_overlapping_is_off_with_one_object_and_scales_to_many() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
select(&mut s, &[a]);
assert!(!enabled(&s, "addon.arrange.groupOverlapping"));
// A long row of pairs: each pair overlaps, neighbouring pairs don't.
let rects: Vec<(usize, Rect)> =
(0..4000).map(|i| (0, Rect::new(i as f64 * 10.0, 0.0, i as f64 * 10.0 + if i % 2 == 0 { 12.0 } else { 3.0 }, 5.0))).collect();
let sets = super::overlapping(&rects);
assert_eq!(sets.len(), 2000);
assert!(sets.iter().all(|s| s.len() == 2 && s[1] == s[0] + 1));
}
+365
View File
@@ -0,0 +1,365 @@
//! Artwork Cleanup (`addon.cleanup.*`), a built-in add-on: Simplify Anchors (Keep Shape), the
//! Document Health Check (check, show and fix the issues it finds), Deep Ungroup and Rejoin Path
//! Pieces, and its text tools ([`text`]). Its panel and its submenu, Object › Plug-ins › Artwork
//! Cleanup, are the UI's.
mod checks;
#[cfg(test)]
mod tests;
mod text;
use serde_json::{Map, Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_geom::{PathData, SubPath};
use vectorcraft_pathops::addons as po;
use self::checks::{Kind, MAX_OBJECTS, Outcome, Scope, set_path};
use super::super::edit::selected_roots;
use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, number_or, switch_or, text_param};
use crate::{EngineError, Result, Session};
/// Simplify Anchors' tolerance by default.
const TOLERANCE: f64 = 20.0;
/// Rejoin Path Pieces' gap by default, and the widest (pt).
const GAP: f64 = 0.1;
const MAX_GAP: f64 = 6.0;
/// Most anchors Simplify Anchors takes at once.
const MAX_ANCHORS: usize = 2_000_000;
/// Most rounds of fixes `addon.cleanup.fix` runs.
const FIX_ROUNDS: usize = 3;
/// Most ids a finding lists (its count counts them all).
const MAX_LISTED: usize = 1000;
pub fn specs() -> Vec<CommandSpec> {
let mut v = vec![
cmd!(
"addon.cleanup.simplify",
"Simplify Anchors (Keep Shape)…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay → {ids, before, after} anchor counts",
has_selection,
simplify
),
cmd!(
query "addon.cleanup.check",
"Health Check",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one, or type on the same type within 1 pt), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found",
has_doc,
check
),
cmd!(
query "addon.cleanup.show",
"Show Issue",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{kind: kind (see addon.cleanup.check), scope?: \"selection\"|\"document\"} select the objects with that issue → {kind, count, ids, artboards}",
has_doc,
show
),
cmd!(
"addon.cleanup.fix",
"Fix All Issues",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{scope?: \"selection\"|\"document\", kinds?: [kind…] (default: all; see addon.cleanup.check)} fix the issues addon.cleanup.check finds, in up to three rounds while a round still finds some (a fix can leave issues for another kind), as one undo step: what is empty, unpainted, invisible, duplicated or off the artboards goes, anchors merge, snap or go, tiny handles retract, paths close and needless groups, compound paths and clipping groups give way to their contents → {scope, total, fixed: {kind: count}}",
has_doc,
fix
),
cmd!(
"addon.cleanup.deepUngroup",
"Deep Ungroup…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in → {ids (the objects left, selected), ungrouped (groups)}",
has_selection,
deep_ungroup
),
cmd!(
"addon.cleanup.rejoin",
"Rejoin Path Pieces…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{gap?: pt 0..6 (0.1), ids?: [id…] (default: the selection)} weld the open paths under the objects whose ends lie within the gap of each other back into paths: an end joins one other end, the one the path turns least to go on into, pieces may run either way, and a chain whose ends meet closes; each path takes the place and paint of its topmost piece, as one undo step → {ids, joined (paths made), removed (pieces merged into them)}",
has_selection,
rejoin
),
];
v.extend(text::specs());
v
}
/// The paths (not guides) under `roots`, compound path members included.
fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for n in roots.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { guide: false, .. }) {
out.push(c.id);
}
});
}
out
}
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.simplify";
let tolerance = number_or(p, "tolerance", TOLERANCE, C)?;
if !(1.0..=100.0).contains(&tolerance) {
return Err(bad(C, "`tolerance` must be from 1 to 100"));
}
let protect = switch_or(p, "protectCorners", false, C)?;
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
let ids = paths_under(d, &roots);
let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect();
let before: usize = paths.iter().map(|(_, p)| p.anchor_count()).sum();
if before > MAX_ANCHORS {
return Err(EngineError::Other("Simplify Anchors: the paths have too many anchors to simplify at once".into()));
}
let changed: Vec<(NodeId, PathData)> = paths
.iter()
.filter_map(|(id, path)| {
let np = po::simplify_keep_shape(path, tolerance * po::PT_PER_TOLERANCE, protect);
(np != **path).then_some((*id, np))
})
.collect();
let removed: usize = changed.iter().filter_map(|(id, np)| Some(d.node(*id)?.path_data()?.anchor_count().saturating_sub(np.anchor_count()))).sum();
let after = before.saturating_sub(removed);
// When no anchor can go, the document and its undo history stay as they are.
if !changed.is_empty() {
s.edit("Simplify Anchors (Keep Shape)", |d, sel| {
for (id, np) in changed {
// The anchors left are new ones: the path is selected whole.
sel.anchors.remove(&id);
sel.segments.remove(&id);
set_path(d, id, np);
}
Ok(())
})?;
}
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
}
/// The scope a check, show or fix request names (by default the selection, or the document while
/// nothing is selected) and its name.
fn scope_of(s: &Session, p: &Value, cmd: &str) -> Result<(Scope, &'static str)> {
let roots = selected_roots(s)?;
match text_param(p, "scope", cmd)? {
Some("document") => Ok((Scope::Document, "document")),
None if roots.is_empty() => Ok((Scope::Document, "document")),
Some("selection") if roots.is_empty() => Err(bad(cmd, "nothing selected")),
Some("selection") | None => Ok((Scope::Objects(roots), "selection")),
Some(other) => Err(bad(cmd, format!("`scope` must be \"selection\" or \"document\", not {other:?}"))),
}
}
/// The kinds a request names (all by default), in the order Fix runs them.
fn kinds_of(p: &Value, cmd: &str) -> Result<Vec<Kind>> {
let given: Vec<Kind> = match p.get("kinds") {
None | Some(Value::Null) => return Ok(Kind::ALL.to_vec()),
Some(Value::Array(a)) => {
a.iter().map(|v| v.as_str().and_then(Kind::parse).ok_or_else(|| bad(cmd, format!("unknown issue kind {v}")))).collect::<Result<_>>()?
}
Some(_) => return Err(bad(cmd, "`kinds` must be a list of issue kinds")),
};
Ok(Kind::ALL.into_iter().filter(|k| given.contains(k)).collect())
}
/// Kind `kind`'s fix run on a copy of the active document: what Fix would change.
fn dry_run(s: &Session, kind: Kind, scope: &Scope) -> Result<Outcome> {
let st = s.doc()?;
let (mut d, mut sel, mut scope) = ((*st.doc).clone(), st.selection.clone(), scope.clone());
kind.fix(&mut d, &mut sel, &mut scope)
}
fn finding(kind: Kind, o: &Outcome) -> Value {
let ids: Vec<u64> = o.ids.iter().take(MAX_LISTED).map(|i| i.0).collect();
json!({ "kind": kind.id(), "label": kind.label(), "count": o.count, "ids": ids, "artboards": o.artboards })
}
fn check(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.check";
let (scope, name) = scope_of(s, p, C)?;
let mut findings = vec![];
let mut total = 0;
for kind in kinds_of(p, C)? {
let o = dry_run(s, kind, &scope)?;
if o.count > 0 {
total += o.count;
findings.push(finding(kind, &o));
}
}
Ok(json!({ "scope": name, "total": total, "findings": findings }))
}
fn show(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.show";
let id = text_param(p, "kind", C)?.ok_or_else(|| bad(C, "missing `kind`"))?;
let kind = Kind::parse(id).ok_or_else(|| bad(C, format!("unknown issue kind {id:?}")))?;
let (scope, _) = scope_of(s, p, C)?;
let o = dry_run(s, kind, &scope)?;
if o.count == 0 {
return Err(EngineError::Other("None of these issues are left: check again".into()));
}
// Artboards are no objects to select: the selection stays as it is for them.
let ids = o.ids.clone();
if !ids.is_empty() {
s.select(|_, sel| sel.set(ids))?;
}
Ok(finding(kind, &o))
}
fn fix(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.fix";
let (mut scope, name) = scope_of(s, p, C)?;
let kinds = kinds_of(p, C)?;
// Fixed on a copy, kind after kind (what one leaves, the next cleans up), then kept as one step.
let st = s.doc()?;
let (mut d, mut sel) = ((*st.doc).clone(), st.selection.clone());
let mut fixed = Map::new();
let mut total = 0;
// A fix can leave issues for a kind that ran before it (groups unwrapped side by side may hold
// duplicates): the kinds run again, a few rounds at most, until a round finds nothing.
for _ in 0..FIX_ROUNDS {
let before = total;
for &kind in &kinds {
let o = kind.fix(&mut d, &mut sel, &mut scope)?;
if o.count > 0 {
total += o.count;
let n = fixed.get(kind.id()).and_then(Value::as_u64).unwrap_or(0);
fixed.insert(kind.id().into(), json!(n + o.count as u64));
}
}
if total == before {
break;
}
}
// With nothing to fix, the document and its undo history stay as they are.
if total > 0 {
sel.prune(&d);
s.edit("Fix Issues", |doc, selection| {
*doc = d;
*selection = sel;
Ok(())
})?;
}
Ok(json!({ "scope": name, "total": total, "fixed": fixed }))
}
/// Is `n` a group Deep Ungroup takes apart (a clipping group only with `clips`)?
fn ungroupable(n: &Node, clips: bool) -> bool {
matches!(n.kind, NodeKind::Group { clip, .. } if clips || !clip)
}
/// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts
/// the groups. Members take on the opacity of the groups they were in, and a clipping path
/// becomes a plain path.
fn flatten(n: Node, clips: bool, out: &mut Vec<Node>, count: &mut usize) {
if !ungroupable(&n, clips) {
out.push(n);
return;
}
let NodeKind::Group { children, clip } = &n.kind else { return };
*count += 1;
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
if *clip
&& k == 0
&& let NodeKind::Path { clipping, .. } = &mut c.kind
{
*clipping = false;
}
c.opacity *= n.opacity;
flatten(c, clips, out, count);
}
}
fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.deepUngroup";
let clips = switch_or(p, "includeClipGroups", false, C)?;
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
let (mut items, mut selected, mut count) = (vec![], vec![], 0);
for id in &roots {
match d.node(*id).filter(|n| ungroupable(n, clips)) {
Some(n) => {
let mut out = vec![];
flatten(n.clone(), clips, &mut out, &mut count);
selected.extend(out.iter().map(|n| n.id));
items.push((*id, out));
}
None => selected.push(*id),
}
}
// With no group to take apart, the document and its undo history stay as they are.
if !items.is_empty() {
let ids = selected.clone();
s.edit("Deep Ungroup", |d, sel| {
d.insert_beside(items, true)?;
sel.set(ids);
Ok(())
})?;
}
Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::<Vec<_>>(), "ungrouped": count }))
}
/// The open single-subpath paths under `roots` that Rejoin Path Pieces takes, in paint order
/// (compound path members, clipping paths and guides stay out).
fn pieces(d: &Document, roots: &[NodeId]) -> Vec<(NodeId, SubPath)> {
fn visit(n: &Node, out: &mut Vec<(NodeId, SubPath)>) {
match &n.kind {
NodeKind::Path { path, guide: false, clipping: false, .. } => {
if let [sp] = path.subpaths.as_slice()
&& !sp.closed
{
out.push((n.id, sp.clone()));
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
_ => {}
}
}
let mut out = vec![];
roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out));
let editable = d.editable();
out.retain(|(id, _)| editable(*id));
out
}
fn rejoin(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.rejoin";
let gap = number_or(p, "gap", GAP, C)?;
if !(0.0..=MAX_GAP).contains(&gap) {
return Err(bad(C, "`gap` must be from 0 to 6 pt"));
}
let roots = checked_ids_or_selection(s, p, C)?;
let found = pieces(&s.doc()?.doc, &roots);
if found.len() > MAX_OBJECTS {
return Err(EngineError::Other("Rejoin Path Pieces: too many paths to rejoin at once".into()));
}
let subpaths: Vec<SubPath> = found.iter().map(|(_, sp)| sp.clone()).collect();
// Each chain becomes its topmost piece (the last in paint order); the others go.
let (mut kept, mut gone) = (vec![], vec![]);
for j in po::rejoin(&subpaths, gap) {
let ids: Vec<NodeId> = j.members.iter().filter_map(|&m| found.get(m).map(|f| f.0)).collect();
let Some(&top) = j.members.iter().max().and_then(|&m| found.get(m).map(|f| &f.0)) else { continue };
gone.extend(ids.into_iter().filter(|id| *id != top));
kept.push((top, j.path));
}
if !kept.is_empty() {
let ids: Vec<NodeId> = kept.iter().map(|k| k.0).collect();
s.edit("Rejoin Path Pieces", |d, sel| {
for (id, sp) in &kept {
set_path(d, *id, PathData::single(sp.clone()));
}
d.remove_many(&gone)?;
sel.set(ids);
Ok(())
})?;
}
let ids: Vec<u64> = kept.iter().map(|k| k.0.0).collect();
Ok(json!({ "ids": ids, "joined": kept.len(), "removed": gone.len() }))
}
@@ -0,0 +1,483 @@
//! Document Health Check: the kinds of issue Artwork Cleanup looks for, and how each is fixed. A
//! check runs a kind's fix on a copy of the document, so what a check finds is what Fix changes.
use std::collections::{HashMap, HashSet};
use vectorcraft_color::BlendMode;
use vectorcraft_doc::appearance::AppearanceItem;
use vectorcraft_doc::{Appearance, Document, Knockout, Node, NodeExtras, NodeId, NodeKind, Selection, SymbolBoxes};
use vectorcraft_geom::{PathData, Point, Rect};
use vectorcraft_pathops::addons as po;
use super::super::super::edit::outermost;
use super::super::super::layer::empty_artboards;
use crate::Result;
/// Sine of the turn below which an anchor on a straight run is collinear.
const COLLINEAR: f64 = 0.005;
/// Distance (pt) within which anchors coincide, handles are tiny and path ends meet.
const NEAR: f64 = 0.01;
/// Most objects a check looks at; the rest of a larger document is left as it is.
pub(super) const MAX_OBJECTS: usize = 250_000;
/// Most anchors Near Anchors compares.
const MAX_ANCHORS: usize = 1_000_000;
/// Most rounds of unwrapping nested groups.
const MAX_PASSES: usize = 64;
/// One kind of issue.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Kind {
EmptyText,
StrayPoints,
Unpainted,
ZeroOpacity,
DuplicateObjects,
OffArtboard,
DuplicateAnchors,
TinyHandles,
CollinearAnchors,
NearPoints,
NearlyClosed,
FilledOpenPaths,
NeedlessClipGroups,
NeedlessCompounds,
NeedlessGroups,
EmptyArtboards,
}
impl Kind {
/// Every kind, in the order Fix runs them (removing art first can leave groups to unwrap).
pub(super) const ALL: [Kind; 16] = [
Kind::EmptyText,
Kind::StrayPoints,
Kind::Unpainted,
Kind::ZeroOpacity,
Kind::DuplicateObjects,
Kind::OffArtboard,
Kind::DuplicateAnchors,
Kind::TinyHandles,
Kind::CollinearAnchors,
Kind::NearPoints,
Kind::NearlyClosed,
Kind::FilledOpenPaths,
Kind::NeedlessClipGroups,
Kind::NeedlessCompounds,
Kind::NeedlessGroups,
Kind::EmptyArtboards,
];
/// (id, label) of the kind.
fn names(self) -> (&'static str, &'static str) {
match self {
Kind::EmptyText => ("emptyText", "Empty Text"),
Kind::StrayPoints => ("strayPoints", "Stray Points"),
Kind::Unpainted => ("unpainted", "Unpainted Objects"),
Kind::ZeroOpacity => ("zeroOpacity", "Zero-Opacity Objects"),
Kind::DuplicateObjects => ("duplicateObjects", "Duplicate Objects"),
Kind::OffArtboard => ("offArtboard", "Off-Artboard Art"),
Kind::DuplicateAnchors => ("duplicateAnchors", "Duplicate Anchors"),
Kind::TinyHandles => ("tinyHandles", "Tiny Handles"),
Kind::CollinearAnchors => ("collinearAnchors", "Collinear Anchors"),
Kind::NearPoints => ("nearPoints", "Nearly Coincident Anchors"),
Kind::NearlyClosed => ("nearlyClosed", "Nearly Closed Paths"),
Kind::FilledOpenPaths => ("filledOpenPaths", "Filled Open Paths"),
Kind::NeedlessClipGroups => ("needlessClipGroups", "Needless Clipping Groups"),
Kind::NeedlessCompounds => ("needlessCompounds", "Needless Compound Paths"),
Kind::NeedlessGroups => ("needlessGroups", "Needless Groups"),
Kind::EmptyArtboards => ("emptyArtboards", "Empty Artboards"),
}
}
pub(super) fn id(self) -> &'static str {
self.names().0
}
pub(super) fn label(self) -> &'static str {
self.names().1
}
pub(super) fn parse(id: &str) -> Option<Kind> {
Kind::ALL.into_iter().find(|k| k.id() == id)
}
/// Fix this kind of issue in `scope` of `d` → what changed.
pub(super) fn fix(self, d: &mut Document, sel: &mut Selection, scope: &mut Scope) -> Result<Outcome> {
match self {
Kind::DuplicateAnchors => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::merge_duplicate_anchors(sp, NEAR)).sum()),
Kind::TinyHandles => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::retract_tiny_handles(sp, NEAR)).sum()),
Kind::CollinearAnchors => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| po::remove_collinear_anchors(sp, COLLINEAR)).sum()),
Kind::NearlyClosed => reshape(d, scope, |_, p| p.subpaths.iter_mut().map(|sp| usize::from(po::close_nearly_closed(sp, NEAR))).sum()),
Kind::FilledOpenPaths => reshape(d, scope, close_filled),
Kind::NearPoints => near_points(d, scope),
Kind::EmptyArtboards => artboards(d, sel, scope),
_ => restructure(d, scope, self),
}
}
/// What replaces object `n` (at `place`) when it is this kind of issue: nothing to remove it,
/// its members to unwrap it. `None` when it isn't one.
fn replacement(self, cx: &Context, n: &Node, place: Place) -> Option<Vec<Node>> {
let removable = !place.clips && !is_guide_or_clip(n);
let gone = |is: bool| is.then(Vec::new);
match self {
Kind::EmptyText => gone(matches!(&n.kind, NodeKind::Text(t) if t.plain_text().trim().is_empty())),
Kind::StrayPoints => gone(removable && matches!(&n.kind, NodeKind::Path { path, .. } if path.anchor_count() <= 1)),
Kind::Unpainted => gone(removable && !place.in_compound && is_shape(n) && !is_painted(&n.appearance)),
Kind::ZeroOpacity => gone(removable && !place.in_compound && n.opacity <= 0.0),
Kind::DuplicateObjects => gone(removable && duplicates_the_one_below(cx.d, n)),
Kind::OffArtboard => gone(place.top && !is_guide_or_clip(n) && off_artboards(cx, n)),
Kind::NeedlessGroups => needless_group(n),
Kind::NeedlessCompounds => needless_compound(n),
Kind::NeedlessClipGroups => needless_clip_group(cx, n),
_ => None,
}
}
}
/// What a fix changed (or a check found): how many issues, the objects they are on and the
/// artboards (by index) they are.
#[derive(Clone, Debug, Default)]
pub(super) struct Outcome {
pub count: usize,
pub ids: Vec<NodeId>,
pub artboards: Vec<usize>,
}
/// What a check looks at: the whole document, or the objects given and what is inside them.
#[derive(Clone, Debug)]
pub(super) enum Scope {
Document,
Objects(Vec<NodeId>),
}
/// Where an object sits.
#[derive(Clone, Copy, Default)]
struct Place {
/// A member of a compound path.
in_compound: bool,
/// The clipping path of its group or layer.
clips: bool,
/// A top-level object of the scope (on a layer, or given).
top: bool,
}
/// The document a pass looks at, with its symbols measured once.
struct Context<'a> {
d: &'a Document,
symbols: SymbolBoxes<'a>,
}
/// The visible, unlocked objects in `scope` with where they sit, in paint order: groups, layers,
/// clip groups and compound paths are looked into; live and generated art (blends, envelopes,
/// graphs, symbols…) is taken as it is.
fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> {
let mut out = vec![];
let top = Place { top: true, ..Place::default() };
match scope {
Scope::Document => d.layers.iter().for_each(|l| visit(l, top, &mut out)),
Scope::Objects(ids) => ids.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, top, &mut out)),
}
let editable = d.editable();
out.retain(|(n, _)| editable(n.id));
out
}
fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) {
if !n.visible || n.locked || out.len() >= MAX_OBJECTS {
return;
}
let inner = |clip: bool, i: usize, top: bool| Place { clips: clip && i == 0, top, in_compound: false };
match &n.kind {
// A layer's objects are top-level ones.
NodeKind::Layer { children, clip, .. } => children.iter().enumerate().for_each(|(i, c)| visit(c, inner(*clip, i, true), out)),
NodeKind::Group { children, clip } if !is_generated(n) => {
out.push((n, place));
children.iter().enumerate().for_each(|(i, c)| visit(c, inner(*clip, i, false), out));
}
NodeKind::Compound { children, .. } => {
out.push((n, place));
children.iter().for_each(|c| visit(c, Place { in_compound: true, ..Place::default() }, out));
}
_ => out.push((n, place)),
}
}
/// A group that stands for live or generated art: its members aren't loose art to clean.
fn is_generated(n: &Node) -> bool {
let e = n.extras();
e.graph.is_some() || e.shaper.is_some() || e.dimension.is_some() || e.objects_on_path.is_some() || e.live_paint_gaps.is_some() || e.compound_shape
}
fn is_guide_or_clip(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: true, .. } | NodeKind::Path { clipping: true, .. })
}
fn is_shape(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
}
fn is_painted(a: &Appearance) -> bool {
a.items.iter().any(|i| match i {
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none(),
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0,
})
}
/// A container with nothing of its own to show: full opacity, normal blending, no fill, stroke,
/// effect, mask, knockout or anything else attached.
fn is_plain(n: &Node) -> bool {
n.opacity >= 1.0
&& n.blend == BlendMode::Normal
&& n.appearance.items.is_empty()
&& n.appearance.effects.is_empty()
&& !n.isolate
&& n.knockout == Knockout::Neutral
&& *n.extras() == NodeExtras::default()
}
/// Is `n` a plain path or type object (full opacity, normal blending) that doubles the object just
/// below it?
fn duplicates_the_one_below(d: &Document, n: &Node) -> bool {
if n.opacity < 1.0 || n.blend != BlendMode::Normal {
return false;
}
let below = d.position(n.id).and_then(|(parent, i, _)| d.children(parent)?.get(i.checked_sub(1)?).cloned());
below.is_some_and(|b| {
b.visible
&& same_art(&b.kind, &n.kind)
&& b.appearance == n.appearance
&& b.opacity == n.opacity
&& b.blend == n.blend
&& b.extras() == n.extras()
})
}
/// Distance (pt) within which doubled type counts as stacked: text set twice from a PDF or an old
/// file, or for a fake bold, is often a fraction of a point off.
const DOUBLED_TYPE: f64 = 1.0;
/// The same path, or the same type (text, styles, size and turn) within [`DOUBLED_TYPE`] of it.
fn same_art(a: &NodeKind, b: &NodeKind) -> bool {
match (a, b) {
(NodeKind::Path { .. }, NodeKind::Path { .. }) => a == b,
(NodeKind::Text(a), NodeKind::Text(b)) => {
let ([a0, a1, a2, a3, ax, ay], [b0, b1, b2, b3, bx, by]) = (a.xf.as_coeffs(), b.xf.as_coeffs());
[a0, a1, a2, a3] == [b0, b1, b2, b3] && (ax - bx).hypot(ay - by) <= DOUBLED_TYPE && {
// `b` placed on `a`, with `a`'s layout caches (they are in document space).
let mut moved = (**b).clone();
moved.xf = a.xf;
moved.cached_bounds = a.cached_bounds;
moved.cached_baselines.clone_from(&a.cached_baselines);
**a == moved
}
}
_ => false,
}
}
fn inside(a: Rect, b: Rect) -> bool {
a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1
}
fn meets(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
/// Does `n` lie wholly off every artboard (a document without artboards has none off them)?
fn off_artboards(cx: &Context, n: &Node) -> bool {
let Some(b) = n.visual_bounds_with(&cx.symbols) else { return false };
// As Delete Empty Artboards measures: a millionth of a point covers rounding at the edges.
!cx.d.artboards.is_empty() && !cx.d.artboards.iter().any(|a| meets(a.rect.inflate(1e-6, 1e-6), b))
}
/// A plain group of one member or none: its member, or nothing.
fn needless_group(n: &Node) -> Option<Vec<Node>> {
let NodeKind::Group { children, clip: false } = &n.kind else { return None };
(children.len() <= 1 && is_plain(n)).then(|| children.iter().map(|c| (**c).clone()).collect())
}
/// A compound path of one path or none: that path, painted as the compound was, or nothing.
fn needless_compound(n: &Node) -> Option<Vec<Node>> {
let NodeKind::Compound { children, rule } = &n.kind else { return None };
let mut path = match children.as_slice() {
[] => return Some(vec![]),
[one] => (**one).clone(),
_ => return None,
};
let NodeKind::Path { rule: r, .. } = &mut path.kind else { return None };
*r = *rule;
path.appearance = n.appearance.clone();
path.opacity = n.opacity;
path.blend = n.blend;
path.extras = n.extras.clone();
Some(vec![path])
}
/// A plain clip group whose unpainted clipping path changes nothing: it clips nothing or lies wholly
/// away from its art (nothing shows: nothing is left), or it is a rectangle around all its art (its
/// art is left).
fn needless_clip_group(cx: &Context, n: &Node) -> Option<Vec<Node>> {
let NodeKind::Group { children, clip: true } = &n.kind else { return None };
if !is_plain(n) {
return None;
}
let Some((clip, art)) = children.split_first() else { return Some(vec![]) };
let paint = clip.clip_paint();
if paint.fill.is_some() || paint.stroke.is_some() {
return None;
}
let clip_box = clip.geometric_bounds()?;
let art_box = art.iter().filter_map(|a| a.visual_bounds_with(&cx.symbols)).reduce(|a, b| a.union(b));
match art_box {
None => Some(vec![]),
Some(b) if !meets(clip_box, b) => Some(vec![]),
Some(b) if is_rectangle(clip) && inside(b, clip_box.inflate(1e-6, 1e-6)) => Some(art.iter().map(|a| (**a).clone()).collect()),
_ => None,
}
}
/// Is `n` a path drawing a square-to-the-page rectangle: four corners on its bounds, no curves?
fn is_rectangle(n: &Node) -> bool {
let NodeKind::Path { path, .. } = &n.kind else { return false };
let (Some(b), [sp]) = (path.bounds(), path.subpaths.as_slice()) else { return false };
let corner = |p: Point| ((p.x - b.x0).abs() < 1e-6 || (p.x - b.x1).abs() < 1e-6) && ((p.y - b.y0).abs() < 1e-6 || (p.y - b.y1).abs() < 1e-6);
sp.closed && sp.anchors.len() == 4 && sp.anchors.iter().all(|a| !a.has_in() && !a.has_out() && corner(a.p))
}
/// Replace the objects with this kind of issue (the outermost ones each round, until none is
/// left) by what [`Kind::replacement`] gives.
fn restructure(d: &mut Document, scope: &mut Scope, kind: Kind) -> Result<Outcome> {
let mut out = Outcome::default();
for _ in 0..MAX_PASSES {
let found: Vec<(NodeId, Vec<Node>)> = {
let d: &Document = d;
let cx = Context { d, symbols: d.symbol_boxes() };
let all: Vec<(NodeId, Vec<Node>)> =
objects(d, scope).into_iter().filter_map(|(n, pl)| Some((n.id, kind.replacement(&cx, n, pl)?))).collect();
let keep: HashSet<NodeId> = outermost(d, all.iter().map(|(id, _)| *id).collect()).into_iter().collect();
all.into_iter().filter(|(id, _)| keep.contains(id)).collect()
};
if found.is_empty() {
break;
}
if let Scope::Objects(roots) = scope {
for (id, nodes) in &found {
if let Some(at) = roots.iter().position(|r| r == id) {
roots.splice(at..=at, nodes.iter().map(|n| n.id));
}
}
}
out.ids.extend(found.iter().map(|(id, _)| *id));
d.insert_beside(found, true)?;
}
out.count = out.ids.len();
Ok(out)
}
/// Run `f` on a copy of each path in scope (not guides), and keep the copies it changed: `f`
/// returns how many issues it fixed.
fn reshape(d: &mut Document, scope: &Scope, f: impl Fn(&Node, &mut PathData) -> usize) -> Result<Outcome> {
let changed: Vec<(NodeId, PathData, usize)> = objects(d, scope)
.into_iter()
.filter_map(|(n, _)| {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
let mut p = path.clone();
let k = f(n, &mut p);
(k > 0).then_some((n.id, p, k))
})
.collect();
let mut out = Outcome::default();
for (id, p, k) in changed {
set_path(d, id, p);
out.count += k;
out.ids.push(id);
}
Ok(out)
}
/// Give path `id` the geometry `p` (it stops being a live shape).
pub(super) fn set_path(d: &mut Document, id: NodeId, p: PathData) {
if let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
*path = p;
*live = None;
}
}
/// Filled Open Paths: close the open subpaths of a path with a fill and no stroke.
fn close_filled(n: &Node, p: &mut PathData) -> usize {
let items = &n.appearance.items;
let filled = items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none()));
let stroked = items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.visible && !s.paint.is_none() && s.width > 0.0));
if !filled || stroked {
return 0;
}
let mut count = 0;
for sp in p.subpaths.iter_mut().filter(|sp| !sp.closed && sp.anchors.len() >= 2) {
sp.closed = true;
count += 1;
}
count
}
/// Nearly Coincident Anchors: anchors of the paths in scope within [`NEAR`] of one another (but
/// not neighbours on one path, which Duplicate Anchors merges) move onto the first of them.
fn near_points(d: &mut Document, scope: &Scope) -> Result<Outcome> {
// Each anchor: (path, subpath, anchor, its subpath's length and closedness).
let mut refs: Vec<(NodeId, usize, usize, usize, bool)> = vec![];
let mut points: Vec<Point> = vec![];
for (n, _) in objects(d, scope) {
let NodeKind::Path { path, guide: false, .. } = &n.kind else { continue };
for (si, sp) in path.subpaths.iter().enumerate() {
for (ai, a) in sp.anchors.iter().enumerate().take(MAX_ANCHORS.saturating_sub(points.len())) {
refs.push((n.id, si, ai, sp.anchors.len(), sp.closed));
points.push(a.p);
}
}
}
let neighbours = |i: usize, j: usize| {
let (Some(a), Some(b)) = (refs.get(i), refs.get(j)) else { return false };
let gap = a.2.abs_diff(b.2);
a.0 == b.0 && a.1 == b.1 && (gap == 1 || (a.4 && gap + 1 == a.3))
};
let mut moves: HashMap<NodeId, Vec<(usize, usize, Point)>> = HashMap::new();
let mut count = 0;
for group in po::near_point_groups(&points, NEAR, neighbours) {
let Some(&to) = group.first().and_then(|&i| points.get(i)) else { continue };
for &i in &group {
if let (Some(&(id, si, ai, ..)), Some(&p)) = (refs.get(i), points.get(i))
&& p != to
{
moves.entry(id).or_default().push((si, ai, to));
count += 1;
}
}
}
let mut ids: Vec<NodeId> = moves.keys().copied().collect();
ids.sort_unstable();
for id in &ids {
let Some(mut p) = d.node(*id).and_then(Node::path_data).cloned() else { continue };
for &(si, ai, to) in moves.get(id).into_iter().flatten() {
if let Some(a) = p.anchor_mut(si, ai) {
a.translate(to - a.p);
}
}
set_path(d, *id, p);
}
Ok(Outcome { count, ids, artboards: vec![] })
}
/// Empty Artboards: the artboards with no art on them (as Delete Empty Artboards finds them),
/// deleted with their guides. Only a check of the whole document looks at artboards.
fn artboards(d: &mut Document, sel: &mut Selection, scope: &Scope) -> Result<Outcome> {
let Scope::Document = scope else { return Ok(Outcome::default()) };
let empty = empty_artboards(d);
// The last first, so the others keep their places.
for &i in empty.iter().rev() {
if i < d.artboards.len() {
let id = d.artboards.remove(i).id;
d.retire_artboard_id(id);
d.retain_guides(sel, |_, g| g.artboard != Some(id));
}
}
Ok(Outcome { count: empty.len(), ids: vec![], artboards: empty })
}
@@ -0,0 +1,356 @@
//! Artwork Cleanup's commands: Simplify Anchors (Keep Shape), the health check's kinds found and
//! fixed one by one, Fix as one undo step, scopes, Show, Deep Ungroup and Rejoin Path Pieces.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind};
use vectorcraft_geom::{FillRule, Rect};
use crate::Session;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
fn id_of(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
}
/// A path through `pts` (anchors as `{x, y, out?}` objects).
fn path(s: &mut Session, anchors: Value, closed: bool) -> NodeId {
id_of(&s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap())
}
fn line(s: &mut Session, pts: &[(f64, f64)]) -> NodeId {
path(s, pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect(), false)
}
fn node(s: &Session, id: NodeId) -> Option<Node> {
s.doc().unwrap().doc.node(id).cloned()
}
fn change(s: &mut Session, id: NodeId, f: impl FnOnce(&mut Node)) {
s.edit("t", |d, _| {
f(d.node_mut(id).unwrap());
Ok(())
})
.unwrap();
}
/// Put `id` inside a new container that `kind` makes of its members.
fn wrap(s: &mut Session, id: NodeId, kind: fn(Vec<std::sync::Arc<Node>>) -> NodeKind) -> NodeId {
s.edit("t", |d, _| {
let w = d.alloc_id();
Ok(d.wrap(&[id], |c| Node::new(w, kind(c)))?)
})
.unwrap()
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.select(|_, sel| sel.set(ids.iter().copied())).unwrap();
}
fn anchors(s: &Session, id: NodeId) -> usize {
node(s, id).unwrap().path_data().unwrap().anchor_count()
}
/// How many issues of `kind` a check of `scope` finds.
fn found(s: &mut Session, kind: &str, scope: &str) -> usize {
let r = s.execute("addon.cleanup.check", &json!({"kinds": [kind], "scope": scope})).unwrap();
r["findings"].as_array().unwrap().iter().find(|f| f["kind"] == kind).map_or(0, |f| f["count"].as_u64().unwrap() as usize)
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn simplify_keeps_the_shape_with_fewer_anchors_in_one_step() {
let mut s = session();
let e = id_of(&s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
assert_eq!(anchors(&s, e), 16);
let bounds = node(&s, e).unwrap().geometric_bounds().unwrap();
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap();
assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(16), Some(anchors(&s, e) as u64)));
assert!(anchors(&s, e) <= 8, "{r}");
let now = node(&s, e).unwrap().geometric_bounds().unwrap();
for (a, b) in [(bounds.x0, now.x0), (bounds.y0, now.y0), (bounds.x1, now.x1), (bounds.y1, now.y1)] {
assert!((a - b).abs() < 0.4, "{bounds:?} → {now:?}");
}
assert_eq!(undo_steps(&s), steps + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(anchors(&s, e), 16);
// A rectangle's corners stay; the anchors on its sides go.
let r = rect(&mut s, 200.0, 50.0, 100.0, 60.0);
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
assert_eq!(anchors(&s, r), 8);
s.execute("addon.cleanup.simplify", &json!({"protectCorners": true})).unwrap();
assert_eq!(anchors(&s, r), 4);
// Nothing left to remove: no undo step.
let steps = undo_steps(&s);
s.execute("addon.cleanup.simplify", &json!({})).unwrap();
assert_eq!(undo_steps(&s), steps);
for bad in [json!({"tolerance": 0}), json!({"tolerance": 101}), json!({"tolerance": "20"}), json!({"protectCorners": 1})] {
assert!(s.execute("addon.cleanup.simplify", &bad).is_err(), "{bad}");
}
}
/// Each kind: what makes the issue in a fresh document (beside a clean rectangle).
fn make_issue(s: &mut Session, kind: &str) {
match kind {
"emptyText" => {
s.execute("text.create", &json!({"x": 20, "y": 20, "text": " "})).unwrap();
}
"strayPoints" => {
path(s, json!([{"x": 30, "y": 30}]), false);
}
"unpainted" => {
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
change(s, id, |n| n.appearance = Appearance::default().into());
}
"zeroOpacity" => {
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
change(s, id, |n| n.opacity = 0.0);
}
"duplicateObjects" => {
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
s.edit("t", |d, _| {
let mut copy = d.node(id).unwrap().clone();
copy.id = d.alloc_id();
let (parent, i, _) = d.position(id).unwrap();
Ok(d.insert(parent, i + 1, copy)?)
})
.unwrap();
}
"offArtboard" => {
rect(s, -500.0, -500.0, 30.0, 30.0);
}
"duplicateAnchors" => {
line(s, &[(10.0, 150.0), (50.0, 150.0), (50.005, 150.0), (50.0, 190.0)]);
}
"tinyHandles" => {
path(s, json!([{"x": 10, "y": 150, "out": [10.004, 150]}, {"x": 60, "y": 190}]), false);
}
"collinearAnchors" => {
line(s, &[(10.0, 150.0), (30.0, 150.0), (50.0, 150.0), (50.0, 190.0)]);
}
"nearPoints" => {
line(s, &[(10.0, 150.0), (50.0, 150.0)]);
line(s, &[(50.005, 150.0), (90.0, 190.0)]);
}
"nearlyClosed" => {
line(s, &[(10.0, 150.0), (50.0, 150.0), (50.0, 190.0), (10.005, 150.0)]);
}
"filledOpenPaths" => {
let id = line(s, &[(10.0, 150.0), (50.0, 150.0), (50.0, 190.0)]);
change(s, id, |n| n.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 1.0).into());
}
"needlessClipGroups" => {
let inner = rect(s, 30.0, 160.0, 20.0, 20.0);
let clip = rect(s, 20.0, 150.0, 50.0, 50.0);
select(s, &[inner, clip]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
}
"needlessCompounds" => {
// A compound path holds the paint of its paths.
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
let look = node(s, id).unwrap().appearance;
let compound = wrap(s, id, |children| NodeKind::Compound { children, rule: FillRule::NonZero });
change(s, compound, |n| n.appearance = look);
}
"needlessGroups" => {
let id = rect(s, 20.0, 150.0, 30.0, 30.0);
wrap(s, id, |children| NodeKind::Group { children, clip: false });
}
"emptyArtboards" => {
s.edit("t", |d, _| {
let mut a = d.artboards[0].clone();
a.id = d.next_artboard_id();
a.rect = Rect::new(1000.0, 1000.0, 1100.0, 1100.0);
d.artboards.push(a);
Ok(())
})
.unwrap();
}
_ => panic!("no setup for {kind}"),
}
}
const KINDS: [&str; 16] = [
"emptyText",
"strayPoints",
"unpainted",
"zeroOpacity",
"duplicateObjects",
"offArtboard",
"duplicateAnchors",
"tinyHandles",
"collinearAnchors",
"nearPoints",
"nearlyClosed",
"filledOpenPaths",
"needlessClipGroups",
"needlessCompounds",
"needlessGroups",
"emptyArtboards",
];
#[test]
fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() {
let mut clean = session();
let r = rect(&mut clean, 200.0, 20.0, 80.0, 60.0);
let before = node(&clean, r).unwrap();
assert_eq!(clean.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0, "clean art has no issues");
assert_eq!(clean.execute("addon.cleanup.fix", &json!({"scope": "document"})).unwrap()["total"], 0);
assert_eq!(node(&clean, r).unwrap(), before);
for kind in KINDS {
let mut s = session();
let r = rect(&mut s, 200.0, 20.0, 80.0, 60.0);
make_issue(&mut s, kind);
select(&mut s, &[]);
assert!(found(&mut s, kind, "document") > 0, "{kind} not found");
let fixed = s.execute("addon.cleanup.fix", &json!({"kinds": [kind], "scope": "document"})).unwrap();
assert!(fixed["fixed"][kind].as_u64().unwrap() > 0, "{kind}: {fixed}");
assert_eq!(found(&mut s, kind, "document"), 0, "{kind} still found after Fix");
assert_eq!(s.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0, "{kind} left other issues");
assert_eq!(node(&s, r).unwrap(), before, "{kind} changed the clean rectangle");
}
}
/// Type set twice in the same place (from a PDF, or for a fake bold) is a duplicate within a point:
/// Show selects the upper copy and Fix removes it. Other text, or the same text further off, stays.
#[test]
fn doubled_type_within_a_point_is_a_duplicate() {
let mut s = session();
let text = |s: &mut Session, x: f64, text: &str| id_of(&s.execute("text.create", &json!({"x": x, "y": 100, "text": text})).unwrap());
let base = text(&mut s, 20.0, "Total");
let doubled = text(&mut s, 20.4, "Total");
let other = text(&mut s, 20.0, "Subtotal");
let far = text(&mut s, 23.0, "Subtotal");
assert_eq!(found(&mut s, "duplicateObjects", "document"), 1);
let shown = s.execute("addon.cleanup.show", &json!({"kind": "duplicateObjects", "scope": "document"})).unwrap();
assert_eq!(shown["ids"], json!([doubled.0]));
s.execute("addon.cleanup.fix", &json!({"kinds": ["duplicateObjects"], "scope": "document"})).unwrap();
assert!(node(&s, doubled).is_none());
for id in [base, other, far] {
assert!(node(&s, id).is_some(), "{id:?} stays");
}
}
#[test]
fn fix_all_is_one_undo_step() {
let mut s = session();
for kind in KINDS {
make_issue(&mut s, kind);
}
select(&mut s, &[]);
let total = s.execute("addon.cleanup.check", &json!({})).unwrap()["total"].as_u64().unwrap();
assert!(total >= 16, "{total}");
let (steps, doc) = (undo_steps(&s), s.doc().unwrap().doc.clone());
let r = s.execute("addon.cleanup.fix", &json!({})).unwrap();
assert_eq!(r["scope"], "document");
assert_eq!(undo_steps(&s), steps + 1);
assert_eq!(s.execute("addon.cleanup.check", &json!({})).unwrap()["total"], 0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers, doc.layers);
assert_eq!(s.doc().unwrap().doc.artboards.len(), doc.artboards.len());
}
#[test]
fn the_scope_is_the_selection_or_the_document() {
let mut s = session();
let a = path(&mut s, json!([{"x": 30, "y": 30}]), false);
let b = path(&mut s, json!([{"x": 60, "y": 30}]), false);
select(&mut s, &[a]);
let r = s.execute("addon.cleanup.check", &json!({"kinds": ["strayPoints"]})).unwrap();
assert_eq!((r["scope"].as_str(), r["total"].as_u64()), (Some("selection"), Some(1)));
assert_eq!(found(&mut s, "strayPoints", "document"), 2);
s.execute("addon.cleanup.fix", &json!({"kinds": ["strayPoints"]})).unwrap();
assert!(node(&s, a).is_none() && node(&s, b).is_some(), "only the selection is fixed");
// Nothing selected: the document.
let r = s.execute("addon.cleanup.check", &json!({})).unwrap();
assert_eq!((r["scope"].as_str(), r["total"].as_u64()), (Some("document"), Some(1)));
assert!(s.execute("addon.cleanup.check", &json!({"scope": "selection"})).is_err(), "nothing selected");
for bad in [json!({"scope": "page"}), json!({"kinds": ["nope"]}), json!({"kinds": "strayPoints"}), json!({"kinds": [1]})] {
assert!(s.execute("addon.cleanup.check", &bad).is_err(), "{bad}");
assert!(s.execute("addon.cleanup.fix", &bad).is_err(), "{bad}");
}
}
#[test]
fn show_selects_the_objects_with_the_issue() {
let mut s = session();
rect(&mut s, 200.0, 20.0, 80.0, 60.0);
let stray = path(&mut s, json!([{"x": 30, "y": 30}]), false);
select(&mut s, &[]);
let r = s.execute("addon.cleanup.show", &json!({"kind": "strayPoints"})).unwrap();
assert_eq!(r["count"], 1);
assert_eq!(s.doc().unwrap().selection.objects, [stray]);
assert!(s.execute("addon.cleanup.show", &json!({"kind": "emptyText", "scope": "document"})).is_err(), "no such issue");
assert!(s.execute("addon.cleanup.show", &json!({})).is_err() && s.execute("addon.cleanup.show", &json!({"kind": "x"})).is_err());
}
#[test]
fn deep_ungroup_takes_apart_every_level_but_clipping_groups() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0);
let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0);
select(&mut s, &[b, c]);
let inner = id_of(&s.execute("object.group", &json!({})).unwrap());
change(&mut s, inner, |n| n.opacity = 0.5);
let clipped = rect(&mut s, 100.0, 10.0, 10.0, 10.0);
let clip = rect(&mut s, 95.0, 5.0, 30.0, 30.0);
select(&mut s, &[clipped, clip]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
let clip_group = s.doc().unwrap().selection.objects[0];
select(&mut s, &[a, inner, clip_group]);
let outer = id_of(&s.execute("object.group", &json!({})).unwrap());
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap();
assert_eq!(r["ungrouped"], 2);
assert_eq!(undo_steps(&s), steps + 1);
let d = &s.doc().unwrap().doc;
let layer: Vec<NodeId> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(layer, [a, b, c, clip_group]);
assert!(d.node(outer).is_none() && d.node(inner).is_none());
assert_eq!(d.node(b).unwrap().opacity, 0.5, "members keep their groups' opacity");
assert_eq!(s.doc().unwrap().selection.objects.len(), 4);
// Asked to, clipping groups come apart too, their clipping paths kept as plain paths.
select(&mut s, &[clip_group]);
s.execute("addon.cleanup.deepUngroup", &json!({"includeClipGroups": true})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(d.node(clip_group).is_none());
assert!(matches!(d.node(clip).unwrap().kind, NodeKind::Path { clipping: false, .. }));
}
#[test]
fn rejoin_joins_pieces_within_the_gap_only() {
let mut s = session();
let a = line(&mut s, &[(10.0, 10.0), (50.0, 10.0)]);
let b = line(&mut s, &[(50.05, 10.0), (90.0, 50.0)]);
let far = line(&mut s, &[(92.0, 50.0), (120.0, 50.0)]);
select(&mut s, &[a, b, far]);
let r = s.execute("addon.cleanup.rejoin", &json!({})).unwrap();
assert_eq!((r["joined"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)));
// The topmost piece takes the joined path.
assert!(node(&s, a).is_none());
assert_eq!(anchors(&s, b), 3);
assert_eq!(anchors(&s, far), 2, "beyond the gap");
select(&mut s, &[b, far]);
let r = s.execute("addon.cleanup.rejoin", &json!({"gap": 3})).unwrap();
assert_eq!(r["joined"], 1);
assert_eq!(anchors(&s, far), 4);
for bad in [json!({"gap": -1}), json!({"gap": 7}), json!({"gap": "1"})] {
assert!(s.execute("addon.cleanup.rejoin", &bad).is_err(), "{bad}");
}
}
@@ -0,0 +1,953 @@
//! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point
//! Text, Join Point Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset
//! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters'
//! styles and stay where the layout drew them: each is moved so the glyph of its first character
//! lands where it was.
#[cfg(test)]
mod tests;
use serde_json::{Value, json};
use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, ParaStyle, RubySpan, TextKind, TextObject};
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit};
use crate::cmd::textedit::editing_range;
use crate::cmd::typecmd::refresh_bounds;
use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param};
use crate::{EngineError, Result, Session};
/// Most text objects one command takes.
const MAX_TEXTS: usize = 10_000;
/// Most pieces Break Text and Split Text at Cursor make at once.
const MAX_PIECES: usize = 20_000;
/// Most breaks Remove Line / Paragraph Breaks removes at once.
const MAX_BREAKS: usize = 200_000;
/// Most pieces Combine Point Text tries to join, counted over every try: past it, what it joined
/// so far is the result.
const MAX_JOIN_TRIES: usize = 200_000;
/// How far (pt) a glyph Combine Point Text sets may land from where it was.
const TOLERANCE: f64 = 0.01;
/// Combine Point Text: pieces whose baselines are within this many ems of each other share a line
/// (the difference becomes baseline shift); a gap wider than `MAX_GAP` ems, or an overlap of more
/// than `MAX_OVERLAP`, starts another object.
const SAME_LINE: f64 = 0.4;
const MAX_GAP: f64 = 10.0;
const MAX_OVERLAP: f64 = 1.0;
/// Tracking Combine Point Text may give a character (thousandths of an em).
const TRACKING: std::ops::RangeInclusive<f64> = -1000.0..=10000.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.cleanup.breakText",
"Break Text",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}",
has_selection,
break_text
),
cmd!(
"addon.cleanup.combineText",
"Combine Point Text",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}",
has_selection,
combine_text
),
cmd!(
"addon.cleanup.joinText",
"Join Point Text",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{ids?: [id…] (default: the selection)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}",
has_selection,
join_text
),
cmd!(
"addon.cleanup.removeBreaks",
"Remove Breaks",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool, else in all the text of the selected type, as one undo step; a break between two words becomes a space → {ids, removed}",
has_selection,
remove_breaks
),
cmd!(
"addon.cleanup.splitText",
"Split Text at Cursor",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{} while a Type tool edits horizontal point or area type: the text before the selected characters, they and the text after become point type objects of their own (at a cursor: before and after it), each where its characters were, as one undo step; spaces at the ends of each piece go, and editing ends with the pieces selected → {ids}",
editing_text,
split_text
),
cmd!(
"addon.cleanup.alignInPlace",
"Set Alignment, Keep Position",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}",
has_selection,
align_in_place
),
cmd!(
"addon.cleanup.resetTextTransform",
"Reset Text Transform",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}",
has_selection,
reset_transform
),
cmd!(
"addon.cleanup.replaceFonts",
"Replace Missing Fonts…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}",
has_doc,
replace_fonts
),
]
}
/// Enabled while a Type tool edits text.
fn editing_text(s: &Session) -> std::result::Result<(), String> {
match s.tool_options().get("editing") {
Some(v) if !v.is_null() => Ok(()),
_ => Err("Click in type with a Type tool to split it".into()),
}
}
/// The type objects under `roots` (inside groups and layers, not live art) that can be edited.
fn texts_under(d: &Document, roots: &[NodeId]) -> Result<Vec<NodeId>> {
fn visit(n: &Node, out: &mut Vec<NodeId>) {
match &n.kind {
NodeKind::Text(_) => out.push(n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
_ => {}
}
}
let mut out = vec![];
roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out));
let editable = d.editable();
out.retain(|id| editable(*id));
out.dedup();
if out.len() > MAX_TEXTS {
return Err(EngineError::Other("Too many type objects to change at once".into()));
}
Ok(out)
}
fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> {
match &d.node(id)?.kind {
NodeKind::Text(t) => Some(t),
_ => None,
}
}
fn layout(t: &TextObject) -> TextLayout {
vectorcraft_text::layout(FontDb::global(), t)
}
/// Where the layout draws the character at `byte` (the one it starts in): its glyph's pen position.
fn glyph_at(lay: &TextLayout, byte: usize) -> Option<Point> {
let start = lay.glyphs.iter().filter(|g| g.len > 0 && g.byte <= byte).map(|g| g.byte).max()?;
lay.glyphs.iter().find(|g| g.len > 0 && g.byte == start).map(|g| g.origin)
}
/// The pen position of the glyph that starts at `byte`, if one does.
fn glyph_starting(lay: &TextLayout, byte: usize) -> Option<Point> {
lay.glyphs.iter().find(|g| g.len > 0 && g.byte == byte).map(|g| g.origin)
}
/// The first glyph of line `l`, in text order.
fn line_start(lay: &TextLayout, l: usize) -> Option<Point> {
let line = lay.lines.get(l)?;
let g = lay.glyphs.get(line.glyph_start..line.glyph_end)?.iter().filter(|g| g.len > 0).min_by_key(|g| g.byte)?;
Some(g.origin)
}
/// `a..b` of `text` without the white space at its ends; None when nothing else is left.
fn trimmed(text: &str, a: usize, b: usize) -> Option<(usize, usize)> {
let s = text.get(a..b)?;
let start = a + (s.len() - s.trim_start().len());
let end = a + s.trim_end().len();
(start < end).then_some((start, end))
}
/// Paragraph alignment as point type keeps it: justified lines align to their side.
fn point_justify(j: Justify) -> Justify {
match j {
Justify::JustifyLeft | Justify::JustifyAll => Justify::Left,
Justify::JustifyCenter => Justify::Center,
Justify::JustifyRight => Justify::Right,
j => j,
}
}
#[derive(Clone, Copy, PartialEq)]
enum By {
Lines,
Paragraphs,
Words,
Glyphs,
}
/// A type object being cut into point type pieces: its text as drawn (Shrink Text to Fit's sizes
/// taken in) and its layout.
struct Source {
t: TextObject,
lay: TextLayout,
text: String,
}
impl Source {
/// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise.
fn of(d: &Document, id: NodeId) -> Option<Self> {
let t = text_of(d, id)?;
if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) {
return None;
}
let lay = layout(t);
// Text left out of a frame would be lost with it.
if lay.overflow {
return None;
}
let t = if (lay.fit_scale - 1.0).abs() > 1e-9 { vectorcraft_text::scaled(t, lay.fit_scale) } else { t.clone() };
Some(Self { text: t.plain_text(), t, lay })
}
/// The byte ranges of the pieces `by` cuts the text into.
fn ranges(&self, by: By) -> Vec<(usize, usize)> {
let text = &self.text;
match by {
By::Paragraphs => {
let mut out = vec![];
let mut a = 0;
for (i, _) in text.match_indices('\n') {
out.push((a, i));
a = i + 1;
}
out.push((a, text.len()));
out
}
By::Lines => self.lay.lines.iter().map(|l| (l.start, l.end)).collect(),
By::Words => {
let mut out = vec![];
for l in &self.lay.lines {
let Some(s) = text.get(l.start..l.end) else { continue };
let mut word: Option<usize> = None;
for (i, c) in s.char_indices() {
match (c.is_whitespace(), word) {
(true, Some(w)) => {
out.push((l.start + w, l.start + i));
word = None;
}
(false, None) => word = Some(i),
_ => {}
}
}
if let Some(w) = word {
out.push((l.start + w, l.end));
}
}
out
}
By::Glyphs => {
// One piece per cluster (a ligature's characters stay together).
let mut clusters = std::collections::BTreeMap::new();
for g in self.lay.glyphs.iter().filter(|g| g.len > 0) {
let end = clusters.entry(g.byte).or_insert(g.byte + g.len);
*end = (*end).max(g.byte + g.len);
}
clusters.into_iter().collect()
}
}
}
/// Point type holding bytes `a..b` (white space at its ends left out) where they were drawn.
/// Lines the layout wrapped end in a forced line break; `lists` keeps bulleted paragraphs'
/// bullets where the piece starts its paragraph.
fn piece(&self, (a, b): (usize, usize), lists: bool) -> Option<TextObject> {
let (a, b) = trimmed(&self.text, a, b)?;
let mut p = TextObject::point(Point::ZERO, "", CharStyle::default());
p.runs = edit::slice_runs(&self.t.runs, a, b);
p.xf = self.t.xf;
p.optical_margin = self.t.optical_margin;
p.system_layout = self.t.system_layout;
p.fractional_widths = self.t.fractional_widths;
p.ruby =
self.t.ruby.iter().filter(|r| a <= r.start && r.end <= b).map(|r| RubySpan { start: r.start - a, end: r.end - a, ..r.clone() }).collect();
// The paragraphs it holds, with their styles; the first keeps its bullet only from its start.
let starts_para = self.text.get(..a).is_some_and(|s| s.rsplit('\n').next().is_some_and(|line| line.trim().is_empty()));
let styles = self.t.paragraph_styles();
let mut paras: Vec<ParaStyle> = styles.get(self.t.paragraphs_in(a, b))?.to_vec();
for (i, para) in paras.iter_mut().enumerate() {
para.justify = point_justify(para.justify);
if !(lists && (i > 0 || starts_para) && para.list.as_ref().is_some_and(|l| l.number.is_none())) {
para.list = None;
}
}
p.set_paragraph_styles(paras);
// Lines the layout wrapped end in a forced line break, in place of the spaces they ended in.
let mut wraps = vec![];
for next in self.lay.lines.iter().skip(1).filter(|l| a < l.start && l.start < b) {
let content = self.text.get(..next.start)?.trim_end_matches([' ', '\t']);
// A line that ends in a break of its own keeps it.
if !content.ends_with(['\n', '\u{2028}']) {
wraps.push((content.len().max(a) - a, next.start - a));
}
}
for &(s, e) in wraps.iter().rev() {
edit::replace_range(&mut p.runs, s, e, "\u{2028}");
p.ruby_after_replace(s, e, '\u{2028}'.len_utf8());
}
self.place(&mut p, a)?;
Some(p)
}
/// Move `p` (cut from byte `a`) so the glyph of its first character lands where it was drawn.
/// A piece of several lines that starts inside a left-aligned line takes a first-line indent,
/// so its later lines stay where they were too.
fn place(&self, p: &mut TextObject, a: usize) -> Option<()> {
let lay = layout(p);
let mut shift: Vec2 = glyph_at(&self.lay, a)? - glyph_at(&lay, 0)?;
let first = self.lay.lines.iter().rposition(|l| l.start <= a);
if let (Some(now), Some(was)) = (line_start(&lay, 1), first.and_then(|l| line_start(&self.lay, l + 1))) {
let off = was.x - (now.x + shift.x);
let left = match p.para.justify {
Justify::Left => true,
Justify::Auto => lay.lines.first().is_some_and(|l| !l.rtl),
_ => false,
};
if left && off.abs() > 1e-6 {
p.edit_paras(Some(0..1), |para| para.first_line_indent -= off);
shift.x += off;
}
}
p.xf = self.t.xf * Affine::translate(shift);
Some(())
}
}
/// Put each object of `items` (id, the node, its pieces) out of the document, its pieces in its
/// place, as one undo step labelled `label`, the pieces selected → their ids.
fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, Vec<TextObject>)>) -> Result<Vec<u64>> {
s.edit(label, |d, sel| {
let (mut made, mut places) = (vec![], vec![]);
for (id, mut node, pieces) in items {
// A light template: the pieces take its paint and attributes, not its text.
node.kind = NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "", CharStyle::default())));
let nodes: Vec<Node> = pieces
.into_iter()
.map(|mut t| {
refresh_bounds(&mut t);
let mut n = d.reid(&node);
n.kind = NodeKind::Text(Box::new(t));
n
})
.collect();
made.extend(nodes.iter().map(|n| n.id));
places.push((id, nodes));
}
d.insert_beside(places, true)?;
sel.set(made.iter().copied());
Ok(made.iter().map(|i| i.0).collect())
})
}
fn ids_json(ids: &[NodeId]) -> Vec<u64> {
ids.iter().map(|i| i.0).collect()
}
fn break_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.breakText";
let by = match text_param(p, "by", C)?.unwrap_or("lines") {
"lines" => By::Lines,
"paragraphs" => By::Paragraphs,
"words" => By::Words,
"glyphs" => By::Glyphs,
other => return Err(bad(C, format!("`by` must be lines, paragraphs, words or glyphs, not {other:?}"))),
};
let d = &s.doc()?.doc;
let (mut items, mut skipped, mut count) = (vec![], vec![], 0);
for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? {
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else {
skipped.push(id);
continue;
};
let ranges = src.ranges(by);
count += ranges.len();
if count > MAX_PIECES {
return Err(EngineError::Other("Too many pieces to make at once".into()));
}
let pieces: Vec<TextObject> = ranges.into_iter().filter_map(|r| src.piece(r, by == By::Paragraphs)).collect();
if pieces.is_empty() {
skipped.push(id);
} else {
items.push((id, node.clone(), pieces));
}
}
if items.is_empty() {
return Err(EngineError::Other("Select horizontal point or area type whose text fits".into()));
}
let ids = replace_with_pieces(s, "Break Text", items)?;
Ok(json!({ "ids": ids, "skipped": ids_json(&skipped) }))
}
fn split_text(s: &mut Session, _: &Value) -> Result<Value> {
let Some((id, a, b)) = editing_range(s)? else {
return Err(EngineError::Other("Click in type with a Type tool to split it".into()));
};
let d = &s.doc()?.doc;
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else {
return Err(EngineError::Other("Select horizontal point or area type whose text fits".into()));
};
let text = &src.text;
let (a, b) = (edit::floor_char(text, a.min(b)), edit::floor_char(text, a.max(b)));
let cuts: Vec<(usize, usize)> = if a == b { vec![(0, a), (a, text.len())] } else { vec![(0, a), (a, b), (b, text.len())] };
let pieces: Vec<TextObject> = cuts.into_iter().filter_map(|r| src.piece(r, true)).collect();
if pieces.len() < 2 {
return Err(EngineError::Other("Nothing to split at the cursor".into()));
}
let node = node.clone();
let ids = replace_with_pieces(s, "Split Text at Cursor", vec![(id, node, pieces)])?;
// The text edited is gone: the pieces stay selected.
s.set_tool_option("stopEditing", &Value::Bool(true));
Ok(json!({ "ids": ids }))
}
/// One piece of point type Combine Point Text takes: its text with its object's scale taken into
/// its characters, and where it starts and ends along its line in the frame of its turn and shear.
struct Piece {
id: NodeId,
t: TextObject,
/// The object's turn and shear (`xf` without its move).
turn: [f64; 4],
/// Where its first character's glyph starts on its baseline, in that frame.
at: Point,
/// Where its last glyph ends along the line, in that frame.
end: f64,
/// Its first character's size.
size: f64,
/// Each character's pen position from the first's (byte, x), as it lays out alone.
pens: Vec<(usize, f64)>,
}
impl Piece {
fn of(d: &Document, id: NodeId) -> Option<(Self, &Node)> {
let node = d.node(id)?;
let NodeKind::Text(t) = &node.kind else { return None };
let text = t.plain_text();
if t.vertical || !matches!(t.kind, TextKind::Point) || text.trim().is_empty() || text.contains(['\n', '\u{2028}']) {
return None;
}
let mut t = (**t).clone();
t.scale_text_attributes();
t.style_scale()?;
let lay = layout(&t);
let line = lay.lines.first().filter(|l| !l.rtl && lay.lines.len() == 1)?;
let first = glyph_at(&lay, 0)?;
// The first glyph of each cluster (a mark drawn over it shares its byte).
let mut seen = std::collections::HashSet::new();
let pens = lay.glyphs.iter().filter(|g| g.len > 0 && seen.insert(g.byte)).map(|g| (g.byte, g.origin.x - first.x)).collect();
let end = lay.glyphs.iter().map(|g| g.origin.x + g.advance).fold(first.x, f64::max) - first.x;
let [a, b, c, dd, _, _] = t.xf.as_coeffs();
let turn = Affine::new([a, b, c, dd, 0.0, 0.0]);
let at = turn.inverse() * (t.xf * Point::new(first.x, line.baseline));
let size = t.runs.first()?.style.size;
if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) {
return None;
}
Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node))
}
/// Whether `o` is turned and sheared alike.
fn turned_like(&self, o: &Piece) -> bool {
self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0))
}
}
/// Whether two objects look alike apart from their text (paint, opacity, blending, effects).
fn same_look(a: &Node, b: &Node) -> bool {
a.appearance == b.appearance
&& a.opacity == b.opacity
&& a.blend == b.blend
&& a.isolate == b.isolate
&& a.knockout == b.knockout
&& a.extras == b.extras
}
/// How wide a space of `style` is.
fn space_width(style: &CharStyle) -> f64 {
let probe = TextObject::point(Point::ZERO, " ", CharStyle { tracking: 0.0, ..style.clone() });
layout(&probe).glyphs.first().map_or(style.size * 0.25, |g| g.advance)
}
/// `chain` (pieces left to right on one line) as one point type object whose glyphs land where
/// theirs were, or the index of the first piece that can't join the ones before it so.
fn merge(chain: &[&Piece]) -> std::result::Result<TextObject, usize> {
let Some(first) = chain.first() else { return Err(0) };
let mut runs = vec![];
// Each piece's first byte, and the byte range of the character before it (whose tracking sets
// the gap).
let (mut starts, mut before) = (vec![], vec![]);
let mut text = String::new();
for (k, p) in chain.iter().enumerate() {
if let Some(prev) = k.checked_sub(1).and_then(|i| chain.get(i)) {
let starts_blank = p.t.plain_text().starts_with(char::is_whitespace);
let last = prev.t.runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default();
if !text.ends_with(char::is_whitespace) && !starts_blank && p.at.x - prev.end >= 0.5 * space_width(&last) {
runs.push(vectorcraft_doc::TextRun::new(" ", last));
text.push(' ');
}
let c = text.chars().next_back().map_or(1, char::len_utf8);
before.push((text.len() - c, text.len()));
}
starts.push(text.len());
// Baselines a little apart: the difference is baseline shift (up is positive).
let shift = first.at.y - p.at.y;
for r in &p.t.runs {
let mut r = r.clone();
r.style.baseline_shift += shift;
text.push_str(&r.text);
runs.push(r);
}
}
let mut m = first.t.clone();
m.set_all_paras(ParaStyle { justify: Justify::Left, ..first.t.para.clone() });
let [a, b, c, d] = first.turn;
let base = Affine::new([a, b, c, d, 0.0, 0.0]) * Affine::translate(first.at.to_vec2());
m.xf = base;
m.runs = runs;
m.ruby.clear();
// Tracking after the character before each piece, until each lands where it was.
let targets: Vec<f64> = chain.iter().map(|p| p.at.x - first.at.x).collect();
let mut lay = layout(&m);
for _ in 0..4 {
// A piece whose first character joins the one before it (a ligature) can't keep its place.
let xs: Vec<f64> = match starts.iter().enumerate().map(|(k, b)| glyph_starting(&lay, *b).map(|g| g.x).ok_or(k)).collect() {
Ok(xs) => xs,
Err(k) => return Err(k.max(1)),
};
let x0 = xs.first().copied().unwrap_or(0.0);
let off: Vec<f64> = targets.iter().zip(&xs).map(|(t, x)| t - (x - x0)).collect();
if off.iter().all(|o| o.abs() <= TOLERANCE * 0.5) {
break;
}
// Moving piece k moves those after it as far.
for (k, w) in off.windows(2).enumerate() {
let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue };
if delta.abs() > 1e-9 {
edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0);
if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) {
return Err(k + 1);
}
}
}
lay = layout(&m);
}
// Every glyph where it was, within the tolerance; else the run ends before the piece that moved.
let x0 = glyph_starting(&lay, 0).map_or(0.0, |g| g.x);
for (k, p) in chain.iter().enumerate() {
let (Some(&start), Some(&target)) = (starts.get(k), targets.get(k)) else { return Err(k.max(1)) };
let moved = p.pens.iter().any(|(byte, x)| glyph_starting(&lay, start + byte).is_none_or(|g| (g.x - x0 - target - x).abs() > TOLERANCE));
if moved {
return Err(k.max(1));
}
}
m.xf = base * Affine::translate((-x0, 0.0));
Ok(m)
}
fn combine_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.combineText";
let d = &s.doc()?.doc;
let mut pieces: Vec<(Piece, &Node)> =
texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect();
// Lines: pieces turned alike and looking alike, by baseline, then left to right.
pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x)));
let mut lines: Vec<Vec<(&Piece, &Node)>> = vec![];
for (piece, node) in &pieces {
let fits = |l: &Vec<(&Piece, &Node)>| {
l.first()
.is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))
};
match lines.iter_mut().find(|l| fits(l)) {
Some(l) => l.push((piece, node)),
None => lines.push(vec![(piece, node)]),
}
}
// Runs along each line: a wide gap or an overlap starts another.
let mut chains: Vec<Vec<&Piece>> = vec![];
for mut l in lines {
l.sort_by(|x, y| x.0.at.x.total_cmp(&y.0.at.x));
let mut chain: Vec<&Piece> = vec![];
for (piece, _) in l {
if let Some(prev) = chain.last() {
let gap = piece.at.x - prev.end;
if gap > MAX_GAP * prev.size || gap < -MAX_OVERLAP * prev.size {
chains.push(std::mem::take(&mut chain));
}
}
chain.push(piece);
}
chains.push(chain);
}
// Each run as one object, or as several where a piece can't keep its place joined.
let mut made: Vec<(NodeId, TextObject, Vec<NodeId>)> = vec![];
let mut tries = 0;
'chains: for chain in chains {
let mut rest: &[&Piece] = &chain;
while rest.len() > 1 {
let mut upto = rest.len();
while let Some(part) = rest.get(..upto) {
tries += part.len();
if tries > MAX_JOIN_TRIES {
break 'chains;
}
match merge(part) {
Ok(m) => {
let gone = part.iter().skip(1).map(|p| p.id).collect();
if let Some(first) = part.first() {
made.push((first.id, m, gone));
}
break;
}
// A run of one stays as it is.
Err(k) if k <= 1 => {
upto = 1;
break;
}
Err(k) => upto = k,
}
}
rest = rest.get(upto.max(1)..).unwrap_or_default();
}
}
if made.is_empty() {
return Err(EngineError::Other("Select two or more one-line point type objects on one baseline to combine".into()));
}
let combined: usize = made.iter().map(|m| m.2.len()).sum();
let ids: Vec<u64> = made.iter().map(|m| m.0.0).collect();
s.edit("Combine Point Text", |d, sel| {
let mut gone = vec![];
for (id, mut t, others) in made {
refresh_bounds(&mut t);
if let Some(n) = d.node_mut(id) {
n.kind = NodeKind::Text(Box::new(t));
}
gone.extend(others);
}
d.remove_many(&gone)?;
sel.set(ids.iter().map(|i| NodeId(*i)));
Ok(())
})?;
Ok(json!({ "ids": ids, "combined": combined }))
}
fn join_text(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.joinText";
let d = &s.doc()?.doc;
let pieces: Vec<Piece> =
texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect();
// The piece top-left on the page sets the turn; the others turned like it are read in that frame.
let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at;
let first = pieces.iter().min_by(|x, y| {
let (a, b) = (on_page(x), on_page(y));
a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x))
});
let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| p.turned_like(f)).collect()).unwrap_or_default();
kept.sort_by(|x, y| x.at.y.total_cmp(&y.at.y).then(x.at.x.total_cmp(&y.at.x)));
// Lines, top to bottom, by baseline; each line's pieces left to right.
let mut lines: Vec<Vec<&Piece>> = vec![];
for piece in kept {
match lines.last_mut().filter(|l| l.first().is_some_and(|f| (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))) {
Some(l) => l.push(piece),
None => lines.push(vec![piece]),
}
}
for line in &mut lines {
line.sort_by(|x, y| x.at.x.total_cmp(&y.at.x));
}
let joined: usize = lines.iter().map(Vec::len).sum();
let (Some(top), true) = (lines.first().and_then(|l| l.first()).copied(), joined >= 2) else {
return Err(EngineError::Other("Select two or more one-line point type objects to join".into()));
};
let mut runs: Vec<vectorcraft_doc::TextRun> = vec![];
let mut prev_y = None;
for line in &lines {
let y = line.first().map_or(top.at.y, |p| p.at.y);
// The line keeps the distance from the baseline above as its leading.
let leading = prev_y.map(|above| y - above).filter(|l: &f64| l.is_finite() && *l > 0.0);
prev_y = Some(y);
let last = |runs: &[vectorcraft_doc::TextRun]| runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default();
if !runs.is_empty() {
runs.push(vectorcraft_doc::TextRun::new("\n", last(&runs)));
}
let start = runs.len();
for (k, piece) in line.iter().enumerate() {
let blank = runs.last().is_some_and(|r| r.text.ends_with(char::is_whitespace)) || piece.t.plain_text().starts_with(char::is_whitespace);
if k > 0 && !blank {
runs.push(vectorcraft_doc::TextRun::new(" ", last(&runs)));
}
runs.extend(piece.t.runs.iter().cloned());
}
if let Some(l) = leading {
for r in runs.iter_mut().skip(start) {
r.style.leading = Some(l);
}
}
}
let mut m = top.t.clone();
m.set_all_paras(ParaStyle { justify: Justify::Left, ..top.t.para.clone() });
m.runs = runs;
edit::normalize(&mut m.runs);
m.ruby.clear();
// The top-left piece's first glyph stays where it was.
let [a, b, c, dd] = top.turn;
let base = Affine::new([a, b, c, dd, 0.0, 0.0]) * Affine::translate(top.at.to_vec2());
m.xf = base;
let x0 = glyph_starting(&layout(&m), 0).map_or(0.0, |g| g.x);
m.xf = base * Affine::translate((-x0, 0.0));
refresh_bounds(&mut m);
let id = top.id;
let gone: Vec<NodeId> = lines.iter().flatten().map(|p| p.id).filter(|g| *g != id).collect();
let count = lines.len();
s.edit("Join Point Text", |d, sel| {
if let Some(n) = d.node_mut(id) {
n.kind = NodeKind::Text(Box::new(m));
}
d.remove_many(&gone)?;
sel.set([id]);
Ok(())
})?;
Ok(json!({ "id": id.0, "joined": joined, "lines": count }))
}
/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many
/// went.
fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
let text = t.plain_text();
let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b));
let Some(range) = text.get(a..b) else { return 0 };
let mut prev = text.get(..a).and_then(|s| s.chars().next_back());
let mut cuts = vec![];
for (i, c) in range.char_indices() {
let at = a + i;
if c != brk {
prev = Some(c);
continue;
}
// The next character left: past the breaks that go too.
let after = at + c.len_utf8();
let next = text.get(after..).and_then(|rest| rest.char_indices().find(|(j, n)| *n != brk || after + j >= b).map(|(_, n)| n));
let space = prev.is_some_and(|p| !p.is_whitespace()) && next.is_some_and(|n| !n.is_whitespace());
if space {
prev = Some(' ');
}
cuts.push((at, space));
}
for &(at, space) in cuts.iter().rev() {
let insert = if space { " " } else { "" };
let end = at + brk.len_utf8();
t.splice_paras(at, end, insert);
edit::replace_range(&mut t.runs, at, end, insert);
t.ruby_after_replace(at, end, insert.len());
}
cuts.len()
}
fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.removeBreaks";
let (brk, label, none) = match text_param(p, "kind", C)? {
Some("line") => ('\u{2028}', "Remove Line Breaks", "No line breaks to remove"),
Some("paragraph") => ('\n', "Remove Paragraph Breaks", "No paragraph breaks to remove"),
Some(other) => return Err(bad(C, format!("`kind` must be \"line\" or \"paragraph\", not {other:?}"))),
None => return Err(bad(C, "missing `kind`")),
};
// The characters a Type tool has selected, else all the text of the selected type.
let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) };
let d = &s.doc()?.doc;
let targets: Vec<(NodeId, usize, usize)> = match editing {
Some((id, a, b)) => vec![(id, a.min(b), a.max(b))].into_iter().filter(|(id, ..)| d.is_editable(*id)).collect(),
None => texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().map(|id| (id, 0, usize::MAX)).collect(),
};
let count: usize = targets
.iter()
.filter_map(|(id, a, b)| {
let text = text_of(d, *id)?.plain_text();
let (a, b) = (edit::floor_char(&text, *a), edit::floor_char(&text, *b));
Some(text.get(a..b)?.matches(brk).count())
})
.sum();
if count == 0 {
return Err(EngineError::Other(none.into()));
}
if count > MAX_BREAKS {
return Err(EngineError::Other("Too many breaks to remove at once".into()));
}
let (removed, ids) = s.edit(label, |d, _| {
let (mut removed, mut ids) = (0, vec![]);
for (id, a, b) in targets {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
let n = strip_breaks(t, a, b, brk);
if n > 0 {
refresh_bounds(t);
removed += n;
ids.push(id.0);
}
}
Ok((removed, ids))
})?;
Ok(json!({ "ids": ids, "removed": removed }))
}
/// Horizontal point type among the objects `p` names.
fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let ids: Vec<NodeId> = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)?
.into_iter()
.filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point)))
.collect();
if ids.is_empty() {
return Err(EngineError::Other("Select point type".into()));
}
Ok(ids)
}
/// Replace the type objects of `changed` as one undo step labelled `label` → their ids.
fn set_texts(s: &mut Session, label: &str, changed: Vec<(NodeId, TextObject)>, reset_angle: bool) -> Result<Value> {
let ids: Vec<u64> = changed.iter().map(|c| c.0.0).collect();
s.edit(label, |d, _| {
for (id, mut t) in changed {
refresh_bounds(&mut t);
if let Some(n) = d.node_mut(id) {
n.kind = NodeKind::Text(Box::new(t));
if reset_angle {
n.bbox_angle = 0.0;
}
}
}
Ok(())
})?;
Ok(json!({ "ids": ids }))
}
fn align_in_place(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.alignInPlace";
let justify = match text_param(p, "align", C)? {
Some("left") => Justify::Left,
Some("center") => Justify::Center,
Some("right") => Justify::Right,
Some(other) => return Err(bad(C, format!("`align` must be \"left\", \"center\" or \"right\", not {other:?}"))),
None => return Err(bad(C, "missing `align`")),
};
let ids = point_texts(s, p, C)?;
let d = &s.doc()?.doc;
let mut changed = vec![];
for id in ids {
let Some(t) = text_of(d, id).filter(|t| !t.vertical && t.para_styles().any(|para| para.justify != justify)) else { continue };
let was = layout(t);
// The longest line stays where it is.
let Some((i, line)) = was.lines.iter().enumerate().max_by(|a, b| (a.1.x1 - a.1.x0).total_cmp(&(b.1.x1 - b.1.x0))) else { continue };
let mut n = t.clone();
n.edit_paras(None, |para| para.justify = justify);
let Some(now) = layout(&n).lines.get(i).map(|l| l.x0) else { continue };
n.xf *= Affine::translate((line.x0 - now, 0.0));
changed.push((id, n));
}
if changed.is_empty() {
return Err(EngineError::Other("The point type is aligned that way already".into()));
}
set_texts(s, "Set Alignment, Keep Position", changed, false)
}
fn reset_transform(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.resetTextTransform";
let scale = switch_or(p, "scale", false, C)?;
let ids = point_texts(s, p, C)?;
let d = &s.doc()?.doc;
let mut changed = vec![];
for id in ids {
let Some(t) = text_of(d, id) else { continue };
let [a, b, c, dd, _, _] = t.xf.as_coeffs();
// The object's own axes: its width along its baseline, and its height square to it.
let (sx, det) = (a.hypot(b), a * dd - b * c);
if !(sx > 1e-12 && sx.is_finite() && det.is_finite() && det.abs() > 1e-12) {
continue;
}
let sy = det.abs() / sx;
let unscaled = |t: &TextObject| t.runs.iter().all(|r| r.style.h_scale == 100.0);
let upright = b.abs() <= 1e-9 * sx && c.abs() <= 1e-9 * sy && a > 0.0 && dd > 0.0;
if upright && (!scale || ((sx - sy).abs() <= 1e-9 * sy && unscaled(t))) {
continue;
}
let mut n = t.clone();
let w = if scale {
n.runs.iter_mut().for_each(|r| r.style.h_scale = 100.0);
sy
} else {
sx
};
// The centre of the text stays where it is.
let centre = t.xf * layout(t).bounds.center();
let turn = Affine::scale_non_uniform(w, sy);
let now = turn * layout(&n).bounds.center();
n.xf = Affine::translate(centre - now) * turn;
changed.push((id, n));
}
if changed.is_empty() {
return Err(EngineError::Other("The point type has no transform to reset".into()));
}
set_texts(s, "Reset Text Transform", changed, true)
}
fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.cleanup.replaceFonts";
let default = CharStyle::default().font_family;
let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default);
let db = FontDb::global();
// Any name of the family is stored as its own name.
let family = match db.resolve(wanted, "Regular") {
Some((f, m)) if m != FontMatch::Missing => f.family.clone(),
_ => return Err(bad(C, "the replacement font is not available")),
};
let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing);
let d = &s.doc()?.doc;
let mut ids = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().any(|r| missing(&r.style))
{
ids.push(n.id);
}
});
}
if ids.is_empty() {
return Err(EngineError::Other("No fonts are missing".into()));
}
let runs = s.edit("Replace Missing Fonts", |d, _| {
let mut runs = 0;
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
for r in t.runs.iter_mut().filter(|r| missing(&r.style)) {
// The style the font has closest to the one the character had.
r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone());
r.style.font_family = family.clone();
r.style.font_version = None;
r.style.font_axes.clear();
runs += 1;
}
edit::normalize(&mut t.runs);
refresh_bounds(t);
}
Ok(runs)
})?;
Ok(json!({ "runs": runs, "font": family }))
}
@@ -0,0 +1,341 @@
//! Artwork Cleanup's text tools: pieces stay where their glyphs were drawn and keep their styles,
//! each command is one undo step and refuses cleanly when there is nothing to do.
use serde_json::{Value, json};
use vectorcraft_doc::{Justify, NodeId, NodeKind, TextKind, TextObject};
use vectorcraft_geom::Point;
use crate::{EngineError, Session};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
s
}
fn text(s: &mut Session, p: Value) -> NodeId {
NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap())
}
fn obj(s: &Session, id: NodeId) -> TextObject {
match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("not text"),
}
}
fn select(s: &mut Session, ids: &[NodeId]) {
s.select(|_, sel| sel.set(ids.iter().copied())).unwrap();
}
fn ids(v: &Value) -> Vec<NodeId> {
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
/// Each drawn character of `ids` (spaces left out) with where its glyph starts on the page, in
/// the order of the objects and their text.
fn glyphs(s: &Session, ids: &[NodeId]) -> Vec<(char, Point)> {
let mut out = vec![];
for id in ids {
let t = obj(s, *id);
let text = t.plain_text();
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
let mut gs: Vec<_> = lay.glyphs.iter().filter(|g| g.len > 0).collect();
gs.sort_by_key(|g| g.byte);
gs.dedup_by_key(|g| g.byte);
for g in gs {
let c = text[g.byte..].chars().next().unwrap();
if !c.is_whitespace() {
out.push((c, t.xf * g.origin));
}
}
}
out
}
fn assert_same_places(was: &[(char, Point)], now: &[(char, Point)]) {
assert_eq!(was.iter().map(|g| g.0).collect::<String>(), now.iter().map(|g| g.0).collect::<String>());
for (a, b) in was.iter().zip(now) {
assert!((a.1 - b.1).hypot() < 0.02, "{:?} moved to {:?}", a, b);
}
}
fn undo_steps(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
fn doc_json(s: &Session) -> String {
serde_json::to_string(&s.doc().unwrap().doc.layers).unwrap()
}
fn other(r: Result<Value, EngineError>) -> String {
match r {
Err(EngineError::Other(m)) => m,
r => panic!("expected a refusal, got {r:?}"),
}
}
#[test]
fn break_into_words_keeps_places_styles_and_is_one_step() {
let mut s = session();
let id = text(&mut s, json!({"x": 40, "y": 100, "text": "Hello big World", "size": 24}));
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 9, "size": 36, "tracking": 50})).unwrap();
let rotated = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned words", "size": 18, "rotation": 30}));
let was = glyphs(&s, &[id, rotated]);
let before = doc_json(&s);
let steps = undo_steps(&s);
select(&mut s, &[id, rotated]);
let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap();
let pieces = ids(&r);
assert_eq!(pieces.len(), 5);
assert_eq!(undo_steps(&s), steps + 1);
assert_eq!(s.doc().unwrap().selection.objects.len(), 5, "the pieces are selected");
let texts: Vec<String> = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect();
assert_eq!(texts, ["Hello", "big", "World", "Turned", "words"]);
assert_same_places(&was, &glyphs(&s, &pieces));
let big = obj(&s, pieces[1]);
assert!(matches!(big.kind, TextKind::Point));
assert_eq!((big.runs[0].style.size, big.runs[0].style.tracking), (36.0, 50.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(doc_json(&s), before);
}
#[test]
fn break_area_type_into_lines_paragraphs_and_glyphs() {
for (by, count) in [("lines", None), ("paragraphs", Some(2)), ("glyphs", None)] {
let mut s = session();
let id = text(
&mut s,
json!({"x": 40, "y": 40, "text": "A paragraph long enough to wrap in its frame\nSecond one", "size": 14, "area": {"width": 140, "height": 200}}),
);
let t = obj(&s, id);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
assert!(lay.lines.len() >= 3, "the first paragraph wraps");
let was = glyphs(&s, &[id]);
select(&mut s, &[id]);
let r = s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap();
let pieces = ids(&r);
let want = count.unwrap_or(if by == "lines" { lay.lines.len() } else { was.len() });
assert_eq!(pieces.len(), want, "{by}");
assert_same_places(&was, &glyphs(&s, &pieces));
if by == "paragraphs" {
// The wrapped lines end in line breaks.
assert!(obj(&s, pieces[0]).plain_text().contains('\u{2028}'));
}
}
}
#[test]
fn break_text_refuses_what_it_cannot_break() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
assert!(other(s.execute("addon.cleanup.breakText", &json!({}))).contains("point or area type"));
let r = s.execute("addon.cleanup.breakText", &json!({"by": "sentences"}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
}
#[test]
fn combine_puts_glyph_pieces_back_together_where_they_were() {
let mut s = session();
let id = text(&mut s, json!({"x": 50, "y": 120, "text": "Water Lily", "size": 30}));
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 10, "fill": "#ff0000"})).unwrap();
let was = glyphs(&s, &[id]);
select(&mut s, &[id]);
let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": "glyphs"})).unwrap());
assert_eq!(pieces.len(), 9);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap();
assert_eq!(undo_steps(&s), steps + 1);
let made = ids(&r);
assert_eq!((made.len(), r["combined"].as_u64()), (1, Some(8)));
let t = obj(&s, made[0]);
assert_eq!(t.plain_text(), "Water Lily", "a space goes back between the words");
assert_same_places(&was, &glyphs(&s, &made));
let red = t.runs.iter().find(|r| r.text.contains('L')).unwrap();
assert_ne!(red.style.fill, t.runs[0].style.fill, "styles stay");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(pieces[1]).is_some(), "undo brings the pieces back");
}
#[test]
fn combine_joins_only_pieces_on_one_line_turned_alike() {
let mut s = session();
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20}));
let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20}));
let c = text(&mut s, json!({"x": 50, "y": 200, "text": "Below", "size": 20}));
let d = text(&mut s, json!({"x": 300, "y": 100, "text": "Turned", "size": 20, "rotation": 45}));
let was = glyphs(&s, &[a, b]);
select(&mut s, &[a, b, c, d]);
let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap();
assert_eq!(ids(&r), [a]);
assert_eq!(obj(&s, a).plain_text(), "Left Right");
assert_same_places(&was, &glyphs(&s, &[a]));
let doc = &s.doc().unwrap().doc;
assert!(doc.node(b).is_none() && doc.node(c).is_some() && doc.node(d).is_some());
select(&mut s, &[c, d]);
assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine"));
}
/// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs,
/// pieces on a line left to right, each line's baseline where it was (its leading is the distance
/// from the line above), in one undo step. A piece turned unlike the top-left one stays.
#[test]
fn join_stacks_lines_into_one_object_with_their_spacing() {
let mut s = session();
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20}));
let b = text(&mut s, json!({"x": 120, "y": 101, "text": "line", "size": 20}));
let c = text(&mut s, json!({"x": 50, "y": 130, "text": "Second", "size": 20}));
let d = text(&mut s, json!({"x": 50, "y": 166, "text": "Third", "size": 20}));
let turned = text(&mut s, json!({"x": 300, "y": 300, "text": "Turned", "size": 20, "rotation": 45}));
let was = glyphs(&s, &[a, c, d]);
select(&mut s, &[d, b, turned, c, a]);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.joinText", &json!({})).unwrap();
assert_eq!(undo_steps(&s), steps + 1);
assert_eq!((r["id"].as_u64(), r["joined"].as_u64(), r["lines"].as_u64()), (Some(a.0), Some(4), Some(3)));
let t = obj(&s, a);
assert_eq!(t.plain_text(), "First line\nSecond\nThird");
assert_eq!(t.runs.iter().filter(|r| r.text.contains("Third")).map(|r| r.style.leading).collect::<Vec<_>>(), [Some(36.0)]);
// Each line's first glyph is where it was.
let now = glyphs(&s, &[a]);
for ch in ['F', 'S', 'T'] {
let (p, q) = (was.iter().find(|g| g.0 == ch).unwrap().1, now.iter().find(|g| g.0 == ch).unwrap().1);
assert!((p - q).hypot() < 0.05, "{ch}: {p:?} moved to {q:?}");
}
let doc = &s.doc().unwrap().doc;
assert!(doc.node(b).is_none() && doc.node(c).is_none() && doc.node(d).is_none() && doc.node(turned).is_some());
select(&mut s, &[turned]);
assert!(other(s.execute("addon.cleanup.joinText", &json!({}))).contains("join"));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(c).is_some(), "undo brings the pieces back");
}
#[test]
fn remove_line_and_paragraph_breaks() {
let mut s = session();
let id = text(&mut s, json!({"x": 40, "y": 60, "text": "one\u{2028}two \u{2028}three\nfour\n\nfive", "size": 12}));
select(&mut s, &[id]);
let before = doc_json(&s);
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"})).unwrap();
assert_eq!(r["removed"], 2);
assert_eq!(obj(&s, id).plain_text(), "one two three\nfour\n\nfive");
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
assert_eq!(r["removed"], 3);
let t = obj(&s, id);
assert_eq!((t.plain_text().as_str(), t.paragraph_count()), ("one two three four five", 1));
assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"}))), "No line breaks to remove");
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(doc_json(&s), before);
assert!(matches!(s.execute("addon.cleanup.removeBreaks", &json!({})), Err(EngineError::BadParams { .. })));
}
/// The Type tool editing `id` with bytes `anchor..caret` selected.
fn edit_text(s: &mut Session, id: NodeId, anchor: usize, caret: usize) {
s.select_tool("type", Default::default()).unwrap();
s.set_tool_option("textSelection", &json!({"editing": id.0, "anchor": anchor, "caret": caret}));
}
#[test]
fn remove_breaks_in_the_characters_a_type_tool_selected() {
let mut s = session();
let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12}));
select(&mut s, &[id]);
edit_text(&mut s, id, 0, 2);
s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
}
#[test]
fn split_at_the_cursor_and_around_a_selection() {
for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] {
let mut s = session();
let id = text(&mut s, json!({"x": 40, "y": 60, "text": "Split here please", "size": 20, "justify": "center"}));
let was = glyphs(&s, &[id]);
select(&mut s, &[id]);
let r = s.execute("addon.cleanup.splitText", &json!({}));
assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("Type tool")), "{r:?}");
edit_text(&mut s, id, anchor, caret);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.splitText", &json!({})).unwrap();
assert_eq!(undo_steps(&s), steps + 1);
let pieces = ids(&r);
let texts: Vec<String> = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect();
assert_eq!(texts, want);
assert_same_places(&was, &glyphs(&s, &pieces));
assert!(s.tool_options()["editing"].is_null(), "editing ends");
}
}
#[test]
fn split_keeps_the_later_lines_of_a_wrapped_piece() {
let mut s = session();
let id = text(
&mut s,
json!({"x": 40, "y": 40, "text": "First words then a long run of text that wraps in the frame", "size": 14, "area": {"width": 160, "height": 200}}),
);
let was = glyphs(&s, &[id]);
select(&mut s, &[id]);
edit_text(&mut s, id, 6, 6);
let pieces = ids(&s.execute("addon.cleanup.splitText", &json!({})).unwrap());
assert_eq!(pieces.len(), 2);
assert_same_places(&was, &glyphs(&s, &pieces));
}
#[test]
fn set_alignment_keeps_the_text_in_place() {
let mut s = session();
let id = text(&mut s, json!({"x": 100, "y": 60, "text": "Stay put", "size": 20}));
let multi = text(&mut s, json!({"x": 100, "y": 160, "text": "Short\u{2028}The longest line", "size": 20}));
let was = glyphs(&s, &[id]);
let longest: Vec<_> = glyphs(&s, &[multi]).into_iter().skip(5).collect();
select(&mut s, &[id, multi]);
for align in ["center", "right", "left"] {
s.execute("addon.cleanup.alignInPlace", &json!({"align": align})).unwrap();
assert_same_places(&was, &glyphs(&s, &[id]));
assert_same_places(&longest, &glyphs(&s, &[multi]).into_iter().skip(5).collect::<Vec<_>>());
}
assert_eq!(obj(&s, id).para.justify, Justify::Left);
assert!(other(s.execute("addon.cleanup.alignInPlace", &json!({"align": "left"}))).contains("already"));
assert!(matches!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "up"})), Err(EngineError::BadParams { .. })));
}
#[test]
fn reset_text_transform_takes_off_rotation_and_with_scale_the_horizontal_scale() {
let mut s = session();
let id = text(&mut s, json!({"x": 100, "y": 100, "text": "Tilted", "size": 20, "rotation": 30}));
s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 150})).unwrap();
let centre = |s: &Session| {
let t = obj(s, id);
t.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds.center()
};
let was = centre(&s);
let size = obj(&s, id).effective_char_style(&obj(&s, id).runs[0].style).unwrap().size;
select(&mut s, &[id]);
s.execute("addon.cleanup.resetTextTransform", &json!({})).unwrap();
let t = obj(&s, id);
let [_, b, c, _, _, _] = t.xf.as_coeffs();
assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "{:?}", t.xf);
assert!((centre(&s) - was).hypot() < 1e-6);
assert!((t.effective_char_style(&t.runs[0].style).unwrap().size - size).abs() < 1e-6, "the size stays");
assert!(t.runs[0].style.h_scale > 100.0, "the horizontal scale stays");
assert!(other(s.execute("addon.cleanup.resetTextTransform", &json!({}))).contains("no transform"));
s.execute("addon.cleanup.resetTextTransform", &json!({"scale": true})).unwrap();
assert_eq!(obj(&s, id).runs[0].style.h_scale, 100.0);
}
#[test]
fn replace_missing_fonts_with_the_default_or_a_chosen_font() {
let mut s = session();
let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family", "style": "Bold"}));
let kept = text(&mut s, json!({"x": 40, "y": 120, "text": "Fine", "size": 20}));
let fine = obj(&s, kept);
let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap();
assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3")));
let t = obj(&s, gone);
assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("Source Sans 3", "Bold"));
assert_eq!(obj(&s, kept), fine);
assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing");
let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "No Such Font Family"}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
}
+133
View File
@@ -0,0 +1,133 @@
//! Color Effects (`addon.color.*`), a built-in add-on: its live effects (Exposure, Desaturate,
//! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as
//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the
//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model,
//! and Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print. Its panel and
//! its submenu, Object › Plug-ins › Color Effects, are the UI's.
mod print_fixes;
#[cfg(test)]
mod tests;
use serde_json::{Value, json};
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
use vectorcraft_render::effects;
use super::super::colorcmds::recolor_links;
use super::super::select::walk_selectable;
use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, text_param};
use crate::{Result, Session};
/// The kinds Select by Color Kind selects (`addon.color.select`).
pub const SELECT_KINDS: [&str; 5] = ["spotColors", "colorEffects", "rgbImages", "cmykImages", "grayImages"];
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"addon.color.select",
"Select by Color Kind",
["Object", "Plug-ins", "Color Effects"],
None,
"{kind: \"spotColors\" (objects painted with a spot colour swatch, solid or in a gradient's stops) | \"colorEffects\" (objects carrying a visible colour adjustment effect, on themselves or on a fill or stroke) | \"rgbImages\" | \"cmykImages\" | \"grayImages\" (embedded or linked images whose pixels are RGB, CMYK, or grayscale or 1-bit)} select those objects among the ones Select All reaches, inside groups too; locked and hidden ones are left out → {count}",
has_doc,
select
),
cmd!(
"addon.color.filter",
"Apply as Filter",
["Object", "Plug-ins", "Color Effects"],
None,
"{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}",
has_selection,
filter
),
cmd!(
"addon.color.printFixes",
"Print Ink Fixes",
["Object", "Plug-ins", "Color Effects"],
None,
"{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}",
has_doc,
print_fixes::run
),
]
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.color.select";
let kind = text_param(p, "kind", C)?.ok_or_else(|| bad(C, "missing `kind`"))?;
if !SELECT_KINDS.contains(&kind) {
return Err(bad(C, "`kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages"));
}
let st = s.doc()?;
let mut ids: Vec<NodeId> = vec![];
walk_selectable(&st.doc, st.isolation, |n| {
if is_of_kind(&st.doc, n, kind) {
ids.push(n.id);
// An object taken whole: what is inside it isn't taken again.
return false;
}
true
});
s.select(|_, sel| sel.set(ids))?;
Ok(json!({ "count": s.doc()?.selection.objects.len() }))
}
/// Is `n` an object of Select by Color Kind's `kind`? Containers count for colour effects only:
/// for the other kinds, the objects inside them are looked at one by one.
fn is_of_kind(d: &Document, n: &Node, kind: &str) -> bool {
match kind {
"colorEffects" => effects::has_adjustment(n),
"spotColors" if !n.is_container() => {
let mut spot = false;
vectorcraft_doc::swatches::node_colors(n, &mut |_, swatch| {
spot |= swatch.is_some_and(|name| d.swatch(name).is_some_and(|w| w.spot));
});
spot
}
_ => {
let NodeKind::Image(img) = &n.kind else { return false };
let Some(blob) = d.images.get(&img.key) else { return false };
let model = if blob.is_cmyk() {
"cmykImages"
} else if is_gray(&blob.bytes) {
"grayImages"
} else {
"rgbImages"
};
model == kind
}
}
}
/// Are the encoded image `bytes` grayscale or 1-bit? Reads only the header.
fn is_gray(bytes: &[u8]) -> bool {
use image::ImageDecoder as _;
let decoder = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok().and_then(|r| r.into_decoder().ok());
decoder.is_some_and(|d| matches!(d.color_type(), image::ColorType::L8 | image::ColorType::La8 | image::ColorType::L16 | image::ColorType::La16))
}
fn filter(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.color.filter";
let id = text_param(p, "effect", C)?.ok_or_else(|| bad(C, "missing `effect`"))?;
if !effects::is_adjustment(id) {
return Err(bad(C, format!("`{id}` is not a colour adjustment")));
}
let params = match p.get("params") {
None | Some(Value::Null) => json!({}),
Some(v @ Value::Object(_)) => v.clone(),
Some(_) => return Err(bad(C, "`params` must be an object")),
};
let fx = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let ids = checked_ids_or_selection(s, p, C)?;
// Settings that change nothing still make the pass, which then leaves every colour as it was.
let Some(map) = effects::color_map(&[fx]) else { return Ok(json!({ "changed": 0 })) };
let mut given = p.clone();
if let Some(o) = given.as_object_mut() {
o.insert("ids".into(), json!(ids.iter().map(|i| i.0).collect::<Vec<_>>()));
}
let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…'));
let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc);
let swatches = |name: &str| kinds.get(name).copied();
recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches))
}
@@ -0,0 +1,170 @@
//! Print Ink Fixes (`addon.color.printFixes`, Object › Plug-ins › Color Effects › Print Ink
//! Fixes…): a CMYK document's inks made ready for print in one undo step. Colours with more total
//! ink than the limit are scaled down to it, white fills and strokes stop overprinting, and black
//! ones start to, as Edit › Edit Colors › Overprint Black sets it.
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_color::swatch::REGISTRATION;
use vectorcraft_doc::overprint::{OverprintBlack, is_white};
use vectorcraft_doc::{ColorMode, Node, NodeKind};
use super::super::super::colorcmds::{Recolor, Scope};
use super::super::super::overprint::edit_counted;
use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param};
use crate::{Result, Session};
const C: &str = "addon.color.printFixes";
/// What `p` asks for.
struct Fixes {
/// Most total ink, 0..4 (0: off).
ink_limit: f32,
exclude_pure_black: bool,
remove_white_overprint: bool,
/// Overprint Black's least black, 0..1 (0: off).
overprint_black: f32,
text_only: bool,
fill: bool,
stroke: bool,
}
/// The percentage `p` gives for `key` in 0..=`max`, as a fraction (0 when left out).
fn percent(p: &Value, key: &str, max: f64) -> Result<f32> {
let v = number_or(p, key, 0.0, C)?;
if !(0.0..=max).contains(&v) {
return Err(bad(C, format!("`{key}` must be 0..{max}")));
}
Ok((v / 100.0) as f32)
}
impl Fixes {
fn of(p: &Value) -> Result<Self> {
let f = Fixes {
ink_limit: percent(p, "inkLimit", 400.0)?,
exclude_pure_black: switch_or(p, "excludePureBlack", true, C)?,
remove_white_overprint: switch_or(p, "removeWhiteOverprint", false, C)?,
overprint_black: percent(p, "overprintBlack", 100.0)?,
text_only: switch_or(p, "textOnly", false, C)?,
fill: switch_or(p, "fill", true, C)?,
stroke: switch_or(p, "stroke", true, C)?,
};
if f.ink_limit <= 0.0 && f.overprint_black <= 0.0 && !f.remove_white_overprint {
return Err(bad(C, "give `inkLimit`, `removeWhiteOverprint` or `overprintBlack`"));
}
Ok(f)
}
/// `c` with its total ink scaled down to the limit: CMYK, and grey (black ink) unless
/// pure blacks are excluded, which CMYK with black ink only is too.
fn limit(&self, c: Color) -> Color {
const E: f32 = 0.005;
let max = self.ink_limit;
match c {
Color::Cmyk { c: cy, m, y, k } => {
let total = cy + m + y + k;
let pure_black = cy <= E && m <= E && y <= E;
if total <= max || (self.exclude_pure_black && pure_black) || !total.is_finite() {
return c;
}
let s = max / total;
Color::cmyk(cy * s, m * s, y * s, k * s)
}
Color::Gray { k } if k > max && !self.exclude_pure_black => Color::gray(max),
_ => c,
}
}
/// Remove white overprint from `n`'s own fills and strokes and its characters': true when
/// that changed anything.
fn unset_white(&self, n: &mut Node) -> bool {
let mut changed = false;
let mut off = |aimed: bool, white: bool, flag: &mut bool| {
if aimed && white && *flag {
*flag = false;
changed = true;
}
};
for it in &mut n.appearance.items {
let aimed = if it.is_fill() { self.fill } else { self.stroke };
let white = is_white(it.paint());
off(aimed, white, it.overprint_mut());
}
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
off(self.fill, is_white(&r.style.fill), &mut r.style.overprint_fill);
off(self.stroke, is_white(&r.style.stroke), &mut r.style.overprint_stroke);
}
}
changed
}
/// The overprint fixes on `n` and everything inside it: (objects whose white overprint was
/// removed, objects set to overprint black).
fn overprint(&self, n: &mut Node, black: &OverprintBlack, spots: &[String]) -> (usize, usize) {
let white = usize::from(self.remove_white_overprint && self.unset_white(n));
let blacks = self.overprint_black > 0.0 && (!self.text_only || matches!(n.kind, NodeKind::Text(_)));
let mut counts = (white, usize::from(blacks && black.apply(n, spots)));
for c in n.children_mut().into_iter().flatten() {
let (w, b) = self.overprint(Arc::make_mut(c), black, spots);
counts = (counts.0 + w, counts.1 + b);
}
counts
}
}
pub(super) fn run(s: &mut Session, p: &Value) -> Result<Value> {
let fixes = Fixes::of(p)?;
let st = s.doc()?;
if st.doc.color_mode != ColorMode::Cmyk {
return Err(bad(C, "Print Ink Fixes works on CMYK documents: choose File › Document Color Mode › CMYK Color first"));
}
let layers = st.doc.layers.iter().map(|l| l.id).collect();
let roots = checked_ids_or_selection(s, p, C)?;
// As Artwork Cleanup's: the selection, or the document while nothing is selected.
let ids = match text_param(p, "scope", C)? {
Some("document") => layers,
None if roots.is_empty() => layers,
Some("selection") if roots.is_empty() => return Err(bad(C, "nothing selected")),
Some("selection") | None => roots,
Some(other) => return Err(bad(C, format!("`scope` must be \"selection\" or \"document\", not {other:?}"))),
};
let black = OverprintBlack { min_k: fixes.overprint_black, fill: fixes.fill, stroke: fixes.stroke, ..OverprintBlack::default() };
let (mut ink, mut white, mut blacks) = (0, 0, 0);
let changed = edit_counted(s, "Print Ink Fixes", |d| {
if fixes.ink_limit > 0.0 {
let spots = d.spot_names();
// Spot inks and Registration aren't process ink: they keep their colour and link.
let links = |c: &mut Color, link: &mut Option<String>, _: &mut f32| {
if link.as_ref().is_some_and(|n| n == REGISTRATION || spots.contains(n)) {
return false;
}
let new = fixes.limit(*c);
if new == *c {
return false;
}
(*c, *link) = (new, None);
true
};
let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false };
ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids);
}
if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 {
return Ok(ink);
}
let spots = d.spot_names();
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let (w, b) = fixes.overprint(n, &black, &spots);
(white, blacks) = (white + w, blacks + b);
}
}
Ok(ink + white + blacks)
})?;
Ok(json!({ "changed": changed, "inkLimited": ink, "whiteOverprintRemoved": white, "blackOverprinted": blacks }))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,129 @@
//! Print Ink Fixes.
use serde_json::{Value, json};
use vectorcraft_color::Color;
use vectorcraft_doc::NodeId;
use crate::Session;
const C: &str = "addon.color.printFixes";
/// A CMYK document with a rectangle per fill, each with overprint as given; nothing selected.
fn doc(fills: &[(Value, bool)]) -> (Session, Vec<NodeId>) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 200, "colorMode": "cmyk"})).unwrap();
let ids = fills
.iter()
.enumerate()
.map(|(i, (fill, overprint))| {
let id = s.execute("shape.rectangle", &json!({"x": 10 + 30 * i, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
s.execute("paint.setFill", &json!({ "color": fill })).unwrap();
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("object.setOverprint", &json!({ "fill": overprint })).unwrap();
NodeId(id)
})
.collect();
s.execute("select.none", &json!({})).unwrap();
(s, ids)
}
fn fill(s: &Session, id: NodeId) -> (Color, bool) {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
let item = n.appearance.items.iter().find(|i| i.is_fill()).unwrap().clone();
(item.paint().color().unwrap(), item.overprint())
}
fn cmyk(c: f64, m: f64, y: f64, k: f64) -> Value {
json!({"c": c, "m": m, "y": y, "k": k})
}
fn close(a: Color, b: [f32; 4]) -> bool {
let Color::Cmyk { c, m, y, k } = a else { return false };
[c, m, y, k].iter().zip(b).all(|(x, y)| (x - y).abs() < 0.001)
}
#[test]
fn the_ink_limit_scales_heavy_colours_down_to_it() {
let (mut s, ids) = doc(&[(cmyk(100.0, 100.0, 100.0, 100.0), false), (cmyk(40.0, 30.0, 20.0, 10.0), false), (cmyk(0.0, 0.0, 0.0, 80.0), false)]);
let undo = s.doc().unwrap().history.undo.len();
// Nothing selected: the whole document.
let v = s.execute(C, &json!({"inkLimit": 300})).unwrap();
assert_eq!((v["inkLimited"].clone(), v["changed"].clone()), (json!(1), json!(1)));
assert!(close(fill(&s, ids[0]).0, [0.75; 4]), "{:?}", fill(&s, ids[0]));
assert!(close(fill(&s, ids[1]).0, [0.4, 0.3, 0.2, 0.1]), "under the limit: as it was");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
// The selection only.
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
s.execute(C, &json!({"inkLimit": 50})).unwrap();
assert!(close(fill(&s, ids[1]).0, [0.2, 0.15, 0.1, 0.05]));
assert!(close(fill(&s, ids[0]).0, [0.75; 4]), "left out");
// Pure black keeps its ink unless it is included.
s.execute(C, &json!({"inkLimit": 50, "scope": "document"})).unwrap();
assert!(close(fill(&s, ids[0]).0, [0.125; 4]));
assert!(close(fill(&s, ids[2]).0, [0.0, 0.0, 0.0, 0.8]));
s.execute(C, &json!({"inkLimit": 50, "scope": "document", "excludePureBlack": false})).unwrap();
assert!(close(fill(&s, ids[2]).0, [0.0, 0.0, 0.0, 0.5]));
}
#[test]
fn spot_and_registration_colours_keep_their_ink() {
let (mut s, ids) = doc(&[(cmyk(0.0, 0.0, 0.0, 0.0), false), (cmyk(0.0, 0.0, 0.0, 0.0), false)]);
s.execute("swatch.new", &json!({"name": "Heavy", "color": cmyk(100.0, 100.0, 100.0, 50.0), "spot": true})).unwrap();
s.execute("select.set", &json!({"ids": [ids[0].0]})).unwrap();
s.execute("paint.setFill", &json!({"swatch": "Heavy"})).unwrap();
s.execute("select.set", &json!({"ids": [ids[1].0]})).unwrap();
s.execute("paint.setFill", &json!({"swatch": "[Registration]"})).unwrap();
s.execute("select.none", &json!({})).unwrap();
let before = [fill(&s, ids[0]), fill(&s, ids[1])];
assert_eq!(s.execute(C, &json!({"inkLimit": 200})).unwrap()["changed"], 0);
assert_eq!([fill(&s, ids[0]), fill(&s, ids[1])], before);
}
#[test]
fn white_overprint_goes_and_black_overprint_comes_at_the_threshold() {
let white = cmyk(0.0, 0.0, 0.0, 0.0);
let (mut s, ids) =
doc(&[(white.clone(), true), (cmyk(0.0, 0.0, 0.0, 95.0), false), (cmyk(0.0, 0.0, 0.0, 80.0), false), (cmyk(30.0, 0.0, 0.0, 100.0), false)]);
let undo = s.doc().unwrap().history.undo.len();
let v = s.execute(C, &json!({"removeWhiteOverprint": true, "overprintBlack": 90})).unwrap();
assert_eq!((v["whiteOverprintRemoved"].clone(), v["blackOverprinted"].clone()), (json!(1), json!(1)), "{v}");
let overprints: Vec<bool> = ids.iter().map(|id| fill(&s, *id).1).collect();
// White stops; 95 % black starts; 80 % is under the threshold; a rich black isn't pure black.
assert_eq!(overprints, [false, true, false, false]);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(ids.iter().map(|id| fill(&s, *id).1).collect::<Vec<_>>(), [true, false, false, false]);
// On type only: the rectangles stay.
let v = s.execute(C, &json!({"overprintBlack": 90, "textOnly": true})).unwrap();
assert_eq!(v["changed"], 0);
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing changed, no undo step");
// Strokes only: the fills stay.
assert_eq!(s.execute(C, &json!({"overprintBlack": 90, "fill": false})).unwrap()["changed"], 0);
}
#[test]
fn bad_requests_and_rgb_documents_are_refused() {
let (mut s, ids) = doc(&[(cmyk(100.0, 100.0, 100.0, 100.0), true)]);
for p in [
json!({}),
json!({"inkLimit": 0}),
json!({"inkLimit": "300"}),
json!({"inkLimit": 401}),
json!({"inkLimit": -1}),
json!({"overprintBlack": 101}),
json!({"overprintBlack": true}),
json!({"removeWhiteOverprint": "yes"}),
json!({"inkLimit": 300, "fill": 1}),
json!({"inkLimit": 300, "scope": "page"}),
json!({"inkLimit": 300, "scope": "selection"}),
json!({"inkLimit": 300, "ids": [999_999]}),
] {
assert!(s.execute(C, &p).is_err(), "{p}");
}
assert!(close(fill(&s, ids[0]).0, [1.0; 4]));
let mut rgb = Session::new();
rgb.execute("file.new", &json!({})).unwrap();
rgb.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let e = rgb.execute(C, &json!({"inkLimit": 300})).unwrap_err().to_string();
assert!(e.contains("CMYK"), "{e}");
}

Some files were not shown because too many files have changed in this diff Show More