Merge main into fix/issue-3748

# Conflicts:
#	crates/tools/src/select/tests.rs
This commit is contained in:
dotCooCoo
2026-10-30 22:48:55 -07:00
963 changed files with 89142 additions and 9323 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
-54
View File
@@ -1,54 +0,0 @@
# Experimental, unsupported Windows 7 SP1 x64 portable build (docs/windows7.md). It runs by hand and
# weekly and for changes to the compatibility path; the weekly run shows when a dependency update starts
# importing a post-Windows-7 API. packaging/windows/windows7.ps1 does the work: it is the only build
# that uses the patched vendor/windows-link. A modern Windows runner proves compilation and host
# smoke tests only: run the checklist in docs/windows7.md on actual Windows 7 before claiming support.
name: Windows 7 x64 compatibility
on:
pull_request:
paths:
- "Cargo.toml"
- "Cargo.lock"
- "apps/vectorcraft/Cargo.toml"
- "apps/vectorcraft/src/printing.rs"
- "apps/vectorcraft/src/printing/**"
- "crates/ui-egui/src/print.rs"
- "crates/ui-egui/src/tests/printps.rs"
- "packaging/windows/windows7.ps1"
- ".github/workflows/windows7.yml"
schedule:
- cron: "23 5 * * 1" # weekly
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs:
build:
runs-on: windows-2022
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
with:
arch: x64
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: nightly-2026-10-01
components: rust-src
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: windows7-x64
- name: Build, check and test the portable package
shell: pwsh
run: ./packaging/windows/windows7.ps1 -Test
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: vectorcraft-windows7-x64-portable
path: dist/release/vectorcraft-windows7-x64-portable.zip
if-no-files-found: error
+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.
+1 -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.
+9 -3
View File
@@ -28,13 +28,14 @@ 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.
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare `#![forbid(unsafe_code)]` before modules or other crate items; crate-level `//!` documentation may precede it.
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
@@ -46,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
@@ -59,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.
@@ -68,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
+68 -63
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",
@@ -1310,7 +1311,6 @@ dependencies = [
"egui-wgpu",
"egui-winit",
"egui_glow",
"glow",
"glutin",
"glutin-winit",
"image",
@@ -1888,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",
]
@@ -3283,6 +3283,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
]
[[package]]
@@ -3899,7 +3900,7 @@ dependencies = [
"concurrent-queue",
"hermit-abi",
"pin-project-lite",
"rustix 1.1.5",
"rustix 1.1.6",
"windows-sys 0.61.2",
]
@@ -4445,9 +4446,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 +4489,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 +5097,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 +5142,7 @@ dependencies = [
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"rustix 1.1.6",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
@@ -5416,7 +5417,7 @@ dependencies = [
"fastrand",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"rustix 1.1.6",
"windows-sys 0.61.2",
]
@@ -5992,7 +5993,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 +6037,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -6053,12 +6054,15 @@ dependencies = [
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-text",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
"pollster 1.0.1",
"raw-window-handle",
"rfd",
"serde_json",
"smithay-clipboard",
"socket2",
"vectorcraft-engine",
"vectorcraft-text",
@@ -6077,7 +6081,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"crc32fast",
"flate2",
@@ -6088,7 +6092,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -6100,7 +6104,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6116,7 +6120,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -6138,7 +6142,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -6150,7 +6154,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"chrono",
"hayro-ccitt",
@@ -6169,7 +6173,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6185,7 +6189,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"craft-docpath",
"crc32fast",
@@ -6194,8 +6198,8 @@ dependencies = [
"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",
@@ -6224,7 +6228,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"fax",
"flate2",
@@ -6248,11 +6252,11 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"flate2",
"proptest",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -6263,7 +6267,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -6275,7 +6279,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"log",
@@ -6294,7 +6298,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6308,7 +6312,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -6320,19 +6324,20 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"aes",
"chrono",
"craft-pdf",
"flate2",
"getrandom 0.4.3",
"image",
"jpeg-encoder",
"krilla",
"kurbo 0.13.1",
"log",
"md-5",
"roxmltree 0.21.1",
"roxmltree 0.21.2",
"serde",
"serde_json",
"sha2",
@@ -6351,7 +6356,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"serde",
"serde_json",
@@ -6365,7 +6370,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"flate2",
"gif",
@@ -6394,14 +6399,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"craft-docpath",
"flate2",
@@ -6426,7 +6431,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"fax",
"image",
@@ -6444,7 +6449,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -6465,7 +6470,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -6473,7 +6478,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6489,7 +6494,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6501,7 +6506,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"bytemuck",
"craft-keymap",
@@ -6512,7 +6517,7 @@ dependencies = [
"egui_kittest",
"image",
"log",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_json",
"toml",
@@ -6539,7 +6544,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"eframe",
"egui",
@@ -6805,7 +6810,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
dependencies = [
"cc",
"downcast-rs",
"rustix 1.1.5",
"rustix 1.1.6",
"scoped-tls",
"smallvec",
"wayland-sys",
@@ -6818,7 +6823,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",
]
@@ -6840,7 +6845,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",
]
@@ -7515,7 +7520,7 @@ dependencies = [
"libc",
"log",
"os_pipe",
"rustix 1.1.5",
"rustix 1.1.6",
"thiserror 2.0.21",
"tree_magic_mini",
"wayland-backend",
@@ -7574,7 +7579,7 @@ dependencies = [
"libc",
"libloading",
"once_cell",
"rustix 1.1.5",
"rustix 1.1.6",
"x11rb-protocol",
]
@@ -7616,7 +7621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix 1.1.5",
"rustix 1.1.6",
]
[[package]]
@@ -7670,7 +7675,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"serde_json",
]
@@ -7720,7 +7725,7 @@ dependencies = [
"hex",
"libc",
"ordered-stream",
"rustix 1.1.5",
"rustix 1.1.6",
"serde",
"serde_repr",
"tracing",
@@ -7904,9 +7909,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",
+6 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.17.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" }
@@ -116,6 +117,9 @@ webbrowser = "1"
unsafe_code = "deny"
[workspace.lints.clippy]
cognitive_complexity = "deny"
too_many_arguments = "deny"
wildcard_imports = "deny"
dbg_macro = "warn"
# VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see
# AGENTS.md › Robustness). Tests may unwrap (clippy.toml).
-5
View File
@@ -30,11 +30,6 @@ based in part on the work of the Independent JPEG Group.
WebAssembly plug-ins run in the wasmi interpreter (MIT OR Apache-2.0); its wasmparser dependency is
under the Apache License 2.0 with the LLVM exception.
The experimental Windows 7 build (packaging/windows/windows7.ps1) compiles a modified copy of the
windows-link crate 0.2.1 (vendor/windows-link; Microsoft, MIT OR Apache-2.0) that binds CoTaskMemFree
through ole32.dll. Its license texts are in vendor/windows-link and ship in that build's ZIP; every
other build uses the unmodified crate from crates.io.
Builds made with craft-fonts (the CRAFT_FONTS_DIR build option; all official releases) embed the
fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic,
Shippori Mincho, BIZ UDMincho, Noto Sans CJK SC and Noto Sans Arabic in the current manifest;
+1 -3
View File
@@ -227,8 +227,6 @@ claude mcp add vectorcraft -- /path/to/vectorcraft-cli mcp --automation-read-roo
The details are in [`docs/mcp.md`](docs/mcp.md) and [`docs/control-protocol.md`](docs/control-protocol.md).
For the experimental, unsupported 64-bit Windows 7 build, see [Windows 7 instructions](docs/windows7.md).
## Status
VectorCraft is under active development. [**ROADMAP.md**](ROADMAP.md) has the stage, the numbers by dimension
@@ -249,7 +247,7 @@ doesn't write Affinity files. Current `.af` validation includes 33 pinned files,
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
The interface
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech and Georgian;
speaks English, German, Japanese, Korean, Vietnamese, Traditional and Simplified Chinese, Spanish, French, Hungarian, Italian, Polish, Brazilian Portuguese, Russian, Swedish, Ukrainian, Finnish, Bulgarian, Greek, Czech, Georgian and Catalan;
[localization-parity.md](docs/localization-parity.md) has the details.
The scores are self-assessed from our code against public documentation;
[target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them.
+10 -4
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -35,7 +35,6 @@ 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.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] }
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
+2 -2
View File
@@ -46,7 +46,7 @@ mod perf;
#[path = "../../vectorcraft/src/prefs_dir.rs"]
mod prefs_dir;
/// The desktop app's DirectWrite font lister, shared: exports and MCP find the same fonts.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
#[path = "../../vectorcraft/src/system_fonts.rs"]
mod system_fonts;
@@ -162,7 +162,7 @@ fn help() -> Result<(), String> {
fn main() -> ExitCode {
// Before any font lookup: the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_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;
+110 -74
View File
@@ -447,113 +447,158 @@ fn mcp_protocol_over_stdio() {
let mut stdin = child.stdin.take().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let mut notes = vec![];
fn send(stdin: &mut std::process::ChildStdin, v: Value) {
writeln!(stdin, "{v}").unwrap();
exercise_mcp_core(&mut stdin, &mut lines, &mut notes);
exercise_mcp_resource_errors(&mut stdin, &mut lines, &mut notes);
exercise_mcp_document_resources(&mut stdin, &mut lines, &mut notes);
exercise_mcp_logging(&mut stdin, &mut lines, &mut notes);
exercise_mcp_parse_error(&mut stdin, &mut lines, &mut notes);
drop(stdin);
for line in lines {
let v: Value = serde_json::from_str(&line.unwrap()).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
notes.push(v);
}
// Next reply with this id; notifications (no id) are stashed for later.
fn recv(lines: &mut std::io::Lines<BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
loop {
let line = lines.next().unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
return v;
}
notes.push(v);
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
}
fn mcp_send(stdin: &mut std::process::ChildStdin, v: Value) {
writeln!(stdin, "{v}").unwrap();
}
fn mcp_recv(lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
loop {
let line = lines.next().unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
return v;
}
notes.push(v);
}
}
send(
&mut stdin,
fn exercise_mcp_core(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
mcp_send(
stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
);
let r = recv(&mut lines, &mut notes, 1);
let r = mcp_recv(lines, notes, 1);
assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft");
assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
mcp_send(stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
let r = recv(&mut lines, &mut notes, 2);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
let r = mcp_recv(lines, notes, 2);
assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
let r = recv(&mut lines, &mut notes, 3);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
let r = mcp_recv(lines, notes, 3);
assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}");
send(
&mut stdin,
mcp_send(
stdin,
json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}),
);
let r = recv(&mut lines, &mut notes, 4);
let r = mcp_recv(lines, notes, 4);
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
send(
&mut stdin,
mcp_send(
stdin,
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}),
);
let r = recv(&mut lines, &mut notes, 7);
let r = mcp_recv(lines, notes, 7);
assert_eq!(r["result"]["isError"], false, "{r}");
let id: i64 = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap();
send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
let r = recv(&mut lines, &mut notes, 8);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
let r = mcp_recv(lines, notes, 8);
let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
}
fn exercise_mcp_resource_errors(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
// Unknown resources (-32002) and bad template values (-32602) stay apart.
send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002);
send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
assert_eq!(mcp_recv(lines, notes, 9)["error"]["code"], -32002);
mcp_send(stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
assert_eq!(mcp_recv(lines, notes, 10)["error"]["code"], -32602);
}
fn exercise_mcp_document_resources(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
// Large containers slice through run_command document.node: a truncated level reports
// childCount, and a bad limit is a tool error, not a crash or a full read.
send(
&mut stdin,
mcp_send(
stdin,
json!({"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":90,"y":10,"width":30,"height":30}}}),
);
assert_eq!(recv(&mut lines, &mut notes, 20)["result"]["isError"], false);
let run = |id: u64, command: &str, params: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}});
send(&mut stdin, run(21, "select.all", json!({})));
recv(&mut lines, &mut notes, 21);
send(&mut stdin, run(22, "object.group", json!({})));
let r = recv(&mut lines, &mut notes, 22);
assert_eq!(mcp_recv(lines, notes, 20)["result"]["isError"], false);
mcp_send(stdin, run_command(21, "select.all", json!({})));
mcp_recv(lines, notes, 21);
mcp_send(stdin, run_command(22, "object.group", json!({})));
let r = mcp_recv(lines, notes, 22);
let group = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone();
send(&mut stdin, run(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1})));
let r = recv(&mut lines, &mut notes, 23);
mcp_send(stdin, run_command(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1})));
let r = mcp_recv(lines, notes, 23);
let sliced: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(sliced["children"].as_array().map(Vec::len), Some(1), "{sliced}");
assert_eq!(sliced["childCount"], 2, "{sliced}");
send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1})));
assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true);
mcp_send(stdin, run_command(24, "document.node", json!({"id": group, "summary": true, "depth": -1})));
assert_eq!(mcp_recv(lines, notes, 24)["result"]["isError"], true);
// Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and
// document.find locates nodes without dumping the tree.
send(&mut stdin, run(25, "document.inspect", json!({"depth": 0})));
let r = recv(&mut lines, &mut notes, 25);
mcp_send(stdin, run_command(25, "document.inspect", json!({"depth": 0})));
let r = mcp_recv(lines, notes, 25);
let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert!(skel["layers"][0].get("children").is_none(), "{skel}");
assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}");
send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"})));
let r = recv(&mut lines, &mut notes, 26);
mcp_send(stdin, run_command(26, "document.find", json!({"kind": "ellipse"})));
let r = mcp_recv(lines, notes, 26);
let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(found["total"], 1, "{found}");
assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}");
send(&mut stdin, run(27, "document.find", json!({})));
assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true);
mcp_send(stdin, run_command(27, "document.find", json!({})));
assert_eq!(mcp_recv(lines, notes, 27)["result"]["isError"], true);
}
fn exercise_mcp_logging(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
// The binary installed its logger: records arrive as notifications/message, and only
// after the client asked for them.
send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
assert!(recv(&mut lines, &mut notes, 11).get("error").is_none());
send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
mcp_send(stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
assert!(mcp_recv(lines, notes, 11).get("error").is_none());
mcp_send(stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
assert!(mcp_recv(lines, notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
}
fn exercise_mcp_parse_error(
stdin: &mut std::process::ChildStdin,
lines: &mut std::io::Lines<std::io::BufReader<std::process::ChildStdout>>,
notes: &mut Vec<Value>,
) {
// Malformed input is a parse error, and the server keeps serving afterwards.
stdin.write_all(b"{not json\n").unwrap();
let parse_err: Value = loop {
@@ -566,21 +611,12 @@ fn mcp_protocol_over_stdio() {
};
assert_eq!(parse_err["error"]["code"], -32700);
assert!(parse_err["id"].is_null(), "{parse_err}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
assert!(recv(&mut lines, &mut notes, 13).get("error").is_none());
drop(stdin);
// Drain whatever the debug window queued; every line off the protocol stream parses.
for line in lines.by_ref() {
let line = line.unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
notes.push(v);
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
mcp_send(stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
assert!(mcp_recv(lines, notes, 13).get("error").is_none());
}
fn run_command(id: u64, command: &str, params: Value) -> Value {
json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}})
}
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
+7
View File
@@ -9,6 +9,13 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="theme-color" content="#262626">
<title>VectorCraft</title>
<!-- Tab and home-screen icons: copied from assets/app-icon (one folder per size, the PNG names are equal). -->
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/scalable/apps/ai.storyteller.vectorcraft.svg" data-target-path="icons" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.vectorcraft.png" data-target-path="icons/32" />
<link data-trunk rel="copy-file" href="../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png" data-target-path="icons/256" />
<link rel="icon" type="image/svg+xml" href="icons/ai.storyteller.vectorcraft.svg">
<link rel="icon" type="image/png" sizes="32x32" href="icons/32/ai.storyteller.vectorcraft.png">
<link rel="apple-touch-icon" href="icons/256/ai.storyteller.vectorcraft.png">
<link data-trunk rel="rust" data-bin="vectorcraft-web" data-wasm-opt="z" data-weak-refs />
<style>
/* iPad: no page scroll, bounce, pinch zoom, text selection or callout; the app takes the gestures. */
+12 -7
View File
@@ -13,8 +13,6 @@ workspace = true
default = ["wgpu", "accessibility"]
wgpu = ["eframe/wgpu", "dep:winsafe"]
accessibility = ["eframe/accesskit"]
# Build with --no-default-features --features windows7 and the win7 Rust target.
windows7 = ["eframe/glow"]
[dependencies]
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
@@ -37,9 +35,6 @@ image = { workspace = true }
webbrowser = { workspace = true }
# The window's monitors, size and position (the same winit eframe runs on).
winit = { version = "0.30", default-features = false }
# The win7 target retains the viewer fallback; craft-print's WinRT PDF renderer needs newer Windows.
[target.'cfg(all(not(target_arch = "wasm32"), not(target_vendor = "win7")))'.dependencies]
craft-print = { workspace = true }
[target.'cfg(windows)'.dependencies]
@@ -49,6 +44,9 @@ clipboard-win = { workspace = true }
# GPU that shows it: drawing on a GPU without a monitor reset the display driver. Safe wrappers over
# Win32 (MIT); the `user` feature is User32.dll only. Only the wgpu renderer chooses an adapter.
winsafe = { version = "0.0.29", default-features = false, features = ["user"], optional = true }
# Waits for Direct3D 12's adapter list before the window starts (src/gpu.rs, #4150). MIT OR
# Apache-2.0, already in the build through eframe.
pollster = "1"
# DirectWrite's system font collection and GDI's font list (src/system_fonts.rs): fonts that font
# services such as Adobe Fonts load in place (#579, #1258). MIT OR Apache-2.0, already in the build
@@ -56,7 +54,6 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o
# 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.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(target_os = "macos")'.dependencies]
@@ -64,7 +61,7 @@ windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_G
# with default features off). Pinned exactly: review the diff before bumping.
muda = { version = "=0.21.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "block2", "objc2-core-foundation", "objc2-quartz-core", "NSApplication", "NSButton", "NSColor", "NSColorSampler", "NSColorSpace", "NSControl", "NSMenu", "NSEvent", "NSPasteboard", "NSPopUpButton", "NSResponder", "NSSavePanel", "NSTextField", "NSTextInputContext", "NSView", "NSWindow"] }
# Files opened from Finder and the Dock (src/open_documents.rs), pasted bitmaps (src/clipboard.rs) and the control channel's App Nap activity (src/control_server.rs); both already come with winit, muda and rfd.
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSData", "NSGeometry", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
block2 = "0.6"
@@ -76,6 +73,10 @@ raw-window-handle = "0.6"
# objc2-foundation.
objc2-core-foundation = { version = "0.3", default-features = false, features = ["std", "CFArray", "CFString", "CFURL"] }
objc2-core-text = { version = "0.3", default-features = false, features = ["std", "CTFontManager"] }
# The windows' Metal layers and the sRGB color space they are tagged with (src/mac_color_space.rs).
# Zlib OR Apache-2.0 OR MIT, already in the build through wgpu with these features.
objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "objc2-core-foundation", "objc2-core-graphics", "CALayer", "CAMetalLayer"] }
objc2-core-graphics = { version = "0.3", default-features = false, features = ["std", "CGColorSpace"] }
# Waits for the answer of a file dialog shown as a sheet (src/main.rs, #867). MIT OR Apache-2.0,
# already in the build through eframe.
pollster = "1"
@@ -91,6 +92,10 @@ x11rb = { version = "0.13", default-features = false, features = ["xinput"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one. Both are already in the build through winit.
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
+21 -10
View File
@@ -1,5 +1,11 @@
use super::prefs::save_prefs;
use super::*;
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
use super::gpu;
use super::prefs::{save_prefs, save_prefs_soon};
#[cfg(all(windows, not(target_vendor = "win7")))]
use super::windows_stylus;
use super::{App, VectorcraftApp, file_access, window};
#[cfg(target_os = "macos")]
use super::{mac_menu, mac_window, open_documents};
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
@@ -10,12 +16,12 @@ pub(crate) fn native_input_hook(app: &VectorcraftApp) -> Option<vectorcraft_ui_e
app.services.native_menu.is_some().then(|| std::sync::Arc::new(mac_menu::raw_input_hook) as vectorcraft_ui_egui::NativeInputHook)
}
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
Some(std::sync::Arc::new(windows_stylus::raw_input_hook))
}
#[cfg(not(any(target_os = "macos", all(windows, not(target_vendor = "win7")))))]
#[cfg(not(any(target_os = "macos", windows)))]
pub(crate) fn native_input_hook(_: &VectorcraftApp) -> Option<vectorcraft_ui_egui::NativeInputHook> {
None
}
@@ -41,9 +47,13 @@ impl eframe::App for App {
mac_menu::raw_input_hook(raw);
}
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
windows_stylus::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(ctx, raw));
// A mouse-up that never reached the window (#4326) is delivered once AppKit reports
// that no mouse button is down.
#[cfg(target_os = "macos")]
self.app.release_lost_buttons(ctx, raw, objc2_app_kit::NSEvent::pressedMouseButtons() != 0);
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -53,11 +63,17 @@ impl eframe::App for App {
let inset = mac_window::center_buttons(frame, ui.ctx().zoom_factor(), bar);
self.app.titlebar_inset = inset.unwrap_or(mac_window::DEFAULT_INSET);
}
// 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);
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")]
@@ -65,15 +81,10 @@ impl eframe::App for App {
discard_marked_text();
}
}
#[cfg(not(feature = "windows7"))]
fn on_exit(&mut self) {
file_access::unconfined(|| save_prefs(&self.app));
end_before_teardown(self.frames);
}
#[cfg(feature = "windows7")]
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
file_access::unconfined(|| save_prefs(&self.app));
}
}
impl App {
+536 -24
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
}
@@ -164,7 +171,9 @@ mod win {
use clipboard_win::{formats, options::NoClear, raw};
use super::*;
use super::{
BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats,
};
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
/// other or a `CF_BITMAP` is: the system converts between them.
@@ -304,12 +313,16 @@ 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::*;
use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
/// What our last write put on the clipboard.
enum Ours {
@@ -317,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())
}
}
@@ -340,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
}
};
@@ -361,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)),
@@ -370,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| {
@@ -400,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()));
}
}
}
@@ -413,7 +925,7 @@ mod mac {
use objc2_app_kit::NSPasteboard;
use objc2_foundation::{NSArray, NSString};
use super::*;
use super::{Flavour, pasteboard_types};
/// Does the pasteboard hold one of `mimes` ([`pasteboard_types`])? Asks for its types only.
pub fn has(mimes: &[&'static str]) -> bool {
+27 -16
View File
@@ -4,12 +4,18 @@ fn short_timeouts() -> ConnectionTimeouts {
ConnectionTimeouts { idle: Duration::from_millis(150), line: Duration::from_millis(120), write: Duration::from_millis(150) }
}
/// The short line deadline, with idle and reply budgets longer than the tests' 3 s waits. On a
/// busy machine either thread can pause for more than 150 ms between connecting and sending, or
/// between starting a reply and writing it, and a short budget then closes the connection with no
/// reply.
/// How long a test waits for the server to reply or to close a connection. The wait only ends a
/// test whose server never does either; the budgets under test are 120 and 150 ms, and 1 s in the
/// persistent test. On a busy machine the server's thread can pause for seconds before or between
/// its reads and writes, and a wait of a few seconds then fails a server that closes the
/// connection on time.
const WAIT: Duration = Duration::from_secs(20);
/// The short line deadline, with idle and reply budgets longer than [`WAIT`]. On a busy machine
/// either thread can pause for more than 150 ms between connecting and sending, or between
/// starting a reply and writing it, and a short budget then closes the connection with no reply.
fn line_timeouts() -> ConnectionTimeouts {
ConnectionTimeouts { idle: Duration::from_secs(10), write: Duration::from_secs(10), ..short_timeouts() }
ConnectionTimeouts { idle: Duration::from_secs(30), write: Duration::from_secs(30), ..short_timeouts() }
}
fn socket_fixture(
@@ -27,12 +33,12 @@ fn socket_fixture(
});
let client = TcpStream::connect(addr).unwrap();
socket2::SockRef::from(&client).set_recv_buffer_size(4096).unwrap();
client.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
client.set_read_timeout(Some(WAIT)).unwrap();
(client, done, worker)
}
fn expect_socket_deadline(client: TcpStream, done: Receiver<()>, worker: std::thread::JoinHandle<()>) {
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
let stopped = done.recv_timeout(WAIT).is_ok();
// Always unblock the old implementation too, so a failed regression leaves no worker behind.
let _ = client.shutdown(std::net::Shutdown::Both);
drop(client);
@@ -93,7 +99,7 @@ fn trickled_control_line_has_an_absolute_deadline() {
std::thread::sleep(Duration::from_millis(20));
}
});
let stopped = done.recv_timeout(Duration::from_secs(3)).is_ok();
let stopped = done.recv_timeout(WAIT).is_ok();
stop.store(true, Ordering::SeqCst);
// Both socket copies belong to this fixture; shutdown cleans up the baseline failure as well.
let _ = client.shutdown(std::net::Shutdown::Both);
@@ -110,26 +116,31 @@ fn a_nonreading_control_peer_cannot_retain_the_writer() {
expect_socket_deadline(client, done, worker);
}
/// Each request gets its own line and reply budgets. The handler takes longer than both budgets,
/// so a deadline kept from the previous request runs out. The line and reply budgets are 1 s, and
/// the idle budget is 30 s, longer than the client's [`WAIT`]: on a busy machine either thread can
/// pause for more than the short budgets of [`short_timeouts`] between starting a budget and the
/// read or write it covers.
#[test]
fn persistent_control_requests_get_fresh_transport_deadlines() {
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(2), ..short_timeouts() };
let timeouts = ConnectionTimeouts { idle: Duration::from_secs(30), line: Duration::from_secs(1), write: Duration::from_secs(1) };
let (mut client, done, worker) = socket_fixture(timeouts, |_, _| {
// Application processing is not transport time; begin the reply budget afterwards.
std::thread::sleep(Duration::from_millis(200));
std::thread::sleep(Duration::from_millis(1250));
Some(json!({"ok": true}))
});
let mut reader = BufReader::new(client.try_clone().unwrap());
for id in [1, 2] {
// The client sends each line in one write. `writeln!` on the socket sends the JSON in many
// small writes, and when the machine is busy, a pause of more than the 120 ms line deadline
// between two of them closes the connection.
// small writes, and when the machine is busy, a pause of more than the line deadline between
// two of them closes the connection.
client.write_all(format!("{}\n", json!({"id": id, "method": "inspect"})).as_bytes()).unwrap();
let mut line = String::new();
reader.read_line(&mut line).unwrap();
assert_eq!(serde_json::from_str::<Value>(&line).unwrap(), json!({"id": id, "ok": true}));
}
client.shutdown(std::net::Shutdown::Write).unwrap();
done.recv_timeout(Duration::from_secs(3)).unwrap();
done.recv_timeout(WAIT).unwrap();
worker.join().unwrap();
}
@@ -155,7 +166,7 @@ fn timed_out_worker_returns_its_connection_slot() {
let (tx, _rx) = channel();
let worker = spawn_connection(stream, tx, egui::Context::default(), slot, short_timeouts()).unwrap();
let start = std::time::Instant::now();
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < Duration::from_secs(3) {
while open.load(Ordering::SeqCst) == MAX_CONNECTIONS && start.elapsed() < WAIT {
std::thread::sleep(Duration::from_millis(10));
}
let released = open.load(Ordering::SeqCst) < MAX_CONNECTIONS;
@@ -322,7 +333,7 @@ fn tcp_connection_is_closed_after_junk() {
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
});
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.set_read_timeout(Some(WAIT)).unwrap();
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
let mut text = String::new();
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
@@ -373,7 +384,7 @@ fn extra_connections_are_refused() {
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
extra.set_read_timeout(Some(WAIT)).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
+54 -7
View File
@@ -126,13 +126,60 @@ pub fn preferred_compositor(pref: Option<&str>, env: Option<PowerPreference>) ->
if automatic(pref, env) { crate::compositor_gpu::compositor_gpu() } else { None }
}
/// The graphics backends the window starts with: on Windows Direct3D 12, falling back to OpenGL,
/// unless `WGPU_BACKEND` names others (`env_set`; `default` then holds them). Creating a Vulkan
/// instance loads every installed Vulkan driver into the process, and a faulty one (Intel's
/// `igvk64.dll`, #806) crashed the app before its window appeared. DX12 is Windows' own backend,
/// as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool) -> Backends {
if cfg!(windows) && !env_set { Backends::DX12 | Backends::GL } else { default }
/// The graphics backends the window starts with: on Windows Direct3D 12, with OpenGL beside it
/// only when `opengl` ([`windows_opengl`]), unless `WGPU_BACKEND` names others (`env_set`;
/// `default` then holds them). Creating a Vulkan instance loads every installed Vulkan driver into
/// the process, and a faulty one (Intel's `igvk64.dll`, #806) crashed the app before its window
/// appeared. DX12 is Windows' own backend, as in PdfCraft. Elsewhere, `default`.
pub fn backends(default: Backends, env_set: bool, opengl: bool) -> Backends {
match (cfg!(windows) && !env_set, opengl) {
(true, true) => Backends::DX12 | Backends::GL,
(true, false) => Backends::DX12,
(false, _) => default,
}
}
/// Whether a Windows start lists OpenGL's adapters beside Direct3D 12's: only when it leaves an
/// adapter out (an earlier start crashed on it, or a restart failed on it), when
/// `WGPU_ADAPTER_NAME` names one (it may be an OpenGL adapter), or when DX12 lists no hardware
/// adapter (Windows 7, or a GPU without a DX12 driver, which then draws through OpenGL as before
/// rather than on the CPU with WARP). wgpu's OpenGL backend keeps a hidden window on a thread that
/// pumps no messages, and Windows waits on it when the input language changes, so the whole app
/// hung on a switch to another keyboard layout (#4150, gfx-rs/wgpu#10504), even drawing with DX12.
pub fn windows_opengl(left_out: &LeftOut) -> bool {
opengl_needed(left_out, !skipped().is_empty(), std::env::var_os(NAME_ENV).is_some(), dx12_lists_hardware)
}
/// [`windows_opengl`], with a restart leaving adapters out (`skipping`) and an adapter named
/// (`named`): `dx12_hardware` is asked only when nothing else decides.
fn opengl_needed(left_out: &LeftOut, skipping: bool, named: bool, dx12_hardware: impl FnOnce() -> bool) -> bool {
left_out.opengl_only || !left_out.adapters.is_empty() || skipping || named || !dx12_hardware()
}
/// Whether Direct3D 12 lists a hardware adapter; WARP, its software one, doesn't count. A driver
/// that crashes here crashes a start while it lists the adapters, which the next start answers with
/// OpenGL alone.
#[cfg(windows)]
fn dx12_lists_hardware() -> bool {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
descriptor.backends = Backends::DX12;
// The shader compiler the window's own instance takes (#1194).
descriptor.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::from_env().unwrap_or(wgpu::Dx12Compiler::Fxc);
let instance = wgpu::Instance::new(descriptor);
let kinds: Vec<DeviceType> = pollster::block_on(instance.enumerate_adapters(Backends::DX12)).iter().map(|a| a.get_info().device_type).collect();
log::info!("Direct3D 12 lists these graphics adapters: {kinds:?}");
any_hardware(&kinds)
}
#[cfg(not(windows))]
fn dx12_lists_hardware() -> bool {
false
}
/// Is one of the adapters of these kinds hardware, not software such as WARP?
#[cfg(any(windows, test))]
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
+52 -12
View File
@@ -451,13 +451,50 @@ fn a_windows_gpu_renders_through_dx12_before_vulkan() {
#[test]
fn windows_never_loads_vulkan_drivers_unless_asked() {
let all = Backends::all();
let start = backends(all, false);
let start = backends(all, false, false);
let fallback = backends(all, false, true);
if cfg!(windows) {
assert_eq!(start, Backends::DX12 | Backends::GL, "#806");
assert_eq!(start, Backends::DX12, "#806, and no OpenGL unless needed (#4150)");
assert_eq!(fallback, Backends::DX12 | Backends::GL);
} else {
assert_eq!(start, all);
assert_eq!((start, fallback), (all, all));
}
assert_eq!(backends(Backends::VULKAN, true), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
assert_eq!(backends(Backends::VULKAN, true, false), Backends::VULKAN, "WGPU_BACKEND=vulkan still picks it");
assert_eq!(backends(Backends::GL, true, false), Backends::GL, "WGPU_BACKEND=gl still picks it");
}
#[test]
fn windows_lists_opengl_once_a_start_leaves_an_adapter_out() {
let crashed = LeftOut { adapters: vec!["Dx12:8086:3ea0".into()], ..LeftOut::default() };
assert!(windows_opengl(&crashed), "#2243: the next start can fall back to OpenGL");
let listing = LeftOut { opengl_only: true, ..LeftOut::default() };
assert!(windows_opengl(&listing));
assert_eq!(listing.backends(backends(Backends::all(), false, true)), Backends::GL);
}
/// OpenGL stays where it drew before #4150's fix: a GPU without a Direct3D 12 driver (DX12 lists
/// WARP alone) and an adapter `WGPU_ADAPTER_NAME` names; a start with a DX12 GPU lists DX12 alone.
#[test]
fn windows_lists_opengl_without_a_dx12_gpu_or_with_an_adapter_named() {
let start = LeftOut::default();
let asked = std::cell::Cell::new(false);
let hardware = |found: bool| {
asked.set(true);
found
};
assert!(!opengl_needed(&start, false, false, || hardware(true)), "a DX12 GPU: DX12 alone (#4150)");
assert!(asked.replace(false));
assert!(opengl_needed(&start, false, false, || hardware(false)), "WARP alone: the GPU draws through OpenGL, not the CPU");
assert!(opengl_needed(&start, false, true, || hardware(true)), "a named adapter may be an OpenGL one");
assert!(opengl_needed(&start, true, false, || hardware(true)), "a restart left an adapter out");
let listing = LeftOut { opengl_only: true, ..LeftOut::default() };
asked.set(false);
assert!(opengl_needed(&listing, false, false, || hardware(true)));
assert!(!asked.get(), "DX12 isn't listed once OpenGL must be");
assert!(any_hardware(&[DeviceType::Cpu, DeviceType::IntegratedGpu]));
assert!(any_hardware(&[DeviceType::VirtualGpu]));
assert!(!any_hardware(&[DeviceType::Cpu]), "WARP");
assert!(!any_hardware(&[]), "Windows 7");
}
#[test]
@@ -548,11 +585,14 @@ fn a_normal_exit_or_another_failure_does_not_restart() {
assert_eq!(finish(Err("a bug".into()), &startup), Err("a bug".into()));
}
/// A log folder of its own for each test.
fn start_dir(tag: &str) -> std::path::PathBuf {
/// A log folder of its own for each test, and the guard that keeps other tests from starting a
/// program while it runs: the test releases the start file's lock and takes it again, and a child
/// starting meanwhile would keep it held (`crate::no_spawns`).
fn start_dir(tag: &str) -> (std::path::PathBuf, std::sync::RwLockReadGuard<'static, ()>) {
let spawns = crate::no_spawns();
let d = std::env::temp_dir().join(format!("vectorcraft-gpu-start-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
d
(d, spawns)
}
/// What a start in `dir` leaves out; it then ends without crashing.
@@ -578,7 +618,7 @@ fn issue_2243() -> Vec<Candidate> {
/// it, without a status-bar note.
#[test]
fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
let dir = start_dir("crash");
let (dir, _spawns) = start_dir("crash");
let c = issue_2243();
let first = Startup::default();
assert_eq!(first.track(&dir, None, false), LeftOut::default());
@@ -609,7 +649,7 @@ fn a_start_that_never_showed_a_frame_leaves_its_adapter_out_of_the_next() {
/// an error, a restart on another adapter), leaves nothing out of the next.
#[test]
fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
let dir = start_dir("shown");
let (dir, _spawns) = start_dir("shown");
let c = issue_2243();
for end in [Startup::presented as fn(&Startup), Startup::ended] {
let first = Startup::default();
@@ -633,7 +673,7 @@ fn a_start_that_showed_its_first_frame_or_ended_is_cleared() {
/// macOS, where wgpu has none.
#[test]
fn a_crash_while_listing_lists_opengl_alone() {
let dir = start_dir("listing");
let (dir, _spawns) = start_dir("listing");
let first = Startup::default();
first.track(&dir, None, false);
drop(first);
@@ -660,7 +700,7 @@ fn a_crash_while_listing_lists_opengl_alone() {
/// start shows its first frame on the adapter that crashed, the adapter counts again.
#[test]
fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
let dir = start_dir("explicit");
let (dir, _spawns) = start_dir("explicit");
let c = issue_2243();
let first = Startup::default();
first.track(&dir, None, false);
@@ -681,7 +721,7 @@ fn wgpu_backend_or_adapter_name_ignores_the_crashes() {
/// are ignored: the start goes on with nothing left out.
#[test]
fn a_damaged_or_unreadable_start_file_is_ignored() {
let dir = start_dir("damaged");
let (dir, _spawns) = start_dir("damaged");
std::fs::create_dir_all(&dir).unwrap();
let damaged: [&[u8]; 9] = [
b"",
+14 -4
View File
@@ -1,4 +1,12 @@
use super::*;
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
use super::portal_save;
#[cfg(target_os = "linux")]
use super::tablet;
#[cfg(all(windows, not(target_vendor = "win7")))]
use super::windows_stylus;
use super::{ClipboardProbeFactory, FilePick, PickRequest, Services, clipboard, cursor_theme, fileio, printing};
#[cfg(target_os = "macos")]
use super::{mac_save_format, mac_screen_color, mac_tablet};
/// The window file dialogs belong to.
pub(crate) type Parent = Option<std::sync::Arc<winit::window::Window>>;
@@ -247,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();
@@ -266,7 +275,7 @@ pub(crate) fn services(parent: Parent, ui: egui::Context) -> Services {
#[cfg(target_os = "macos")]
stylus: mac_tablet::start(),
// Windows reads it from the pen's pointer messages (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
stylus: Some(windows_stylus::start()),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
@@ -278,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);
+139
View File
@@ -0,0 +1,139 @@
//! macOS: the windows' Metal layers are tagged sRGB, so macOS converts what VectorCraft draws to
//! the display's color profile (#2249, #3161).
//!
//! VectorCraft draws sRGB values. Apple's documentation of `CAMetalLayer.colorspace` says: "When a
//! color space is present, Core Animation performs any necessary color space transformations when
//! compositing this content. The default value is `nil`, indicating that the rendered content
//! isn’t color-matched"
//! (<https://developer.apple.com/documentation/quartzcore/cametallayer/colorspace>). Without a
//! color space, a wide-gamut display shows the sRGB values as its own, so `#FF0000` is drawn as
//! the display's most saturated red, brighter than the same color in an exported PNG.
//!
//! wgpu 30 sets the layer's color space to nil each time it configures the surface: at start, on
//! a resize and when the window moves to a display with another scale (gfx-rs/wgpu#10013). egui
//! configures the surface while it paints a frame, after [`eframe::App::ui`] has run, so [`tag`]
//! adds a paint callback that draws nothing. egui calls it after the configuration and before the
//! frame is presented, and it sets sRGB on every window's Metal layer that has no color space. A
//! layer that already has a color space keeps it.
//!
//! Reading a layer's sublayers has no safe API, hence the scoped `unsafe_code` allowance.
#![allow(unsafe_code)]
use eframe::egui_wgpu::{self, CallbackResources, CallbackTrait, wgpu};
use objc2::MainThreadMarker;
use objc2::rc::Retained;
use objc2::{DowncastTarget, Message};
use objc2_app_kit::NSApplication;
use objc2_core_graphics::{CGColorSpace, kCGColorSpaceSRGB};
use objc2_quartz_core::{CALayer, CAMetalLayer};
/// Adds the callback that tags the windows' Metal layers to the frame `ui` draws: a point in its
/// top-left corner, in the background layer, with nothing drawn.
pub fn tag(ui: &egui::Ui) {
let ctx = ui.ctx();
let rect = egui::Rect::from_min_size(ctx.content_rect().min, egui::vec2(1.0, 1.0));
ctx.layer_painter(egui::LayerId::background()).add(egui_wgpu::Callback::new_paint_callback(rect, TagLayers));
}
struct TagLayers;
impl CallbackTrait for TagLayers {
fn paint(&self, _: egui::PaintCallbackInfo, _: &mut wgpu::RenderPass<'static>, _: &CallbackResources) {
// AppKit's windows and views are read on the main thread only, where eframe paints.
let Some(mtm) = MainThreadMarker::new() else {
return;
};
for window in NSApplication::sharedApplication(mtm).windows() {
if let Some(root) = window.contentView().and_then(|view| view.layer()) {
for layer in metal_layers(&root) {
tag_srgb(&layer);
}
}
}
}
}
/// The Metal layers that draw a view whose layer is `root`: `root` itself, or the sublayers wgpu
/// adds to it when it is a plain layer.
fn metal_layers(root: &CALayer) -> Vec<Retained<CAMetalLayer>> {
if let Some(layer) = downcast(root) {
return vec![layer];
}
// SAFETY: the returned array is a copy; the layers in it are retained while it lives.
let Some(sublayers) = (unsafe { root.sublayers() }) else {
return Vec::new();
};
sublayers.iter().filter_map(|layer| downcast(&layer)).collect()
}
fn downcast<T: DowncastTarget>(layer: &CALayer) -> Option<Retained<T>> {
layer.retain().downcast::<T>().ok()
}
/// Sets sRGB as `layer`'s color space when it has none. Returns whether it was set.
fn tag_srgb(layer: &CAMetalLayer) -> bool {
if layer.colorspace().is_some() {
return false;
}
// SAFETY: a constant that CoreGraphics exports.
let Some(srgb) = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceSRGB })) else {
return false;
};
layer.setColorspace(Some(&srgb));
true
}
#[cfg(test)]
mod tests {
use objc2_core_foundation::CFString;
use objc2_core_graphics::kCGColorSpaceDisplayP3;
use super::*;
fn space_name(layer: &CAMetalLayer) -> Option<String> {
CGColorSpace::name(layer.colorspace().as_deref()).map(|name| name.to_string())
}
fn name(constant: &CFString) -> String {
constant.to_string()
}
/// A layer without a color space is tagged sRGB once; the second call leaves it as it is.
#[test]
fn an_untagged_layer_becomes_srgb() {
let layer = CAMetalLayer::new();
assert_eq!(space_name(&layer), None, "a new layer has no color space");
assert!(tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
assert!(!tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceSRGB })));
}
/// A layer with a color space of its own keeps it.
#[test]
fn a_layer_with_a_color_space_keeps_it() {
let layer = CAMetalLayer::new();
let p3 = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })).unwrap();
layer.setColorspace(Some(&p3));
assert!(!tag_srgb(&layer));
assert_eq!(space_name(&layer), Some(name(unsafe { kCGColorSpaceDisplayP3 })));
}
/// The Metal layer is found as the view's layer or as one of its sublayers, and only Metal
/// layers are returned.
#[test]
fn metal_layers_are_found_under_the_view_layer() {
let metal = CAMetalLayer::new();
assert_eq!(metal_layers(&metal), vec![metal.clone()]);
let root = CALayer::new();
assert!(metal_layers(&root).is_empty());
root.addSublayer(&CALayer::new());
root.addSublayer(&metal);
assert_eq!(metal_layers(&root), vec![metal.clone()]);
for layer in metal_layers(&root) {
tag_srgb(&layer);
}
assert_eq!(space_name(&metal), Some(name(unsafe { kCGColorSpaceSRGB })));
}
}
+32 -17
View File
@@ -15,13 +15,6 @@
//! keyboard isn't confined.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#[cfg(all(feature = "windows7", any(feature = "wgpu", feature = "accessibility")))]
compile_error!("windows7 requires --no-default-features (wgpu and accessibility must be disabled)");
#[cfg(all(windows, feature = "windows7", not(target_vendor = "win7")))]
compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinary Windows target still imports newer APIs");
#[cfg(all(target_vendor = "win7", not(feature = "windows7")))]
compile_error!("the win7 target requires --no-default-features --features windows7");
mod automation_args;
mod clipboard;
#[cfg(feature = "wgpu")]
@@ -33,6 +26,8 @@ mod gpu;
#[cfg(target_os = "linux")]
mod linux_stylus;
mod logging;
#[cfg(all(target_os = "macos", feature = "wgpu"))]
mod mac_color_space;
#[cfg(target_os = "macos")]
mod mac_fonts;
#[cfg(target_os = "macos")]
@@ -50,7 +45,7 @@ mod open_documents;
mod portal_save;
mod prefs_dir;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
mod system_fonts;
mod tablet;
mod window;
@@ -133,7 +128,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).
@@ -190,7 +185,7 @@ fn main() -> std::process::ExitCode {
let logger = logging::install();
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
// Before the first font scan (the app's start): the fonts font services load (#579).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
system_fonts::install();
#[cfg(target_os = "macos")]
mac_fonts::install();
@@ -295,7 +290,7 @@ fn main() -> std::process::ExitCode {
// With Windows Ink, winit reports a pen as a touch and drops its barrel button and which end
// is down; the message hook notes each pointer contact and whether it starts with the barrel
// button held, and writes the pen's end into the pen feed (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
let options = {
use winit::platform::windows::EventLoopBuilderExtWindows as _;
let mut options = options;
@@ -318,8 +313,9 @@ fn main() -> std::process::ExitCode {
// Metal on an Iris Pro, #651). `WGPU_VALIDATION_INDIRECT_CALL=1` turns it back on.
create.instance_descriptor.flags =
(eframe::wgpu::InstanceFlags::from_build_config() - eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
create.instance_descriptor.backends =
left_out.backends(gpu::backends(create.instance_descriptor.backends, std::env::var_os("WGPU_BACKEND").is_some()));
let env_set = std::env::var_os("WGPU_BACKEND").is_some();
let opengl = cfg!(windows) && !env_set && gpu::windows_opengl(&left_out);
create.instance_descriptor.backends = left_out.backends(gpu::backends(create.instance_descriptor.backends, env_set, opengl));
// DX12 shaders compile with FXC, part of Windows: wgpu's default loads whichever
// dxcompiler.dll the search path finds, and one without its dxil.dll fails every shader,
// losing the device before the first frame (#1194). `WGPU_DX12_COMPILER` picks another.
@@ -436,10 +432,6 @@ fn main() -> std::process::ExitCode {
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
}
#[cfg(feature = "windows7")]
{
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
}
app.custom_titlebar = custom_titlebar;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
@@ -494,5 +486,28 @@ fn main() -> std::process::ExitCode {
}
}
/// Tests only: while a test starts another program, the child briefly holds a copy of every file
/// this process has open, until it runs that program. A file lock held through such a copy outlives
/// its own handle, so a GPU start test that released the start file and opened it again found it
/// still locked, as if another copy of the app were running, and failed. A test that starts a
/// program holds [`spawning`]; a test that depends on the start file's lock holds [`no_spawns`].
#[cfg(all(
test,
any(feature = "wgpu", any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))
))]
static CHILD_SPAWN: std::sync::RwLock<()> = std::sync::RwLock::new(());
/// Held while a test starts another program (see [`CHILD_SPAWN`]).
#[cfg(all(test, any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))]
fn spawning() -> std::sync::RwLockWriteGuard<'static, ()> {
CHILD_SPAWN.write().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Held by a test that releases a locked file and opens it again (see [`CHILD_SPAWN`]).
#[cfg(all(test, feature = "wgpu"))]
fn no_spawns() -> std::sync::RwLockReadGuard<'static, ()> {
CHILD_SPAWN.read().unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(all(test, feature = "wgpu"))]
mod tests;
+98 -37
View File
@@ -169,28 +169,37 @@ pub fn pick(pick: &FilePick, window: Option<&winit::window::Window>) -> Option<V
fn save_file(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
use std::sync::atomic::{AtomicU32, Ordering};
use zbus::blocking::Proxy;
use zbus::blocking::{MessageIterator, Proxy};
use zbus::zvariant::OwnedObjectPath;
const DESKTOP: &str = "org.freedesktop.portal.Desktop";
const REQUEST: &str = "org.freedesktop.portal.Request";
static NEXT: AtomicU32 = AtomicU32::new(0);
let sender = conn.unique_name().ok_or("no name on the session bus")?.as_str().trim_start_matches(':').replace('.', "_");
let token = format!("vectorcraft_save_{}_{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed));
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
// Listen before asking, so that an answer that comes at once is not missed.
let mut replies = Proxy::new(conn, DESKTOP, path.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?;
// Listen before asking, on every request path: a portal older than version 0.9 ignores
// `handle_token` and answers on the request path it returns, and its answer can come before
// the call's reply. The stream keeps every `Response` from the time it is made.
let rule = zbus::MatchRule::builder()
.msg_type(zbus::message::Type::Signal)
.sender(DESKTOP)
.and_then(|b| b.interface(REQUEST))
.and_then(|b| b.member("Response"))
.map_err(|e| e.to_string())?
.build();
let responses = MessageIterator::for_match_rule(rule, conn, None).map_err(|e| e.to_string())?;
let chooser = Proxy::new(conn, DESKTOP, "/org/freedesktop/portal/desktop", "org.freedesktop.portal.FileChooser").map_err(|e| e.to_string())?;
// The title is empty, as rfd sends it for a save.
let handle: OwnedObjectPath = chooser.call("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
// A portal older than version 0.9 ignores `handle_token` and answers on the request path it
// returns.
let reply = if handle.as_str() == path {
replies.next()
} else {
Proxy::new(conn, DESKTOP, handle.as_str(), REQUEST).and_then(|p| p.receive_signal("Response")).map_err(|e| e.to_string())?.next()
}
.ok_or("the save dialog closed without an answer")?;
let call = chooser.call_method("SaveFile", &(request.parent.as_str(), "", options(request, &token))).map_err(|e| e.to_string())?;
let handle: OwnedObjectPath = call.body().deserialize().map_err(|e| e.to_string())?;
// The answer is the `Response` on the path returned, from the connection that returned it.
let portal = call.header().sender().map(|s| s.as_str().to_string());
let reply = responses
.filter_map(Result::ok)
.find(|m| {
let header = m.header();
header.path().map(|p| p.as_str()) == Some(handle.as_str()) && header.sender().map(|s| s.as_str()) == portal.as_deref()
})
.ok_or("the save dialog closed without an answer")?;
let (code, results): (u32, HashMap<String, OwnedValue>) = reply.body().deserialize().map_err(|e| e.to_string())?;
answer(code, &results)
}
@@ -420,20 +429,34 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
type Seen = std::sync::Arc<std::sync::Mutex<Vec<(Option<String>, Option<String>)>>>;
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script`, as PhotoCraft's portal tests run it. With `own_paths`
/// it is a portal older than 0.9, which answers on a request path of its own. `None` when there
/// is no `dbus-daemon` to run.
/// How the mock portal answers `SaveFile`.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn mock_portal(script: Script, own_paths: bool) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
#[derive(Clone, Copy, PartialEq, Eq)]
enum Portal {
/// On the request path that `handle_token` names, after its reply.
Current,
/// As a portal older than 0.9: on a request path of its own, 300 ms after its reply, as a
/// person answers the dialog.
Old,
/// As `Old`, with the `Response` sent before the reply that names its path.
OldEarly,
}
/// A session bus of its own (`dbus-daemon`) with a mock `org.freedesktop.portal.FileChooser`
/// that answers `SaveFile` from `script` as `portal` says, as PhotoCraft's portal tests run it.
/// `None` when there is no `dbus-daemon` to run.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn mock_portal(script: Script, portal: Portal) -> Option<(zbus::blocking::Connection, Seen, std::process::Child)> {
use std::io::BufRead as _;
use zbus::zvariant::OwnedObjectPath;
let mut daemon = std::process::Command::new("dbus-daemon")
let spawning = crate::spawning();
let daemon = std::process::Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(std::process::Stdio::piped())
.spawn()
.ok()?;
.spawn();
drop(spawning);
let mut daemon = daemon.ok()?;
let mut address = String::new();
std::io::BufReader::new(daemon.stdout.take()?).read_line(&mut address).ok()?;
let address = address.trim().to_string();
@@ -441,9 +464,12 @@ mod tests {
zbus::blocking::connection::Builder::address(address.as_str()).ok()?.name("org.freedesktop.portal.Desktop").ok()?.build().ok()?;
let seen = Seen::default();
let log = seen.clone();
// Made before the client exists: the connection drops a call that comes while no stream
// reads it.
let incoming = zbus::blocking::MessageIterator::from(&server);
std::thread::spawn(move || {
let mut script = script.into_iter();
for msg in zbus::blocking::MessageIterator::from(&server) {
for msg in incoming {
let Ok(msg) = msg else { break };
let header = msg.header();
if header.message_type() != zbus::message::Type::MethodCall || header.member().map(|m| m.as_str()) != Some("SaveFile") {
@@ -460,16 +486,12 @@ mod tests {
l.len()
});
let Some(sender) = header.sender().map(|s| s.as_str().trim_start_matches(':').replace('.', "_")) else { break };
let token = if own_paths { format!("old{}", calls.unwrap_or_default()) } else { text("handle_token").unwrap_or_default() };
let token = match portal {
Portal::Current => text("handle_token").unwrap_or_default(),
Portal::Old | Portal::OldEarly => format!("old{}", calls.unwrap_or_default()),
};
let path = format!("/org/freedesktop/portal/desktop/request/{sender}/{token}");
let Ok(handle) = OwnedObjectPath::try_from(path.as_str()) else { break };
if server.reply(&header, &handle).is_err() {
break;
}
if own_paths {
// As a person answers the dialog: after the caller listened on the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
let (code, pick) = script.next().unwrap_or((2, None));
let mut results: HashMap<&str, Value<'_>> = HashMap::new();
if let Some((uri, label)) = pick {
@@ -477,7 +499,18 @@ mod tests {
results.insert("current_filter", Value::from((label.to_string(), vec![(0u32, "*.x".to_string())])));
}
let to = header.sender().cloned().map(zbus::names::BusName::from);
if server.emit_signal(to, path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, results)).is_err() {
let respond = || server.emit_signal(to.clone(), path.as_str(), "org.freedesktop.portal.Request", "Response", &(code, &results));
if portal == Portal::OldEarly && respond().is_err() {
break;
}
if server.reply(&header, &handle).is_err() {
break;
}
if portal == Portal::Old {
// As a person answers the dialog: after the caller has the path returned.
std::thread::sleep(std::time::Duration::from_millis(300));
}
if portal != Portal::OldEarly && respond().is_err() {
break;
}
}
@@ -486,20 +519,32 @@ mod tests {
Some((client, seen, daemon))
}
/// [`save_file`] on `conn`, given 30 seconds to answer: an answer that is never awaited fails
/// the test instead of leaving it waiting.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
fn save_within(conn: &zbus::blocking::Connection, request: &SaveRequest) -> Result<Option<Saved>, String> {
let (tx, rx) = std::sync::mpsc::channel();
let (conn, request) = (conn.clone(), request.clone());
std::thread::spawn(move || {
let _ = tx.send(save_file(&conn, &request));
});
rx.recv_timeout(std::time::Duration::from_secs(30)).expect("the portal's answer was not awaited within 30 seconds")
}
/// Through a real D-Bus: the call starts the dialog on the first file type with the suggested
/// name, and the answer names the file and the type picked; Cancel answers no file.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_portal_is_asked_on_the_first_file_type_and_answers_the_one_picked() {
let script = vec![(0, Some(("file:///home/me/Art/poster", "PDF-compatible .ai"))), (1, None)];
let Some((conn, seen, mut daemon)) = mock_portal(script, false) else {
let Some((conn, seen, mut daemon)) = mock_portal(script, Portal::Current) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
let saved = save_file(&conn, &request).unwrap();
let saved = save_within(&conn, &request).unwrap();
assert_eq!(pick_answer(saved, &request), ["/home/me/Art/poster", "ai"]);
assert_eq!(save_file(&conn, &request), Ok(None));
assert_eq!(save_within(&conn, &request), Ok(None));
let seen = seen.lock().map(|s| s.clone()).unwrap_or_default();
assert_eq!(seen.first(), Some(&(Some("Untitled-1.vectorcraft".to_string()), Some(request.filters[0].0.clone()))));
let _ = daemon.kill();
@@ -510,12 +555,28 @@ mod tests {
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn the_answer_is_awaited_on_the_path_the_portal_returns() {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], true) else {
let Some((conn, _seen, mut daemon)) = mock_portal(vec![(0, Some(("file:///tmp/card", "SVG")))], Portal::Old) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_file(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
let _ = daemon.kill();
}
/// A portal older than 0.9 can answer before its reply names the request path: the answer is
/// kept until the path is known, and the next dialog does not take it.
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))]
#[test]
fn an_answer_that_comes_before_the_path_is_kept() {
let script = vec![(0, Some(("file:///tmp/card", "SVG"))), (1, None)];
let Some((conn, _seen, mut daemon)) = mock_portal(script, Portal::OldEarly) else {
eprintln!("no dbus-daemon: skipped");
return;
};
let request = request();
assert_eq!(pick_answer(save_within(&conn, &request).unwrap(), &request), ["/tmp/card", "svg"]);
assert_eq!(save_within(&conn, &request), Ok(None));
let _ = daemon.kill();
}
+184 -8
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{VectorcraftApp, fileio, prefs_dir};
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
@@ -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);
}
}
+3 -11
View File
@@ -1,15 +1,8 @@
//! Desktop printer transport. VectorCraft composes every page and copy before submission;
//! craft-print owns discovery and spooling. Windows 7 keeps its viewer-based transport because
//! Windows.Data.Pdf is unavailable there.
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
//! craft-print owns discovery and spooling.
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, PrintService, Printer};
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
mod windows7;
#[cfg(all(windows, any(feature = "windows7", target_vendor = "win7")))]
use windows7 as system;
pub struct SystemPrint;
impl PrintService for SystemPrint {
fn printers(&mut self) -> Vec<Printer> {
@@ -36,9 +29,8 @@ impl PrintService for SystemPrint {
}
}
#[cfg(not(all(windows, any(feature = "windows7", target_vendor = "win7"))))]
mod system {
use super::*;
use super::{Command, PrintJob, Printer};
pub fn printers() -> Vec<Printer> {
craft_print::printers().into_iter().map(|p| Printer { name: p.name, default: p.default }).collect()
}
@@ -95,7 +87,7 @@ mod system {
}
}
#[cfg(all(test, windows, not(any(feature = "windows7", target_vendor = "win7"))))]
#[cfg(all(test, windows))]
mod tests {
use super::system::setup_command;
use std::ffi::OsStr;
-129
View File
@@ -1,129 +0,0 @@
//! The legacy viewer print verb is needed on Windows 7, where Windows.Data.Pdf is absent.
use std::path::PathBuf;
use std::process::Command;
use vectorcraft_ui_egui::print::{PrintJob, Printer};
/// Where a job's PDF waits for the spooler: a fresh file in the temp folder. Files of jobs more
/// than an hour old (the print verb reads them after Print returns) are removed first.
fn job_file(pdf: &[u8]) -> Result<PathBuf, String> {
let dir = std::env::temp_dir().join("vectorcraft-print");
std::fs::create_dir_all(&dir).map_err(|e| format!("can't make {}: {e}", dir.display()))?;
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let old = e.metadata().and_then(|m| m.modified()).ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600);
if old {
// Another job may still be reading it: leave it for next time.
let _ = std::fs::remove_file(e.path());
}
}
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let path = dir.join(format!("job-{}-{stamp}.pdf", std::process::id()));
// The viewer reads asynchronously, so this cannot use immediate drop cleanup. Never
// truncate an existing path, and remove incomplete files or refused launches immediately.
use std::io::Write as _;
let mut file =
std::fs::OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't create {}: {e}", path.display()))?;
if let Err(e) = file.write_all(pdf).and_then(|_| file.flush()) {
drop(file);
let _ = std::fs::remove_file(&path);
return Err(format!("can't write {}: {e}", path.display()));
}
Ok(path)
}
/// Run `c` → its output, or its error message.
fn output(c: &mut Command) -> Result<String, String> {
let program = c.get_program().to_string_lossy().to_string();
let out = c.output().map_err(|e| format!("can't run {program}: {e}"))?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
Err(if err.is_empty() { format!("{program} failed ({})", out.status) } else { err })
}
/// PowerShell's `<Default>\t<Name>` lines → the printers.
fn windows_printers(text: &str) -> Vec<Printer> {
text.lines()
.filter_map(|l| l.trim_end_matches('\r').split_once('\t'))
.filter(|(_, name)| !name.trim().is_empty())
.map(|(default, name)| Printer { name: name.trim().to_string(), default: default.trim().eq_ignore_ascii_case("true") })
.collect()
}
/// `s` as a PowerShell single-quoted string.
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn powershell(script: &str) -> Command {
use std::os::windows::process::CommandExt as _;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let mut c = Command::new(
std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()))
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe"),
);
c.args([
"-NoProfile",
"-NonInteractive",
"-Command",
&format!("$ErrorActionPreference = 'Stop'; [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); {script}"),
])
.creation_flags(CREATE_NO_WINDOW);
c
}
pub fn printers() -> Vec<Printer> {
let script = "Get-WmiObject Win32_Printer | ForEach-Object { \"$($_.Default)`t$($_.Name)\" }";
output(&mut powershell(script)).map(|t| windows_printers(&t)).unwrap_or_default()
}
fn submit(job: &PrintJob, file: &std::path::Path) -> Result<String, String> {
// The PDF viewer's print verb reads the file after this returns: it stays for a while.
let path = ps_quote(&file.to_string_lossy());
let script = match job.printer {
Some(p) => format!(
"Start-Process -FilePath {path} -Verb PrintTo -ArgumentList {} -WindowStyle Hidden",
ps_quote(&format!("\"{}\"", p.replace('"', "")))
),
None => format!("Start-Process -FilePath {path} -Verb Print -WindowStyle Hidden"),
};
output(&mut powershell(&script)).map(|_| String::new()).map_err(|e| format!("no app prints PDF files here ({e})"))
}
pub fn setup(_printer: Option<&str>) -> Result<(), String> {
let mut command = {
let mut command = Command::new("control.exe");
command.arg("printers");
command
};
command.spawn().map(|_| ()).map_err(|e| e.to_string())
}
pub fn print(job: &PrintJob) -> Result<String, String> {
let file = job_file(job.pdf)?;
let result = submit(job, &file);
if result.is_err() {
// No viewer accepted the launch, so no asynchronous reader needs this document.
let _ = std::fs::remove_file(file);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn powershell_lines_and_quotes_are_preserved() {
assert_eq!(windows_printers("True\tOffice Laser\r\nFalse\t "), [Printer { name: "Office Laser".into(), default: true }]);
assert_eq!(ps_quote("it's.pdf"), "'it''s.pdf'");
}
#[test]
fn job_files_hold_the_pdf_for_the_async_viewer() {
let path = job_file(b"%PDF-1.7 test").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"%PDF-1.7 test");
std::fs::remove_file(path).unwrap();
}
}
+3 -3
View File
@@ -1,4 +1,6 @@
use super::*;
use super::window;
#[cfg(target_os = "linux")]
use super::xwayland;
/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets
/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches
@@ -113,8 +115,6 @@ pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
.with_title_shown(false)
.with_icon(app_icon())
.with_app_id("ai.storyteller.vectorcraft"),
#[cfg(feature = "windows7")]
renderer: eframe::Renderer::Glow,
..Default::default()
}
}
+2 -2
View File
@@ -16,7 +16,7 @@
//! mouse message that Windows did not make from a pen or a touch
//! (<https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages>)
//! clears the feed.
#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))]
#![cfg_attr(not(windows), allow(dead_code))]
use std::cell::RefCell;
use std::sync::OnceLock;
@@ -68,7 +68,7 @@ pub fn start() -> StylusFeed {
/// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id
/// and whether the barrel button was held, and writes the pen's pressure and end into the feed.
/// It never consumes the message.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[cfg(windows)]
#[allow(unsafe_code)]
pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
use windows::Win32::UI::Input::Pointer::{GetPointerPenInfo, POINTER_FLAG_INCONTACT, POINTER_PEN_INFO};
+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
+1
View File
@@ -6,3 +6,4 @@ allow-panic-in-tests = true
disallowed-types = [
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
]
cognitive-complexity-threshold = 30
+16 -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"
@@ -17,6 +14,11 @@
# To remove your names, delete your entry (or set fields empty) in a PR. The next
# contributors.py run, and the next build, drop them.
[people.el-j]
real_name = "Fabian Althaus"
display_name = "faff (el-j)"
sync_github_name = false
[people.lollinoo]
sync_github_name = true
@@ -169,3 +171,12 @@ sync_github_name = false
real_name = "Andrés Ortega Montoya"
display_name = "Andrés Ortega Montoya"
sync_github_name = false
[people.krosk93]
sync_github_name = true
[people.ashokram-si]
real_name = "Ashok Ram"
[people.dylanmtaylor]
sync_github_name = true
+1 -1
View File
@@ -1,5 +1,5 @@
use super::read::MAX_TREE_DEPTH;
use super::*;
use super::{Affine, Kind, Node, ObjId, Point, Rect, Stream, Tag};
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
/// and tables), in document pixels.
+32 -1
View File
@@ -1,5 +1,8 @@
use super::bounds::{describe, is_container, mask_extent, nodes_bounds, pages, rect, tree_bounds, union};
use super::*;
use super::{
Affine, Archive, BTreeMap, Blend, Document, Effect, Error, HashSet, Image, Kind, Limits, Node, ObjId, Paint, Path, Point, Rect, Spread, Stream,
Tag, paint, stream,
};
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
const MAX_PROFILE_BYTES: usize = 32 << 20;
@@ -438,8 +441,34 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
_ => self.warn("layer effects of other kinds (left out)"),
}
}
for a in s.objs(id, b"AdCh") {
if let Some(effect) = self.attached_recolor(a) {
out.push(effect);
}
}
out
}
fn attached_recolor(&mut self, a: ObjId) -> Option<Effect> {
let s = self.s;
if !s.is(a, b"RcRA") {
return None;
}
if s.bool(a, b"Visi") == Some(false) {
return None;
}
let adjp = s.obj(a, b"AdjP")?;
if !s.is(adjp, b"RecP") {
return None;
}
let h = s.f64(adjp, b"RecH").unwrap_or(0.0);
let sat = s.f64(adjp, b"RecS").unwrap_or(1.0);
let l = s.f64(adjp, b"RecL").unwrap_or(0.5);
let (r, g, b) = crate::paint::hsl(h, sat, l);
let color = crate::paint::Color::Rgb { r, g, b, a: 1.0 };
let opacity = s.f64(a, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
let blend = self.effect_blend(a);
Some(Effect::ColorOverlay { color, opacity, blend })
}
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
fn effect_blend(&mut self, e: ObjId) -> Blend {
@@ -476,6 +505,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
}
} else if class == Tag::of(b"RcRA") {
// Handled in `effects` as a colour overlay.
} else {
self.warn(format!("{} layers attached to other layers (imported without them)", describe(class)));
}
+15 -1
View File
@@ -194,7 +194,7 @@ fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option<Color> {
})
}
fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
pub(crate) fn hsl(h: f64, s: f64, l: f64) -> (f64, f64, f64) {
let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
let hp = (h.rem_euclid(1.0)) * 6.0;
let x = c * (1.0 - (hp % 2.0 - 1.0).abs());
@@ -355,3 +355,17 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
}
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hsl_lightness_1_is_white_and_0_is_black() {
assert_eq!(hsl(0.0, 1.0, 1.0), (1.0, 1.0, 1.0));
assert_eq!(hsl(0.5, 1.0, 1.0), (1.0, 1.0, 1.0));
assert_eq!(hsl(0.0, 1.0, 0.0), (0.0, 0.0, 0.0));
let (r, g, b) = hsl(0.0, 1.0, 0.5);
assert!((r - 1.0).abs() < 1e-6 && g.abs() < 1e-6 && b.abs() < 1e-6);
}
}
+48
View File
@@ -343,6 +343,44 @@ pub enum PatternFit {
Approximate,
}
/// What a pattern brush draws at a kind of corner it has no corner art for: no tile, or a tile
/// made from the side tile.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum CornerSource {
/// No corner tile: the side tiles run up to the corner.
None,
/// One side tile stretched around the corner and centered on it.
AutoCentered,
/// A side tile on each side reaching into the corner, stretched so the two meet on the
/// corner's diagonal.
AutoBetween,
/// A side tile on each side cut on the corner's diagonal, the two meeting like a mitered frame.
AutoSliced,
/// A side tile on each side running past the corner, the two overlapping there.
AutoOverlap,
}
impl CornerSource {
pub const ALL: [CornerSource; 5] =
[CornerSource::None, CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap];
/// The id in a brush definition and in `brush.options` params.
pub fn id(self) -> &'static str {
match self {
CornerSource::None => "none",
CornerSource::AutoCentered => "autoCentered",
CornerSource::AutoBetween => "autoBetween",
CornerSource::AutoSliced => "autoSliced",
CornerSource::AutoOverlap => "autoOverlap",
}
}
pub fn parse(s: &str) -> Option<CornerSource> {
CornerSource::ALL.into_iter().find(|a| a.id() == s)
}
}
/// Pattern brush: tiles repeated along the path, with corner and end tiles.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -352,6 +390,14 @@ pub struct PatternBrush {
pub outer_corner: Option<Node>,
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_corner: Option<Node>,
/// 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")]
pub outer_source: Option<CornerSource>,
/// What the inner corners take when there is no `inner_corner` art. Without art or a source,
/// they take the `outer_corner` art.
#[serde(skip_serializing_if = "Option::is_none")]
pub inner_source: Option<CornerSource>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start: Option<Node>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -378,6 +424,8 @@ impl Default for PatternBrush {
side: defaults::stitch_tile(),
outer_corner: None,
inner_corner: None,
outer_source: None,
inner_source: None,
start: None,
end: None,
scale: 100.0,
+487 -22
View File
@@ -1,6 +1,6 @@
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{BezPath, Point, Rect, shapes};
use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes};
use super::*;
@@ -27,10 +27,16 @@ fn near(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
/// Points along every outline under `n`, its anchors among them. Bent art keeps its curves and
/// long lines since #3926, so its anchors alone no longer trace it.
fn all_points(n: &Node, out: &mut Vec<Point>) {
n.walk(&mut |c| {
if let Some(p) = c.path_data() {
out.extend(p.anchors().map(|(_, _, a)| a.p));
for sp in c.path_data().into_iter().flat_map(|p| &p.subpaths) {
out.extend(sp.anchors.iter().map(|a| a.p));
for i in 0..sp.segment_count() {
let seg = sp.segment(i);
out.extend((1..32).map(|j| kurbo::ParamCurve::eval(&seg, j as f64 / 32.0)));
}
}
});
}
@@ -185,9 +191,12 @@ fn art_brush_follows_curves() {
}
}
let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0));
// The long edges bend as curves (#3926), where they used to be cut into some 400 chords. The
// path is run along in 0.05 pt chords, which the curves follow, so a few dozen remain.
let (anchors, curved) = anchors_and_curved(&out[0]);
assert!(anchors < 100 && curved > anchors / 2, "long edges bend as curves: {curved} of {anchors} anchors with handles");
let mut pts = vec![];
all_points(&out[0], &mut pts);
assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len());
for p in pts {
let r = p.distance(Point::new(100.0, 100.0));
assert!((r - 100.0).abs() <= 2.0 + 0.3, "point {p:?} is {r} from the centre");
@@ -273,15 +282,362 @@ fn pattern_tile_count_by_fit() {
fn pattern_places_corner_tiles() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0));
let corners = out
.iter()
.filter(
|n| matches!(&n.kind, NodeKind::Path { path, .. } if path.subpaths[0].anchors.len() == 4 && n.geometric_bounds().unwrap().width() < 7.0),
)
.count();
assert_eq!(corners, 4);
// The corner tile is an ellipse and the side tile a rectangle, so a kept handle tells them
// apart. Anchor count no longer does: the side tiles keep their own four anchors now that a
// straight run is not flattened (#3926), where they used to arrive as many-anchor polylines.
let curved = |n: &Node| {
matches!(&n.kind, NodeKind::Path { path, .. }
if path.subpaths.iter().any(|s| s.anchors.iter().any(|a| a.h_in != a.p || a.h_out != a.p)))
};
let corners = out.iter().filter(|n| curved(n)).count();
assert_eq!(corners, 4, "the four corner ellipses, still curves");
// Sides lose half a corner tile at each end: 94 pt per side → 9 tiles each.
assert_eq!(out.len() - corners, 36);
for n in out.iter().filter(|n| !curved(n)) {
let NodeKind::Path { path, .. } = &n.kind else { continue };
assert_eq!(path.subpaths[0].anchors.len(), 4, "a rectangle tile on a straight run stays four anchors");
}
}
/// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art.
fn auto_corner_brush(outer: Option<CornerSource>, inner: Option<CornerSource>) -> Brush {
let mut b = pattern_brush(PatternFit::Stretch, false);
if let BrushKind::Pattern(p) = &mut b.kind {
p.outer_source = outer;
p.inner_source = inner;
}
b
}
const AUTO: [CornerSource; 4] = [CornerSource::AutoCentered, CornerSource::AutoBetween, CornerSource::AutoSliced, CornerSource::AutoOverlap];
/// Whether `n` paints a fill over `q`: inside one of its paths, and inside the mask of every
/// clipping group it is in.
fn covers(n: &Node, q: Point) -> bool {
let inside = |n: &Node| n.path_data().is_some_and(|p| kurbo::Shape::winding(&p.to_bezpath(), q) != 0);
match &n.kind {
NodeKind::Group { children, clip: true } => children.first().is_some_and(|m| inside(m)) && children.iter().skip(1).any(|c| covers(c, q)),
NodeKind::Path { .. } => inside(n),
_ => n.children().is_some_and(|c| c.iter().any(|c| covers(c, q))),
}
}
fn points_of(nodes: &[Node]) -> Vec<Point> {
let mut pts = vec![];
for n in nodes {
all_points(n, &mut pts);
}
pts
}
/// The corners of a 100 pt square and, for each, the outer corner of the 4 pt band around it.
const SQUARE_CORNERS: [((f64, f64), (f64, f64)); 4] =
[((0.0, 0.0), (-2.0, -2.0)), ((100.0, 0.0), (102.0, -2.0)), ((100.0, 100.0), (102.0, 102.0)), ((0.0, 100.0), (-2.0, 102.0))];
/// Whether some point of `pts` is within `tol` of `(x, y)`.
fn reaches(pts: &[Point], (x, y): (f64, f64), tol: f64) -> bool {
pts.iter().any(|p| p.distance(Point::new(x, y)) <= tol)
}
/// Distance from `p` to the outline of the square (0, 0)–(100, 100).
fn from_square_outline(p: Point) -> f64 {
let dx = (p.x - p.x.clamp(0.0, 100.0)).abs();
let dy = (p.y - p.y.clamp(0.0, 100.0)).abs();
if dx > 0.0 || dy > 0.0 { dx.hypot(dy) } else { p.x.min(100.0 - p.x).min(p.y).min(100.0 - p.y) }
}
#[test]
fn pattern_auto_corners_fill_each_corner_of_a_square() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let mut outputs = vec![];
for kind in AUTO {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &sq, &stroke(1.0));
let pts = points_of(&out);
for (corner, outside) in SQUARE_CORNERS {
assert!(reaches(&pts, outside, 0.8), "{kind:?}: the art reaches the band's outer corner at {corner:?}");
}
let far = pts.iter().copied().map(from_square_outline).fold(0.0, f64::max);
assert!(far <= 2.0 * 2f64.sqrt() + 0.05, "{kind:?}: every point stays in the 4 pt band, {far}");
// The band is filled up to each corner along both of its sides.
let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y)));
for ((cx, cy), _) in SQUARE_CORNERS {
let (sx, sy) = ((50.0 - cx).signum(), (50.0 - cy).signum());
for d in [0.5, 2.5, 4.5, 6.5, 8.5] {
assert!(filled(cx + sx * d, cy) && filled(cx, cy + sy * d), "{kind:?}: filled {d} pt from ({cx}, {cy})");
}
}
outputs.push(out);
}
for i in 0..outputs.len() {
for j in i + 1..outputs.len() {
assert_ne!(outputs[i], outputs[j], "{:?} and {:?} draw differently", AUTO[i], AUTO[j]);
}
}
}
/// A side tile three times as tall as it is long still fills a square's corners without a gap:
/// the two sides meet on the corner's diagonal.
#[test]
fn pattern_auto_corners_of_a_tall_tile_meet_on_the_diagonal() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)).to_bezpath();
for kind in AUTO {
let mut b = auto_corner_brush(Some(kind), Some(kind));
if let BrushKind::Pattern(p) = &mut b.kind {
p.side = rect_art(10.0, 30.0);
}
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
let filled = |x: f64, y: f64| out.iter().any(|n| covers(n, Point::new(x, y)));
for (cx, cy) in [(0.0_f64, 0.0_f64), (200.0, 0.0), (200.0, 200.0), (0.0, 200.0)] {
let (sx, sy) = ((100.0 - cx).signum(), (100.0 - cy).signum());
for d in [1.0, 5.0, 10.0, 14.0] {
// Along the diagonal, outside and inside the square, and along both sides.
assert!(filled(cx - sx * d, cy - sy * d), "{kind:?}: outside ({cx}, {cy}) by {d}");
assert!(filled(cx + sx * d, cy + sy * d), "{kind:?}: inside ({cx}, {cy}) by {d}");
assert!(filled(cx + sx * 3.0 * d, cy) && filled(cx, cy + sy * 3.0 * d), "{kind:?}: {} pt from ({cx}, {cy})", 3.0 * d);
}
}
}
}
/// Where a curve meets a sharp corner, the corner tile reaches the side tiles on the curve: the
/// middle of the band is filled all the way along the path.
#[test]
fn pattern_auto_corners_join_a_curve_into_the_corner() {
// A straight run into a quarter circle of radius 10, then a sharp turn.
let mut bp = BezPath::new();
bp.move_to((-50.0, 0.0));
bp.line_to((10.0, 0.0));
bp.curve_to((15.52, 0.0), (20.0, 4.48), (20.0, 10.0));
bp.line_to((-80.0, 10.0));
let track = &crate::track::tracks(&bp, 0.05)[0];
let l = track.len();
for kind in AUTO {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
let mut s = 0.25;
while s < l - 0.25 {
let q = track.map(s, 0.0);
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} of {l:.2} ({q:?})");
s += 0.25;
}
}
}
/// Where a curve meets a sharp corner, the two halves of a corner tile still meet on the corner's
/// diagonal: the band is filled across its width near the corner, and both Auto-Between halves end
/// at the diagonal's two ends.
#[test]
fn pattern_auto_corners_after_a_curve_meet_across_the_band() {
// A quarter circle of radius 4 straight into a sharp turn.
let mut bp = BezPath::new();
bp.move_to((-50.0, 0.0));
bp.line_to((10.0, 0.0));
bp.curve_to((12.21, 0.0), (14.0, 1.79), (14.0, 4.0));
bp.line_to((-80.0, 4.0));
let track = &crate::track::tracks(&bp, 0.05)[0];
let corner = track.corner_positions().first().map(|c| c.1).unwrap();
for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
let mut s = corner - 3.0;
while s < corner + 3.0 {
for j in -19..=19 {
let y = j as f64 / 10.0;
let q = track.map(s, y);
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {s:.2} ({y} across, {q:?})");
}
s += 0.05;
}
}
// The path leaves the corner at (14, 4) heading left, so the 4 pt band's diagonal ends on the
// lines y = 2 and y = 6, where both Auto-Between halves end at the same point.
let both = auto_corner_brush(Some(CornerSource::AutoBetween), Some(CornerSource::AutoBetween));
let out = stroke_pieces(&Document::new(1.0, 1.0), &both, &bp, &stroke(1.0));
for edge in [2.0, 6.0] {
let on_edge: Vec<Vec<Point>> = out
.iter()
.map(|n| points_of(std::slice::from_ref(n)).into_iter().filter(|q| near(q.y, edge, 1e-6) && near(q.x, 14.0, 3.0)).collect())
.collect();
let shared = on_edge.iter().enumerate().any(|(i, a)| on_edge.iter().skip(i + 1).any(|b| a.iter().any(|p| reaches(b, (p.x, p.y), 1e-6))));
assert!(shared, "both halves end at the same point on y = {edge}");
}
}
/// On a path shorter than the side tile, a corner tile is squeezed into the path it has: it does
/// not run past the ends, nor past half the way to the next corner.
#[test]
fn pattern_auto_corners_stay_within_a_short_path() {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((2.0, 0.0));
bp.line_to((2.0, 2.0));
let mut zigzag = BezPath::new();
zigzag.move_to((0.0, 0.0));
for j in 1..=6 {
zigzag.line_to((3.0 * j as f64, if j % 2 == 1 { 3.0 } else { 0.0 }));
}
for kind in AUTO {
for (path, (x0, y0, x1, y1)) in [(&bp, (0.0, 0.0, 2.0, 2.0)), (&zigzag, (0.0, 0.0, 18.0, 3.0))] {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), path, &stroke(1.0));
assert!(!out.is_empty(), "{kind:?}: something is drawn");
// The band is 4 pt wide, and the diagonal of a sharp turn reaches further from it.
let reach = 2.0 * 3.0;
for p in points_of(&out) {
assert!(p.x >= x0 - reach && p.x <= x1 + reach && p.y >= y0 - reach && p.y <= y1 + reach, "{kind:?}: {p:?} is past the path's ends");
}
}
}
}
/// A corner squeezed into a path shorter than the band is wide still fills the band up to the
/// corner's diagonal from both sides, with no gap between the two halves.
#[test]
fn pattern_auto_corners_on_a_short_path_meet_on_the_diagonal() {
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((1.0, 0.0));
bp.line_to((1.0, 1.0));
for kind in [CornerSource::AutoCentered, CornerSource::AutoBetween] {
let out = stroke_pieces(&Document::new(1.0, 1.0), &auto_corner_brush(Some(kind), Some(kind)), &bp, &stroke(1.0));
// The diagonal runs from the corner (1, 0) to the band's outer corner (3, -2).
for f in [0.25, 0.5, 0.75] {
let (dx, dy) = (1.0 + 2.0 * f, -2.0 * f);
for q in [Point::new(dx - 0.05, dy), Point::new(dx, dy - 0.05)] {
assert!(out.iter().any(|n| covers(n, q)), "{kind:?}: the band is filled at {q:?}");
}
}
}
}
#[test]
fn pattern_without_corner_tiles_or_auto_corners_leaves_corners_empty() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let doc = Document::new(1.0, 1.0);
let plain = stroke_pieces(&doc, &auto_corner_brush(None, None), &sq, &stroke(1.0));
assert_eq!(plain.len(), 40, "ten side tiles a side");
let pts = points_of(&plain);
for (corner, outside) in SQUARE_CORNERS {
assert!(!reaches(&pts, outside, 1.5), "no art at the band's outer corner at {corner:?}");
}
// A square turns the same way at every corner, so only one of the two kinds of corner tile
// draws on it; the other leaves the stroke as it is without automatic corners.
let outer = stroke_pieces(&doc, &auto_corner_brush(Some(CornerSource::AutoBetween), None), &sq, &stroke(1.0));
let inner = stroke_pieces(&doc, &auto_corner_brush(None, Some(CornerSource::AutoBetween)), &sq, &stroke(1.0));
assert!((outer == plain) != (inner == plain), "exactly one kind of corner draws on a square");
}
#[test]
fn pattern_corner_art_wins_over_corner_sources() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let doc = Document::new(1.0, 1.0);
let saved = pattern_brush(PatternFit::Stretch, true);
let mut with_auto = saved.clone();
if let BrushKind::Pattern(p) = &mut with_auto.kind {
p.outer_source = Some(CornerSource::AutoSliced);
p.inner_source = Some(CornerSource::None);
}
assert_eq!(stroke_pieces(&doc, &with_auto, &sq, &stroke(1.0)), stroke_pieces(&doc, &saved, &sq, &stroke(1.0)));
}
/// A kind of corner without art takes the other kind's art, as saved brushes draw, unless its
/// source is None.
#[test]
fn pattern_corner_source_none_leaves_that_kind_of_corner_empty() {
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let doc = Document::new(1.0, 1.0);
let draw = |b: &Brush| stroke_pieces(&doc, b, &sq, &stroke(1.0));
let (saved, plain) = (draw(&pattern_brush(PatternFit::Stretch, true)), draw(&auto_corner_brush(None, None)));
let without = |outer: bool, source: Option<CornerSource>| {
let mut b = pattern_brush(PatternFit::Stretch, true);
if let BrushKind::Pattern(p) = &mut b.kind {
if outer {
(p.outer_corner, p.outer_source) = (None, source);
} else {
(p.inner_corner, p.inner_source) = (None, source);
}
}
draw(&b)
};
assert!(without(true, None) == saved && without(false, None) == saved, "the other kind's art fills in");
let (outer, inner) = (without(true, Some(CornerSource::None)), without(false, Some(CornerSource::None)));
assert!((outer == plain && inner == saved) || (inner == plain && outer == saved), "only the corners set to None are empty");
}
#[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();
assert!(saved.get("outer_source").is_none() && saved.get("inner_source").is_none(), "{saved}");
let BrushKind::Pattern(p) = Brush::from_json(saved).unwrap().kind else { panic!("a pattern brush") };
assert_eq!((p.outer_source, p.inner_source), (None, None));
let v = serde_json::to_value(auto_corner_brush(Some(CornerSource::AutoCentered), Some(CornerSource::None))).unwrap();
assert_eq!((v["outer_source"].as_str(), v["inner_source"].as_str()), (Some("autoCentered"), Some("none")));
let BrushKind::Pattern(p) = Brush::from_json(v).unwrap().kind else { panic!("a pattern brush") };
assert_eq!((p.outer_source, p.inner_source), (Some(CornerSource::AutoCentered), Some(CornerSource::None)));
}
/// Auto-Sliced masks each side tile at the corner's diagonal instead of cutting its paths, so a
/// tile's outline gains no stroke along the cut.
#[test]
fn pattern_auto_sliced_masks_stroked_tiles_at_the_diagonal() {
let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced));
if let BrushKind::Pattern(p) = &mut b.kind {
p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 0.5).into();
}
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
assert_eq!(clips.len(), 8, "two masked tiles at each corner");
for g in clips {
let kids = g.children().unwrap();
assert_eq!(kids.len(), 2);
assert!(kids[0].appearance.items.is_empty(), "the mask paints nothing");
let mask = kids[0].path_data().unwrap().to_bezpath();
let tile = kids[1].path_data().unwrap();
assert!(
tile.anchors().any(|(_, _, a)| kurbo::Shape::winding(&mask, a.p) == 0),
"the tile keeps its outline past the diagonal, where the mask hides it"
);
}
}
/// Auto-Sliced on a corner whose legs are too short for the side tile squeezes the tile along,
/// but not its stroke: the mask still takes in the stroke past the tile's far end.
#[test]
fn pattern_auto_sliced_masks_keep_the_stroke_of_a_squeezed_tile() {
let mut b = auto_corner_brush(Some(CornerSource::AutoSliced), Some(CornerSource::AutoSliced));
if let BrushKind::Pattern(p) = &mut b.kind {
p.side.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0).into();
}
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 8.0, 8.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &sq, &stroke(1.0));
let clips: Vec<&Node> = out.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
assert_eq!(clips.len(), 8, "two masked tiles at each corner");
let center = Point::new(4.0, 4.0);
for g in clips {
let kids = g.children().unwrap();
let mask = kids[0].path_data().unwrap().to_bezpath();
let half = kids[1].appearance.stroke_width() / 2.0;
assert!(half > 0.0);
let tile = kids[1].path_data().unwrap();
let anchors: Vec<Point> = tile.anchors().map(|(_, _, a)| a.p).collect();
let mid = anchors.iter().fold(Point::ZERO, |m, a| m + a.to_vec2() / anchors.len() as f64);
// The tile's corner, and its diagonal through the square's center; each mask keeps one
// side of the diagonal.
let p = [(0.0, 0.0), (8.0, 0.0), (8.0, 8.0), (0.0, 8.0)]
.map(|(x, y)| Point::new(x, y))
.into_iter()
.min_by(|a, b| a.distance(mid).total_cmp(&b.distance(mid)))
.unwrap();
let cross = |q: Point| (q - p).cross(center - p);
let side = cross(mid).signum();
for a in &anchors {
// The points of the stroke round the anchor, along and across the tile's leg.
for d in [(1.0, 0.0), (-1.0, 0.0), (0.0, 1.0), (0.0, -1.0)] {
let q = *a + kurbo::Vec2::new(d.0, d.1) * half * 0.9;
if cross(q) * side > 0.01 && (q - p).hypot() < 7.0 {
assert!(kurbo::Shape::winding(&mask, q) != 0, "the mask takes in the stroke at {q:?} (half width {half})");
}
}
}
}
}
#[test]
@@ -800,19 +1156,22 @@ fn pattern_output_is_capped_across_subpaths_not_only_per_run() {
let a1 = anchors(&one);
assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}");
// Sixteen tracks would be sixteen times that without a total budget.
// Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the
// budget must not bite before the per-factor caps have actually multiplied past it.
let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0));
assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece");
assert_eq!(anchors(&four), 4 * a1);
// Two tracks are well inside the budget, so nothing is cut: it must not bite before the
// per-factor caps have actually multiplied past it.
let two = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(2), &stroke(1.0));
assert_eq!(two.len(), 2 * 20_000, "two tracks are under the budget and keep every piece");
assert_eq!(anchors(&two), 2 * a1);
// Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps.
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0));
// Enough tracks that the per-run caps alone would pass the budget. Derived from what one
// track costs rather than written down, because how many anchors a tile holds is not fixed:
// a tile on a straight run keeps its own anchors instead of being flattened (#3926), which
// moved this by a factor of four.
let tracks = 2_000_000 / a1 + 2;
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(tracks), &stroke(1.0));
let an = anchors(&many);
assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}");
assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}");
assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
assert!(an <= 2_000_000, "{tracks} subpaths must not exceed the budget: {an}");
assert!(an > anchors(&two), "but more is drawn than two tracks' worth: {an}");
assert!(many.len() < tracks * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
}
/// The same for scatter, whose per-track guard is also 20,000 copies.
@@ -843,3 +1202,109 @@ fn an_ordinary_stroke_is_unaffected_by_the_budget() {
assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget");
assert!(anchors(&out) < 2_000_000);
}
/// Anchors under `n`, and how many of them keep a handle (so the segment is a curve).
fn anchors_and_curved(n: &Node) -> (usize, usize) {
let NodeKind::Path { path, .. } = &n.kind else { return (0, 0) };
let all: Vec<_> = path.subpaths.iter().flat_map(|s| s.anchors.iter()).collect();
(all.len(), all.iter().filter(|a| a.h_in != a.p || a.h_out != a.p).count())
}
/// #3926: a Pattern Brush on a straight path only moves, turns and scales its tile, so the
/// tile's curves survive. They used to be flattened into chords by the bending code, which is
/// visible in the stroke itself and leaves no handles after Expand Brush Strokes.
#[test]
fn a_pattern_tile_keeps_its_curves_on_a_straight_path() {
let circle =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let (tile_anchors, tile_curved) = anchors_and_curved(&circle);
assert_eq!((tile_anchors, tile_curved), (4, 4), "the tile is a 4-anchor smooth circle");
let brush = Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
};
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));
assert!(!out.is_empty(), "the brush drew something");
for n in &out {
assert_eq!(anchors_and_curved(n), (4, 4), "each circle is still a 4-anchor curve, not a polygon");
}
// The ink is in the same place: a circle tile still covers the path it was laid along.
let b = bounds(&out);
assert!(near(b.width(), 480.0, 1.0), "the tiles still span the path: {}", b.width());
assert!(near(b.height(), 40.0, 1.0), "and still stand the tile's height: {}", b.height());
}
/// The other half of the same behaviour: where the path really does bend, an affine cannot
/// reproduce the bend, so the tile is bent, and it still comes out as curves: cubics fitted to the
/// bent art rather than the chords it used to be flattened into (#3926). Here a 4-anchor circle
/// tile runs round a circle of radius 200, so its outline stays in the band 20 either side of it.
#[test]
fn a_pattern_tile_on_a_bent_path_keeps_its_curves() {
let circle =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let brush = Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush { side: circle, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
};
// A circle to run along: every tile sits on a curving section.
let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &round, &stroke(1.0));
assert!(out.len() > 20, "tiles all the way round: {}", out.len());
let (mut lo, mut hi) = (f64::MAX, f64::MIN);
for n in &out {
let (anchors, curved) = anchors_and_curved(n);
assert!((4..=16).contains(&anchors), "a bent circle tile is a few curves, not a polygon: {anchors} anchors");
assert!(curved == anchors, "every anchor keeps a handle: {curved} of {anchors}");
let mut pts = vec![];
all_points(n, &mut pts);
for q in pts {
let r = q.distance(Point::new(200.0, 200.0));
(lo, hi) = (lo.min(r), hi.max(r));
}
}
// The path is a cubic circle (0.06 out at most) run along in chords of 0.05, and the fit holds
// a quarter of the 0.25 flattening tolerance, so the band holds to 0.2.
assert!(lo > 179.8 && hi < 220.2, "the curves stay in the tile's band round the path: {lo:.3}..{hi:.3}");
assert!(lo < 180.2 && hi > 219.8, "and reach both edges of it: {lo:.3}..{hi:.3}");
}
/// The kind of every node in a piece of brushed art, in paint order.
fn kinds(n: &Node) -> Vec<&'static str> {
let mut out = vec![match &n.kind {
NodeKind::Path { .. } => "path",
NodeKind::Group { .. } => "group",
NodeKind::SymbolInstance { .. } => "symbol",
_ => "other",
}];
for c in n.children().into_iter().flatten() {
out.extend(kinds(c));
}
out
}
/// The straight-run route differs from the bending route in the tile's curves and in nothing else,
/// which includes the node kinds it keeps. A tile holding a kind neither route can map (here a
/// symbol instance, whose art lives in the symbol and not in the node) loses it on a straight run
/// exactly as it does on a bend: an affine is not a way for unmappable art to arrive untouched.
#[test]
fn a_straight_run_keeps_the_same_node_kinds_as_a_bend() {
let circle =
Node::path(NodeId(0), shapes::ellipse(Rect::new(0.0, -20.0, 40.0, 20.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
let sym = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: Affine::IDENTITY });
let tile = Node::group(NodeId(2), vec![Arc::new(circle), Arc::new(sym)]);
let brush = Brush {
name: "p".into(),
kind: BrushKind::Pattern(PatternBrush { side: tile, fit: PatternFit::Stretch, colorization: Colorization::None, ..Default::default() }),
};
let doc = Document::new(1.0, 1.0);
let straight = stroke_pieces(&doc, &brush, &line(0.0, 0.0, 480.0, 0.0), &stroke(1.0));
let round = shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let bent = stroke_pieces(&doc, &brush, &round, &stroke(1.0));
assert!(!straight.is_empty() && !bent.is_empty(), "both paths drew something");
for (what, out) in [("a straight run", &straight), ("a bend", &bent)] {
for n in out {
assert_eq!(kinds(n), ["group", "path"], "{what} keeps the path and drops the symbol instance");
}
}
}
+100
View File
@@ -29,6 +29,12 @@ fn unit(v: Vec2) -> Vec2 {
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { v / l }
}
/// Whether two unit vectors point the same way, to within the slack a unit vector carries after
/// a few multiplications (a bend of even a thousandth of a degree fails this).
fn aligned(a: Vec2, b: Vec2) -> bool {
a.dot(b) > 1.0 - 1e-12
}
/// Left-hand normal in y-down document space (for a path running +x it points +y).
pub fn normal(t: Vec2) -> Vec2 {
Vec2::new(-t.y, t.x)
@@ -154,6 +160,59 @@ impl Track {
let (p, t) = self.frame(s);
p + normal(t) * d
}
/// Whether [`Self::frame`] holds one constant tangent over arc lengths `s0..s1`, so mapping
/// through it is an affine of the art and nothing more.
///
/// Read off the track, not sampled. A bend in the span is two segments with different
/// directions, however short the bent part is, so this cannot miss one; a sampled check can,
/// because a wiggle narrower than the sample spacing passes between the samples (#3926).
///
/// Fails closed: a span that needs reasoning this does not do (a closed track's seam, a
/// non-finite bound, a track with no segments) is reported as not straight, and the caller
/// bends it the long way.
pub fn is_straight(&self, s0: f64, s1: f64) -> bool {
// Checked before the min/max, which quietly drop a NaN rather than propagate it.
let n = self.segments();
if !(s0.is_finite() && s1.is_finite()) || n == 0 {
return false;
}
let (lo, hi) = (s0.min(s1), s0.max(s1));
let l = self.len();
// A closed track wraps, so a span reaching past either end covers segments twice.
if self.closed && (lo < -1e-9 || hi > l + 1e-9) {
return false;
}
// Past an open track's ends `frame` extrapolates along the end segment's direction, so
// those segments are part of the span too.
let ends = [(!self.closed && lo < 0.0).then_some(0), (!self.closed && hi > l).then(|| n - 1)];
let touched = (0..n).filter(|&i| {
let (a, b) = (self.cum.get(i), self.cum.get(i + 1));
matches!((a, b), (Some(a), Some(b)) if *a < hi + 1e-9 && *b > lo - 1e-9)
});
let mut dir: Option<Vec2> = None;
let mut any = false;
for i in touched.chain(ends.into_iter().flatten()) {
let (Some(&d), Some(&t0), Some(&t1), Some(&c0), Some(&c1)) =
(self.dirs.get(i), self.tangents.get(i), self.tangents.get(i + 1), self.corner.get(i), self.corner.get(i + 1))
else {
return false;
};
if dir.is_some_and(|first: Vec2| !aligned(first, d)) {
return false;
}
dir = Some(d);
any = true;
// `frame` blends the two vertex tangents across the segment unless the vertex is a
// corner, where it uses the segment's own direction. So a neighbour outside the span
// can still bend the tangent inside it, and checking the directions alone would miss
// that: segments d, d, e leave vertex 2's tangent at unit(d + e).
if !(c0 || aligned(t0, d)) || !(c1 || aligned(t1, d)) {
return false;
}
}
any
}
}
/// The most flattened elements of a path kept.
@@ -253,6 +312,47 @@ pub fn seed_of(bp: &BezPath, salt: &str) -> u64 {
mod tests {
use super::*;
/// A straight run is straight; a bend in the span is not, however short it is. This is the
/// property a sampled check cannot offer, so it is worth pinning on its own (#3926 review).
#[test]
fn straightness_is_read_off_the_track_not_sampled() {
let line = |pts: &[(f64, f64)], closed: bool| {
let mut bp = BezPath::new();
bp.move_to(pts[0]);
for p in &pts[1..] {
bp.line_to(*p);
}
if closed {
bp.close_path();
}
tracks(&bp, 0.1).remove(0)
};
let straight = line(&[(0.0, 0.0), (100.0, 0.0), (200.0, 0.0)], false);
assert!(straight.is_straight(0.0, 200.0), "two collinear segments are one straight run");
// Open tracks extrapolate along the end directions, which is still the same line.
assert!(straight.is_straight(-50.0, 250.0), "and so is the extrapolation past both ends");
// A detour of a fifth of a point, 0.1% of the run: far below any sampling grid this code
// would choose, and still a bend.
let wiggle = line(&[(0.0, 0.0), (99.9, 0.0), (100.0, 0.2), (100.1, 0.0), (200.0, 0.0)], false);
assert!(!wiggle.is_straight(0.0, 200.0), "a 0.2 pt detour is a bend");
assert!(!wiggle.is_straight(99.0, 101.0), "and is caught when the span is only around it");
assert!(wiggle.is_straight(0.0, 50.0), "while a span before it is straight");
// `frame` blends vertex tangents, so the segment before a bend is not straight all the way
// to the vertex: the tangent there is already turning. Pinning this because checking the
// segment directions alone would call that span straight and be wrong.
let kink = line(&[(0.0, 0.0), (100.0, 0.0), (160.0, 60.0)], false);
assert!(!kink.is_straight(0.0, 100.0), "the last segment's tangent is already turning at its far vertex");
assert!(kink.is_straight(0.0, 50.0), "a span clear of the turning vertex is straight");
// Fails closed where it does not reason: a closed track's seam, and nonsense bounds.
let ring = line(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)], true);
assert!(!ring.is_straight(-10.0, 10.0), "a span across a closed track's seam is refused");
assert!(ring.is_straight(10.0, 90.0), "a span inside one of its sides is straight");
assert!(!straight.is_straight(f64::NAN, 10.0), "a non-finite bound is refused");
}
#[test]
fn straight_track_frames() {
let mut bp = BezPath::new();
+676 -74
View File
@@ -1,16 +1,18 @@
//! Art, scatter and pattern brushes: art bent along the path by arc length.
//!
//! Art coordinates `(x, y)` map to the path point at arc length `s(x)` offset by
//! `(y - centre) × cross` along the path's left normal. Art outlines are flattened and
//! subdivided first so long straight edges bend with the path instead of cutting chords.
//! `(y - centre) × cross` along the path's left normal. Art outlines come out as cubics fitted
//! to the bent art, so curves stay curves and long straight edges bend with the path (#3926);
//! where that would take more anchors than chords, they are flattened and subdivided instead.
use std::sync::Arc;
use vectorcraft_doc::{Node, NodeKind, PressureProfile};
use vectorcraft_geom::{Affine, BezPath, PathData, PathEl, Point, Rect, SubPath};
use kurbo::ParamCurveNearest;
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile};
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
use crate::track::{Rng, Track, seed_of, tracks};
use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
use crate::track::{Rng, Track, normal, seed_of, tracks};
use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
/// Longest mapped segment (points in document space).
const MAX_SEG: f64 = 1.5;
@@ -31,23 +33,29 @@ const MAX_SUB: usize = 4000;
const MAX_ANCHORS: usize = 2_000_000;
/// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget.
const MAX_WALK: usize = 10_000;
/// Points on each side of the box an Auto-Sliced pattern brush corner masks its tiles with.
const MASK_STEPS: usize = 32;
/// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and
/// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by
/// `width_scale`. Text, images and symbol instances can't bend and are dropped.
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, width_scale: f64) -> Option<Node> {
/// Bend every path under `n` through `f`. Each art segment comes out as cubics that follow it
/// through the bend to within [`fit_tol`] of `doc_tol`, the flattening tolerance in document
/// 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.
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);
match &mut out.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
*path = warp_path(path, f, flat_tol, max_du);
*path = warp_path(path, f, flat_tol, max_du, fit_tol(doc_tol));
if path.is_empty() {
return None;
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {
let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, width_scale)).map(Arc::new).collect();
let kids: Vec<Arc<Node>> =
children.iter().filter_map(|c| warp_node(c, f, flat_tol, max_du, doc_tol, width_scale)).map(Arc::new).collect();
if kids.is_empty() {
return None;
}
@@ -58,54 +66,156 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max
Some(out)
}
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64) -> PathData {
let bp = p.to_bezpath();
/// 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 {
let mut subs = vec![];
let mut cur: Vec<Point> = vec![];
let mut els = vec![];
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| els.push(e));
let finish = |cur: &mut Vec<Point>, closed: bool, subs: &mut Vec<SubPath>| {
let pts = std::mem::take(cur);
if pts.len() < 2 {
return;
for sp in &p.subpaths {
let Some(dense) = densified(sp, flat_tol, max_du) else { continue };
if let Some(fit) = fitted(sp, f, fit_tol, dense.len()) {
subs.push(fit);
continue;
}
let mut dense = vec![pts[0]];
let n = pts.len();
let segs = if closed { n } else { n - 1 };
for i in 0..segs {
let (a, b) = (pts[i], pts[(i + 1) % n]);
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
for j in 1..=k {
let q = a.lerp(b, j as f64 / k as f64);
if closed && i == segs - 1 && j == k {
break;
}
dense.push(q);
}
}
let mapped: Vec<Point> = dense.into_iter().map(f).collect();
subs.push(SubPath::polyline(&mapped, closed));
};
for e in els {
match e {
PathEl::MoveTo(q) => {
finish(&mut cur, false, &mut subs);
cur.push(q);
}
PathEl::LineTo(q) => cur.push(q),
PathEl::ClosePath => {
if cur.len() > 1 && cur[0].distance(cur[cur.len() - 1]) < 1e-9 {
cur.pop();
}
finish(&mut cur, true, &mut subs);
}
_ => {}
let mapped: Vec<Point> = dense.into_iter().map(f).filter(|q| q.is_finite()).collect();
if mapped.len() >= 2 {
subs.push(SubPath::polyline(&mapped, sp.closed));
}
}
finish(&mut cur, false, &mut subs);
PathData::new(subs)
}
/// `sp` flattened with `flat_tol` and its chords subdivided so none is longer than `max_du` along
/// x, in art space: the points the chord route maps. `None` below two points.
fn densified(sp: &SubPath, flat_tol: f64, max_du: f64) -> Option<Vec<Point>> {
let mut bp = BezPath::new();
sp.to_bezpath_into(&mut bp);
let mut pts = vec![];
kurbo::flatten(bp.iter(), flat_tol.max(1e-4), |e| {
if let PathEl::MoveTo(q) | PathEl::LineTo(q) = e {
pts.push(q);
}
});
if sp.closed && pts.len() > 1 && pts.first()?.distance(*pts.last()?) < 1e-9 {
pts.pop();
}
if pts.len() < 2 {
return None;
}
let segs = if sp.closed { pts.len() } else { pts.len() - 1 };
let mut dense = vec![*pts.first()?];
for (i, (&a, &b)) in pts.iter().zip(pts.iter().cycle().skip(1)).take(segs).enumerate() {
let k = (((b.x - a.x).abs() / max_du).ceil() as usize).clamp(1, MAX_SUB);
for j in 1..=k {
if sp.closed && i == segs - 1 && j == k {
break;
}
dense.push(a.lerp(b, j as f64 / k as f64));
}
}
Some(dense)
}
/// Times an art segment is halved at most while looking for cubics that fit it.
const FIT_DEPTH: u32 = 12;
/// Parameter step of the finite differences that stand in for the bend's derivative.
const FIT_DT: f64 = 1e-6;
/// Sections a fitted piece is checked at the ends of (the ends themselves are exact).
const FIT_CHECKS: usize = 8;
/// Share of the flattening tolerance the fitted curves hold to. The chords were exact at their
/// points and a few hundredths off between them, so a straight edge bends about as closely.
const FIT_SHARE: f64 = 0.25;
/// Tolerance paths are flattened to before art is laid along them.
const TRACK_TOL: f64 = 0.05;
/// The distance (document space) fitted curves keep to the bent art, for flattening tolerance
/// `doc_tol`: never below [`TRACK_TOL`], since following the flattened path more closely than it
/// follows the real one only traces its chords.
fn fit_tol(doc_tol: f64) -> f64 {
(doc_tol * FIT_SHARE).max(TRACK_TOL)
}
/// `sp` through `f` as cubics within `tol` (document space), one or more per art segment so its
/// anchors, corners included, stay anchors. `None` when that takes more than `most` anchors, when
/// a piece still does not fit after [`FIT_DEPTH`] halvings, or when `f` cannot place a point.
///
/// Each piece is the cubic with the bent piece's ends and their derivatives, which is the piece
/// itself where the bend is affine and converges fast where it is not. It is checked against the
/// bend at the same parameters, so the distance measured is never less than the true one. The
/// halving is our own rather than kurbo's `fit_to_bezpath`, which halves until the floats run out
/// on a map that never fits; the depth and the anchor count bound this one.
fn fitted(sp: &SubPath, f: &dyn Fn(Point) -> Point, tol: f64, most: usize) -> Option<SubPath> {
if !(tol.is_finite() && tol > 0.0) {
return None;
}
let start = f(sp.anchors.first()?.p);
if !start.is_finite() {
return None;
}
let mut bp = BezPath::new();
bp.move_to(start);
// Pieces allowed: an open subpath's first anchor is one more, a closed one's last piece ends on it.
let mut room = if sp.closed { most } else { most.checked_sub(1)? };
for i in 0..sp.segment_count() {
let c = sp.segment(i);
let line = sp.segment_is_line(i);
// A line's handles sit on its ends, which stalls its parameter there; thirds keep it even.
let c = if line { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c };
let at = |t: f64| Some(f(c.eval(t))).filter(|q| q.is_finite());
fit_into(&at, (0.0, 1.0), line, tol, FIT_DEPTH, &mut bp, &mut room)?;
}
if sp.closed {
bp.close_path();
}
// The last piece ends on the first anchor's image, so a closed subpath merges it back in; the
// count is checked again for a map that jumps right there and keeps the two apart.
PathData::from_bezpath(&bp).subpaths.into_iter().next().filter(|s| s.anchors.len() <= most)
}
/// Pieces for parameters `t0..t1` of the bent segment `at` onto `bp`, which ends at `at(t0)`:
/// halved until each is within `tol`, at most `depth` times and `room` pieces. A `line` that is
/// still straight within `tol` stays a line.
fn fit_into(
at: &dyn Fn(f64) -> Option<Point>,
(t0, t1): (f64, f64),
line: bool,
tol: f64,
depth: u32,
bp: &mut BezPath,
room: &mut usize,
) -> Option<()> {
let span = t1 - t0;
let (p0, p3) = (at(t0)?, at(t1)?);
// Derivatives read from inside the piece, so a kink at either end (a corner, a turn of the
// track) stays one.
let d0 = (at(t0 + FIT_DT)? - p0) * (span / FIT_DT);
let d1 = (p3 - at(t1 - FIT_DT)?) * (span / FIT_DT);
let c = CubicBez::new(p0, p0 + d0 / 3.0, p3 - d1 / 3.0, p3);
let chord = kurbo::Line::new(p0, p3);
let (mut curve_fits, mut line_fits) = (c.is_finite(), line);
for k in 1..FIT_CHECKS {
if !(curve_fits || line_fits) {
break;
}
let u = k as f64 / FIT_CHECKS as f64;
let q = at(t0 + span * u)?;
curve_fits &= q.distance(c.eval(u)) <= tol;
line_fits &= chord.nearest(q, 1e-9).distance_sq <= tol * tol;
}
if line_fits || curve_fits {
*room = room.checked_sub(1)?;
if line_fits {
bp.line_to(p3);
} else {
bp.curve_to(c.p1, c.p2, p3);
}
return Some(());
}
let depth = depth.checked_sub(1)?;
let mid = t0 + span / 2.0;
fit_into(at, (t0, mid), line, tol, depth, bp, room)?;
fit_into(at, (mid, t1), line, tol, depth, bp, room)
}
/// Art turned so it runs left → right, flipped as requested; returns it with its bounds.
fn oriented(art: &Node, dir: Direction, flip_along: bool, flip_across: bool) -> Option<(Node, Rect)> {
let mut a = art.clone();
@@ -153,12 +263,111 @@ impl AlongMap {
let w = self.xs[i + 1] - self.xs[i];
if w <= 1e-12 { 1.0 } else { (self.ss[i + 1] - self.ss[i]) / w }
}
/// Whether `at` is one linear function across the art, so it adds no bend of its own.
///
/// [`AlongMap::linear`] always is: its outer two pieces have no width and the middle one
/// spans everything. Between Guides is not, once its three pieces really do differ, which is
/// the whole point of it.
fn is_linear(&self) -> bool {
let mut slope: Option<f64> = None;
for i in 0..3 {
// A piece with no width constrains nothing: no art x lands inside it.
if self.xs[i + 1] - self.xs[i] <= 1e-12 {
continue;
}
let s = self.slope(i);
if slope.is_some_and(|first: f64| (first - s).abs() > 1e-9 * first.abs().max(1.0)) {
return false;
}
slope = Some(s);
}
slope.is_some()
}
/// The arc lengths the art spans, low to high.
fn span(&self) -> (f64, f64) {
(self.ss[0].min(self.ss[3]), self.ss[0].max(self.ss[3]))
}
/// Largest along-scale (arc length per art unit) of the pieces.
fn max_scale(&self) -> f64 {
(0..3).map(|i| self.slope(i).abs()).fold(1e-6, f64::max)
}
}
/// The affine that reproduces `f` over `b` to within `tol`, if one does.
///
/// Bending is only non-linear where the track bends. On a straight run the arc-length map and the
/// normal offset are both linear, so the art is just moved, turned and scaled, and flattening it
/// into chords throws its curves away for nothing (#3926).
///
/// Derived from three corners and then checked against a grid. The check is a second opinion, not
/// the gate: `bend` only calls this once the track and the along map have been read and found
/// straight, because sampling can reject a bend and cannot promise there is none (#3926 review).
fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option<Affine> {
let (w, h) = (b.width(), b.height());
if !(w > 1e-9 && h > 1e-9 && tol.is_finite() && tol > 0.0) {
return None;
}
let origin = Point::new(b.x0, b.y0);
let q0 = f(origin);
// Images of the unit x and y directions.
let u = (f(Point::new(b.x1, b.y0)) - q0) / w;
let v = (f(Point::new(b.x0, b.y1)) - q0) / h;
if ![q0.x, q0.y, u.x, u.y, v.x, v.y].iter().all(|k| k.is_finite()) {
return None;
}
let a = Affine::translate(q0.to_vec2()) * Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]) * Affine::translate(-origin.to_vec2());
// Five by five including the corners the fit came from, so a map that merely agrees at those
// three is rejected.
for i in 0..=4 {
for j in 0..=4 {
let p = Point::new(b.x0 + w * i as f64 / 4.0, b.y0 + h * j as f64 / 4.0);
let (want, got) = (f(p), a * p);
if !(want.x.is_finite() && want.y.is_finite()) || want.distance(got) > tol {
return None;
}
}
}
Some(a)
}
/// [`warp_node`] for a map that is affine: the art is transformed rather than flattened, so its
/// curves survive.
///
/// 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.
fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option<Node> {
let mut out = prepared(n, width_scale)?;
out.transform(a, false);
Some(out)
}
/// The part of [`affine_node`] that mirrors [`warp_node`]'s walk, before the single transform.
fn prepared(n: &Node, width_scale: f64) -> Option<Node> {
let mut out = n.clone();
out.appearance.scale_strokes(width_scale);
match &mut out.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
if path.is_empty() {
return None;
}
}
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } => {
let kids: Vec<Arc<Node>> = children.iter().filter_map(|c| prepared(c, width_scale)).map(Arc::new).collect();
if kids.is_empty() {
return None;
}
*children = kids;
}
_ => return None,
}
Some(out)
}
/// Bend `art` (already oriented, bounds `b`) along `t` using `along` and cross scale `cross`.
fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<Node> {
let cy = b.center().y;
@@ -166,7 +375,21 @@ fn bend(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64) -> Option<N
let f = move |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
// A straight run needs no bending: move the art and keep its curves.
//
// Straightness is read off the track and the along map, not sampled. A bend anywhere in the
// span is two segment directions that differ, so this cannot miss one however short the bent
// part is; the five by five grid below could, since a wiggle narrower than its spacing passes
// between the samples (#3926 review). The grid now only checks the affine that structure has
// already said exists, so it can reject and never admit.
let (s_lo, s_hi) = along.span();
if along.is_linear()
&& t.is_straight(s_lo, s_hi)
&& let Some(a) = affine_fit(&f, b, flat_tol.max(1e-9))
{
return affine_node(art, a, cross.abs());
}
warp_node(art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
}
/// [`bend`], with the cross scale at arc length `s` multiplied by `widen(s)` (0..=1). The strokes
@@ -182,7 +405,7 @@ fn bend_widened(t: &Track, art: &Node, b: Rect, along: AlongMap, cross: f64, wid
let art = widen_strokes(art, &factor);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), cross.abs())
warp_node(&art, &f, flat_tol, MAX_SEG / along.max_scale(), flat_tol * scale, cross.abs())
}
/// `n` with the strokes of it and of everything inside it scaled by `factor` of the object they
@@ -262,7 +485,7 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
let width = light.max(heavy);
let cross = weight * width / 100.0;
let mut out = vec![];
for t in tracks(bp, 0.05) {
for t in tracks(bp, TRACK_TOL) {
let l = t.len();
if l <= 1e-9 {
continue;
@@ -304,7 +527,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
let mut rng = Rng::new(seed_of(bp, name));
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
let mut out = Budget::default();
for t in tracks(bp, 0.05) {
for t in tracks(bp, TRACK_TOL) {
if out.full() {
break;
}
@@ -403,6 +626,160 @@ fn tile_on(t: &Track, tile: &(Node, Rect), s0: f64, s1: f64, cross: f64) -> Opti
bend(t, &tile.0, tile.1, AlongMap::linear(tile.1.width(), s0, s1), cross)
}
/// What a pattern brush draws at a corner: corner tile art (oriented, with its bounds), or what
/// [`CornerSource`] gives.
#[derive(Clone, Copy)]
enum Corner<'a> {
Art(&'a (Node, Rect)),
Source(CornerSource),
}
/// The pieces of the corner tile that `source` makes at corner `i` of `t` from `side` (oriented,
/// with its bounds) at scale `k`, and the length of path on each side of the corner they cover,
/// which is at most `room`.
///
/// At the corner, the pieces run straight along the directions the path arrives at and leaves
/// the corner in, and towards their far ends they move onto the path itself, so they meet the side
/// tiles where the path curves. The corner's diagonal is the line through the corner that halves
/// the turn, where the two offset edges of the band meet. [`CornerSource::None`] and a turn of
/// nearly 180° get no pieces.
fn auto_corner(t: &Track, i: usize, source: CornerSource, side: &(Node, Rect), k: f64, room: f64) -> (Vec<Node>, f64) {
let (art, b) = side;
let (a_dir, b_dir) = t.in_out(i);
let (Some(&p), Some(&sc)) = (t.verts.get(i), t.cum.get(i)) else { return (vec![], 0.0) };
let bt = a_dir + b_dir;
if source == CornerSource::None || a_dir.dot(bt) < 1e-3 {
return (vec![], 0.0);
}
let (tw, r) = (b.width() * k, b.height() * k / 2.0);
let (x0, cy) = (b.x0, b.center().y);
// Art point → (distance along from the left edge, offset across), in points.
let local = move |q: Point| ((q.x - x0) * k, (q.y - cy) * k);
// How far along `d` (from the corner) the diagonal is at offset `y` across `d`'s left normal.
let diag = move |d: Vec2, y: f64| -y * normal(d).dot(bt) / d.dot(bt);
// `u` along `d` from the corner and `y` across, moved from the straight line onto the path as
// `u` goes from `from` to `far` (signed: negative before the corner; `from` is the corner or
// a point between it and `far`).
let on_path = move |d: Vec2, u: f64, y: f64, from: f64, far: f64| {
let straight = p + d * u + normal(d) * y;
let span = far - from;
let w = if span.abs() > 1e-9 { ((u - from) / span).clamp(0.0, 1.0) } else { 0.0 };
if w > 0.0 { straight.lerp(t.map(sc + u, y), w) } else { straight }
};
// The most the diagonal is ahead of the corner, along either direction, within the band. Each
// source stretches the side tile along to `len` where the band is too tall for the tile to
// reach the diagonal, so the two sides always meet on it.
let ahead = r * normal(a_dir).dot(bt).abs() / a_dir.dot(bt);
// Before the corner, `u` in [-len, 0] maps onto [-len, diagonal]; after it, [0, len] onto
// [diagonal, len]. Both ends stay on the diagonal when `len` is shorter than `ahead`, and the
// points on the diagonal stay on the straight lines, where the two sides meet.
let into = move |u: f64, y: f64, len: f64| {
let reach = len.max(ahead);
let e = diag(a_dir, y).clamp(-reach, reach);
on_path(a_dir, -len + (u + len) * (e + len) / len.max(1e-9), y, e.min(0.0), -len)
};
let out_of = move |u: f64, y: f64, len: f64| {
let reach = len.max(ahead);
let e = diag(b_dir, y).clamp(-reach, reach);
on_path(b_dir, e + u * (len - e) / len.max(1e-9), y, e.max(0.0), len)
};
let flat_tol = tolerance(b.height().max(b.width()) * k) / k;
// The tile is squeezed along so that it covers at most `room` of the path on each side.
let room = room.max(0.0);
let len = tw.max(2.0 * ahead).min(match source {
CornerSource::AutoCentered => 2.0 * room,
CornerSource::AutoBetween => room,
_ => room + ahead,
});
if len <= 1e-9 {
return (vec![], 0.0);
}
let s = if tw > 1e-9 { len / tw } else { 1.0 };
let max_du = MAX_SEG / (2.0 * k * s.max(1.0));
let warp = |f: &dyn Fn(Point) -> Point| warp_node(art, f, flat_tol, max_du, flat_tol * k, k);
// Art point → (distance along from the left edge, stretched to `len`, and offset across).
let along = move |q: Point| {
let (x, y) = local(q);
(x * s, y)
};
match source {
CornerSource::None => (vec![], 0.0),
CornerSource::AutoCentered => {
let half = len / 2.0;
let f = move |q: Point| {
let (x, y) = along(q);
let u = x - half;
if u < 0.0 { into(u, y, half) } else { out_of(u, y, half) }
};
(warp(&f).into_iter().collect(), half)
}
CornerSource::AutoBetween => {
let before = move |q: Point| {
let (x, y) = along(q);
into(x - len, y, len)
};
let after = move |q: Point| {
let (x, y) = along(q);
out_of(x, y, len)
};
(warp(&before).into_iter().chain(warp(&after)).collect(), len)
}
CornerSource::AutoSliced | CornerSource::AutoOverlap => {
// Each side's tile runs `ahead` past the corner.
let far = len - ahead;
let before = move |q: Point| {
let (x, y) = along(q);
on_path(a_dir, x - far, y, 0.0, -far)
};
let after = move |q: Point| {
let (x, y) = along(q);
on_path(b_dir, x - ahead, y, 0.0, far)
};
let (first, second) = (warp(&before), warp(&after));
if source == CornerSource::AutoOverlap {
return (first.into_iter().chain(second).collect(), far);
}
// Auto-Sliced masks each tile by the part of the outline of the box round its art
// (strokes included), placed as the tile is, on its own side of the diagonal. A tile
// squeezed along keeps its stroke width, so the box's margins at its ends grow by as
// much as the tile is squeezed.
let vb = art.visual_bounds().unwrap_or(*b);
let grow = 1.0 / s.min(1.0);
let (x0, x1) = (b.x0 - (b.x0 - vb.x0).max(0.0) * grow, b.x1 + (vb.x1 - b.x1).max(0.0) * grow);
let corners = [(x0, vb.y0), (x1, vb.y0), (x1, vb.y1), (x0, vb.y1), (x0, vb.y0)].map(|(x, y)| Point::new(x, y));
let outline = |f: &dyn Fn(Point) -> Point| -> Vec<Point> {
let steps = (0..MASK_STEPS).map(|j| j as f64 / MASK_STEPS as f64);
corners.windows(2).flat_map(|w| steps.clone().map(move |s| w[0].lerp(w[1], s))).map(f).collect()
};
let pieces =
first.and_then(|n| masked(n, &outline(&before), p, bt)).into_iter().chain(second.and_then(|n| masked(n, &outline(&after), p, -bt)));
(pieces.collect(), far)
}
}
}
/// `n` in a clipping group whose mask is the part of polygon `quad` on the side of the line through
/// `p` that `towards` points away from. `None` when nothing of `quad` is on that side.
fn masked(n: Node, quad: &[Point], p: Point, towards: Vec2) -> Option<Node> {
let side = |q: Point| (q - p).dot(towards);
let mut kept = vec![];
for (j, &a) in quad.iter().enumerate() {
let b = quad.get(j + 1).or(quad.first()).copied().unwrap_or(a);
let (da, db) = (side(a), side(b));
if da <= 0.0 {
kept.push(a);
}
if (da <= 0.0) != (db <= 0.0) {
kept.push(a.lerp(b, da / (da - db)));
}
}
if kept.len() < 3 {
return None;
}
let mask = Node::path(NodeId(0), PathData::new(vec![SubPath::polyline(&kept, true)]), Appearance::default());
Some(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(n)], clip: true }))
}
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![] };
@@ -413,8 +790,11 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
let end = pb.end.as_ref().and_then(orient);
let tw = side.1.width() * k;
let gap = tw * pb.spacing.max(0.0) / 100.0;
// Each kind of corner takes its own art, else its source, else the other kind's art.
let outer_tile = outer.as_ref().map(Corner::Art).or(pb.outer_source.map(Corner::Source));
let inner_tile = inner.as_ref().map(Corner::Art).or(pb.inner_source.map(Corner::Source));
let mut out = Budget::default();
for t in tracks(bp, 0.05) {
for t in tracks(bp, TRACK_TOL) {
if out.full() {
break;
}
@@ -423,13 +803,51 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
continue;
}
let corners = t.corner_positions();
// Corner tiles, centred on the corner and turned to the bisector.
// Corner tiles, centred on the corner and turned to the bisector. The path on each side of
// a corner is shortened by `trims` (`None`: half the widest corner tile art).
let mut corner_half = 0.0_f64;
for &(i, _) in &corners {
let mut trims: Vec<Option<f64>> = Vec::with_capacity(corners.len());
// The path a tile made at corner `ci` may cover on each side: up to the ends of an open
// track, and half the way to the next corner.
let room = |ci: usize| {
let at = |j: usize| corners.get(j).map(|c| c.1);
let s = at(ci).unwrap_or(0.0);
let last = corners.len().saturating_sub(1);
let before = match (ci.checked_sub(1).and_then(at), t.closed) {
(Some(prev), _) => (s - prev) / 2.0,
(None, true) => (s - (at(last).unwrap_or(s) - l)) / 2.0,
(None, false) => s,
};
let after = match (at(ci + 1), t.closed) {
(Some(next), _) => (next - s) / 2.0,
(None, true) => (at(0).unwrap_or(s) + l - s) / 2.0,
(None, false) => l - s,
};
before.min(after)
};
for (ci, &(i, _)) in corners.iter().enumerate() {
if out.full() {
break;
}
let (a, b) = t.in_out(i);
// Turning towards the left normal puts the tile's +y side on the inside.
let tile = if a.cross(b) > 0.0 { inner.as_ref().or(outer.as_ref()) } else { outer.as_ref().or(inner.as_ref()) };
let Some((art, bb)) = tile else { continue };
let tile = if a.cross(b) > 0.0 { inner_tile.or(outer.as_ref().map(Corner::Art)) } else { outer_tile.or(inner.as_ref().map(Corner::Art)) };
let (art, bb) = match tile {
Some(Corner::Art(tile)) => tile,
Some(Corner::Source(source)) => {
let (pieces, trim) = auto_corner(&t, i, source, &side, k, room(ci));
for n in pieces {
out.take(Some(n));
}
trims.push(Some(trim));
continue;
}
None => {
trims.push(Some(0.0));
continue;
}
};
trims.push(None);
corner_half = corner_half.max(bb.width() * k / 2.0);
let bis = a + b;
let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() };
@@ -441,30 +859,30 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
n.transform(xf, true);
out.take(Some(n));
}
// Runs between corners (closed tracks wrap past the end).
let trim = |c: Option<usize>| c.and_then(|c| trims.get(c).copied()).map_or(0.0, |t| t.unwrap_or(corner_half));
// Runs between corners (closed tracks wrap past the end), with the corners they start and
// end at.
let mut bounds: Vec<f64> = corners.iter().map(|c| c.1).collect();
let runs: Vec<(f64, f64, bool, bool)> = if bounds.is_empty() {
vec![(0.0, l, false, false)]
let nc = bounds.len();
let runs: Vec<(f64, f64, Option<usize>, Option<usize>)> = if bounds.is_empty() {
vec![(0.0, l, None, None)]
} else if t.closed {
let first = bounds[0];
bounds.push(first + l);
bounds.windows(2).map(|w| (w[0], w[1], true, true)).collect()
bounds.windows(2).enumerate().map(|(i, w)| (w[0], w[1], Some(i), Some((i + 1) % nc))).collect()
} else {
let mut v = vec![0.0];
v.extend(bounds);
v.push(l);
let n = v.len();
v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i > 0, i + 2 < n)).collect()
v.windows(2).enumerate().map(|(i, w)| (w[0], w[1], i.checked_sub(1), (i < nc).then_some(i))).collect()
};
let nruns = runs.len();
let corner_tiles = inner.is_some() || outer.is_some();
for (ri, (mut a, mut b, ca, cb)) in runs.into_iter().enumerate() {
if ca && corner_tiles {
a += corner_half;
}
if cb && corner_tiles {
b -= corner_half;
if out.full() {
break;
}
a += trim(ca);
b -= trim(cb);
if !t.closed
&& ri == 0
&& let Some(st) = &start
@@ -532,3 +950,187 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
}
out.out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::track::tracks;
/// #3926 review: the five by five grid cannot promise a run is straight, only that it looks
/// straight where it was asked. A zigzag whose vertices land on the sample positions is
/// invisible to it: every sample sits on a y = 0 vertex whose blended tangent is flat, so all
/// 25 points agree with the affine exactly, while the art between them is thrown 4 pt off.
///
/// This is why the gate is `Track::is_straight` and the grid is only a second opinion.
#[test]
fn a_bend_shorter_than_the_sample_grid_is_still_a_bend() {
// Half-period 20 pt, amplitude 4 pt. The 11.3 degree turn is under `CORNER_DEG`, so the
// vertices are not corners and their tangents blend flat, which is what hides them.
let (d, a) = (20.0, 4.0);
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
for i in 1..=16 {
bp.line_to((d * i as f64, if i % 2 == 1 { a } else { 0.0 }));
}
let t = tracks(&bp, 0.01).remove(0);
assert_eq!(t.corner.iter().filter(|c| **c).count(), 0, "no vertex is a corner, so every tangent is blended");
// A span over four full periods, held clear of both ends so no sample hits the
// extrapolation branch: the five samples land on vertices 4, 6, 8, 10 and 12, all at y = 0.
let seglen = (d * d + a * a).sqrt();
let (s0, s1) = (4.0 * seglen, 12.0 * seglen);
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
let along = AlongMap::linear(b.width(), s0, s1);
let (cy, x0, cross) = (b.center().y, b.x0, 1.0);
let f = |p: Point| t.map(along.at(p.x - x0), (p.y - cy) * cross);
let scale = along.max_scale().max(cross.abs()).max(1e-6);
let flat_tol = tolerance(b.height().max(b.width()) * scale) / scale;
// The grid is satisfied, and it should not be.
let fit = affine_fit(&f, b, flat_tol.max(1e-9)).expect("the sampled check accepts this bend");
// How wrong it is: an art x landing on a peak of the zigzag, which no sample reaches.
let peak_x = x0 + (5.0 * seglen - s0) / (s1 - s0) * b.width();
let at_peak = Point::new(peak_x, cy);
let off = (f(at_peak) - fit * at_peak).hypot();
assert!(
off > 3.0 && off > flat_tol * 20.0,
"the affine the grid approved is {off:.3} pt out at the peak, against a {flat_tol:.4} pt tolerance"
);
// Reading the track instead refuses it, and still admits the straight run it is made of.
assert!(!t.is_straight(s0, s1), "a bend between the samples is still a bend");
let straight = tracks(
&{
let mut bp = BezPath::new();
bp.move_to((0.0, 0.0));
bp.line_to((320.0, 0.0));
bp
},
0.01,
)
.remove(0);
assert!(straight.is_straight(s0, s1), "and the same span of an actually straight track is straight");
// End to end through `bend`: the aliased bend is subdivided, the straight run keeps its
// curves. This is the gate itself, not the two predicates it is made of.
let anchors = |n: &Node| match &n.kind {
NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>(),
_ => 0,
};
let circle = Node::path(
vectorcraft_doc::NodeId(0),
vectorcraft_geom::shapes::ellipse(b),
vectorcraft_doc::Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
);
assert_eq!(anchors(&circle), 4, "the art is a 4-anchor circle to begin with");
let bent = bend(&t, &circle, b, along, cross).expect("the zigzag still draws something");
assert!(anchors(&bent) > 8, "the aliased bend is subdivided, not affine-mapped: {} anchors", anchors(&bent));
let moved = bend(&straight, &circle, b, along, cross).expect("the straight run still draws something");
assert_eq!(anchors(&moved), 4, "the straight run keeps the tile's curves");
}
/// A 4-anchor circle tile filling `b`.
fn circle_tile(b: Rect) -> Node {
Node::path(
NodeId(0),
vectorcraft_geom::shapes::ellipse(b),
Appearance::basic(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), vectorcraft_color::Paint::None, 0.0),
)
}
/// `per` points along each segment of `sp`, through `f`.
fn traced(sp: &SubPath, f: &dyn Fn(Point) -> Point, per: usize) -> Vec<Point> {
(0..sp.segment_count())
.flat_map(|i| {
let c = sp.segment(i);
(0..per).map(move |j| f(c.eval(j as f64 / per as f64)))
})
.collect()
}
/// How far apart two closed outlines get (Hausdorff distance, each taken as a polygon).
fn apart(a: &[Point], b: &[Point]) -> f64 {
let to = |q: Point, pts: &[Point]| {
pts.iter()
.zip(pts.iter().cycle().skip(1))
.map(|(&a, &b)| {
let ab = b - a;
let t = ((q - a).dot(ab) / ab.hypot2().max(1e-18)).clamp(0.0, 1.0);
q.distance(a + ab * t)
})
.fold(f64::MAX, f64::min)
};
let one_way = |a: &[Point], b: &[Point]| a.iter().map(|&q| to(q, b)).fold(0.0, f64::max);
one_way(a, b).max(one_way(b, a))
}
/// #3926: on a bend the tile is fitted with cubics instead of flattened into chords. A circle
/// tile bent round a circle stays a handful of smooth curves, closer to the bent art than the
/// chords were, in a small fraction of their anchors.
#[test]
fn a_circle_tile_bent_round_a_circle_stays_a_few_curves() {
let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
let t = tracks(&round, 0.05).remove(0);
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
let along = AlongMap::linear(b.width(), 100.0, 140.0);
let tile = circle_tile(b);
let NodeKind::Path { path: art, .. } = &tile.kind else { panic!("the tile is a path") };
let art = &art.subpaths[0];
let bent = bend(&t, &tile, b, along, 1.0).expect("the tile is drawn");
let NodeKind::Path { path, .. } = &bent.kind else { panic!("a bent path is a path") };
assert_eq!(path.subpaths.len(), 1);
let sp = &path.subpaths[0];
assert!(sp.closed, "still a closed outline");
let n = sp.anchors.len();
assert!((4..=16).contains(&n), "a few anchors, the tile's own four among them: {n}");
assert!(sp.anchors.iter().all(|a| a.has_in() && a.has_out()), "every anchor keeps both handles");
assert!(sp.anchors.first().map(|a| a.p) != sp.anchors.last().map(|a| a.p), "closed without a duplicate anchor");
// The same map `bend` uses, at scale 1, and the tolerance it fits to.
let (cy, x0) = (b.center().y, b.x0);
let f = |p: Point| t.map(along.at(p.x - x0), p.y - cy);
let tol = tolerance(b.height().max(b.width()));
let exact = traced(art, &f, 1000);
let chords: Vec<Point> = densified(art, tol, MAX_SEG).expect("the tile flattens").into_iter().map(f).collect();
let curves = traced(sp, &|p| p, 200);
let (e_curves, e_chords) = (apart(&curves, &exact), apart(&chords, &exact));
// Checked at eight points a piece, so a little room between them.
assert!(e_curves <= fit_tol(tol) * 1.5, "the curves follow the bent art within {}: {e_curves:.4}", fit_tol(tol));
assert!(e_curves < e_chords, "closer than the chords were: {e_curves:.4} against {e_chords:.4}");
assert!(chords.len() >= 4 * n, "far fewer anchors than the {} chords took: {n}", chords.len());
}
/// Fitting is bounded however badly the map behaves: where it gives points that are not finite
/// the tile falls back to chords with those points left out (or to nothing), and where it never
/// fits (a map that jumps everywhere) the halving stops at its depth and anchor count. Either
/// way the call comes back promptly with finite geometry.
#[test]
fn a_map_that_misbehaves_still_returns_promptly_and_finite() {
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
let tile = circle_tile(b);
let finite = |n: &Node| match &n.kind {
NodeKind::Path { path, .. } => {
path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite())
}
_ => false,
};
let maps: [&dyn Fn(Point) -> Point; 4] = [
&|p| if p.x > 25.0 { Point::new(f64::NAN, p.y) } else { p },
&|p| if p.y > 10.0 { Point::new(f64::INFINITY, p.y) } else { p + Vec2::new(p.x.sin(), 0.0) },
&|p| Point::new(p.x, p.y + if (p.x * 1e3).sin() > 0.0 { 5.0 } else { 0.0 }),
&|_| Point::new(f64::NAN, f64::NAN),
];
for (i, f) in maps.iter().enumerate() {
let t0 = std::time::Instant::now();
let out = warp_node(&tile, f, 0.25, 1.5, 0.25, 1.0);
assert!(t0.elapsed() < std::time::Duration::from_secs(2), "map {i} took {:?}", t0.elapsed());
match (i, &out) {
(3, out) => assert!(out.is_none(), "a map that places nothing draws nothing"),
(_, Some(n)) => assert!(finite(n), "map {i} drew non-finite geometry"),
(_, None) => panic!("map {i} places most points, so something is drawn"),
}
}
}
}
+12 -1
View File
@@ -66,7 +66,7 @@ pub(crate) fn number(s: &str) -> Option<f64> {
/// A whole number; some writers put integers as `1.0`.
pub(crate) fn integer(s: &str) -> Option<i64> {
let s = s.trim();
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15).map(|v| v as i64))
s.parse::<i64>().ok().or_else(|| number(s).filter(|v| v.abs() < 9.0e15 && v.fract() == 0.0).map(|v| v as i64))
}
/// One object: its type (the value of its `0` group) and the groups up to the next object.
@@ -456,3 +456,14 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, enti
}
std::iter::once(LayoutDef { name: MODEL.into(), block: None }).chain(paper.into_iter().map(|(_, l)| l)).collect()
}
#[cfg(test)]
mod tests {
use super::integer;
#[test]
fn integer_rejects_fractional_float_values() {
assert_eq!(integer("1.0"), Some(1));
assert_eq!(integer("1.9"), None);
}
}
+55
View File
@@ -126,6 +126,61 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
if samples.iter().any(|p| (centre.distance(*p) - radius).abs() > tolerance) {
return None;
}
// Verify that the curve progresses monotonically around the circle without reversing,
// both along each cubic segment's angular derivative and across sampled intervals,
// and that closed subpaths make exactly one full turn while open subpaths do not exceed one turn.
let mut total_turn = 0.0;
let mut dir: Option<bool> = None;
let ang_threshold = radius * radius * 1e-9;
let mut seg_from = sub.start;
for seg in &sub.segs {
let Seg::Cubic(a, b, to) = *seg else { return None };
for step in 0..=32 {
let t = f64::from(step) / 32.0;
let p = cubic_point(seg_from, a, b, to, t);
let u = 1.0 - t;
let dx = 3.0 * u * u * (a.x - seg_from.x) + 6.0 * u * t * (b.x - a.x) + 3.0 * t * t * (to.x - b.x);
let dy = 3.0 * u * u * (a.y - seg_from.y) + 6.0 * u * t * (b.y - a.y) + 3.0 * t * t * (to.y - b.y);
let cross = (p.x - centre.x) * dy - (p.y - centre.y) * dx;
if cross.abs() > ang_threshold {
let is_ccw = cross > 0.0;
if let Some(expected_ccw) = dir {
if is_ccw != expected_ccw {
return None;
}
} else {
dir = Some(is_ccw);
}
}
}
seg_from = to;
}
for w in samples.windows(2) {
let (p0, p1) = (w[0], w[1]);
let (v0x, v0y) = (p0.x - centre.x, p0.y - centre.y);
let (v1x, v1y) = (p1.x - centre.x, p1.y - centre.y);
let cross = v0x * v1y - v0y * v1x;
let dot = v0x * v1x + v0y * v1y;
let d_theta = cross.atan2(dot);
if d_theta.abs() > 1e-9 {
let is_ccw = d_theta > 0.0;
if let Some(expected_ccw) = dir {
if is_ccw != expected_ccw {
return None;
}
} else {
dir = Some(is_ccw);
}
total_turn += d_theta;
}
}
if sub.closed {
if (total_turn.abs() - std::f64::consts::TAU).abs() > 1e-3 {
return None;
}
} else if total_turn.abs() < 1e-6 || total_turn.abs() >= std::f64::consts::TAU - 1e-3 {
return None;
}
Some(CircularSub { centre, radius, samples })
}
+46
View File
@@ -246,6 +246,52 @@ fn closed_circular_arc_with_a_closing_chord_is_not_a_circle() {
assert!(!kinds(&r12).iter().any(|k| k == "CIRCLE"));
}
#[test]
fn a_closed_sliver_along_a_circle_is_not_a_circle() {
let d = doc_with(|d| {
let (cx, cy, r) = (60.0, 250.0, 200.0);
let at = |deg: f64| {
let rad = deg.to_radians();
Point::new(cx + r * rad.cos(), cy + r * rad.sin())
};
let along = |sliver: &mut vectorcraft_geom::BezPath, deg0: f64, deg1: f64| {
let p0 = at(deg0);
let p3 = at(deg1);
let step = (deg1 - deg0).to_radians();
let k = 4.0 / 3.0 * (step / 4.0).tan();
let r0 = deg0.to_radians();
let r1 = deg1.to_radians();
let p1 = Point::new(p0.x - r * r0.sin() * k, p0.y + r * r0.cos() * k);
let p2 = Point::new(p3.x + r * r1.sin() * k, p3.y - r * r1.cos() * k);
sliver.curve_to(p1, p2, p3);
};
let mut sliver = vectorcraft_geom::BezPath::new();
sliver.move_to(at(-110.0));
along(&mut sliver, -110.0, -90.0);
along(&mut sliver, -90.0, -70.0);
along(&mut sliver, -70.0, -110.0);
sliver.close_path();
vec![path(d, PathData::from_bezpath(&sliver), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let modern = export(&d, &opts(&d)).unwrap();
assert_ne!(kinds(&modern), ["CIRCLE"]);
assert_eq!(kinds(&modern), ["SPLINE"]);
}
#[test]
fn cubic_with_hidden_angular_reversal_is_not_promoted_to_arc() {
let d = doc_with(|d| {
let mut b = vectorcraft_geom::BezPath::new();
b.move_to(Point::new(-8.404028665134, 62.061475842818));
b.curve_to(Point::new(-13.285253116737, 62.103123153545), Point::new(52.811163576591, 52.471253544459), Point::new(60.0, 50.0));
vec![path(d, PathData::from_bezpath(&b), Paint::None, Paint::solid(Color::BLACK), 1.0)]
});
let dxf = export(&d, &opts(&d)).unwrap();
assert_ne!(kinds(&dxf), ["ARC"]);
assert_eq!(kinds(&dxf), ["SPLINE"]);
}
#[test]
fn spline_closed_flags_follow_path_intent_across_versions_without_changing_geometry() {
let cases = [(true, true), (true, false), (false, false), (false, true)];
+9 -1
View File
@@ -69,7 +69,7 @@ impl fmt::Display for ColorKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Components are hundredths of the written units: exact with two decimals.
let num = |q: i32| {
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.abs() / 100, q.abs() % 100);
let s = format!("{}{}.{:02}", if q < 0 { "-" } else { "" }, q.unsigned_abs() / 100, q.unsigned_abs() % 100);
s.trim_end_matches('0').trim_end_matches('.').to_string()
};
let [a, b, c, k] = self.v.map(num);
@@ -461,6 +461,14 @@ impl Rng {
mod tests {
use super::*;
#[test]
fn key_display_survives_saturated_lab() {
for text in ["lab 50 -1e20 0", "lab 50 0 -1e20", "lab 50 1e20 0"] {
let k = ColorKey::parse(text).unwrap();
assert_eq!(ColorKey::parse(&k.to_string()), Some(k), "{text}");
}
}
fn close(a: Color, b: Color) -> bool {
let v = |c: Color| match c {
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
+1 -1
View File
@@ -1,6 +1,6 @@
use super::profile::{PressureProfile, WidthProfile};
use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
use super::*;
use super::{BlendMode, Deserialize, Paint, Serialize};
/// A live effect in an appearance stack. Parameters are interpreted by `vectorcraft-effects`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
/// in order along the path. Two points at the same position make a discontinuous point: the
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum LineCap {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, Deserialize, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artboard {
+270 -4
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,
};
@@ -1059,13 +1063,146 @@ pub fn steps_knockout_shows(steps: &[Arc<Node>]) -> bool {
!steps.iter().all(|c| paints_opaquely(c))
}
/// Evaluate a blend: the keys and generated steps in paint order.
/// Most steps a blend makes in all, the steps of the blends inside its keys and steps included.
/// Each step of a blend whose keys are blends is a copy of one of them, which makes its own steps
/// when drawn, so nesting multiplies the step counts capped pair by pair: five levels of 15 steps
/// are 1.3 million steps from a 23 KB file, 3.4 GB to draw (#3579). Past it, a blend makes
/// proportionally fewer steps between each pair of keys ([`fit_steps`]).
pub const MAX_BLEND_STEPS: usize = 100_000;
/// The steps between each pair of keys, as the blend's spacing asks for them.
fn requested_steps(keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> Vec<usize> {
keys.windows(2)
.enumerate()
.map(|(i, w)| match w {
[a, b] => blend_step_count(a, b, spec.spacing, rail.map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i))),
_ => 0,
})
.collect()
}
/// Most steps `spacing` makes between two keys, whatever their size and distance
/// ([`blend_step_count`]'s clamps).
fn most_steps(spacing: BlendSpacing) -> usize {
match spacing {
BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize,
BlendSpacing::Distance(_) => 1000,
BlendSpacing::SmoothColor => 256,
}
}
/// `want` steps between each pair of keys, fewer where they would make more than
/// [`MAX_BLEND_STEPS`] in all with `keys` steps already made by the keys and `each[i]` made by
/// one step of pair `i` (itself and what the blends in it make): every pair keeps the same share
/// of its steps, rounded down, and none when the keys alone fill the budget.
fn fit_steps(want: &[usize], each: &[usize], keys: usize) -> Vec<usize> {
let room = MAX_BLEND_STEPS.saturating_sub(keys) as u128;
let demand = want.iter().zip(each).map(|(&n, &c)| n as u128 * c as u128).fold(0u128, u128::saturating_add);
if demand <= room {
return want.to_vec();
}
// `demand` is more than `room`, so it isn't 0, and each share is below `n`.
want.iter().map(|&n| usize::try_from(n as u128 * room / demand).unwrap_or(0)).collect()
}
/// Counts the steps blends make in all ([`MAX_BLEND_STEPS`]), each object counted once by its
/// address while the count runs.
#[derive(Default)]
struct StepCount {
known: std::collections::HashMap<(usize, bool), usize>,
}
impl StepCount {
/// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`].
/// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced
/// by distance (or by distance where the colours match) makes more steps scaled up, so a
/// copy counts the most its spacing can make between each pair, an upper bound for any scale.
fn node(&mut self, n: &Node, copy: bool) -> usize {
let at = (std::ptr::from_ref(n) as usize, copy);
if let Some(&known) = self.known.get(&at) {
return known;
}
let count = match &n.kind {
NodeKind::Blend { children, spec } if copy => self.most(children, spec),
NodeKind::Blend { children, spec } => self.blend(children, spec, Rail::new(children, spec).as_ref()).1,
// Each instance draws the source again.
NodeKind::Repeat(r) => r.instances(None).saturating_mul(self.nodes(&r.source, copy)),
_ => self.nodes(n.children().map_or(&[], Vec::as_slice), copy),
};
self.known.insert(at, count);
count
}
fn nodes(&mut self, nodes: &[Arc<Node>], copy: bool) -> usize {
nodes.iter().map(|c| self.node(c, copy)).fold(0, usize::saturating_add)
}
/// What one step between keys `a` and `b` makes: itself and the steps of the blends in it.
/// Groups pair their children as [`Lerp::new`] does; anything else is a copy of the nearer key.
fn step(&mut self, a: &Node, b: &Node) -> usize {
let inner = match (&a.kind, &b.kind) {
(NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 })
if k1 == k2 && !(*k1 && ca.len() != cb.len()) =>
{
(0..ca.len().max(cb.len()))
.map(|i| match (ca.get(i), cb.get(i)) {
(Some(x), Some(y)) => self.step(x, y).saturating_sub(1),
(Some(x), None) | (None, Some(x)) => self.node(x, true),
(None, None) => 0,
})
.fold(0, usize::saturating_add)
}
_ => self.node(a, true).max(self.node(b, true)),
};
inner.saturating_add(1)
}
/// What each step between each pair of `keys` makes ([`Self::step`]).
fn steps(&mut self, keys: &[Arc<Node>]) -> Vec<usize> {
keys.windows(2)
.map(|w| match w {
[a, b] => self.step(a, b),
_ => 1,
})
.collect()
}
/// The steps between each pair of keys a blend makes ([`fit_steps`]), and the steps it makes
/// in all, its keys' included.
fn blend(&mut self, keys: &[Arc<Node>], spec: &BlendSpec, rail: Option<&Rail>) -> (Vec<usize>, usize) {
let inside = self.nodes(keys, false);
let each = self.steps(keys);
let kept = fit_steps(&requested_steps(keys, spec, rail), &each, inside);
let total = kept.iter().zip(&each).map(|(&n, &c)| n.saturating_mul(c)).fold(inside, usize::saturating_add);
(kept, total)
}
/// The most steps a copy of a blend makes in all, at any scale: what [`Self::blend`] counts
/// with [`most_steps`] between each pair, before rounding.
fn most(&mut self, keys: &[Arc<Node>], spec: &BlendSpec) -> usize {
let inside = self.nodes(keys, true);
let each = self.steps(keys);
let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add);
inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside)))
}
}
/// How many steps `keys` blended with `spec` make between each pair of keys: as many as the
/// spacing asks for, fewer when they and the steps of the blends nested in them would pass
/// [`MAX_BLEND_STEPS`] ([`blend_expand`] makes these).
pub fn blend_steps(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<usize> {
StepCount::default().blend(keys, spec, Rail::new(keys, spec).as_ref()).0
}
/// Evaluate a blend: the keys and generated steps in paint order. The steps keep to
/// [`MAX_BLEND_STEPS`] in all ([`blend_steps`]).
pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
let k = keys.len();
if k < 2 {
return keys.iter().map(|n| (**n).clone()).collect();
}
let rail = Rail::new(keys, spec);
let (counts, _) = StepCount::default().blend(keys, spec, rail.as_ref());
let mut out = Vec::new();
for (i, a) in keys.iter().enumerate() {
let mut key = (**a).clone();
@@ -1074,8 +1211,7 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
}
out.push(key);
let Some(b) = keys.get(i + 1) else { break };
let len = rail.as_ref().map_or_else(|| center_of(a).distance(center_of(b)), |r| r.len(i));
let n = blend_step_count(a, b, spec.spacing, len);
let n = counts.get(i).copied().unwrap_or(0);
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
for j in 1..=n {
// Even position of the step, then where the easing puts its shape and its colours.
@@ -1291,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()));
@@ -1301,4 +1443,128 @@ mod tests {
mid.walk(&mut |n| ids.push(n.id));
assert!(ids.iter().all(|i| *i == NodeId(0)), "{ids:?}");
}
/// A blend of `a` and `b` spaced by `spacing`.
fn blend_of(a: Node, b: Node, spacing: BlendSpacing) -> Node {
let spec = BlendSpec { spacing, ..Default::default() };
Node::new(NodeId(1), NodeKind::Blend { children: vec![Arc::new(a), Arc::new(b)], spec: Box::new(spec) })
}
/// A blend's keys and options.
fn blend_parts(n: &Node) -> Option<(&Vec<Arc<Node>>, &BlendSpec)> {
match &n.kind {
NodeKind::Blend { children, spec } => Some((children, spec)),
_ => None,
}
}
/// A square of side `side` at (`x`, `y`).
fn square(x: f64, y: f64, side: f64) -> Node {
Node::path(NodeId(1), shapes::rectangle(Rect::new(x, y, x + side, y + side)), Appearance::default())
}
/// `levels` of blends of 15 steps whose keys are the blends a level down (#3579's input).
fn nested(levels: u32, x: f64) -> Node {
if levels == 0 {
return square(x, 0.0, 4.0);
}
let w = 40.0 * f64::from(levels);
blend_of(nested(levels - 1, x), nested(levels - 1, x + w), BlendSpacing::Steps(15))
}
/// The steps drawing `n` makes in all, each blend evaluated as the renderer does: one level at
/// a time, the blends in its keys and steps evaluated again.
fn drawn_steps(n: &Node) -> usize {
match &n.kind {
NodeKind::Blend { children, spec } => {
let out = blend_expand(children, spec);
out.len() - children.len() + out.iter().map(drawn_steps).sum::<usize>()
}
_ => n.children().map_or(0, |c| c.iter().map(|c| drawn_steps(c)).sum()),
}
}
/// Steps `levels` of [`nested`] blends of 15 steps make in all: each level its 15 steps,
/// and again the steps of the level below in each key and each step.
fn nested_steps(levels: u32) -> usize {
(17usize.pow(levels) - 1) * 15 / 16
}
/// #3579: blends whose keys are blends draw their steps again in every step above them. Five
/// levels of 15 steps made 1.3 million steps from a 23 KB file. Now a blend makes fewer steps
/// where they would pass `MAX_BLEND_STEPS`, and none where its keys already fill it, so each
/// level past that adds only what its keys draw.
#[test]
fn nested_blends_keep_to_the_step_budget() {
assert_eq!(nested_steps(5), 1_331_115);
let four = nested_steps(4);
assert!(four < MAX_BLEND_STEPS && 2 * four > MAX_BLEND_STEPS);
let five = nested(5, 0.0);
let (children, spec) = blend_parts(&five).unwrap();
assert_eq!(blend_steps(children, spec), vec![0]);
assert_eq!(drawn_steps(&five), 2 * four);
assert_eq!(drawn_steps(&nested(6, 0.0)), 4 * four);
// A blend whose keys leave room keeps the same share of its steps between each pair.
let mut three = nested(3, 0.0);
if let NodeKind::Blend { spec, .. } = &mut three.kind {
spec.spacing = BlendSpacing::Steps(1000);
}
let (children, spec) = blend_parts(&three).unwrap();
let inside = 2 * nested_steps(2);
let each = 1 + nested_steps(2);
let kept = (MAX_BLEND_STEPS - inside) * 1000 / (1000 * each);
assert_eq!(blend_steps(children, spec), vec![kept]);
let drawn = drawn_steps(&three);
assert_eq!(drawn, inside + kept * each);
assert!(drawn <= MAX_BLEND_STEPS && drawn > MAX_BLEND_STEPS - each, "{drawn}");
}
/// Nested blends within the budget make every step their spacing asks for, as before.
#[test]
fn nested_blends_within_the_budget_draw_every_step() {
for levels in 1..=4 {
let n = nested(levels, 0.0);
let (children, spec) = blend_parts(&n).unwrap();
assert_eq!(blend_steps(children, spec), vec![15]);
assert_eq!(drawn_steps(&n), nested_steps(levels), "{levels}");
}
// The keys, then 15 steps, then the last key, each a blend whose keys moved with it.
let two = nested(2, 0.0);
let (children, spec) = blend_parts(&two).unwrap();
let out = blend_expand(children, spec);
assert_eq!(out.len(), 17);
assert_eq!(out[0], *children[0]);
assert!(out.iter().all(|s| matches!(s.kind, NodeKind::Blend { .. })));
assert_eq!(out[8].geometric_bounds(), children[0].geometric_bounds().map(|b| b + Vec2::new(40.0, 0.0)));
}
/// A blend spaced by distance makes more steps scaled up, so a step's copy of one counts the
/// most its spacing can make. Here a tiny blend of one step is blended with a large blend of
/// one step: copies of the tiny one, scaled up toward the large one's size, make up to 1000
/// steps each, which neither key's own count shows.
#[test]
fn scaled_copies_of_blends_spaced_by_distance_keep_to_the_budget() {
let tiny = blend_of(square(0.0, 0.0, 1.0), square(2.0, 0.0, 1.0), BlendSpacing::Distance(1.0));
let large = blend_of(square(0.0, 100.0, 1000.0), square(2000.0, 100.0, 1000.0), BlendSpacing::Steps(1));
assert_eq!((drawn_steps(&tiny), drawn_steps(&large)), (1, 1));
let n = blend_of(tiny, large, BlendSpacing::Steps(1000));
let (children, spec) = blend_parts(&n).unwrap();
let steps = blend_steps(children, spec);
assert!(steps[0] < 1000, "{steps:?}");
let drawn = drawn_steps(&n);
assert!(drawn <= MAX_BLEND_STEPS && drawn > 1000, "{drawn}");
}
/// A flat blend of many keys shares the budget evenly between its pairs.
#[test]
fn many_keys_share_the_step_budget() {
let keys: Vec<Arc<Node>> = (0..300).map(|i| key(f64::from(i) * 20.0)).collect();
let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() };
let steps = blend_steps(&keys, &spec);
assert!(steps.iter().all(|&n| n == MAX_BLEND_STEPS * 1000 / (299 * 1000)), "{:?}", &steps[..3]);
assert_eq!(blend_expand(&keys, &spec).len(), 300 + steps.iter().sum::<usize>());
// Within the budget, every step stays.
assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]);
assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]);
}
}
+328
View File
@@ -0,0 +1,328 @@
//! A read document brought within the canvas: what lies beyond [`MAX_COORD`] is removed (#3575).
//! Edits refuse to leave anything there (`doc_sane` in the engine checks the artboards, the guides,
//! the ruler origin and the objects an edit touches), so a file holding something beyond it could be
//! opened but not edited: one object, artboard or guide out there made every move fail.
use std::sync::Arc;
use vectorcraft_geom::{Point, Rect};
use crate::{Document, MAX_COORD, Node, NodeId, NodeKind};
/// What [`Document::drop_beyond_canvas`] removed.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct BeyondCanvas {
/// Objects removed whole (a layer or group keeps its children within the canvas).
pub objects: usize,
pub artboards: usize,
/// Guides beyond the canvas, and the artboard guides of the artboards removed.
pub guides: usize,
/// The ruler origin was beyond the canvas and is back at the origin.
pub ruler_origin: bool,
}
impl BeyondCanvas {
/// The warning for what was removed, or `None` when nothing was.
pub fn note(&self) -> Option<String> {
let count = |n: usize, one: &str, many: &str| match n {
0 => None,
1 => Some(format!("1 {one}")),
n => Some(format!("{n} {many}")),
};
let parts: Vec<String> =
[count(self.objects, "object", "objects"), count(self.artboards, "artboard", "artboards"), count(self.guides, "guide", "guides")]
.into_iter()
.flatten()
.collect();
let limit = format!("more than {MAX_COORD} pt from the origin");
match (parts.is_empty(), self.ruler_origin) {
(true, false) => None,
(true, true) => Some(format!("the ruler origin lay beyond the canvas ({limit}) and was reset")),
(false, origin) => Some(format!(
"{} beyond the canvas ({limit}) {} removed{}",
parts.join(", "),
if self.objects + self.artboards + self.guides == 1 { "was" } else { "were" },
if origin { "; the ruler origin, beyond it too, was reset" } else { "" }
)),
}
}
}
/// Is every side of `r` finite and within [`MAX_COORD`] of the origin?
fn rect_in_range(r: Rect) -> bool {
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| in_range(*v))
}
fn in_range(v: f64) -> bool {
v.is_finite() && v.abs() <= MAX_COORD
}
/// Does `n` reach beyond the canvas? An object without geometry doesn't.
fn beyond(n: &Node) -> bool {
n.geometric_bounds().is_some_and(|b| !rect_in_range(b))
}
/// Whether `n` is a layer or group, and if so whether it clips: the containers whose objects are
/// looked at one by one. Anything else is one object, measured whole.
fn clipping(n: &Node) -> Option<bool> {
match n.kind {
NodeKind::Layer { clip, .. } | NodeKind::Group { clip, .. } => Some(clip),
_ => None,
}
}
/// Does `n`, or any object in it, reach beyond the canvas? A container is looked into object by
/// object: its own bounds leave out a layer's hidden objects and, when it clips, all but its
/// clipping path.
fn reaches_beyond(n: &Node) -> bool {
match clipping(n) {
None => beyond(n),
Some(_) => n.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))),
}
}
/// Does `n` clip by a path that reaches beyond the canvas? Then the path and the objects it clips
/// go together, so no other object becomes the clipping path.
fn clips_beyond(n: &Node) -> bool {
clipping(n) == Some(true) && n.children().and_then(|c| c.first()).is_some_and(|c| reaches_beyond(c))
}
/// Add the ids of `n` and the layers in it, if it is a layer, to `layers`: their layer guides go
/// with them.
fn layers_in(n: &Node, layers: &mut Vec<NodeId>) {
if matches!(n.kind, NodeKind::Layer { .. }) {
layers.push(n.id);
}
if clipping(n).is_some() {
for c in n.children().into_iter().flatten() {
layers_in(c, layers);
}
}
}
/// Remove the objects in `node` that reach beyond the canvas: a layer or group keeps those of its
/// objects within it, unless its clipping path reaches beyond (see [`clips_beyond`]); anything
/// else goes whole. Only containers holding such an object are copied. Recursion follows the
/// nesting, which [`Document::limit_depth`] bounds. The layers removed are added to `layers`.
fn drop_children_beyond(node: &mut Arc<Node>, dropped: &mut usize, layers: &mut Vec<NodeId>) {
if !node.children().is_some_and(|c| c.iter().any(|c| reaches_beyond(c))) {
return;
}
let Some(children) = Arc::make_mut(node).children_mut() else { return };
children.retain_mut(|c| {
if !reaches_beyond(c) {
return true;
}
if clipping(c).is_some() && !clips_beyond(c) {
drop_children_beyond(c, dropped, layers);
return true;
}
layers_in(c, layers);
*dropped += 1;
false
});
}
impl Document {
/// Remove what a read file has beyond the canvas ([`MAX_COORD`], or not finite): objects
/// (see [`BeyondCanvas::objects`]; a top-level layer clipping by a path beyond it is emptied
/// and stops clipping), artboards with their ruler origins and artboard guides (their ids
/// are retired, as deleting them does), guides with the layer guides of the layers removed,
/// and put a ruler origin beyond it back at the origin. Saved selections, assets and variable
/// bindings then forget the objects removed. Run after [`Document::limit_depth`]. A document
/// whose artboards were all removed has none left.
pub fn drop_beyond_canvas(&mut self) -> BeyondCanvas {
let mut gone = BeyondCanvas::default();
let mut layers = vec![];
for layer in &mut self.layers {
if clips_beyond(layer) {
let layer = Arc::make_mut(layer);
if let NodeKind::Layer { children, clip, .. } = &mut layer.kind {
for c in children.iter() {
layers_in(c, &mut layers);
}
gone.objects += children.len();
children.clear();
*clip = false;
}
} else {
drop_children_beyond(layer, &mut gone.objects, &mut layers);
}
}
if gone.objects > 0 {
self.tidy_saved_selections();
self.prune_assets();
self.prune_variable_bindings();
}
let removed: Vec<u32> = self.artboards.iter().filter(|a| !rect_in_range(a.rect)).map(|a| a.id).collect();
self.artboards.retain(|a| rect_in_range(a.rect));
for id in &removed {
self.artboard_ruler_origins.remove(id);
self.retire_artboard_id(*id);
}
gone.artboards = removed.len();
let guides = self.guides.len();
self.guides.retain(|g| in_range(g.pos) && g.artboard.is_none_or(|a| !removed.contains(&a)) && g.layer.is_none_or(|l| !layers.contains(&l)));
gone.guides = guides - self.guides.len();
if !(in_range(self.ruler_origin.x) && in_range(self.ruler_origin.y)) {
self.ruler_origin = Point::ZERO;
gone.ruler_origin = true;
}
gone
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Appearance, Artboard, Guide};
use vectorcraft_geom::shapes;
fn rect(id: u64, y: f64) -> Arc<Node> {
Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, y, 10.0, y + 10.0)), Appearance::default_art()))
}
fn doc(children: Vec<Arc<Node>>) -> Document {
let mut d = Document::new(100.0, 100.0);
if let Some(ch) = d.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
ch.extend(children);
}
d
}
fn ids(d: &Document) -> Vec<u64> {
let mut out = vec![];
fn walk(n: &Node, out: &mut Vec<u64>) {
out.push(n.id.0);
for c in n.children().into_iter().flatten() {
walk(c, out);
}
}
for l in &d.layers {
walk(l, &mut out);
}
out
}
#[test]
fn a_document_within_the_canvas_is_left_alone() {
let mut d = doc(vec![rect(10, 0.0), rect(11, MAX_COORD - 10.0), rect(12, -MAX_COORD)]);
let before = d.clone();
assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default());
assert_eq!(d, before);
assert_eq!(BeyondCanvas::default().note(), None);
}
#[test]
fn objects_beyond_the_canvas_are_removed_and_groups_keep_the_rest() {
let group = Arc::new(Node::group(NodeId(20), vec![rect(21, 0.0), rect(22, 1e17)]));
let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, f64::NAN), rect(32, 0.0)], clip: true }));
let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), group, clip, rect(12, -MAX_COORD - 1.0)]);
let layer = d.layers[0].id.0;
let gone = d.drop_beyond_canvas();
assert_eq!(gone, BeyondCanvas { objects: 4, ..Default::default() });
assert_eq!(ids(&d), [layer, 10, 20, 21], "the group keeps its object within the canvas; the clip group goes with its clipping path");
assert!(d.layers.iter().all(|l| !beyond(l)));
assert_eq!(gone.note().unwrap(), format!("4 objects beyond the canvas (more than {MAX_COORD} pt from the origin) were removed"));
}
#[test]
fn artboards_guides_and_the_ruler_origin_beyond_the_canvas_are_removed_or_reset() {
let mut d = doc(vec![rect(10, 0.0)]);
let far = Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() };
d.artboards.push(far);
let guide = |pos: f64, artboard| Guide { artboard, ..Guide::new(false, pos) };
d.guides = vec![guide(50.0, None), guide(1e17, None), guide(f64::INFINITY, None), guide(20.0, Some(9))];
d.ruler_origin = Point::new(1e17, 0.0);
let gone = d.drop_beyond_canvas();
assert_eq!(gone, BeyondCanvas { objects: 0, artboards: 1, guides: 3, ruler_origin: true });
assert_eq!(d.artboards.len(), 1);
assert_eq!(d.guides, [guide(50.0, None)], "the far artboard's own guide goes with it");
assert_eq!(d.ruler_origin, Point::ZERO);
assert_eq!(
gone.note().unwrap(),
format!(
"1 artboard, 3 guides beyond the canvas (more than {MAX_COORD} pt from the origin) were removed; the ruler origin, beyond it too, was reset"
)
);
}
/// A sublayer like the document's first layer, holding `children`.
fn sublayer(d: &Document, id: u64, children: Vec<Arc<Node>>, clip: bool) -> Arc<Node> {
let mut l = (*d.layers[0]).clone();
l.id = NodeId(id);
if let NodeKind::Layer { children: c, clip: k, .. } = &mut l.kind {
*c = children;
*k = clip;
}
Arc::new(l)
}
#[test]
fn objects_hidden_or_clipped_inside_a_container_are_looked_at_one_by_one() {
// A layer's bounds leave out its hidden objects, and a clip group's are its clipping path's.
let mut hidden = (*rect(41, 1e17)).clone();
hidden.visible = false;
let probe = Document::new(1.0, 1.0);
let sub = sublayer(&probe, 40, vec![Arc::new(hidden), rect(42, 0.0)], false);
let clip = Arc::new(Node::new(NodeId(30), NodeKind::Group { children: vec![rect(31, 0.0), rect(32, 1e17), rect(33, 5.0)], clip: true }));
let mut d = doc(vec![sub, clip]);
let layer = d.layers[0].id.0;
assert_eq!(d.drop_beyond_canvas().objects, 2);
assert_eq!(ids(&d), [layer, 40, 42, 30, 31, 33], "the far hidden object and the far clipped one go; the clipping path stays first");
}
#[test]
fn a_top_level_layer_clipping_by_a_path_beyond_the_canvas_is_emptied() {
let mut d = doc(vec![rect(10, 1e17), rect(11, 0.0), rect(12, 5.0)]);
if let NodeKind::Layer { clip, .. } = &mut Arc::make_mut(&mut d.layers[0]).kind {
*clip = true;
}
assert_eq!(d.drop_beyond_canvas().objects, 3, "the clipping path and what it clipped go together");
assert!(
matches!(&d.layers[0].kind, NodeKind::Layer { children, clip: false, .. } if children.is_empty()),
"no other object becomes the clipping path"
);
// A clipping sublayer goes whole, as a clip group does.
let mut d = doc(vec![]);
let sub = sublayer(&d, 40, vec![rect(41, 1e17), rect(42, 0.0)], true);
let mut d2 = doc(vec![sub, rect(10, 0.0)]);
let layer = d2.layers[0].id.0;
assert_eq!(d2.drop_beyond_canvas().objects, 1);
assert_eq!(ids(&d2), [layer, 10]);
assert_eq!(d.drop_beyond_canvas(), BeyondCanvas::default());
}
#[test]
fn a_removed_artboards_ruler_origin_goes_and_its_id_is_not_given_again() {
let mut d = doc(vec![]);
let kept = d.artboards[0].id;
d.artboards.push(Artboard { id: 9, rect: Rect::new(0.0, 1e17, 100.0, 1e17 + 100.0), ..d.artboards[0].clone() });
d.artboard_ruler_origins.insert(kept, vectorcraft_geom::Vec2::new(1.0, 2.0));
d.artboard_ruler_origins.insert(9, vectorcraft_geom::Vec2::new(3.0, 4.0));
assert_eq!(d.drop_beyond_canvas().artboards, 1);
assert_eq!(d.artboard_ruler_origins.keys().copied().collect::<Vec<_>>(), [kept]);
assert!(d.next_artboard_id() > 9, "a new artboard doesn't take the removed one's id");
}
#[test]
fn what_names_a_removed_object_forgets_it() {
let probe = Document::new(1.0, 1.0);
let sub = sublayer(&probe, 40, vec![rect(41, 1e17), rect(42, 0.0)], true);
let mut d = doc(vec![rect(10, 0.0), rect(11, 1e17), sub]);
d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }];
d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into();
d.assets = vec![
crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] },
crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] },
];
d.variables.bindings.insert(NodeId(11), ["v".to_string()].into());
d.variables.bindings.insert(NodeId(10), ["w".to_string()].into());
let gone = d.drop_beyond_canvas();
assert_eq!((gone.objects, gone.guides), (2, 1), "the far rectangle, the clipping sublayer and its layer guide");
assert_eq!(d.guides.len(), 1, "the guide of a layer that stays, stays");
assert_eq!(d.saved_selections[0].objects, [NodeId(10)]);
assert_eq!(d.assets.iter().map(|a| (a.id, a.nodes.clone())).collect::<Vec<_>>(), [(2, vec![NodeId(10)])]);
assert_eq!(d.variables.bindings.keys().copied().collect::<Vec<_>>(), [NodeId(10)]);
}
}
+1 -1
View File
@@ -125,7 +125,7 @@ pub fn oversized_tiff(bytes: &[u8]) -> Option<(u32, u32)> {
}
/// The color type of TIFF `bytes` and its decoder; `None` for [`oversized_tiff`] images too.
fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Decoder<Cursor<&[u8]>>)> {
if !is_tiff(bytes) || oversized_tiff(bytes).is_some() {
return None;
}
+76
View File
@@ -0,0 +1,76 @@
//! Compound shapes (Pathfinder panel › Make Compound Shape): a group whose members each have a
//! shape mode. It draws as the one area the modes make, from the backmost member up, painted with
//! the group's own appearance; the members stay editable. Evaluated in `vectorcraft-effects`.
use serde::{Deserialize, Deserializer, Serialize};
use crate::{Node, NodeKind};
/// How a compound shape's member combines with the area of the members below it. The backmost
/// member's mode does not matter: it starts the area.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum ShapeMode {
/// Adds the member's area.
#[default]
Add,
/// Takes the member's area away.
Subtract,
/// Keeps only what the member's area overlaps.
Intersect,
/// Keeps where exactly one of the area and the member's area is.
Exclude,
}
impl ShapeMode {
/// Every mode, in the Pathfinder panel's order.
pub const ALL: [ShapeMode; 4] = [ShapeMode::Add, ShapeMode::Subtract, ShapeMode::Intersect, ShapeMode::Exclude];
/// The mode's name in commands and files.
pub fn name(self) -> &'static str {
match self {
ShapeMode::Add => "add",
ShapeMode::Subtract => "subtract",
ShapeMode::Intersect => "intersect",
ShapeMode::Exclude => "exclude",
}
}
/// The mode named `s` ([`ShapeMode::name`]).
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.name() == s)
}
}
/// Files are untrusted: a mode that isn't one of the names reads as Add.
impl<'de> Deserialize<'de> for ShapeMode {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_str().and_then(ShapeMode::parse).unwrap_or_default())
}
}
impl Node {
/// Is this a compound shape? Only a group that doesn't clip can be one: the mark on anything
/// else (from a file) is ignored.
pub fn is_compound_shape(&self) -> bool {
matches!(self.kind, NodeKind::Group { clip: false, .. }) && self.extras().compound_shape
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_read_back_and_unknown_ones_are_add() {
for m in ShapeMode::ALL {
let v = serde_json::to_value(m).unwrap();
assert_eq!(v, serde_json::json!(m.name()));
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), m);
}
for v in [serde_json::json!("divide"), serde_json::json!(3), serde_json::json!(null), serde_json::json!({"a": [1]})] {
assert_eq!(serde_json::from_value::<ShapeMode>(v).unwrap(), ShapeMode::Add);
}
}
}
+4 -1
View File
@@ -3,7 +3,10 @@ use super::graphic_style::default_graphic_styles;
use super::guides::{GridPrefs, RulerMode};
use super::limits::{DocError, FORMAT_VERSION, MAX_COORD, MAX_NEXT_ID};
use super::units::{ColorMode, Unit};
use super::*;
use super::{
Arc, BTreeMap, ColorProfiles, DocMetadata, DocSetup, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, PatternDef, Places, Point,
RasterEffectsSettings, Rect, Vec2,
};
impl Document {
/// Is everything but the layers the same in `other`, as the next version of the document after
+4 -1
View File
@@ -1,7 +1,10 @@
use super::graphic_style::GraphicStyle;
use super::limits::MAX_NEXT_ID;
use super::records::{ImageBlob, SavedSelection, SavedView};
use super::*;
use super::{
Arc, Document, ImageObject, Node, NodeKind, SYMBOL_EXPORT_TYPE, SYMBOL_NINE_SLICE, SYMBOL_REGISTRATION, SymbolExportType, SymbolNineSlice,
SymbolRegistration,
};
impl Document {
/// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's.
+1 -1
View File
@@ -1,6 +1,6 @@
use super::document_ext::free_id;
use super::guides::Guide;
use super::*;
use super::{Affine, Arc, Document, Node, NodeId, NodeKind, PatternDef, Point, Rect, Selection, SymbolBoxes, Vec2, artboard_holds};
impl Document {
/// Union of the geometric bounds of `ids` (their visual bounds when `visual`), with symbol
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Appearance, Deserialize, Knockout, Node, Rect, Serialize, skip};
/// A named graphic style (Graphic Styles panel): an appearance plus the object's transparency.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, NodeId, Serialize, Vec2};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Guide {
+68 -1
View File
@@ -110,6 +110,66 @@ pub fn hit_test_visible(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit
hit_test_skipping(doc, p, opt, &|_| false, 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
/// art instead of starting a marquee: an image in a mask, picked in the Layers panel, can be
/// dragged by its hidden part, whose frame shows while it is selected. An object that a click
/// can't hit anyway doesn't count: hidden, locked, a template, a guide while guides are locked
/// or hidden, in a Shaper group's part a click can't reach, outside the isolated container
/// (`opt.scope`) or on a locked artboard. On a layer whose Preview is off it counts by its
/// outline, as a click there hits it.
pub fn selected_hidden_at(doc: &Document, p: Point, opt: HitOptions, selected: &[NodeId]) -> Option<NodeId> {
let symbols = doc.symbol_boxes();
let locks = doc.locked_artboards();
selected.iter().rev().copied().find(|&id| {
let Some(chain) = doc.ancestry(id) else { return false };
let nodes: Vec<&Node> = chain.iter().filter_map(|a| doc.node(*a)).collect();
if nodes.len() != chain.len()
|| nodes.iter().any(|n| !n.visible || n.locked || n.is_template())
|| opt.scope.is_some_and(|s| !chain.contains(&s))
|| locks.holds_id(id)
{
return false;
}
// How a click reaches it, if one does.
let Some(opt) = path_options(&nodes, &chain, opt) else { return false };
// Hidden by a mask: one of the containers around it clips, and not to `p`.
let masked = nodes
.iter()
.rev()
.skip(1)
.any(|n| n.clips() && n.children().and_then(|c| c.first()).is_some_and(|clip| clip.id != id && !clip_contains(clip, p)));
// A click may reach it there ([`may_hit`]: not a guide while guides are locked or
// hidden, nor a Shaper group's hidden part), and its own art is what a click would hit
// in it: a group's hidden, locked or masked contents don't count.
let [.., parent, n] = nodes.as_slice() else { return false };
let reachable =
parent.children().and_then(|c| c.iter().position(|c| c.id == id)).is_some_and(|i| may_hit(parent, i, n, p, opt, &symbols, true));
masked && reachable && object_hit(n, p, opt, &symbols, true)
})
}
/// The options a click goes down `nodes` (an object's ancestry, `chain`, the layer first) with, as
/// [`hit_test`] goes: from the isolated container (`opt.scope`), else from the layer, in outline
/// below a layer whose Preview is off. `None` when a Shaper group's part it can't reach lies on
/// the way ([`shaper_hides`]).
fn path_options(nodes: &[&Node], chain: &[NodeId], opt: HitOptions) -> Option<HitOptions> {
let scope_at = opt.scope.and_then(|s| chain.iter().position(|a| *a == s));
let child_index = |parent: &Node, child: &Node| parent.children().and_then(|c| c.iter().position(|c| c.id == child.id));
let hidden = nodes.windows(2).skip(scope_at.unwrap_or(0)).any(|w| match w {
[parent, child] => child_index(parent, child).is_some_and(|i| shaper_hides(parent, i, opt.scope)),
_ => false,
});
if hidden {
return None;
}
// The isolated container's own Preview doesn't count: a click starts inside it.
let below = scope_at.map_or(0, |i| i + 1);
let outline = nodes.iter().skip(below).any(|n| matches!(n.kind, NodeKind::Layer { preview: false, .. }));
Some(if outline { HitOptions { outline: true, ..opt } } else { opt })
}
/// The objects under `p` that [`Hit::top_object`] picks in `scope` (isolation mode), topmost
/// first: what clicks there select, one below the other (Cmd/Ctrl-click selects behind).
pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeId>) -> Vec<NodeId> {
@@ -277,7 +337,14 @@ fn locked_children(parent: &Node, p: Point, opt: HitOptions, symbols: &SymbolBox
/// Would a click at `p` hit the locked object `n` if it were not locked?
fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> bool {
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), false).is_some();
object_hit(n, p, opt, symbols, false)
}
/// Would a click at `p` hit the object `n`, wherever it is? A container is hit by its contents as
/// [`hit_test`] tries them (its own clipping mask, and with `editable_only` none of its hidden,
/// locked or template contents).
fn object_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> bool {
let inside = || hit_children(n, p, opt, symbols, &|_| false, &mut Vec::new(), editable_only).is_some();
match &n.kind {
NodeKind::Group { .. } | NodeKind::Intertwine { .. } | NodeKind::Envelope { editing: true, .. } => inside(),
NodeKind::Blend { .. } => inside() || hit_leaf(n, p, opt, symbols).is_some(),
+142
View File
@@ -77,6 +77,148 @@ fn half_clipped(d: &mut Document) -> (Node, Node) {
(mask, art)
}
/// The art a mask hides counts only for a selected object: there a click hits nothing, and the
/// selection tools grab the selection by that art. Not where the art shows (the click hits it),
/// beyond the art, for the clip group selected as a whole, nor for art a click can't hit (locked,
/// hidden, outside the isolated container). A layer's clipping mask hides art as a clip group's
/// does.
#[test]
fn a_selected_objects_art_hidden_by_a_mask_is_found_under_the_pointer() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id) = (d.alloc_id(), art.id);
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
let (hidden, shown, beyond) = (Point::new(150.0, 50.0), Point::new(50.0, 50.0), Point::new(250.0, 50.0));
let opt = HitOptions::default();
assert!(hit_test(&d, hidden, opt).is_none());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
assert_eq!(selected_hidden_at(&d, hidden, opt, &[]), None);
assert_eq!(selected_hidden_at(&d, shown, opt, &[art_id]), None);
assert_eq!(selected_hidden_at(&d, beyond, opt, &[art_id]), None);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None);
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(g), ..opt }, &[art_id]), Some(art_id));
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(art_id), ..opt }, &[art_id]), Some(art_id));
let other = d.add_layer(Some("Other"));
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(other), ..opt }, &[art_id]), None);
for change in [|n: &mut Node| n.locked = true, |n: &mut Node| n.visible = false] {
let mut d = d.clone();
change(d.node_mut(art_id).unwrap());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), None);
}
// A layer's clipping mask.
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let art_id = art.id;
d.insert(Some(l), 0, mask).unwrap();
d.insert(Some(l), 1, art).unwrap();
d.node_mut(l).unwrap().set_clips(true);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[art_id]), Some(art_id));
}
/// A selected group's own art is what a click would hit in it: not a hidden or locked child, nor
/// art its own clipping mask hides, all under the pointer where an outer mask hides them.
#[test]
fn a_selected_groups_hidden_art_counts_only_where_a_click_would_hit_it() {
// An outer clip group masking to the left half (0..100); the selected group inside it holds
// `child` (100..200 × 0..100, under the pointer at (150, 50)) and `shown` (inside the mask).
let build = |change: &dyn Fn(&mut Node, &mut Node)| {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let outer_mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let mut child = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
let (gid, cid) = (d.alloc_id(), d.alloc_id());
let mut group = Node::group(gid, vec![Arc::new(shown)]);
change(&mut group, &mut child);
if let NodeKind::Group { children, .. } = &mut group.kind {
children.push(Arc::new(child));
}
let mut clip = Node::group(cid, vec![Arc::new(outer_mask), Arc::new(group)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
(d, gid)
};
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
let (d, g) = build(&|_, _| {});
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), Some(g));
let (d, g) = build(&|_, child| child.visible = false);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a hidden child");
let (d, g) = build(&|_, child| child.locked = true);
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "a locked child");
// The selected group is a clip group too, whose own mask (its first child, 10..40) leaves
// `child` out.
let (d, g) = build(&|group, _| group.set_clips(true));
assert_eq!(selected_hidden_at(&d, hidden, opt, &[g]), None, "its own mask");
}
/// A selected guide path where a mask hides it counts only while guides can be clicked: not
/// while they are locked or hidden (`skip_guides`), as a click on it then hits nothing.
#[test]
fn a_selected_guide_hidden_by_a_mask_counts_only_while_guides_can_be_clicked() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, mut art) = half_clipped(&mut d);
if let NodeKind::Path { guide, .. } = &mut art.kind {
*guide = true;
}
let (g, guide_id) = (d.alloc_id(), art.id);
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[guide_id]), Some(guide_id));
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { skip_guides: true, ..opt }, &[guide_id]), None);
}
/// A selected path in a Shaper group's original shapes, where an outer mask hides it, counts only
/// while those shapes can be clicked (Construction mode, with them isolated), however deep it
/// lies in them.
#[test]
fn a_selected_path_in_a_shaper_groups_hidden_shapes_counts_only_in_construction_mode() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
// The outer clip group masks to 0..100; the path, 100..200, lies in the Shaper group's
// original shapes (its first child), under (150, 50).
let mask = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
let path = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(100.0, 0.0, 200.0, 100.0)), Appearance::default_art());
let path_id = path.id;
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(path)]);
sources.name = Some(crate::shaper::SOURCES.into());
let sources_id = sources.id;
let face = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), Appearance::default_art());
let mut shaper = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
shaper.extras_mut().shaper = Some(Box::default());
let mut clip = Node::group(d.alloc_id(), vec![Arc::new(mask), Arc::new(shaper)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
let (hidden, opt) = (Point::new(150.0, 50.0), HitOptions::default());
assert_eq!(selected_hidden_at(&d, hidden, opt, &[path_id]), None);
assert_eq!(selected_hidden_at(&d, hidden, HitOptions { scope: Some(sources_id), ..opt }, &[path_id]), Some(path_id));
}
/// On a layer whose Preview is off, a click hits art in outline: a selected object's hidden art
/// counts on its outline, not inside its fill.
#[test]
fn a_selected_objects_hidden_art_on_a_preview_off_layer_counts_on_its_outline() {
let mut d = Document::new(300.0, 200.0);
let l = d.layers[0].id;
let (mask, art) = half_clipped(&mut d);
let (g, art_id) = (d.alloc_id(), art.id);
let mut clip = Node::group(g, vec![Arc::new(mask), Arc::new(art)]);
clip.set_clips(true);
d.insert(Some(l), 0, clip).unwrap();
if let NodeKind::Layer { preview, .. } = &mut d.node_mut(l).unwrap().kind {
*preview = false;
}
let opt = HitOptions::default();
assert_eq!(selected_hidden_at(&d, Point::new(150.0, 50.0), opt, &[art_id]), None, "inside the fill");
assert_eq!(selected_hidden_at(&d, Point::new(200.0, 50.0), opt, &[art_id]), Some(art_id), "on the outline");
}
/// #2345: a marquee touches a clip group only where its art shows. Over the clipped-away
/// half alone it selects nothing, and around a rectangle drawn beside the group's mask it
/// takes that rectangle alone; reaching into the mask takes the group. The same holds for
+6 -3
View File
@@ -109,9 +109,9 @@ fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
}
}
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, not
/// opacity masks) with its swatch link and tint. Mesh points have no link: changes to it are
/// dropped.
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an
/// image's colorize color, not opacity masks) with its swatch link and tint. Mesh points have no
/// link: changes to it are dropped.
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
for it in &mut n.appearance.items {
match it {
@@ -119,6 +119,9 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f),
}
}
if let Some(p) = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut()) {
visit_paint(p, f);
}
match &mut n.kind {
NodeKind::Text(t) => {
for r in &mut t.runs {
+5 -1
View File
@@ -11,6 +11,7 @@ pub mod blend;
pub mod cancel;
pub mod clipnest;
pub mod cmyk;
pub mod compound_shape;
pub mod corners;
pub mod depth;
pub mod dimension;
@@ -42,7 +43,7 @@ pub mod overprint;
pub mod pattern;
pub mod perspective;
mod pixels;
pub use pixels::MAX_RASTER_ALLOC;
pub use pixels::{GraySource, MAX_RASTER_ALLOC};
pub mod placed_document;
pub mod plugin_data;
pub mod profiles;
@@ -76,6 +77,7 @@ pub use appearance::{
StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use compound_shape::ShapeMode;
pub use corners::LiveCorners;
pub use depth::{MAX_DEPTH, MAX_SYMBOL_NODES};
pub use graph::{
@@ -117,6 +119,7 @@ use vectorcraft_color::{Swatch, SwatchGroup};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
mod artboard;
mod canvas_range;
mod document;
mod document_ext;
mod document_guides;
@@ -127,6 +130,7 @@ mod records;
mod units;
pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS};
pub use canvas_range::BeyondCanvas;
#[cfg(test)]
use document_ext::free_id;
pub use graphic_style::{DEFAULT_GRAPHIC_STYLE, GraphicStyle};
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::NodeId;
/// Coordinates beyond this many points exceed the supported document range.
pub const MAX_COORD: f64 = 4.0e6;
+65 -1
View File
@@ -126,6 +126,40 @@ impl ImageBlob {
self
}
/// This blob after its pixels are decoded once, within [`crate::MAX_RASTER_ALLOC`], to check
/// that they can be read; with `proxy`, it also gets the preview [`Self::with_proxy`] makes,
/// made from that decode. `None` when the pixels can't be read, as with a file cut short that
/// has a header but not all its pixels. An image whose pixels would take more than the
/// allowance as RGBA is not decoded, and one whose decode needs more than the allowance stops
/// decoding; either is returned as it is.
pub fn checked_with_proxy(self, proxy: bool) -> Option<Self> {
self.checked_with_pixels(proxy).map(|(blob, _)| blob)
}
/// [`Self::checked_with_proxy`], with the pixels it decoded: `None` beside the blob when it
/// decoded none (an image past the allowance).
pub fn checked_with_pixels(mut self, proxy: bool) -> Option<(Self, Option<image::DynamicImage>)> {
let max = crate::MAX_RASTER_ALLOC;
let reader = || image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok();
let (w, h) = reader()?.into_dimensions().ok()?;
if u64::from(w) * u64::from(h) > max / 4 {
return Some((self, None));
}
let mut r = reader()?;
let mut limits = image::Limits::default();
limits.max_alloc = Some(max);
r.limits(limits);
let img = match r.decode() {
Ok(img) => img,
Err(image::ImageError::Limits(_)) => return Some((self, None)),
Err(_) => return None,
};
if proxy && self.proxy.is_none() && w.max(h) > PROXY_SIZE {
self.proxy = proxy_of(&img).map(Arc::new);
}
Some((self, Some(img)))
}
/// Are the bytes the preview (the linked file hasn't been read)?
pub fn is_proxy(&self) -> bool {
self.proxy.as_ref().is_some_and(|p| Arc::ptr_eq(p, &self.bytes))
@@ -149,7 +183,12 @@ fn proxy_png(bytes: &[u8]) -> Option<Vec<u8>> {
let mut limits = image::Limits::default();
limits.max_alloc = Some(crate::MAX_RASTER_ALLOC);
reader.limits(limits);
let small = reader.decode().ok()?.thumbnail(PROXY_SIZE, PROXY_SIZE);
proxy_of(&reader.decode().ok()?)
}
/// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG.
fn proxy_of(img: &image::DynamicImage) -> Option<Vec<u8>> {
let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE);
let mut png = Vec::new();
small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
Some(png)
@@ -442,6 +481,31 @@ mod tests {
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).with_proxy().proxy.is_none(), "undecodable");
}
/// A checked image's pixels are decoded once: the preview comes from that decode, and an image
/// whose header reads but whose pixels don't is refused.
#[test]
fn a_checked_image_is_refused_when_its_pixels_cant_be_read() {
let full = png(600, 300);
let big = ImageBlob::new("image/png", full.clone()).checked_with_proxy(true).unwrap();
let p = image::load_from_memory(big.proxy.as_ref().unwrap()).unwrap();
assert_eq!((p.width(), p.height()), (256, 128));
assert!(ImageBlob::new("image/png", full.clone()).checked_with_proxy(false).unwrap().proxy.is_none(), "no preview asked for");
assert!(ImageBlob::new("image/png", png(40, 20)).checked_with_proxy(true).unwrap().proxy.is_none(), "small images keep their pixels");
let cut = full[..full.len() / 2].to_vec();
assert_eq!(image::ImageReader::new(Cursor::new(&cut)).with_guessed_format().unwrap().into_dimensions().unwrap(), (600, 300));
assert!(ImageBlob::new("image/png", cut).checked_with_proxy(true).is_none(), "cut to half its length");
assert!(ImageBlob::new("image/png", vec![1, 2, 3]).checked_with_proxy(false).is_none(), "not an image");
}
/// A checked image hands on the pixels it decoded, so they need not be decoded again.
#[test]
fn a_checked_image_hands_on_its_pixels() {
let full = png(600, 300);
let (blob, pixels) = ImageBlob::new("image/png", full.clone()).checked_with_pixels(true).unwrap();
assert!(blob.proxy.is_some(), "the preview is made as checked_with_proxy makes it");
assert_eq!(pixels.map(|p| p.to_rgba8()), Some(image::load_from_memory(&full).unwrap().to_rgba8()));
}
/// PNG CRC-32 (the same polynomial ZIP uses), so a hand-written header is one a decoder accepts.
fn crc32(data: &[u8]) -> u32 {
let mut crc = !0u32;
+19
View File
@@ -221,6 +221,25 @@ mod tests {
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Rgb { .. }));
}
/// #3232: the Appearance highlight lightens each point towards white in its own model, so a
/// CMYK mesh stays CMYK (K shrinks with the other inks) and a grey one stays grey.
#[test]
fn mesh_highlight_keeps_each_points_model() {
let square = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let ink = Color::Cmyk { c: 0.0, m: 0.4, y: 0.6, k: 0.1 };
let m = GradientMesh::for_path(&square, 2, 2, ink, MeshAppearance::ToCenter, 50.0).unwrap();
assert_eq!(m.points[0].color, ink, "corners stay");
let centre = m.points[m.idx(1, 1)].color;
let Color::Cmyk { c, m: mg, y, k } = centre else { panic!("{centre:?}") };
assert!(c.abs() < 1e-6 && (mg - 0.2).abs() < 1e-6 && (y - 0.3).abs() < 1e-6 && (k - 0.05).abs() < 1e-6, "{c} {mg} {y} {k}");
assert!(matches!(m.color_at(0, 0, 0.5, 0.5).0, Color::Cmyk { .. }), "patches mix in CMYK");
let m = GradientMesh::for_path(&square, 2, 2, Color::gray(0.8), MeshAppearance::ToEdge, 100.0).unwrap();
assert_eq!((m.points[0].color, m.points[m.idx(1, 1)].color), (Color::gray(0.0), Color::gray(0.8)));
// RGB lightens in RGB as before.
let m = GradientMesh::for_path(&square, 2, 2, Color::rgb(1.0, 0.0, 0.0), MeshAppearance::ToCenter, 100.0).unwrap();
assert_eq!(m.points[m.idx(1, 1)].color.to_hex(), "#ffffff");
}
/// Alt-clicking a point of a mesh from a file with fewer points than its rows and columns need
/// deletes nothing (it indexed past the points, a panic).
#[test]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, PathData, Serialize};
// =====================================================================================
// Data
+1 -1
View File
@@ -1,6 +1,6 @@
use super::expand::{Hooks, Outliner, Warper, map_node};
use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of};
use super::*;
use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes};
/// How an envelope distorts its content.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+20 -6
View File
@@ -1,7 +1,10 @@
use super::envelope::{EnvelopeOptions, expand_envelope};
use super::envelope::{EnvelopeOptions, PreserveShape, expand_envelope};
use super::mesh::GradientMesh;
use super::warp::map_nonlinear;
use super::*;
use super::{
Affine, Appearance, AppearanceItem, Arc, Color, FillLayer, ImageObject, Node, NodeId, NodeKind, Paint, PathData, Point, Rect, SubPath, Vec2,
blend_expand,
};
/// Optional hook that converts nodes the pure evaluation can't map (text) to outlines.
pub type Outliner<'a> = Option<&'a dyn Fn(&Node) -> Option<Node>>;
@@ -154,8 +157,10 @@ fn affine_between(s: [Point; 3], d: [Point; 3]) -> Option<Affine> {
/// Image `n` (`im`) pushed through the warp as a raster mesh warp: its frame cut into
/// `w.cells`² cells of two triangles, each a clip group showing the image under the affine map
/// that takes the triangle where the warp takes its corners. Each piece reaches a little past its
/// edges (at most half a point) so neighbours overlap and no seams show.
/// that takes the triangle where the warp takes its corners (with Preserve Shape Using
/// Transparency, the image masked by the triangle; with Anti-Alias off, drawn without smoothing).
/// Each piece reaches a little past its edges (at most half a point) so neighbours overlap and no
/// seams show.
fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
let k = w.cells.max(1);
// Two triangles a cell, three objects each; an image an inner envelope already cut into
@@ -186,11 +191,20 @@ fn warp_image(n: &Node, im: &ImageObject, w: &Warper) -> Node {
if len > 1e-12 { *p + v * (grow / len) } else { *p }
})
.collect();
let mut clip = Node::path(n.id, PathData::single(SubPath::polyline(&tri, true)), Appearance::default());
let shape = PathData::single(SubPath::polyline(&tri, true));
let mut image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
image.extras_mut().pixelated = !w.options.anti_alias;
if w.options.preserve_shape == PreserveShape::Transparency {
// Preserve Shape Using Transparency: the triangle is the piece's alpha (a
// white opacity mask) instead of its clipping path.
let white = Node::path(n.id, shape, Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
pieces.push(Arc::new(image.with_mask(Some(Box::new(crate::OpacityMask::new(white, true))))));
continue;
}
let mut clip = Node::path(n.id, shape, Appearance::default());
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let image = Node::new(n.id, NodeKind::Image(Box::new(ImageObject { xf, ..im.clone() })));
pieces.push(Arc::new(Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true })));
}
}
+8 -3
View File
@@ -1,6 +1,9 @@
use super::envelope::{EnvelopeKind, H_DOWN, H_LEFT, H_RIGHT, H_UP, envelope_grid, mesh_at};
use super::warp::{WarpStyle, flatten_subpath, resample, warp_point};
use super::*;
use super::{
Affine, Anchor, Arc, Color, CubicBez, Deserialize, Node, ParamCurve, PathData, Point, Rect, Serialize, Shape, SubPath, Vec2, fifty, lerp_color,
one,
};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeshPoint {
@@ -264,7 +267,8 @@ impl GradientMesh {
}
/// Create Gradient Mesh → Appearance: lighten the points towards white, to the centre or to
/// the edges, by at most `highlight` %.
/// the edges, by at most `highlight` %. Each point lightens towards white in its own colour
/// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232).
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
let (rows, cols) = (self.rows as usize, self.cols as usize);
@@ -279,7 +283,8 @@ impl GradientMesh {
};
if w > 0.0 {
let i = self.idx(r, c);
self.points[i].color = lerp_color(&self.points[i].color, &Color::WHITE, w);
let color = self.points[i].color;
self.points[i].color = lerp_color(&color, &Color::WHITE.in_model(color.model()), w);
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Anchor, BezPath, CubicBez, PI, ParamCurve, PathData, Point, SubPath};
// =====================================================================================
// Warp maps (shared with vectorcraft-effects' Warp effects)
+1 -1
View File
@@ -250,7 +250,7 @@ fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
if let Some(e) = n.extras_box() {
b += ALLOC + size_of::<NodeExtras>();
b += boxed(&e.mask) + boxed(&e.graph) + boxed(&e.shaper) + boxed(&e.attrs) + boxed(&e.slice) + boxed(&e.perspective);
b += boxed(&e.dimension) + boxed(&e.live_paint_gaps);
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));
b += e.objects_on_path.as_deref().map_or(0, |o| {
+2 -1
View File
@@ -28,10 +28,11 @@ pub use attributes::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttribut
pub use clip::{ClipPaint, Uniter};
pub use extras::{Extras, ExtrasMut, NodeExtras};
pub use ids::{LAYER_COLORS, LayerColor, NodeId};
pub use kind::{ImageObject, NodeKind};
pub use kind::{ImageObject, MAX_BUFFERED_VALUES, NodeKind, with_buffered_value_budget};
pub use live_shape::{LiveShape, Scaling};
pub use opacity::{Knockout, OpacityMask, whiten};
use wire::NodeWire;
pub use wire::{MAX_READ_NODES, with_node_budget};
fn yes() -> bool {
true
+1 -1
View File
@@ -1,6 +1,6 @@
use super::kind::NodeKind;
use super::live_shape::LiveShape;
use super::*;
use super::{Deserialize, Node, Serialize};
/// Window → Attributes: an object's settings that don't change how it prints.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::kind::NodeKind;
use super::*;
use super::{BezPath, FillRule, Node, Outliner, Rect, shapes};
/// Unites filled regions (each under its own fill rule) into one path filled non-zero. Booleans
/// live above this crate (`vectorcraft-pathops`), so callers supply it.
+17 -1
View File
@@ -2,7 +2,7 @@ use super::attributes::ObjectAttributes;
use super::kind::NodeKind;
use super::live_shape::LiveShape;
use super::opacity::OpacityMask;
use super::*;
use super::{Node, PathData};
/// The parts of a [`Node`] that few objects have: kept out of line, so that the nodes without
/// them stay small ([`Node::extras`]).
@@ -23,6 +23,9 @@ pub struct NodeExtras {
/// The group holds the glyph outlines of type. Geometry effects that reshape it move and scale
/// the placed gradients in it with the outlines.
pub type_outlines: bool,
/// An image drawn without smoothing: each pixel of the picture takes the color of the image
/// pixel nearest to it (an envelope's distorted image with Envelope Options › Anti-Alias off).
pub pixelated: bool,
/// Editable Shaper composition; the original art is retained in its source child.
pub shaper: Option<Box<crate::shaper::ShaperSpec>>,
/// The [`crate::GraphicStyle::id`] last applied to this object. It stays linked while it keeps
@@ -46,6 +49,15 @@ pub struct NodeExtras {
/// Object › Objects on Path: the group's path and the objects attached to it (see
/// [`crate::objects_on_path`]).
pub objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
/// A grayscale or 1-bit image's fill color (`paint.setFill` on the image): a solid paint that
/// colorizes it. Each pixel is drawn in this color, as opaque as the pixel is dark, so white
/// pixels are transparent. `None`: the image shows its own pixels.
pub colorize: Option<Box<vectorcraft_color::Paint>>,
/// Pathfinder panel › Make Compound Shape: this group is a compound shape
/// ([`Node::is_compound_shape`], [`crate::compound_shape`]).
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,
}
/// No extras: what [`Node::extras`] gives for a node without any.
@@ -56,6 +68,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
graph: None,
series_index: None,
type_outlines: false,
pixelated: false,
shaper: None,
graphic_style: None,
attrs: None,
@@ -65,6 +78,9 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
intertwine: None,
live_paint_gaps: None,
objects_on_path: None,
colorize: None,
compound_shape: false,
shape_mode: crate::ShapeMode::Add,
};
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// Stable per-document object id. Never reused.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+147 -3
View File
@@ -1,6 +1,6 @@
use super::ids::LayerColor;
use super::live_shape::LiveShape;
use super::*;
use super::{Affine, Arc, BlendSpec, Deserialize, EnvelopeKind, FillRule, GradientMesh, Node, PathData, RepeatSpec, Serialize, TextObject, yes};
/// Embedded or linked raster image.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -129,6 +129,8 @@ pub enum NodeKind {
/// anywhere among its fields. Files have it first (it is written first): then the kind is read
/// straight from the file. Otherwise (hand-written files, values whose keys are sorted) the
/// fields before `type` are buffered, and only those.
pub use kind_de::{MAX_BUFFERED_VALUES, with_buffered_value_budget};
mod kind_de {
use std::fmt;
use std::sync::Arc;
@@ -237,12 +239,150 @@ mod kind_de {
let tag: String = map.next_value()?;
return KindDef::deserialize(Tagged { tag, map: Buffered { before: before.into_iter(), value: None, map } });
}
before.push((key, map.next_value::<serde_json::Value>()?));
before.push((key, map.next_value::<Counted>()?.0));
}
Err(de::Error::missing_field("type"))
}
}
/// Most JSON values (each number, string, array, object…) the fields read before an object's
/// `type` may hold together while [`with_buffered_value_budget`] runs: 32M (about 1 GB as
/// `serde_json::Value`s, some 32 bytes each), 4M in the web build. VectorCraft writes `type`
/// first, so its files buffer nothing; a reordered file buffers what comes before it, and
/// a crafted one could fill memory with values two bytes each in the file before any object in
/// them is counted (#3587).
pub const MAX_BUFFERED_VALUES: usize = if cfg!(target_arch = "wasm32") { 4 << 20 } else { 32 << 20 };
std::thread_local! {
/// Values the fields buffered on this thread may still hold, and the budget they come from.
static VALUES_LEFT: std::cell::Cell<Option<(usize, usize)>> = const { std::cell::Cell::new(None) };
/// Buffered fields being read back: what they hold was counted when it was buffered.
static REPLAYING: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
/// Run `f` (which reads objects with serde) letting the fields it buffers before an object's
/// `type` hold at most `max` values together; buffering more fails.
pub fn with_buffered_value_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
struct Restore(Option<(usize, usize)>);
impl Drop for Restore {
fn drop(&mut self) {
VALUES_LEFT.set(self.0);
}
}
let _restore = Restore(VALUES_LEFT.replace(Some((max, max))));
f()
}
/// While buffered fields are read back ([`REPLAYING`]).
struct Replaying;
impl Replaying {
fn enter() -> Self {
REPLAYING.set(REPLAYING.get().saturating_add(1));
Replaying
}
}
impl Drop for Replaying {
fn drop(&mut self) {
REPLAYING.set(REPLAYING.get().saturating_sub(1));
}
}
/// Count one buffered value against [`with_buffered_value_budget`].
fn count<E: de::Error>() -> Result<(), E> {
if REPLAYING.get() > 0 {
return Ok(());
}
if let Some((left, max)) = VALUES_LEFT.get() {
let left = left.checked_sub(1).ok_or_else(|| E::custom(format!("an object has more than {max} values before its type")))?;
VALUES_LEFT.set(Some((left, max)));
}
Ok(())
}
/// A field read before `type`, as a `serde_json::Value` counted value by value as it is read,
/// so the budget stops a large one before it is all in memory.
struct Counted(serde_json::Value);
impl<'de> Deserialize<'de> for Counted {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
d.deserialize_any(CountedVisitor)
}
}
struct CountedVisitor;
impl<'de> Visitor<'de> for CountedVisitor {
type Value = Counted;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a JSON value")
}
fn visit_bool<E: de::Error>(self, v: bool) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Value::Bool(v)))
}
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Counted, E> {
count()?;
Ok(Counted(v.into()))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Counted, E> {
count()?;
Ok(Counted(v.into()))
}
fn visit_f64<E: de::Error>(self, v: f64) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Number::from_f64(v).map_or(serde_json::Value::Null, serde_json::Value::Number)))
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Value::String(v.to_owned())))
}
fn visit_string<E: de::Error>(self, v: String) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Value::String(v)))
}
fn visit_none<E: de::Error>(self) -> Result<Counted, E> {
self.visit_unit()
}
fn visit_unit<E: de::Error>(self) -> Result<Counted, E> {
count()?;
Ok(Counted(serde_json::Value::Null))
}
fn visit_some<D: Deserializer<'de>>(self, d: D) -> Result<Counted, D::Error> {
Counted::deserialize(d)
}
fn visit_seq<S: de::SeqAccess<'de>>(self, mut seq: S) -> Result<Counted, S::Error> {
count()?;
let mut out = Vec::new();
while let Some(Counted(v)) = seq.next_element()? {
out.push(v);
}
Ok(Counted(serde_json::Value::Array(out)))
}
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Counted, M::Error> {
count()?;
let mut out = serde_json::Map::new();
while let Some(key) = map.next_key::<String>()? {
let Counted(v) = map.next_value()?;
out.insert(key, v);
}
Ok(Counted(serde_json::Value::Object(out)))
}
}
/// The fields read before `type` (buffered), then the rest of the object.
struct Buffered<A> {
before: std::vec::IntoIter<(String, serde_json::Value)>,
@@ -265,7 +405,11 @@ mod kind_de {
fn next_value_seed<V: DeserializeSeed<'de>>(&mut self, seed: V) -> Result<V::Value, A::Error> {
match self.value.take() {
Some(value) => seed.deserialize(value).map_err(de::Error::custom),
Some(value) => {
// Counted when it was read: the objects it holds buffer it again as they read it.
let _replaying = Replaying::enter();
seed.deserialize(value).map_err(de::Error::custom)
}
None => self.map.next_value_seed(seed),
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Affine, CornerKind, Deserialize, PathData, Point, Rect, Serialize, cut_corners, shapes};
/// Live shape parameters (Illustrator's "Live Shapes"). The node's path is regenerated from these
/// while the shape stays live; editing anchors directly converts it to a plain path.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::kind::NodeKind;
use super::*;
use super::{Arc, Color, Deserialize, Node, Paint, Serialize, yes};
/// Opacity mask: the luminance of the mask art sets the object's opacity (white = opaque).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+13 -4
View File
@@ -3,7 +3,7 @@ use super::ids::{LayerColor, NodeId};
use super::kind::NodeKind;
use super::live_shape::{Scaling, moves_only};
use super::opacity::Knockout;
use super::*;
use super::{Affine, Appearance, Arc, BlendMode, EnvelopeKind, FillRule, Node, PathData, Point, Rect, RepeatSpec, SYMBOL_SQUARE, Shape, SymbolBoxes};
impl Node {
pub fn new(id: NodeId, kind: NodeKind) -> Self {
@@ -127,6 +127,7 @@ impl Node {
NodeKind::Layer { .. } => "Layer",
NodeKind::Group { clip: true, .. } => "Clip Group",
NodeKind::Group { .. } if self.extras().objects_on_path.is_some() => "Objects on Path",
NodeKind::Group { .. } if self.extras().compound_shape => "Compound Shape",
NodeKind::Group { .. } => "Group",
NodeKind::Path { live: Some(l), .. } => l.label(),
NodeKind::Path { guide: true, .. } => "Guide",
@@ -545,9 +546,16 @@ impl Node {
/// item) stands in for the topmost fill or stroke when it is of the proxy's kind. Otherwise
/// type objects paint their runs (the first run speaks for all) in text space, fitted to the
/// layout bounds; everything else paints its fill or stroke in the document, fitted to the
/// geometric bounds. Strokes fit the box grown by half their weight.
/// geometric bounds. Strokes fit the box grown by half their weight. A colorized image shows
/// its colorize paint as its fill.
pub fn proxy_paint(&self, stroke: bool, item: Option<usize>) -> Option<(&vectorcraft_color::Paint, Affine, Rect)> {
let item = self.appearance.item_of_kind(item, !stroke);
if item.is_none()
&& !stroke
&& let Some(c) = self.extras().colorize.as_deref()
{
return Some((c, Affine::IDENTITY, self.geometric_bounds().unwrap_or(Rect::ZERO)));
}
if item.is_none()
&& let NodeKind::Text(t) = &self.kind
{
@@ -675,8 +683,8 @@ 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 {
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));
@@ -685,6 +693,7 @@ 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())
|| effects(&self.appearance.effects)
|| self
.appearance
+39
View File
@@ -19,6 +19,8 @@ fn extras_are_written_flat_in_order() {
e.slice = Some(Box::default());
e.perspective = Some(Box::new(crate::PerspectiveAttachment::new("left", 1.0)));
e.live_paint_gaps = Some(Box::default());
e.compound_shape = true;
e.shape_mode = crate::ShapeMode::Exclude;
}
/// The top-level keys of a JSON object, in the order written.
struct Keys(Vec<String>);
@@ -61,8 +63,11 @@ fn extras_are_written_flat_in_order() {
"bbox_angle",
"perspective",
"live_paint_gaps",
"compound_shape",
"shape_mode",
];
assert_eq!(keys, order);
assert_eq!(v["shape_mode"], "exclude");
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
@@ -433,3 +438,37 @@ fn bad_kinds_are_errors() {
assert!(serde_json::from_str::<NodeKind>(bad).is_err(), "{bad}");
}
}
/// #3587: [`with_node_budget`] counts every object read, nested ones included, refuses the one past
/// it with a message naming the budget, and is gone once its reading is done.
#[test]
fn objects_are_read_within_the_node_budget() {
let group = Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![])), Arc::new(Node::group(NodeId(3), vec![]))]);
let json = serde_json::to_string(&group).unwrap();
let read = || serde_json::from_str::<Node>(&json);
assert_eq!(with_node_budget(3, read).unwrap(), group, "three objects fit a budget of three");
let why = with_node_budget(2, read).unwrap_err().to_string();
assert!(why.contains("more than 2 objects"), "{why}");
assert_eq!(read().unwrap(), group, "no budget outside a read");
// A budget left by a read that failed doesn't carry over to the next one.
assert!(with_node_budget(1, read).is_err());
assert!(with_node_budget(3, read).is_ok());
}
/// #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
/// buffers nothing.
#[test]
fn fields_before_the_type_are_buffered_within_the_value_budget() {
let reordered = r#"{"id":1,"kind":{"children":[{"id":2,"kind":{"children":[],"type":"group"}}],"type":"group"}}"#;
let read = |json: &str| serde_json::from_str::<Node>(json);
// The children's array, the inner object, its id, its kind, the kind's empty children and
// its type: six values.
assert!(with_buffered_value_budget(6, || read(reordered)).is_ok());
let why = with_buffered_value_budget(5, || read(reordered)).unwrap_err().to_string();
assert!(why.contains("more than 5 values before its type"), "{why}");
let written = serde_json::to_string(&Node::group(NodeId(1), vec![Arc::new(Node::group(NodeId(2), vec![]))])).unwrap();
assert!(with_buffered_value_budget(0, || read(&written)).is_ok(), "a saved object buffers nothing: {written}");
assert!(read(reordered).is_ok(), "no budget outside a read");
}
+65 -6
View File
@@ -3,7 +3,13 @@ use super::extras::{Extras, NO_EXTRAS, NodeExtras};
use super::ids::NodeId;
use super::kind::NodeKind;
use super::opacity::{Knockout, OpacityMask};
use super::*;
use super::{Appearance, BlendMode, Deserialize, Node, PathData, Serialize, one, yes};
/// Field `name` of a [`Node`] written as `v`, or skipped. A function of its own, so the long field
/// list stays one straight run of calls.
fn write_field<S: serde::ser::SerializeStruct, T: Serialize + ?Sized>(st: &mut S, skip: bool, name: &'static str, v: &T) -> Result<(), S::Error> {
if skip { st.skip_field(name) } else { st.serialize_field(name, v) }
}
/// How a [`Node`] is written: the flat field list of the file format, in its order, borrowed from
/// the node and its extras (nothing is copied). The node reads back through `NodeWire`.
@@ -30,6 +36,7 @@ impl Serialize for Node {
e.graph.is_none(),
e.series_index.is_none(),
is_default(&e.type_outlines),
is_default(&e.pixelated),
e.shaper.is_none(),
false,
e.graphic_style.is_none(),
@@ -41,16 +48,15 @@ impl Serialize for Node {
e.intertwine.is_none(),
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),
];
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
let mut skip = skips.into_iter();
macro_rules! field {
($name:literal, $v:expr) => {
if skip.next().unwrap_or(false) {
st.skip_field($name)?;
} else {
st.serialize_field($name, $v)?;
}
write_field(&mut st, skip.next().unwrap_or(false), $name, $v)?;
};
}
field!("id", &self.id);
@@ -69,6 +75,7 @@ impl Serialize for Node {
field!("graph", &e.graph);
field!("series_index", &e.series_index);
field!("type_outlines", &e.type_outlines);
field!("pixelated", &e.pixelated);
field!("shaper", &e.shaper);
field!("kind", &self.kind);
field!("graphic_style", &e.graphic_style);
@@ -80,15 +87,55 @@ impl Serialize for Node {
field!("intertwine", &e.intertwine);
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);
st.end()
}
}
/// Most objects [`with_node_budget`] lets a document read: 4M (about 1 GB of the smallest
/// objects), 512K in the web build. Paths have their own anchor budget
/// ([`vectorcraft_geom::path::MAX_READ_ANCHORS`]); this one bounds the objects themselves.
pub const MAX_READ_NODES: usize = if cfg!(target_arch = "wasm32") { 512 << 10 } else { 4 << 20 };
std::thread_local! {
/// Objects the document being read on this thread may still have, and the budget they come
/// from ([`with_node_budget`]).
static NODES_LEFT: std::cell::Cell<Option<(usize, usize)>> = const { std::cell::Cell::new(None) };
}
/// Run `f` (which reads nodes with serde) letting it read at most `max` objects together; reading
/// more fails. A compressed file of repeated empty objects stays small while they take gigabytes
/// once read, about 270 bytes each against some 50 in the file (#3587).
pub fn with_node_budget<T>(max: usize, f: impl FnOnce() -> T) -> T {
struct Restore(Option<(usize, usize)>);
impl Drop for Restore {
fn drop(&mut self) {
NODES_LEFT.set(self.0);
}
}
let _restore = Restore(NODES_LEFT.replace(Some((max, max))));
f()
}
/// A node's id, counting the node against [`with_node_budget`]. Every node has exactly one id, so
/// 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)?;
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")))?;
NODES_LEFT.set(Some((left, max)));
}
Ok(id)
}
/// How a [`Node`] is read: the flat field list of the file format. Fields move into the node
/// (and its extras, allocated only when one is set) without a copy of what they own.
#[derive(Deserialize)]
#[serde(rename = "Node")]
pub(super) struct NodeWire {
#[serde(deserialize_with = "counted_id")]
id: NodeId,
#[serde(default)]
name: Option<String>,
@@ -121,6 +168,8 @@ pub(super) struct NodeWire {
#[serde(default)]
type_outlines: bool,
#[serde(default)]
pixelated: bool,
#[serde(default)]
shaper: Option<Box<crate::shaper::ShaperSpec>>,
kind: NodeKind,
#[serde(default)]
@@ -141,6 +190,12 @@ pub(super) struct NodeWire {
live_paint_gaps: Option<Box<crate::live_paint::GapOptions>>,
#[serde(default)]
objects_on_path: Option<Box<crate::objects_on_path::ObjectsOnPath>>,
#[serde(default)]
colorize: Option<Box<vectorcraft_color::Paint>>,
#[serde(default)]
compound_shape: bool,
#[serde(default)]
shape_mode: crate::ShapeMode,
}
impl From<NodeWire> for Node {
@@ -152,6 +207,7 @@ impl From<NodeWire> for Node {
graph: w.graph,
series_index: w.series_index,
type_outlines: w.type_outlines,
pixelated: w.pixelated,
shaper: w.shaper,
graphic_style: w.graphic_style,
attrs: w.attrs,
@@ -161,6 +217,9 @@ impl From<NodeWire> for Node {
intertwine: w.intertwine,
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,
};
Node {
id: w.id,
+117
View File
@@ -62,6 +62,61 @@ impl ImageBlob {
}
}
/// How a grayscale image's file stores its pixels ([`ImageBlob::gray_source`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct GraySource {
/// One bit a pixel: black and white only.
pub one_bit: bool,
/// The file has an alpha channel.
pub alpha: bool,
}
impl ImageBlob {
/// Whether the image's file stores grayscale or 1-bit pixels (the images a fill color
/// colorizes), and how; `None` for color images and data that isn't an image. Reads only its
/// header.
pub fn gray_source(&self) -> Option<GraySource> {
let gray = |one_bit: bool, alpha: bool| Some(GraySource { one_bit, alpha });
if let Some((kind, _)) = crate::cmyk::tiff(&self.bytes) {
return match kind {
tiff::ColorType::Gray(bits) => gray(bits == 1, false),
tiff::ColorType::GrayA(bits) => gray(bits == 1, true),
_ => None,
};
}
if crate::cmyk::jpeg_components(&self.bytes) == Some(1) {
return gray(false, false);
}
use image::{ExtendedColorType as E, ImageDecoder as _};
let reader = image::ImageReader::new(Cursor::new(self.bytes.as_slice())).with_guessed_format().ok()?;
match reader.into_decoder().ok()?.original_color_type() {
E::L1 => gray(true, false),
E::La1 => gray(true, true),
E::L2 | E::L4 | E::L8 | E::L16 => gray(false, false),
E::La2 | E::La4 | E::La8 | E::La16 => gray(false, true),
_ => None,
}
}
/// How much of a colorize paint each pixel shows, row by row from the top: how dark the pixel
/// is (255 less its Rec. 709 luma) scaled by its alpha, so white and transparent pixels are 0.
/// Returns the width, the height and one byte a pixel; `None` when the image can't be decoded
/// within [`MAX_RASTER_ALLOC`].
pub fn coverage(&self) -> Option<(u32, u32, Vec<u8>)> {
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
let (w, h) = img.dimensions();
let cover = img
.pixels()
.map(|p| {
let [r, g, b, a] = p.0;
let luma = 0.2126 * r as f32 + 0.7152 * g as f32 + 0.0722 * b as f32;
((255.0 - luma).clamp(0.0, 255.0) * a as f32 / 255.0).round() as u8
})
.collect();
Some((w, h, cover))
}
}
impl ImageBlob {
/// The colour around pixel (`x`, `y`) (pixel space): the alpha-weighted average of the `size` ×
/// `size` pixels centred on it, clipped to the image, as straight RGBA. `None` outside the
@@ -298,4 +353,66 @@ mod tests {
assert!(b.map_rgb(|c| c).is_none(), "nothing changed");
assert!(ImageBlob::png(vec![1, 2, 3]).map_rgb(|c| c).is_none(), "undecodable");
}
/// An uncompressed 1-bit TIFF, BlackIsZero, of `w` × `h` pixels: `rows` holds each row's bits,
/// padded to a byte, most significant bit first.
fn bilevel_tiff(w: u16, h: u16, rows: &[u8]) -> Vec<u8> {
// Tag, type (3 short, 4 long) and value of each entry, in tag order.
let data_at = 8 + 2 + 9 * 12 + 4;
let entries: [(u16, u16, u32); 9] = [
(256, 3, w.into()),
(257, 3, h.into()),
(258, 3, 1),
(259, 3, 1),
(262, 3, 1),
(273, 4, data_at),
(277, 3, 1),
(278, 3, h.into()),
(279, 4, rows.len() as u32),
];
let mut out = b"II*\0".to_vec();
out.extend_from_slice(&8u32.to_le_bytes());
out.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, kind, value) in entries {
out.extend_from_slice(&tag.to_le_bytes());
out.extend_from_slice(&kind.to_le_bytes());
out.extend_from_slice(&1u32.to_le_bytes());
out.extend_from_slice(&value.to_le_bytes());
}
out.extend_from_slice(&0u32.to_le_bytes());
out.extend_from_slice(rows);
out
}
fn png_of(img: image::DynamicImage) -> ImageBlob {
let mut png = Vec::new();
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
ImageBlob::png(png)
}
#[test]
fn grayscale_and_1_bit_files_are_told_from_color_ones() {
let gray = |one_bit, alpha| Some(GraySource { one_bit, alpha });
let l8 = png_of(image::DynamicImage::ImageLuma8(image::GrayImage::from_raw(2, 1, vec![0, 255]).unwrap()));
assert_eq!(l8.gray_source(), gray(false, false));
let la8 = png_of(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(1, 1, vec![0, 128]).unwrap()));
assert_eq!(la8.gray_source(), gray(false, true));
let mut jpeg = Vec::new();
jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[0, 255, 0, 255], 2, 2, jpeg_encoder::ColorType::Luma).unwrap();
assert_eq!(ImageBlob::new("image/jpeg", jpeg).gray_source(), gray(false, false));
// Black, white; white, black.
let tif = ImageBlob::new("image/tiff", bilevel_tiff(2, 2, &[0b0100_0000, 0b1000_0000]));
assert_eq!(tif.gray_source(), gray(true, false));
assert_eq!(tif.coverage(), Some((2, 2, vec![255, 0, 0, 255])), "black covers, white doesn't");
// Color images and data that isn't an image.
assert_eq!(blob(&[[0, 0, 0, 255], [9, 9, 9, 255]], 2, 1).gray_source(), None);
assert_eq!(ImageBlob::png(vec![1, 2, 3]).gray_source(), None);
}
#[test]
fn coverage_is_darkness_times_alpha() {
let la8 = png_of(image::DynamicImage::ImageLumaA8(image::GrayAlphaImage::from_raw(4, 1, vec![0, 255, 255, 255, 51, 255, 0, 128]).unwrap()));
assert_eq!(la8.coverage(), Some((4, 1, vec![255, 0, 204, 128])));
assert_eq!(ImageBlob::png(vec![1, 2, 3]).coverage(), None);
}
}
+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;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, Deserialize, Node, NodeId, Point, Serialize};
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
/// place, so a selection change that sets it doesn't replace the document.
+1
View File
@@ -145,6 +145,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[
("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: ['›', '‹'] }),
+45 -7
View File
@@ -108,9 +108,10 @@ impl Document {
Some(list.remove(i))
}
/// Visit every colour that can link to a swatch — solid paints and gradient stops of fills,
/// strokes and text runs — in the art (opacity masks included), symbol definitions, pattern
/// tiles, graphic styles and the swatches themselves (tint swatches, gradient swatches' stops).
/// Visit every color that can link to a swatch (solid paints and gradient stops of fills,
/// strokes and text runs) in the art (opacity masks included), symbol definitions, graph
/// designs, pattern tiles and the tile that pattern editing's Cancel puts back, graphic styles
/// and the swatches themselves (tint swatches, gradient swatches' stops).
/// `f(colour, link, tint)` may change them and returns true when it did
/// ([`Paint::map_links`]). Only nodes holding a changed paint (and the paths to them) are
/// copied. Returns the number of changed paints.
@@ -119,7 +120,10 @@ impl Document {
for s in &mut self.symbols {
n += map_tree(&mut s.art, f);
}
for p in &mut self.patterns {
for g in &mut self.graph_designs {
n += map_tree(&mut g.art, f);
}
for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) {
n += map_trees(&mut p.art, f);
}
for gs in &mut self.graphic_styles {
@@ -225,13 +229,14 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint {
}
}
/// The paints a node holds itself (not its children): appearance items, then text runs.
/// The paints a node holds itself (not its children): appearance items, then text runs, then an
/// image's colorize paint.
pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
let runs = match &n.kind {
NodeKind::Text(t) => t.runs.as_slice(),
_ => &[],
};
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke]))
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref())
}
/// Mutable [`node_paints`], in the same order.
@@ -240,7 +245,8 @@ pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
NodeKind::Text(t) => t.runs.as_mut_slice(),
_ => &mut [],
};
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke]))
let colorize = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut());
n.appearance.items.iter_mut().map(item_paint_mut).chain(runs.iter_mut().flat_map(|r| [&mut r.style.fill, &mut r.style.stroke])).chain(colorize)
}
/// `n` with `f` applied to its subtree's linkable colours, or `None` when nothing changed (then
@@ -307,6 +313,19 @@ pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
}
}
/// [`Document::map_solid_paints`] for art a document keeps outside its trees, such as the art of a
/// brush: `f` applied to the linkable colors of `n` and its subtree (opacity masks included). Only
/// the nodes holding a changed paint are copied. Returns the number of changed paints.
pub fn map_node_solid_paints(n: &mut Node, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
*n = new;
count
}
None => 0,
}
}
fn map_tree(n: &mut Arc<Node>, f: &mut LinkFn) -> usize {
match map_node(n, f) {
Some((new, count)) => {
@@ -400,6 +419,25 @@ mod tests {
assert!(Arc::ptr_eq(&d.layers[0], &again.layers[0]));
}
#[test]
fn mapping_reaches_graph_designs_and_the_tile_that_pattern_cancel_puts_back() {
let (mut d, a, _) = doc();
let art = d.node_arc(a).cloned().unwrap();
d.graph_designs.push(crate::GraphDesign { name: "Bar".into(), art: art.clone() });
d.pattern_edit =
Some(crate::PatternEdit { pattern: "Tile".into(), layer: NodeId(0), original: Some(crate::PatternDef::new("Tile", vec![art])) });
let mut loose = d.node(a).unwrap().clone();
let blue = Color::rgb(0.0, 0.0, 1.0);
let mut to_blue = |c: &mut Color, s: &mut Option<String>, _: &mut f32| s.as_deref() == Some("Brand") && std::mem::replace(c, blue) != blue;
assert_eq!(d.map_solid_paints(&mut to_blue), 3, "the layer's rectangle, the design and the Cancel tile");
assert_eq!(d.graph_designs[0].art.appearance.fill_paint(), linked(blue, "Brand"));
let tile = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0];
assert_eq!(tile.appearance.fill_paint(), linked(blue, "Brand"));
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 1);
assert_eq!(loose.appearance.fill_paint(), linked(blue, "Brand"));
assert_eq!(map_node_solid_paints(&mut loose, &mut to_blue), 0, "nothing left to change");
}
#[test]
fn mapping_within_ids_visits_each_paint_once() {
let (mut d, a, b) = doc();
+30
View File
@@ -71,6 +71,8 @@ pub struct SymbolBoxes<'a> {
/// The effect reach of each symbol's art with no transform, measured past [`MAX_REACHES`]
/// ([`Self::unplaced_reach`]).
unplaced: RefCell<HashMap<String, f64>>,
/// How many times [`Self::measure_art`] has run a measure on symbol art ([`Self::art_measured`]).
runs: Cell<usize>,
}
impl Document {
@@ -87,6 +89,7 @@ impl Document {
holders: Cell::new(0),
farthest: RefCell::default(),
unplaced: RefCell::default(),
runs: Cell::new(0),
}
}
}
@@ -146,9 +149,16 @@ impl<'a> SymbolBoxes<'a> {
pub fn measure_art<R>(&self, name: &str, measure: impl FnOnce(&Arc<Node>) -> R) -> Option<R> {
self.depth(name)?;
let sym = self.doc.symbols.iter().find(|s| s.name == name)?;
self.runs.set(self.runs.get() + 1);
Some(measure(&sym.art))
}
/// How many times [`Self::measure_art`] has run a measure on symbol art for these boxes, the
/// measures behind the cached art boxes and placed visual boxes included.
pub fn art_measured(&self) -> usize {
self.runs.get()
}
/// The geometric box of the art of the symbol named `name` in symbol space, with the instances
/// in the art measured by their own art boxes. `None` when [`Self::art`] is `None`: instances of
/// the symbol are then measured by [`SYMBOL_SQUARE`].
@@ -460,6 +470,26 @@ mod tests {
assert_eq!(pair.geometric_bounds_with(&d.symbol_boxes()), Some(Rect::new(100.0, 100.0, 154.0, 154.0)));
}
#[test]
fn each_symbols_art_box_is_measured_once() {
// `Pair` holds two instances of `Dot`; three instances of `Pair` are measured twice each.
let mut d = Document::new(200.0, 200.0);
let dot = rect(&mut d, Rect::new(0.0, 0.0, 4.0, 4.0), 0.0);
symbol(&mut d, "Dot", vec![dot]);
let right = instance(&mut d, "Dot", Affine::translate((50.0, 0.0)));
let below = instance(&mut d, "Dot", Affine::translate((0.0, 50.0)));
symbol(&mut d, "Pair", vec![right, below]);
let pairs: Vec<Node> = (0..3).map(|i| instance(&mut d, "Pair", Affine::translate((f64::from(i) * 10.0, 0.0)))).collect();
let s = d.symbol_boxes().quick();
assert_eq!(s.art_measured(), 0);
for _ in 0..2 {
for p in &pairs {
p.geometric_bounds_with(&s);
}
}
assert_eq!(s.art_measured(), 2, "the art of Pair and of Dot");
}
#[test]
fn symbols_nested_32_deep_measure_as_the_square_whatever_was_measured_first() {
// A chain of 40 symbols, each holding a 1 × 1 square and an instance of the next one 1 pt to
+1 -1
View File
@@ -1,5 +1,5 @@
use super::para_style::FirstBaseline;
use super::*;
use super::{Deserialize, PathData, Serialize};
/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+1 -1
View File
@@ -1,5 +1,5 @@
use super::para_style::ParaDirection;
use super::*;
use super::{Appearance, AppearanceItem, Color, Dash, Deserialize, FillLayer, LineCap, LineJoin, Paint, Serialize, StrokeAlign, StrokeLayer};
/// Character attributes (the Character panel).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+1 -1
View File
@@ -3,7 +3,7 @@ use super::char_style::CharStyle;
use super::para_style::ParaStyle;
use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
use super::tabs_lists::ParaList;
use super::*;
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
impl TextObject {
/// None for collapsed or unrepresentable transforms, whose type dimensions cannot
+1 -1
View File
@@ -1,6 +1,6 @@
use super::char_style::CharStyle;
use super::tabs_lists::{ParaList, TabStop};
use super::*;
use super::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Justify {
+1 -1
View File
@@ -1,5 +1,5 @@
use super::char_style::CharStyle;
use super::*;
use super::{Affine, Deserialize, PathData, Point, Rect, Serialize, Vec2, yes};
/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or
/// [`ParaStyle`] fields, by their serialized names). Text using it records the name.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::char_style::{CharPosition, CharStyle};
use super::*;
use super::{Deserialize, Serialize};
/// Tab stop alignment (Tabs panel).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Deserialize, Serialize};
/// Measurement units (display only; the model is always in points).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+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;

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