mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 21:56:24 +00:00
Merge main into fix/undo-history-follows-memory
This commit is contained in:
@@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Creative Commons with redistribution allowed.
|
||||
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
|
||||
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
|
||||
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
|
||||
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
|
||||
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
|
||||
@@ -46,7 +46,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
## Running and looking at the app
|
||||
@@ -59,7 +59,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
## Roadmap and progress docs
|
||||
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
|
||||
The progress docs:
|
||||
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
|
||||
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
|
||||
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
|
||||
@@ -72,20 +72,17 @@ When a task lands, update in the same PR: the area row in `docs/target-app-parit
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Creative Commons with redistribution allowed.
|
||||
- **Every asset file must have a row in [`ASSETS.md`](ASSETS.md)** (path, author, source URL, licence, notes). Put licence texts next to the assets (e.g. `assets/fonts/OFL-*.txt`) and summarize them in `NOTICE`.
|
||||
- `cargo xtask assets` (run by `cargo xtask ci`) fails on any unattributed asset.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo** (rules: craftrules [`standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo**. Never commit a font file here; add new fonts to craft-fonts. The app uses it only through the optional `CRAFT_FONTS_DIR` build option (`CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo xtask ci`; use an absolute path), read by `crates/text/build.rs` into `vectorcraft_text::CRAFT_FONTS`; never add it to a `Cargo.toml`. Everything must build, test and run with `CRAFT_FONTS` empty, and tests of its glyphs skip when it is. Details: `docs/development.md` › Fonts.
|
||||
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
|
||||
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
|
||||
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
|
||||
@@ -47,7 +47,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
|
||||
|
||||
## Running and looking at the app
|
||||
@@ -60,7 +60,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; write manifests atomically. Each target dir grows to ~30 GB: delete yours when you finish (a full disk fails links with `errno=28`).
|
||||
|
||||
## Roadmap and progress docs
|
||||
The progress docs follow craftrules' [`standards/progress-docs.md`](https://github.com/storytold/craftrules/blob/main/standards/progress-docs.md):
|
||||
The progress docs:
|
||||
- [`ROADMAP.md`](ROADMAP.md): stage, headline numbers, dimensions, features, languages, upcoming, progress log.
|
||||
- [`docs/target-app-parity.md`](docs/target-app-parity.md): the authoritative parity assessment (area table, methodology, calibration, evidence of what ships).
|
||||
- [`docs/gaps.md`](docs/gaps.md): every known shortfall, ranked; the work list. [`docs/roadmap.md`](docs/roadmap.md): milestones and current focus.
|
||||
|
||||
Generated
+31
-29
@@ -1047,7 +1047,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -6037,7 +6037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"block2 0.6.2",
|
||||
@@ -6065,6 +6065,7 @@ dependencies = [
|
||||
"smithay-clipboard",
|
||||
"socket2",
|
||||
"vectorcraft-engine",
|
||||
"vectorcraft-render",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-ui-egui",
|
||||
"wayland-backend",
|
||||
@@ -6081,7 +6082,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-affinity"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -6092,7 +6093,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"serde",
|
||||
@@ -6104,7 +6105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cad"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6120,7 +6121,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-cli"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"objc2-core-foundation",
|
||||
@@ -6142,7 +6143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-color"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"moxcms",
|
||||
@@ -6154,7 +6155,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-doc"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-ccitt",
|
||||
@@ -6173,7 +6174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-effects"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6189,7 +6190,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-engine"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"crc32fast",
|
||||
@@ -6228,7 +6229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-eps"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -6252,7 +6253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-format"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -6267,7 +6268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-geom"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
@@ -6279,7 +6280,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-mcp"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
@@ -6298,7 +6299,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-metafile"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6312,7 +6313,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pathops"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"linesweeper",
|
||||
@@ -6324,7 +6325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-pdf"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"chrono",
|
||||
@@ -6356,7 +6357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-plugins"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6370,7 +6371,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-render"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"gif",
|
||||
@@ -6382,6 +6383,7 @@ dependencies = [
|
||||
"psd",
|
||||
"serde_json",
|
||||
"tiff",
|
||||
"unicode-general-category",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -6399,14 +6401,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-scriptcheck"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-svg"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"flate2",
|
||||
@@ -6431,7 +6433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-testkit"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"image",
|
||||
@@ -6449,7 +6451,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-text"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"harfrust 0.13.3",
|
||||
"icu_segmenter",
|
||||
@@ -6470,7 +6472,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-pathops",
|
||||
@@ -6478,7 +6480,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6494,7 +6496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-trace"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
@@ -6506,7 +6508,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-ui-egui"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"craft-keymap",
|
||||
@@ -6544,7 +6546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-web"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7675,7 +7677,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -20,7 +20,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
# internal
|
||||
vectorcraft-geom = { path = "crates/geom" }
|
||||
vectorcraft-color = { path = "crates/color" }
|
||||
|
||||
+8
-4
File diff suppressed because one or more lines are too long
@@ -34,8 +34,9 @@ winsafe = { version = "0.0.29", default-features = false, features = ["kernel",
|
||||
|
||||
# DirectWrite's system font collection and GDI's font list (the desktop app's src/system_fonts.rs,
|
||||
# shared): fonts that font services such as Adobe Fonts load in place (#579, #1258). MIT OR
|
||||
# Apache-2.0.
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi"] }
|
||||
# Apache-2.0. Win32_System_Threading has `GetProcessAffinityMask`, which the desktop app's
|
||||
# src/windows_cores.rs, shared, uses to count the cores the process may run on.
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading"] }
|
||||
|
||||
# CoreText's font manager (the desktop app's src/mac_fonts.rs, shared): fonts that apps and font
|
||||
# managers register from their own folders (#579). Zlib OR Apache-2.0 OR MIT.
|
||||
|
||||
@@ -49,6 +49,10 @@ mod prefs_dir;
|
||||
#[cfg(windows)]
|
||||
#[path = "../../vectorcraft/src/system_fonts.rs"]
|
||||
mod system_fonts;
|
||||
/// The desktop app's affinity-mask reader, shared: renders start workers only for usable cores.
|
||||
#[cfg(windows)]
|
||||
#[path = "../../vectorcraft/src/windows_cores.rs"]
|
||||
mod windows_cores;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
@@ -161,6 +165,9 @@ fn help() -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// Before the first render: the cores the process may run on.
|
||||
#[cfg(windows)]
|
||||
windows_cores::install();
|
||||
// Before any font lookup: the fonts font services load (#579).
|
||||
#[cfg(windows)]
|
||||
system_fonts::install();
|
||||
|
||||
@@ -54,7 +54,11 @@ pollster = "1"
|
||||
# Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to
|
||||
# read a pen's barrel button from its pointer messages. Win32_UI_Input_Pointer has
|
||||
# `GetPointerPenInfo`, which it uses to read the pen's pressure and which end of the pen is down.
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
|
||||
# Win32_System_Threading has `GetProcessAffinityMask`, which src/windows_cores.rs uses to count the
|
||||
# cores the process may run on.
|
||||
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_Graphics_Gdi", "Win32_System_Threading", "Win32_UI_Input_Pointer", "Win32_UI_WindowsAndMessaging"] }
|
||||
# The renderer's worker count follows those cores (src/windows_cores.rs).
|
||||
vectorcraft-render = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
|
||||
|
||||
@@ -50,6 +50,8 @@ mod system_fonts;
|
||||
mod tablet;
|
||||
mod window;
|
||||
mod window_frame;
|
||||
#[cfg(windows)]
|
||||
mod windows_cores;
|
||||
mod windows_stylus;
|
||||
mod xwayland;
|
||||
|
||||
@@ -184,6 +186,9 @@ fn main() -> std::process::ExitCode {
|
||||
// Before desktop services, so every start-up warning is recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
// Before the first render: the cores the process may run on.
|
||||
#[cfg(windows)]
|
||||
windows_cores::install();
|
||||
// Before the first font scan (the app's start): the fonts font services load (#579).
|
||||
#[cfg(windows)]
|
||||
system_fonts::install();
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Windows: the cores the process may run on, from its affinity mask, for the renderer's worker
|
||||
//! count (`vectorcraft_render::set_usable_cores`). `available_parallelism` counts every processor
|
||||
//! in the machine, also when the process may run on fewer (started with `start /affinity`, or
|
||||
//! pinned by a launcher). The desktop app and `vectorcraft-cli` (exports, MCP) both install it; the
|
||||
//! CLI shares this file.
|
||||
//!
|
||||
//! `GetProcessAffinityMask` is a C API with no safe binding, hence the scoped `unsafe_code`
|
||||
//! allowance.
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use windows::Win32::System::Threading::{GetCurrentProcess, GetProcessAffinityMask};
|
||||
|
||||
/// Tells the renderer how many cores the process may run on.
|
||||
pub fn install() {
|
||||
vectorcraft_render::set_usable_cores(usable_cores());
|
||||
}
|
||||
|
||||
/// The number of cores in the process's affinity mask, or `None` when Windows reports none (a
|
||||
/// process with threads in more than one processor group).
|
||||
fn usable_cores() -> Option<usize> {
|
||||
let (mut process, mut system) = (0usize, 0usize);
|
||||
// SAFETY: `GetCurrentProcess` returns a pseudo-handle that needs no closing, and both pointers
|
||||
// point at live `usize`s for the length of the call.
|
||||
unsafe { GetProcessAffinityMask(GetCurrentProcess(), &mut process, &mut system) }.ok()?;
|
||||
(process != 0).then(|| process.count_ones() as usize)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The mask Windows reports for this process gives at least one core and no more than the
|
||||
/// machine has. (A child process started with `start /affinity` would test a one-core mask
|
||||
/// end to end, but a process spawned while other tests hold sockets inherits them and resets
|
||||
/// their connections; `automatic_threads` covers the one-core count.)
|
||||
#[test]
|
||||
fn the_affinity_mask_gives_the_cores_the_process_may_use() {
|
||||
let cores = super::usable_cores().unwrap();
|
||||
let all = std::thread::available_parallelism().unwrap().get();
|
||||
assert!((1..=all).contains(&cores), "{cores} of {all}");
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
@@ -183,3 +180,8 @@ real_name = "Ashok Ram"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marshiyar]
|
||||
display_name = "Arshiya"
|
||||
real_name = "Arshiya Rahgozar"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -41,7 +41,8 @@ const MASK_STEPS: usize = 32;
|
||||
/// space, so the art keeps its curves (#3926). Where that cannot be done in no more anchors than
|
||||
/// chords would take, the path is flattened with `flat_tol` (art space) and subdivided so no chord
|
||||
/// is longer than `max_du` along x, as it always was. Stroke widths scale by `width_scale`.
|
||||
/// Text, images and symbol instances can't bend and are dropped.
|
||||
/// Text, images and symbol instances can't bend and are dropped. The art of a linked opacity mask
|
||||
/// bends with the object it masks.
|
||||
pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, doc_tol: f64, width_scale: f64) -> Option<Node> {
|
||||
let mut out = n.clone();
|
||||
out.appearance.scale_strokes(width_scale);
|
||||
@@ -63,9 +64,21 @@ pub(crate) fn warp_node(n: &Node, f: &dyn Fn(Point) -> Point, flat_tol: f64, max
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
map_mask(&mut out, |art| warp_node(art, f, flat_tol, max_du, doc_tol, width_scale));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Replace the art of `n`'s linked opacity mask, if it has one, with what `map` makes of it. Where
|
||||
/// `map` draws nothing, the mask's art becomes an empty group.
|
||||
fn map_mask(n: &mut Node, map: impl FnOnce(&Node) -> Option<Node>) {
|
||||
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut())
|
||||
&& m.linked
|
||||
{
|
||||
let id = m.art.id;
|
||||
m.art = Arc::new(map(&m.art).unwrap_or_else(|| Node::group(id, vec![])));
|
||||
}
|
||||
}
|
||||
|
||||
/// Each subpath of `p` through `f`: fitted cubics when they take no more anchors than the chords
|
||||
/// would, else the chords. Points `f` cannot place (not finite) are left out of the chords.
|
||||
fn warp_path(p: &PathData, f: &dyn Fn(Point) -> Point, flat_tol: f64, max_du: f64, fit_tol: f64) -> PathData {
|
||||
@@ -338,7 +351,8 @@ fn affine_fit(f: &dyn Fn(Point) -> Point, b: Rect, tol: f64) -> Option<Affine> {
|
||||
/// Everything else matches [`warp_node`] deliberately, so curves are the only difference between
|
||||
/// the two routes: the same node kinds are kept and the rest dropped, `live` is cleared on every
|
||||
/// path, an empty path or a group left with no children is dropped, and strokes are scaled by
|
||||
/// `width_scale` at every level rather than by the transform's own factor.
|
||||
/// `width_scale` at every level rather than by the transform's own factor, the art of linked
|
||||
/// opacity masks included.
|
||||
fn affine_node(n: &Node, a: Affine, width_scale: f64) -> Option<Node> {
|
||||
let mut out = prepared(n, width_scale)?;
|
||||
out.transform(a, false);
|
||||
@@ -365,6 +379,7 @@ fn prepared(n: &Node, width_scale: f64) -> Option<Node> {
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
map_mask(&mut out, |art| prepared(art, width_scale));
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -416,6 +431,7 @@ fn widen_strokes(n: &Node, factor: &dyn Fn(&Node) -> f64) -> Node {
|
||||
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } | NodeKind::Compound { children, .. } = &mut out.kind {
|
||||
*children = children.iter().map(|c| Arc::new(widen_strokes(c, factor))).collect();
|
||||
}
|
||||
map_mask(&mut out, |art| Some(widen_strokes(art, factor)));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -590,6 +606,9 @@ fn anchors_of(n: &Node) -> usize {
|
||||
if let Some(c) = n.children() {
|
||||
stack.extend(c.iter().map(Arc::as_ref));
|
||||
}
|
||||
if let Some(m) = &n.extras().mask {
|
||||
stack.push(&m.art);
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
@@ -1133,4 +1152,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A linked opacity mask on brush art bends and widens with the art, on a bent run and on a
|
||||
/// straight one, so a mask drawn over the art still covers it. An unlinked mask stays put.
|
||||
#[test]
|
||||
fn a_linked_opacity_mask_bends_with_the_art() {
|
||||
let b = Rect::new(0.0, -20.0, 40.0, 20.0);
|
||||
let stroked = |id: u64| {
|
||||
Node::path(
|
||||
NodeId(id),
|
||||
vectorcraft_geom::shapes::ellipse(b),
|
||||
Appearance::basic(
|
||||
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
|
||||
vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK),
|
||||
2.0,
|
||||
),
|
||||
)
|
||||
};
|
||||
let masked = |linked: bool| {
|
||||
let mut n = stroked(0);
|
||||
let mut mask = vectorcraft_doc::OpacityMask::new(stroked(1), true);
|
||||
mask.linked = linked;
|
||||
n.extras_mut().mask = Some(Box::new(mask));
|
||||
n
|
||||
};
|
||||
let round = vectorcraft_geom::shapes::ellipse(Rect::new(0.0, 0.0, 400.0, 400.0)).to_bezpath();
|
||||
let bent_track = tracks(&round, 0.05).remove(0);
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 0.0));
|
||||
line.line_to((320.0, 0.0));
|
||||
let straight_track = tracks(&line, 0.01).remove(0);
|
||||
let along = AlongMap::linear(b.width(), 100.0, 140.0);
|
||||
for (name, t) in [("bent", &bent_track), ("straight", &straight_track)] {
|
||||
let out = bend(t, &masked(true), b, along, 2.0).expect("the art is drawn");
|
||||
let mask = out.extras().mask.as_ref().expect("the mask is kept");
|
||||
assert_eq!(mask.art.kind, out.kind, "{name} run: the mask's art bends with the art it was drawn over");
|
||||
assert_eq!(mask.art.appearance, out.appearance, "{name} run: the mask's strokes widen with the art's");
|
||||
|
||||
let out = bend(t, &masked(false), b, along, 2.0).expect("the art is drawn");
|
||||
let mask = out.extras().mask.as_ref().expect("the mask is kept");
|
||||
assert_eq!(*mask.art, stroked(1), "{name} run: an unlinked mask stays where it was");
|
||||
|
||||
let out = bend_widened(t, &masked(true), b, along, 2.0, &|_| 0.5).expect("the art is drawn");
|
||||
let mask = out.extras().mask.as_ref().expect("the mask is kept");
|
||||
assert_eq!(mask.art.kind, out.kind, "{name} run, widened: the mask's art bends with the art");
|
||||
assert_eq!(mask.art.appearance, out.appearance, "{name} run, widened: the mask's strokes narrow with the art's");
|
||||
}
|
||||
assert_eq!(anchors_of(&masked(true)), 8, "the anchor budget counts the mask's art");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1049,6 +1049,34 @@ fn drawings_the_export_writes_read_back() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_segment_reads_back_from_the_line_the_export_writes() {
|
||||
let mut doc = Document::new(200.0, 100.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut b = kurbo::BezPath::new();
|
||||
b.move_to((20.0, 30.0));
|
||||
b.line_to((120.0, 70.0));
|
||||
let id = doc.alloc_id();
|
||||
let black = Appearance::basic(Paint::None, Paint::solid(Color::rgb8(0, 0, 0)), 1.0);
|
||||
doc.insert(Some(layer), 0, Node::path(id, vectorcraft_geom::PathData::from_bezpath(&b), black)).unwrap();
|
||||
for version in crate::DxfVersion::ALL.into_iter().filter(|v| v.r2000()) {
|
||||
let opts = crate::DxfOptions { version, unit: Unit::Points, region: doc.artboards[0].rect, ..crate::DxfOptions::default() };
|
||||
let out = crate::export(&doc, &opts).unwrap();
|
||||
assert!(std::str::from_utf8(&out.bytes).unwrap().contains("\nLINE\n"), "{version:?}");
|
||||
let back = open(&out.bytes, &points());
|
||||
let art = leaves(&back.document);
|
||||
assert_eq!(art.len(), 1, "{version:?}");
|
||||
let path = art[0].path_data().unwrap();
|
||||
assert_eq!(path.subpaths.len(), 1, "{version:?}");
|
||||
assert!(!path.subpaths[0].closed, "{version:?}");
|
||||
let shift = Affine::translate((0.0, 100.0 - height(&back.document)));
|
||||
let points = path_points(&art[0]);
|
||||
assert_eq!(points.len(), 17, "{version:?}: one segment");
|
||||
let ends = [shift * points[0], shift * points[16]];
|
||||
assert!(ends[0].distance(Point::new(20.0, 30.0)) < 1e-6 && ends[1].distance(Point::new(120.0, 70.0)) < 1e-6, "{version:?}: {ends:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_control_point_splines_stay_closed_through_export_and_import() {
|
||||
// (explicitly closed, ends where it starts): coincident endpoints alone do not close a path.
|
||||
|
||||
@@ -233,7 +233,8 @@ impl RasterFormat {
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Preserve {
|
||||
/// Type becomes glyph outlines; strokes a plain CAD line can't draw (aligned inside or
|
||||
/// outside, width profiles, arrowheads, fitted dashes) become their filled outlines; brush
|
||||
/// outside, width profiles, arrowheads, fitted dashes, dashes offset into their pattern)
|
||||
/// become their filled outlines; brush
|
||||
/// strokes become their brush art.
|
||||
#[default]
|
||||
Appearance,
|
||||
@@ -277,7 +278,9 @@ pub struct DxfOptions {
|
||||
pub colors: ColorDepth,
|
||||
pub raster: RasterFormat,
|
||||
pub preserve: Preserve,
|
||||
/// Alter Paths for Appearance: every stroke is written as the filled area it paints.
|
||||
/// Alter Paths for Appearance: a stroke a CAD line can't draw (one [`Preserve::Appearance`]
|
||||
/// outlines, one wider than the widest lineweight, or any stroke in R13 and R14, which have
|
||||
/// no lineweights) is written as the filled area it paints.
|
||||
pub alter_paths: bool,
|
||||
/// Type is written as glyph outlines (always with [`Preserve::Appearance`]).
|
||||
pub outline_text: bool,
|
||||
|
||||
+142
-9
@@ -10,7 +10,7 @@ use kurbo::PathEl;
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, Node, NodeKind, StrokeLayer, SymbolBoxes, TextKind, TextObject, Unit};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::aci::nearest_aci;
|
||||
use crate::writer::{Codes, Drawing, EntityStyle, Handle, ImageDef, Layer, LineType, fixed};
|
||||
@@ -184,6 +184,46 @@ fn circular_subpath(sub: &Sub) -> Option<CircularSub> {
|
||||
Some(CircularSub { centre, radius, samples })
|
||||
}
|
||||
|
||||
/// A closed subpath that is the four-cubic circle under an affine map: an ellipse, or a circle
|
||||
/// scaled out of proportion. `u` and `v` are the conjugate semi-diameters through its first two
|
||||
/// anchors.
|
||||
struct EllipticalSub {
|
||||
centre: Point,
|
||||
u: Vec2,
|
||||
v: Vec2,
|
||||
}
|
||||
|
||||
/// Fit a closed subpath of four cubics to one ellipse. Its anchors give the center and two
|
||||
/// conjugate semi-diameters; the subpath mapped by their inverse must pass [`circular_subpath`] as
|
||||
/// the unit circle, so the same tolerance and turn checks apply.
|
||||
fn elliptical_subpath(sub: &Sub) -> Option<EllipticalSub> {
|
||||
if !sub.closed || sub.segs.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let (a0, a1, a2) = (sub.start, sub.segs.first()?.end(), sub.segs.get(1)?.end());
|
||||
let centre = a0.midpoint(a2);
|
||||
let (u, v) = (a0 - centre, a1 - centre);
|
||||
let det = u.cross(v);
|
||||
let scale2 = u.hypot2().max(v.hypot2());
|
||||
if !det.is_finite() || !scale2.is_finite() || det.abs() <= scale2 * 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let unit = |p: Point| {
|
||||
let d = p - centre;
|
||||
Point::new(d.cross(v) / det, u.cross(d) / det)
|
||||
};
|
||||
let mut segs = Vec::with_capacity(4);
|
||||
for seg in &sub.segs {
|
||||
let Seg::Cubic(a, b, to) = *seg else { return None };
|
||||
segs.push(Seg::Cubic(unit(a), unit(b), unit(to)));
|
||||
}
|
||||
let circle = circular_subpath(&Sub { start: unit(sub.start), segs, closed: true })?;
|
||||
if circle.centre.to_vec2().hypot() > 5e-4 || (circle.radius - 1.0).abs() > 5e-4 {
|
||||
return None;
|
||||
}
|
||||
Some(EllipticalSub { centre, u, v })
|
||||
}
|
||||
|
||||
fn angle_deg(p: Point, centre: Point) -> f64 {
|
||||
(p.y - centre.y).atan2(p.x - centre.x).to_degrees().rem_euclid(360.0)
|
||||
}
|
||||
@@ -233,6 +273,17 @@ const MAX_ENTITIES: u32 = 10_000_000;
|
||||
const MAX_COORD: f64 = 1e9;
|
||||
const MAX_POINTS: usize = 4_000_000;
|
||||
|
||||
/// Do the dashes of `d` start at the start of their pattern, as a linetype's do? A pattern of
|
||||
/// odd length repeats twice in its linetype.
|
||||
fn starts_with_pattern(d: &Dash) -> bool {
|
||||
if !d.is_dashed() || !d.offset.is_finite() {
|
||||
return true;
|
||||
}
|
||||
let period = d.pattern.iter().sum::<f64>() * if d.pattern.len() % 2 == 1 { 2.0 } else { 1.0 };
|
||||
let phase = d.offset.rem_euclid(period);
|
||||
phase.min(period - phase) <= period * 1e-9
|
||||
}
|
||||
|
||||
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
|
||||
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
|
||||
// Far outside any drawing, a curve flattens to millions of points at this tolerance.
|
||||
@@ -612,8 +663,7 @@ impl<'a> Scene<'a> {
|
||||
|
||||
/// The DXF lineweight nearest to `width` points (scaled with the drawing when asked).
|
||||
fn lineweight(&mut self, width: f64) -> i32 {
|
||||
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
|
||||
let hundredths = width * 2540.0 / 72.0 * scale;
|
||||
let hundredths = self.hundredths(width);
|
||||
let max = LINEWEIGHTS[LINEWEIGHTS.len() - 1];
|
||||
if hundredths > f64::from(max) + 5.0 {
|
||||
self.warn(WIDE_LINES);
|
||||
@@ -621,6 +671,21 @@ impl<'a> Scene<'a> {
|
||||
LINEWEIGHTS.into_iter().min_by(|a, b| (f64::from(*a) - hundredths).abs().total_cmp(&(f64::from(*b) - hundredths).abs())).unwrap_or(max)
|
||||
}
|
||||
|
||||
/// `width` points in hundredths of a millimetre, the unit of DXF lineweights (scaled with
|
||||
/// the drawing when asked).
|
||||
fn hundredths(&self, width: f64) -> f64 {
|
||||
let scale = if self.o.scale_lineweights { self.o.scale } else { 1.0 };
|
||||
width * 2540.0 / 72.0 * scale
|
||||
}
|
||||
|
||||
/// Can a CAD line draw a plain stroke `width` points wide in this version? R12 polylines
|
||||
/// carry their width, R13 and R14 have no lineweights, and from 2000 a lineweight holds up
|
||||
/// to 2.11 mm, to the hundredth.
|
||||
fn line_keeps_width(&self, width: f64) -> bool {
|
||||
let v = self.o.version;
|
||||
!v.handles() || (v.r2000() && self.hundredths(width).round() <= f64::from(LINEWEIGHTS[LINEWEIGHTS.len() - 1]))
|
||||
}
|
||||
|
||||
/// A linetype for dash pattern `d` (one per distinct pattern).
|
||||
fn linetype(&mut self, d: &Dash) -> String {
|
||||
let mut lengths: Vec<f64> = d.pattern.iter().map(|v| v * self.k).collect();
|
||||
@@ -673,7 +738,11 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
let Some(rgb) = self.paint_rgb(&st.paint) else { return };
|
||||
let plain = stroke::is_plain(st);
|
||||
if self.o.alter_paths || (appearance && !plain) {
|
||||
// A linetype has no phase: dashes that start part way into their pattern need outlines.
|
||||
let lined = plain && st.dash.as_ref().is_none_or(starts_with_pattern);
|
||||
// Alter Paths for Appearance outlines a stroke only when a line can't draw it.
|
||||
let altered = self.o.alter_paths && !(lined && self.line_keeps_width(st.width));
|
||||
if altered || (appearance && !lined) {
|
||||
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st);
|
||||
let style = self.style(rgb, st.opacity, None, None);
|
||||
self.area(®ion.to_bezpath(), FillRule::NonZero, &style, true);
|
||||
@@ -740,9 +809,9 @@ impl<'a> Scene<'a> {
|
||||
self.e.int(98, 0);
|
||||
}
|
||||
|
||||
/// Path `bp` as lines: polylines for straight subpaths (and for curves in R12, flattened),
|
||||
/// cubic splines for curved ones. `width`: the stroke weight, which DXF before 2000 gives
|
||||
/// polylines as a width.
|
||||
/// Path `bp` as lines: a line for an open subpath of one straight segment, polylines for
|
||||
/// other straight subpaths (and for curves in R12, flattened), cubic splines for curved ones.
|
||||
/// `width`: the stroke weight, which DXF before 2000 gives polylines as a width.
|
||||
fn lines(&mut self, bp: &BezPath, style: &EntityStyle, width: f64) {
|
||||
let v = self.o.version;
|
||||
let width = (!v.r2000() && width > 0.0).then_some(width * self.k);
|
||||
@@ -755,11 +824,17 @@ impl<'a> Scene<'a> {
|
||||
if sub.closed && pts.len() > 2 && pts.last().is_some_and(|p| p.distance(sub.start) <= 1e-9) {
|
||||
pts.pop();
|
||||
}
|
||||
self.polyline(&pts, sub.closed, style, width);
|
||||
// A LINE has no width, so a stroke before 2000 keeps its polyline width.
|
||||
match (sub.closed, width, pts.as_slice()) {
|
||||
(false, None, &[a, b]) => self.line(a, b, style),
|
||||
_ => self.polyline(&pts, sub.closed, style, width),
|
||||
}
|
||||
} else if (v.handles() || width.is_none()) && self.circular(&sub, style) {
|
||||
// Native circles/arcs are preferred by CAD/CAM software. In R12, however, a
|
||||
// stroked curve must stay on the flattened POLYLINE path so groups 40/41 retain
|
||||
// its width. Non-circular curves continue through the existing fallback.
|
||||
} else if v.handles() && self.elliptical(&sub, style) {
|
||||
// R13 and later write an ellipse as a native ELLIPSE.
|
||||
} else if v.handles() {
|
||||
self.spline(&sub, style);
|
||||
} else {
|
||||
@@ -778,6 +853,16 @@ impl<'a> Scene<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A line from `a` to `b` (document space).
|
||||
fn line(&mut self, a: Point, b: Point, style: &EntityStyle) {
|
||||
let (a, b) = (self.pt(a), self.pt(b));
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
self.e.entity("LINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbLine");
|
||||
self.e.xyz(10, a.x, a.y);
|
||||
self.e.xyz(11, b.x, b.y);
|
||||
}
|
||||
|
||||
/// A polyline through `pts` (document space): lightweight from R14, else a polyline with a
|
||||
/// vertex entity per point.
|
||||
fn polyline(&mut self, pts: &[Point], closed: bool, style: &EntityStyle, width: Option<f64>) {
|
||||
@@ -787,7 +872,8 @@ impl<'a> Scene<'a> {
|
||||
let pts: Vec<Point> = pts.iter().map(|p| self.pt(*p)).collect();
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
let flags = i64::from(closed);
|
||||
// Bit 128: a linetype runs on through the vertices instead of restarting at each one.
|
||||
let flags = i64::from(closed) | if style.linetype.is_some() { 128 } else { 0 };
|
||||
if self.o.version.lwpolyline() {
|
||||
self.e.entity("LWPOLYLINE", h, fixed::MODEL_RECORD, &layer, style, "AcDbPolyline");
|
||||
self.e.int(90, pts.len() as i64);
|
||||
@@ -887,6 +973,53 @@ impl<'a> Scene<'a> {
|
||||
true
|
||||
}
|
||||
|
||||
/// Write a recognized elliptical subpath as a native DXF ELLIPSE (R13 and later): its center,
|
||||
/// the end of its major axis relative to the center, and the minor to major ratio. Returns
|
||||
/// false when the curve is not an ellipse, leaving the caller to write it as a spline.
|
||||
fn elliptical(&mut self, sub: &Sub, style: &EntityStyle) -> bool {
|
||||
let Some(last) = sub.segs.last().map(|s| s.end()) else { return false };
|
||||
if !sub.closed || last.distance(sub.start) > 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let Some(ellipse) = elliptical_subpath(sub) else { return false };
|
||||
let centre = self.to_dxf * ellipse.centre;
|
||||
let u = self.to_dxf * (ellipse.centre + ellipse.u) - centre;
|
||||
let v = self.to_dxf * (ellipse.centre + ellipse.v) - centre;
|
||||
// The semi-axes are the square roots of the eigenvalues of [[a, b], [b, d]] = u u^T + v v^T.
|
||||
let (a, b, d) = (u.x * u.x + v.x * v.x, u.x * u.y + v.x * v.y, u.y * u.y + v.y * v.y);
|
||||
let mean = (a + d) / 2.0;
|
||||
let spread = ((a - d) / 2.0).hypot(b);
|
||||
let (major, minor) = ((mean + spread).sqrt(), (mean - spread).max(0.0).sqrt());
|
||||
let angle = 0.5 * (2.0 * b).atan2(a - d);
|
||||
let axis = Vec2::new(major * angle.cos(), major * angle.sin());
|
||||
let ratio = (minor / major).min(1.0);
|
||||
// A thinner ellipse stays a spline: DXF readers such as ezdxf need a ratio of 1e-6 or more.
|
||||
if !centre.x.is_finite() || !centre.y.is_finite() || !axis.x.is_finite() || !axis.y.is_finite() || !ratio.is_finite() || ratio < 1e-6 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The drawing extents take the ellipse's bounding box: half-widths hypot(u.x, v.x) and
|
||||
// hypot(u.y, v.y) about the center.
|
||||
let (c, eu, ev) = (ellipse.centre, ellipse.u, ellipse.v);
|
||||
let (hx, hy) = (eu.x.hypot(ev.x), eu.y.hypot(ev.y));
|
||||
for p in [Point::new(c.x - hx, c.y), Point::new(c.x + hx, c.y), Point::new(c.x, c.y - hy), Point::new(c.x, c.y + hy)] {
|
||||
self.pt(p);
|
||||
}
|
||||
|
||||
let layer = self.layer.clone();
|
||||
let h = self.handle();
|
||||
self.e.entity("ELLIPSE", h, fixed::MODEL_RECORD, &layer, style, "AcDbEllipse");
|
||||
self.e.xyz(10, centre.x, centre.y);
|
||||
self.e.xyz(11, axis.x, axis.y);
|
||||
self.e.normal();
|
||||
// The ratio and the end parameter are written in full: rounded to six decimals, a thin
|
||||
// ellipse's minor axis would change size, and a whole turn would read back as an open curve.
|
||||
self.e.raw(40, &ratio.to_string());
|
||||
self.e.num(41, 0.0);
|
||||
self.e.raw(42, &std::f64::consts::TAU.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// A curved subpath as one cubic B-spline whose clamped knots repeat at every joint, so it
|
||||
/// passes through every anchor (straight segments become straight cubics).
|
||||
fn spline(&mut self, sub: &Sub, style: &EntityStyle) {
|
||||
|
||||
+319
-7
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, Node, NodeKind, TextObject, Unit};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
|
||||
use vectorcraft_geom::{Affine, ParamCurve, PathData, Point, Rect, Shape, shapes};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -125,6 +125,71 @@ fn every_version_declares_its_acadver_and_is_well_formed() {
|
||||
assert_eq!(DxfVersion::from_id("2019"), None);
|
||||
}
|
||||
|
||||
/// The structure strict readers (Autodesk Fusion, #3085) need: the required sections and table
|
||||
/// entries, handles that every reference resolves to, and LAYOUT objects with only the group codes
|
||||
/// the DXF Reference defines for PLOTSETTINGS and LAYOUT (AutoCAD DXF Reference, OBJECTS section:
|
||||
/// https://help.autodesk.com/view/OARX/2018/ENU/?guid=GUID-1113675E-AB07-4567-BD47-B7FBE7290D1C).
|
||||
#[test]
|
||||
fn r2000_and_later_files_have_the_structure_cad_apps_require() {
|
||||
use std::collections::HashSet;
|
||||
// Common object codes, then AcDbPlotSettings, then AcDbLayout (76 is the shade plot mode in
|
||||
// the first and the UCS type in the second).
|
||||
let layout_codes: HashSet<i32> = [0, 5, 102, 330, 360, 100]
|
||||
.into_iter()
|
||||
.chain([1, 2, 4, 6, 7, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 140, 141, 142, 143, 70, 72, 73, 74, 75, 76, 77, 78, 147, 148, 149, 333])
|
||||
.chain([71, 10, 20, 11, 21, 12, 22, 32, 14, 24, 34, 15, 25, 35, 146, 13, 23, 33, 16, 26, 36, 17, 27, 37, 331, 345, 346])
|
||||
.collect();
|
||||
let d = shapes_doc();
|
||||
for v in DxfVersion::ALL.into_iter().filter(|v| *v >= DxfVersion::R2000) {
|
||||
let out = export(&d, &DxfOptions { version: v, ..opts(&d) }).unwrap();
|
||||
let p = pairs(&out.bytes);
|
||||
for name in ["HEADER", "CLASSES", "TABLES", "BLOCKS", "ENTITIES", "OBJECTS"] {
|
||||
assert!(p.windows(2).any(|w| w[0] == (0, "SECTION".into()) && w[1] == (2, name.into())), "{v:?}: {name} section");
|
||||
}
|
||||
let tables = section(&p, "TABLES");
|
||||
let names = |k: &str| -> Vec<String> { tables.iter().filter(|o| kind(o) == k).filter_map(|o| get(o, 2).map(str::to_string)).collect() };
|
||||
for (k, want) in [
|
||||
("LTYPE", "ByBlock"),
|
||||
("LTYPE", "ByLayer"),
|
||||
("LTYPE", "Continuous"),
|
||||
("LAYER", "0"),
|
||||
("STYLE", "Standard"),
|
||||
("APPID", "ACAD"),
|
||||
("DIMSTYLE", "Standard"),
|
||||
("BLOCK_RECORD", "*Model_Space"),
|
||||
("BLOCK_RECORD", "*Paper_Space"),
|
||||
] {
|
||||
assert!(names(k).iter().any(|n| n == want), "{v:?}: {k} {want}");
|
||||
}
|
||||
// Every object after the header has a handle below $HANDSEED, and every reference to
|
||||
// another object (owner 330, soft/hard pointers 340/350/390) names one of them.
|
||||
let body = &p[p.iter().position(|x| *x == (0, "ENDSEC".into())).unwrap()..];
|
||||
let handles: HashSet<u32> = body.iter().filter(|x| x.0 == 5 || x.0 == 105).map(|x| u32::from_str_radix(&x.1, 16).unwrap()).collect();
|
||||
let seed = u32::from_str_radix(&header(&p, "$HANDSEED").unwrap(), 16).unwrap();
|
||||
assert!(handles.iter().all(|h| *h < seed), "{v:?}: handles below the seed");
|
||||
for (code, value) in body.iter().filter(|x| matches!(x.0, 330 | 340 | 350 | 390)) {
|
||||
let h = u32::from_str_radix(value, 16).unwrap();
|
||||
assert!(h == 0 || handles.contains(&h), "{v:?}: group {code} points at missing handle {value}");
|
||||
}
|
||||
let objects = section(&p, "OBJECTS");
|
||||
let layouts: Vec<&Pairs> = objects.iter().filter(|o| kind(o) == "LAYOUT").collect();
|
||||
assert_eq!(layouts.len(), 2, "{v:?}: Model and Layout1");
|
||||
for l in &layouts {
|
||||
let bad: Vec<i32> = l.iter().map(|x| x.0).filter(|c| !layout_codes.contains(c)).collect();
|
||||
assert!(bad.is_empty(), "{v:?}: LAYOUT has group codes the DXF Reference doesn't define: {bad:?}");
|
||||
// The paper image origin is 148 (x) and 149 (y).
|
||||
assert!(get(l, 148).is_some() && get(l, 149).is_some(), "{v:?}: paper image origin");
|
||||
// Flag 1024 marks the model space layout, and only it.
|
||||
let name = l.iter().skip_while(|x| x.1 != "AcDbLayout").find(|x| x.0 == 1).map(|x| x.1.as_str());
|
||||
let flags: i64 = get(l, 70).unwrap().parse().unwrap();
|
||||
assert_eq!(flags & 1024 != 0, name == Some("Model"), "{v:?}: model type flag of {name:?}");
|
||||
}
|
||||
// The plot style placeholder carries only the common object codes.
|
||||
let holder = objects.iter().find(|o| kind(o) == "ACDBPLACEHOLDER").expect("placeholder");
|
||||
assert!(holder.iter().all(|x| matches!(x.0, 0 | 5 | 102 | 330)), "{v:?}: placeholder {holder:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r12_viewport_has_no_duplicate_group_codes() {
|
||||
let d = shapes_doc();
|
||||
@@ -208,10 +273,168 @@ fn circles_and_circular_arcs_are_native_dxf_entities() {
|
||||
assert_eq!((all(&e[1], 10), all(&e[1], 20), all(&e[1], 40)), (vec![120.0], vec![50.0], vec![30.0]), "{version:?}");
|
||||
assert_eq!((all(&e[1], 50), all(&e[1], 51)), (vec![0.0], vec![90.0]), "{version:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// An actual ellipse is not close enough to a circle and remains a spline.
|
||||
let ellipse = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
assert_eq!(kinds(&export(&ellipse, &opts(&ellipse)).unwrap()), ["SPLINE"]);
|
||||
/// The path `p` with `xf` applied.
|
||||
fn transformed(p: &PathData, xf: Affine) -> PathData {
|
||||
PathData::from_bezpath(&(xf * p.to_bezpath()))
|
||||
}
|
||||
|
||||
fn close_to(a: &[f64], b: &[f64], tol: f64) -> bool {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipses_and_unevenly_scaled_circles_are_native_ellipses() {
|
||||
let circle = shapes::ellipse(Rect::new(20.0, 20.0, 80.0, 80.0));
|
||||
let d = doc_with(|d| {
|
||||
let stroke = || Paint::solid(Color::BLACK);
|
||||
vec![
|
||||
// 80 × 40 pt about (60, 40): DXF center (60, 60), major axis (40, 0), ratio 0.5.
|
||||
path(d, shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Paint::None, stroke(), 1.0),
|
||||
// A 60 pt circle scaled 150% across about (50, 50): 90 × 60 pt, ratio 2/3.
|
||||
path(
|
||||
d,
|
||||
transformed(&circle, Affine::translate((50.0, 50.0)) * Affine::scale_non_uniform(1.5, 1.0) * Affine::translate((-50.0, -50.0))),
|
||||
Paint::None,
|
||||
stroke(),
|
||||
1.0,
|
||||
),
|
||||
// The first ellipse turned 30° about its center, filled: the outline is the ellipse too.
|
||||
path(
|
||||
d,
|
||||
transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0))),
|
||||
Paint::solid(Color::BLACK),
|
||||
Paint::None,
|
||||
0.0,
|
||||
),
|
||||
]
|
||||
});
|
||||
for version in DxfVersion::ALL.into_iter().filter(|version| *version >= DxfVersion::R13) {
|
||||
let out = export(&d, &DxfOptions { version, ..opts(&d) }).unwrap();
|
||||
let e = entities(&out);
|
||||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["ELLIPSE", "ELLIPSE", "ELLIPSE", "HATCH"], "{version:?}");
|
||||
let ellipse = |x: &Pairs| (all(x, 10), all(x, 20), all(x, 11), all(x, 21), all(x, 40), all(x, 41), all(x, 42));
|
||||
let (cx, cy, mx, my, ratio, start, end) = ellipse(&e[0]);
|
||||
assert_eq!((cx, cy, mx, my, ratio), (vec![60.0], vec![60.0], vec![40.0], vec![0.0], vec![0.5]), "{version:?}");
|
||||
assert!(close_to(&start, &[0.0], 1e-9) && close_to(&end, &[std::f64::consts::TAU], 1e-12), "{version:?}: a whole turn");
|
||||
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[1]);
|
||||
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[50.0, 50.0, 45.0, 0.0, 2.0 / 3.0], 1e-6), "{version:?}: {:?}", e[1]);
|
||||
// Turned 30° counterclockwise on screen, so clockwise with DXF's y up.
|
||||
let (cx, cy, mx, my, ratio, ..) = ellipse(&e[2]);
|
||||
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
assert!(close_to(&[cx, cy, mx, my, ratio].concat(), &[60.0, 60.0, 40.0 * cos, -40.0 * sin, 0.5], 1e-6), "{version:?}: {:?}", e[2]);
|
||||
assert!(e[0].contains(&(100, "AcDbEllipse".into())), "{version:?}");
|
||||
}
|
||||
// R12 has no ELLIPSE: the curves are flattened polylines.
|
||||
let r12 = kinds(&export(&d, &DxfOptions { version: DxfVersion::R12, ..opts(&d) }).unwrap());
|
||||
assert!(!r12.iter().any(|k| k == "ELLIPSE") && r12.iter().any(|k| k == "POLYLINE"), "{r12:?}");
|
||||
// A thin ellipse keeps its ratio in full (six decimals would write 0.000002).
|
||||
let thin = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(0.0, 50.0, 200.0, 50.0003)), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
let e = entities(&export(&thin, &opts(&thin)).unwrap());
|
||||
assert_eq!(kind(&e[0]), "ELLIPSE");
|
||||
assert!(close_to(&all(&e[0], 40), &[1.5e-6], 1e-10), "{:?}", e[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_ellipses_read_back_as_the_same_ellipse() {
|
||||
let xf = Affine::rotate_about(30f64.to_radians(), Point::new(60.0, 40.0));
|
||||
let d = doc_with(|d| {
|
||||
vec![path(d, transformed(&shapes::ellipse(Rect::new(20.0, 20.0, 100.0, 60.0)), xf), Paint::None, Paint::solid(Color::BLACK), 1.0)]
|
||||
});
|
||||
let out = export(&d, &opts(&d)).unwrap();
|
||||
assert_eq!(kinds(&out), ["ELLIPSE"]);
|
||||
let o = ImportOptions { unit: Some(Unit::Points), scale: Some(1.0), center: false, ..ImportOptions::default() };
|
||||
let back = import(&out.bytes, &o).unwrap().document;
|
||||
let mut paths = vec![];
|
||||
back.walk(|n| {
|
||||
if let NodeKind::Path { path, .. } = &n.kind {
|
||||
paths.push(path.to_bezpath());
|
||||
}
|
||||
});
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert!(paths[0].elements().last().is_some_and(|el| matches!(el, kurbo::PathEl::ClosePath)), "the ellipse reads back closed");
|
||||
// Every point of the curve read back lies on the 80 × 40 pt ellipse turned 30°, once both are centered.
|
||||
let bp = &paths[0];
|
||||
let found = bp.bounding_box().center();
|
||||
let inverse = xf.inverse();
|
||||
let original = Point::new(60.0, 40.0);
|
||||
for seg in bp.segments() {
|
||||
for i in 0..=16 {
|
||||
let p = seg.eval(f64::from(i) / 16.0) - found.to_vec2() + original.to_vec2();
|
||||
let q = inverse * p;
|
||||
let r = ((q.x - 60.0) / 40.0).hypot((q.y - 40.0) / 20.0);
|
||||
assert!((r - 1.0).abs() < 1e-3, "{p:?} is off the ellipse ({r})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_four_cubic_shapes_that_are_not_writable_ellipses_stay_splines() {
|
||||
// A rounded square: four cubics through the ellipse's anchors with longer handles.
|
||||
let (c, r, k) = (Point::new(50.0, 50.0), 30.0, 30.0 * 0.8);
|
||||
let mut squircle = vectorcraft_geom::BezPath::new();
|
||||
squircle.move_to(Point::new(c.x + r, c.y));
|
||||
squircle.curve_to(Point::new(c.x + r, c.y - k), Point::new(c.x + k, c.y - r), Point::new(c.x, c.y - r));
|
||||
squircle.curve_to(Point::new(c.x - k, c.y - r), Point::new(c.x - r, c.y - k), Point::new(c.x - r, c.y));
|
||||
squircle.curve_to(Point::new(c.x - r, c.y + k), Point::new(c.x - k, c.y + r), Point::new(c.x, c.y + r));
|
||||
squircle.curve_to(Point::new(c.x + k, c.y + r), Point::new(c.x + r, c.y + k), Point::new(c.x + r, c.y));
|
||||
squircle.close_path();
|
||||
// An egg: one half wider than the other.
|
||||
let egg = shapes::ellipse(Rect::new(110.0, 20.0, 190.0, 60.0)).to_bezpath();
|
||||
let mut els: Vec<kurbo::PathEl> = egg.elements().to_vec();
|
||||
if let Some(kurbo::PathEl::CurveTo(a, b, _)) = els.get_mut(1) {
|
||||
a.x -= 10.0;
|
||||
b.x -= 10.0;
|
||||
}
|
||||
let d = doc_with(|d| {
|
||||
vec![
|
||||
path(d, PathData::from_bezpath(&squircle), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
path(d, PathData::from_bezpath(&kurbo::BezPath::from_vec(els)), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
// An ellipse thinner than the smallest ratio DXF readers take (5e-7 against 1e-6).
|
||||
path(d, shapes::ellipse(Rect::new(0.0, 90.0, 200.0, 90.0001)), Paint::None, Paint::solid(Color::BLACK), 1.0),
|
||||
]
|
||||
});
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["SPLINE", "SPLINE", "SPLINE"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_straight_segment_is_a_line() {
|
||||
let segment = |closed: bool, extra: Option<Point>| {
|
||||
let mut b = vectorcraft_geom::BezPath::new();
|
||||
b.move_to(Point::new(20.0, 30.0));
|
||||
b.line_to(Point::new(120.0, 70.0));
|
||||
if let Some(p) = extra {
|
||||
b.line_to(p);
|
||||
}
|
||||
if closed {
|
||||
b.close_path();
|
||||
}
|
||||
PathData::from_bezpath(&b)
|
||||
};
|
||||
let d = doc_with(|d| vec![path(d, segment(false, None), Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
for version in DxfVersion::ALL.into_iter().filter(|v| v.r2000()) {
|
||||
let e = entities(&export(&d, &DxfOptions { version, ..opts(&d) }).unwrap());
|
||||
assert_eq!(e.iter().map(|x| kind(x)).collect::<Vec<_>>(), ["LINE"], "{version:?}");
|
||||
assert_eq!(e[0].iter().rfind(|p| p.0 == 100).map(|p| p.1.as_str()), Some("AcDbLine"), "{version:?}");
|
||||
assert_eq!((all(&e[0], 10), all(&e[0], 20)), (vec![20.0], vec![70.0]), "{version:?}: y up from the artboard's bottom");
|
||||
assert_eq!((all(&e[0], 11), all(&e[0], 21)), (vec![120.0], vec![30.0]), "{version:?}");
|
||||
}
|
||||
|
||||
// Without a width (R12 writes a fill as its outline), R12 writes a LINE too; a stroke weight
|
||||
// before 2000 keeps its polyline width.
|
||||
let filled = doc_with(|d| vec![path(d, segment(false, None), Paint::solid(Color::BLACK), Paint::None, 0.0)]);
|
||||
let r12 = |d: &Document| kinds(&export(d, &DxfOptions { version: DxfVersion::R12, ..opts(d) }).unwrap());
|
||||
assert_eq!(r12(&filled), ["LINE"]);
|
||||
assert_eq!(r12(&d), ["POLYLINE", "VERTEX", "VERTEX", "SEQEND"]);
|
||||
let r14 = entities(&export(&d, &DxfOptions { version: DxfVersion::R14, ..opts(&d) }).unwrap());
|
||||
assert_eq!((kind(&r14[0]), get(&r14[0], 43)), ("LWPOLYLINE", Some("1")));
|
||||
|
||||
// A closed segment and two segments stay polylines.
|
||||
for p in [segment(true, None), segment(false, Some(Point::new(150.0, 20.0)))] {
|
||||
let d = doc_with(|d| vec![path(d, p, Paint::None, Paint::solid(Color::BLACK), 1.0)]);
|
||||
assert_eq!(kinds(&export(&d, &opts(&d)).unwrap()), ["LWPOLYLINE"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -453,14 +676,44 @@ fn dashes_become_linetypes_and_opacity_transparency() {
|
||||
|
||||
#[test]
|
||||
fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
|
||||
// Each fill and stroke: its outlines, then its hatch.
|
||||
let fill = ["LWPOLYLINE", "HATCH"];
|
||||
let rect_stroke = ["LWPOLYLINE", "LWPOLYLINE", "HATCH"];
|
||||
// Strokes a lineweight draws stay lines: the rectangle, the circle and an open arc.
|
||||
let mut d = shapes_doc();
|
||||
let c = Point::new(150.0, 95.0);
|
||||
let k = 20.0 * shapes::KAPPA;
|
||||
let mut arc = vectorcraft_geom::BezPath::new();
|
||||
arc.move_to(Point::new(c.x + 20.0, c.y));
|
||||
arc.curve_to(Point::new(c.x + 20.0, c.y - k), Point::new(c.x + k, c.y - 20.0), Point::new(c.x, c.y - 20.0));
|
||||
let arc = path(&mut d, PathData::from_bezpath(&arc), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
let layer = d.layers[0].id;
|
||||
d.insert(Some(layer), usize::MAX, arc).unwrap();
|
||||
let out = export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&out), [&fill[..], &["LWPOLYLINE", "CIRCLE", "ARC"]].concat());
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
// R12 polylines carry any width, so its strokes stay lines too.
|
||||
let r12 = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R12, ..opts(&d) }).unwrap();
|
||||
assert!(!kinds(&r12).iter().any(|k| k == "HATCH"));
|
||||
assert!(entities(&r12).iter().any(|e| kind(e) == "POLYLINE" && get(e, 40) == Some("2")));
|
||||
// R13 and R14 have no lineweights: every stroke becomes its outline, then its hatch.
|
||||
let d = shapes_doc();
|
||||
let out = export(&d, &DxfOptions { alter_paths: true, version: DxfVersion::R14, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&out), [&fill[..], &rect_stroke, &["CIRCLE", "CIRCLE", "HATCH"]].concat());
|
||||
// The rectangle's stroke: an outer and an inner boundary.
|
||||
assert_eq!(get(&entities(&out)[4], 91), Some("2"));
|
||||
// A stroke wider than the widest lineweight (2.11 mm) is outlined at its true width.
|
||||
let wide = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 8.0)]);
|
||||
let out = export(&wide, &DxfOptions { alter_paths: true, ..opts(&wide) }).unwrap();
|
||||
assert_eq!(kinds(&out), rect_stroke);
|
||||
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
|
||||
let lines = export(&wide, &opts(&wide)).unwrap();
|
||||
assert_eq!(kinds(&lines), ["LWPOLYLINE"]);
|
||||
assert!(lines.warnings.iter().any(|w| w.contains("2.11 mm")));
|
||||
// To the hundredth of a millimetre: 6 pt (2.117 mm) is outlined, 5.98 pt (2.110 mm) is a line.
|
||||
for (width, expected) in [(6.0, &rect_stroke[..]), (5.98, &["LWPOLYLINE"][..])] {
|
||||
let d = doc_with(|d| vec![path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), width)]);
|
||||
assert_eq!(kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap()), expected, "{width} pt");
|
||||
}
|
||||
// Preserve Appearance outlines only the strokes a line can't draw.
|
||||
let mut d = shapes_doc();
|
||||
let rect = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
|
||||
@@ -471,6 +724,65 @@ fn alter_paths_writes_strokes_as_filled_outlines() {
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE", "HATCH", "LWPOLYLINE", "CIRCLE"]);
|
||||
assert!(editable.warnings.iter().any(|w| w.contains("stroke alignment")));
|
||||
// With Maximize Editability, Alter Paths outlines that stroke and keeps the circle a line.
|
||||
let altered = export(&d, &DxfOptions { preserve: Preserve::Editability, alter_paths: true, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&altered), [&fill[..], &rect_stroke, &["CIRCLE"]].concat());
|
||||
assert!(altered.warnings.is_empty(), "{:?}", altered.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_that_start_part_way_into_their_pattern_are_outlined() {
|
||||
let dashed = |offset: f64| {
|
||||
doc_with(|d| {
|
||||
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
if let Some(st) = n.appearance.stroke_mut() {
|
||||
st.dash = Some(Dash { pattern: vec![6.0, 3.0, 2.0], offset, align_corners: false });
|
||||
}
|
||||
vec![n]
|
||||
})
|
||||
};
|
||||
// A linetype starts at the start of its pattern: an offset of a whole period (the odd
|
||||
// pattern twice) keeps the line, any other offset needs the dashes' outlines and a hatch.
|
||||
for (offset, outlined) in [(0.0, false), (22.0, false), (4.0, true), (11.0, true)] {
|
||||
let d = dashed(offset);
|
||||
let k = kinds(&export(&d, &opts(&d)).unwrap());
|
||||
if outlined {
|
||||
assert!(k.len() > 2 && k.last().is_some_and(|k| k == "HATCH"), "offset {offset}: {k:?}");
|
||||
} else {
|
||||
assert_eq!(k, ["LWPOLYLINE"], "offset {offset}");
|
||||
}
|
||||
let altered = kinds(&export(&d, &DxfOptions { alter_paths: true, ..opts(&d) }).unwrap());
|
||||
assert_eq!(altered, k, "offset {offset}, Alter Paths");
|
||||
}
|
||||
// Maximize Editability keeps the line and its linetype.
|
||||
let d = dashed(4.0);
|
||||
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
assert_eq!(kinds(&editable), ["LWPOLYLINE"]);
|
||||
assert_eq!(get(&entities(&editable)[0], 6), Some("DASHED1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashed_polylines_continue_their_pattern_around_corners() {
|
||||
let rect = |dash: Option<Dash>| {
|
||||
doc_with(|d| {
|
||||
let mut n = path(d, shapes::rectangle(Rect::new(10.0, 10.0, 110.0, 60.0)), Paint::None, Paint::solid(Color::BLACK), 1.0);
|
||||
if let Some(st) = n.appearance.stroke_mut() {
|
||||
st.dash = dash.clone();
|
||||
}
|
||||
vec![n]
|
||||
})
|
||||
};
|
||||
let flags = |out: &DxfOutput| entities(out).first().and_then(|e| get(e, 70).map(str::to_string));
|
||||
// Group 70 bit 128 makes the linetype run on through the vertices instead of restarting
|
||||
// at each one; a solid polyline keeps only its closed bit.
|
||||
let dashed = rect(Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }));
|
||||
for o in [opts(&dashed), DxfOptions { alter_paths: true, ..opts(&dashed) }, DxfOptions { preserve: Preserve::Editability, ..opts(&dashed) }] {
|
||||
assert_eq!(flags(&export(&dashed, &o).unwrap()).as_deref(), Some("129"));
|
||||
}
|
||||
let r12 = export(&dashed, &DxfOptions { version: DxfVersion::R12, ..opts(&dashed) }).unwrap();
|
||||
assert_eq!((kinds(&r12).first().map(String::as_str), flags(&r12).as_deref()), (Some("POLYLINE"), Some("129")));
|
||||
let solid = rect(None);
|
||||
assert_eq!(flags(&export(&solid, &opts(&solid)).unwrap()).as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
fn text_doc(text: &str, size: f64) -> Document {
|
||||
|
||||
@@ -564,7 +564,10 @@ impl Drawing {
|
||||
c.handle(350, fixed::PLOT_NORMAL);
|
||||
c.raw(100, "AcDbDictionaryWithDefault");
|
||||
c.handle(340, fixed::PLOT_NORMAL);
|
||||
c.object("ACDBPLACEHOLDER", fixed::PLOT_NORMAL, fixed::PLOT_STYLES, "AcDbPlaceHolder");
|
||||
// A placeholder has no data and no subclass marker of its own.
|
||||
c.raw(0, "ACDBPLACEHOLDER");
|
||||
c.handle(5, fixed::PLOT_NORMAL);
|
||||
c.handle(330, fixed::PLOT_STYLES);
|
||||
self.layout(c, fixed::MODEL_LAYOUT, "Model", 0, fixed::MODEL_RECORD);
|
||||
self.layout(c, fixed::PAPER_LAYOUT, "Layout1", 1, fixed::PAPER_RECORD);
|
||||
}
|
||||
@@ -618,14 +621,23 @@ impl Drawing {
|
||||
c.num(141, 0.0);
|
||||
c.num(142, 1.0);
|
||||
c.num(143, 1.0);
|
||||
c.int(70, 688);
|
||||
// Plot flags; 1024 marks the model space layout.
|
||||
c.int(70, if record == fixed::MODEL_RECORD { 688 | 1024 } else { 688 });
|
||||
c.int(72, 1);
|
||||
c.int(73, 0);
|
||||
c.int(74, 5);
|
||||
c.raw(7, "");
|
||||
c.int(75, 16);
|
||||
if self.version.true_color() {
|
||||
// Shade plot mode (as displayed), resolution level (normal) and custom DPI.
|
||||
c.int(76, 0);
|
||||
c.int(77, 2);
|
||||
c.int(78, 300);
|
||||
}
|
||||
c.num(147, 1.0);
|
||||
c.xy(148, 0.0, 0.0);
|
||||
// The paper image origin is 148 (x) and 149 (y), not a point at 148 and 158.
|
||||
c.num(148, 0.0);
|
||||
c.num(149, 0.0);
|
||||
c.raw(100, "AcDbLayout");
|
||||
c.raw(1, name);
|
||||
c.int(70, 1);
|
||||
|
||||
@@ -53,6 +53,15 @@ impl WidthProfile {
|
||||
// Two swells between narrow necks.
|
||||
ProfilePreset { id: "wave", label: "Wave", points: &[(0.0, 0.3, 0.3), (0.25, 1.0, 1.0), (0.5, 0.3, 0.3), (0.75, 1.0, 1.0), (1.0, 0.3, 0.3)] },
|
||||
];
|
||||
/// The widest the stroke gets, as a multiple of its weight: the largest total of the left and
|
||||
/// right factors at any of the points (between them the profile is straight), 1 for a profile
|
||||
/// without points.
|
||||
pub fn widest(&self) -> f64 {
|
||||
if self.points.is_empty() {
|
||||
return 1.0;
|
||||
}
|
||||
self.points.iter().map(|&(_, l, r)| (l.max(0.0) + r.max(0.0)) / 2.0).fold(0.0, f64::max)
|
||||
}
|
||||
/// The built-in profile with this id.
|
||||
pub fn preset(id: &str) -> Option<Self> {
|
||||
Self::PRESETS.iter().find(|p| p.id == id).map(|p| Self { points: p.points.to_vec() })
|
||||
|
||||
+39
-1
@@ -63,7 +63,7 @@ pub use expand::{
|
||||
};
|
||||
#[cfg(test)]
|
||||
use mesh::grid_eval;
|
||||
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
|
||||
pub use mesh::{Coons, GradientMesh, MeshAppearance, MeshPiece, MeshPoint, MeshQuad, default_fidelity, grid_points, nodes_bounds};
|
||||
pub use warp::{WarpStyle, fit_piece, map_nonlinear, map_nonlinear_within, pieces, warp_point};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -181,6 +181,44 @@ mod tests {
|
||||
assert!((p.x - 37.0).abs() < 1e-6 && (p.y - 81.0).abs() < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
/// #3592: a mesh read from a file can have any number of patches, and the view any zoom, so the
|
||||
/// quads one tessellation makes are budgeted: the subdivision per patch gives way before memory
|
||||
/// does, and each patch stays at least one quad.
|
||||
#[test]
|
||||
fn a_large_mesh_tessellates_within_the_quad_budget() {
|
||||
let mesh = |n: u32| GradientMesh::from_surface(n, n, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
|
||||
// 3,600 patches at 32 x 32 would be 3.7 million quads.
|
||||
let m = mesh(60);
|
||||
let quads = m.quads(32);
|
||||
assert!(quads.len() <= GradientMesh::MAX_QUADS, "{} quads", quads.len());
|
||||
assert!(quads.len() >= 3600 * 4, "still finer than a quad per patch: {}", quads.len());
|
||||
// A small mesh keeps the subdivision asked for.
|
||||
assert_eq!(mesh(3).quads(8).len(), 9 * 64);
|
||||
// The finest the view asks for, on a mesh of 22,500 patches, is a few quads per patch.
|
||||
let big = mesh(150).quads(64);
|
||||
assert!(big.len() <= GradientMesh::MAX_QUADS && big.len() >= 22_500, "{} quads", big.len());
|
||||
// More patches than the budget has quads is malformed, as a point count that doesn't fit is.
|
||||
let point = MeshPoint { p: Point::ZERO, color: Color::rgb(0.0, 0.0, 0.0), opacity: 1.0, handles: [Vec2::ZERO; 4] };
|
||||
let huge = GradientMesh { rows: 2000, cols: 2000, points: vec![point; 2001 * 2001] };
|
||||
assert!(!huge.is_valid());
|
||||
assert!(huge.quads(1).is_empty());
|
||||
}
|
||||
|
||||
/// Flat pieces of a mesh over the quad budget follow the coarser subdivision [`GradientMesh::quads`]
|
||||
/// gives way to: each patch's pieces are its own quads', so together they cover the mesh once.
|
||||
#[test]
|
||||
fn a_large_meshs_pieces_follow_the_quad_budget() {
|
||||
let m = GradientMesh::from_surface(60, 60, &|u, v| Point::new(u * 300.0, v * 300.0), &|u, v| Color::rgb(u as f32, v as f32, 0.5));
|
||||
let area = |pts: &[Point]| {
|
||||
let n = pts.len();
|
||||
(0..n).map(|i| pts[i].x * pts[(i + 1) % n].y - pts[(i + 1) % n].x * pts[i].y).sum::<f64>().abs() / 2.0
|
||||
};
|
||||
let pieces = m.pieces(32);
|
||||
assert!(pieces.len() >= 3600, "{} pieces", pieces.len());
|
||||
let total: f64 = pieces.iter().map(|p| area(&p.pts)).sum();
|
||||
assert!((total - 90_000.0).abs() < 90.0, "covered {total}");
|
||||
}
|
||||
|
||||
/// A file's rows and columns can be anything: a point count too large to compute is no match
|
||||
/// for the points (u32::MAX × u32::MAX wrapped to 0 and passed for a grid without points).
|
||||
#[test]
|
||||
|
||||
@@ -293,9 +293,10 @@ pub fn is_live(n: &Node) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// Mesh tessellation as filled quad paths (no stroke), `n`×`n` per patch.
|
||||
/// Mesh tessellation as flat-filled paths (no stroke): each patch cut `n`×`n`, its quads joined
|
||||
/// where the colour hardly changes ([`GradientMesh::pieces`]).
|
||||
pub fn mesh_quad_nodes(m: &GradientMesh, n: usize) -> Vec<Node> {
|
||||
m.quads(n)
|
||||
m.pieces(n)
|
||||
.into_iter()
|
||||
.map(|q| {
|
||||
let mut ap = Appearance::default();
|
||||
|
||||
+130
-4
@@ -35,6 +35,15 @@ pub struct MeshQuad {
|
||||
pub corners: [(Color, f32); 4],
|
||||
}
|
||||
|
||||
/// One flat-coloured piece of a mesh ([`GradientMesh::pieces`]): a closed outline through
|
||||
/// tessellation points, its colour and opacity.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MeshPiece {
|
||||
pub pts: Vec<Point>,
|
||||
pub color: Color,
|
||||
pub opacity: f32,
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh → Appearance.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum MeshAppearance {
|
||||
@@ -170,11 +179,22 @@ pub(super) fn grid_len(rows: u32, cols: u32) -> Option<usize> {
|
||||
}
|
||||
|
||||
impl GradientMesh {
|
||||
/// The most quads one tessellation ([`Self::quads`]) makes, and so the most patches a mesh can
|
||||
/// have: about 170 bytes a quad, so about 170 MB. A file's mesh can have any number of patches
|
||||
/// and the view any zoom; the subdivision per patch gives way before memory does, and a mesh
|
||||
/// with more patches than that is malformed, like a point count that doesn't fit (#3592).
|
||||
pub const MAX_QUADS: usize = 1 << 20;
|
||||
|
||||
pub fn idx(&self, r: usize, c: usize) -> usize {
|
||||
r * (self.cols as usize + 1) + c
|
||||
}
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len())
|
||||
self.rows >= 1 && self.cols >= 1 && grid_len(self.rows, self.cols) == Some(self.points.len()) && self.patches() <= Self::MAX_QUADS
|
||||
}
|
||||
|
||||
/// The number of patches (rows times columns), saturating.
|
||||
fn patches(&self) -> usize {
|
||||
(self.rows as usize).saturating_mul(self.cols as usize)
|
||||
}
|
||||
|
||||
/// A 1×1 mesh from its four corners (bottom-left, top-left, top-right, bottom-right in u/v
|
||||
@@ -347,13 +367,20 @@ impl GradientMesh {
|
||||
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
|
||||
}
|
||||
|
||||
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
|
||||
/// The quads per patch side [`Self::quads`] cuts at when `n` is asked: 1 to 64, and coarser
|
||||
/// where the patches at `n` would pass [`Self::MAX_QUADS`] (a quad per patch is the least).
|
||||
fn subdivision(&self, n: usize) -> usize {
|
||||
n.clamp(1, 64).min(((Self::MAX_QUADS / self.patches().max(1)) as f64).sqrt() as usize).max(1)
|
||||
}
|
||||
|
||||
/// Tessellate into `n`×`n` quads per patch ([`Self::subdivision`]), each coloured at its centre
|
||||
/// and its corners.
|
||||
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
|
||||
if !self.is_valid() {
|
||||
return vec![];
|
||||
}
|
||||
let n = n.clamp(1, 64);
|
||||
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
|
||||
let n = self.subdivision(n);
|
||||
let mut out = Vec::with_capacity(self.patches() * n * n);
|
||||
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
|
||||
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
|
||||
for r in 0..self.rows as usize {
|
||||
@@ -383,6 +410,105 @@ impl GradientMesh {
|
||||
out
|
||||
}
|
||||
|
||||
/// Flat-coloured pieces covering the mesh, for outputs that can't shade a mesh smoothly (SVG,
|
||||
/// Expand): each patch cut into `n`×`n` quads as by [`Self::quads`], then neighbouring quads
|
||||
/// joined into square blocks as large as keeps the colour across each block within about one
|
||||
/// 8-bit step. A patch of one colour is one piece; a patch whose colour changes a lot keeps
|
||||
/// all its quads. A piece's outline runs through its quads' outer corners, so it covers what
|
||||
/// they covered, and it takes the colour at its centre.
|
||||
pub fn pieces(&self, n: usize) -> Vec<MeshPiece> {
|
||||
let n = self.subdivision(n);
|
||||
let quads = self.quads(n);
|
||||
let cols = (self.cols as usize).max(1);
|
||||
let mut out = Vec::with_capacity(quads.len());
|
||||
for (k, patch) in quads.chunks_exact(n * n).enumerate() {
|
||||
let (r, c) = (k / cols, k % cols);
|
||||
let side = self.block_side(r, c, n, patch);
|
||||
if side == 1 {
|
||||
out.extend(patch.iter().map(|q| MeshPiece { pts: q.pts.to_vec(), color: q.color, opacity: q.opacity }));
|
||||
continue;
|
||||
}
|
||||
// Tessellation point (i, j) of the patch (0..=n each), from a quad it is a corner of.
|
||||
let at = |i: usize, j: usize| -> Option<Point> {
|
||||
let (qi, qj) = (i.min(n - 1), j.min(n - 1));
|
||||
let q = patch.get(qj * n + qi)?;
|
||||
q.pts
|
||||
.get(match (i > qi, j > qj) {
|
||||
(false, false) => 0,
|
||||
(true, false) => 1,
|
||||
(true, true) => 2,
|
||||
(false, true) => 3,
|
||||
})
|
||||
.copied()
|
||||
};
|
||||
for j0 in (0..n).step_by(side) {
|
||||
for i0 in (0..n).step_by(side) {
|
||||
let (i1, j1) = (i0 + side, j0 + side);
|
||||
// Around the block, in the quads' order: along its top, down its right side,
|
||||
// back along its bottom and up its left side.
|
||||
let ring = (i0..i1)
|
||||
.map(|i| (i, j0))
|
||||
.chain((j0..j1).map(|j| (i1, j)))
|
||||
.chain((i0 + 1..=i1).rev().map(|i| (i, j1)))
|
||||
.chain((j0 + 1..=j1).rev().map(|j| (i0, j)));
|
||||
let Some(pts) = ring.map(|(i, j)| at(i, j)).collect::<Option<Vec<Point>>>() else { continue };
|
||||
let half = side as f64 / 2.0;
|
||||
let (color, opacity) = self.color_at(r, c, (i0 as f64 + half) / n as f64, (j0 as f64 + half) / n as f64);
|
||||
out.push(MeshPiece { pts, color, opacity });
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The side, in quads, of the blocks [`Self::pieces`] joins patch (r, c) into, cut into
|
||||
/// `n`×`n` `quads`: the largest divisor of `n` that keeps the colour changing by at most one
|
||||
/// 8-bit step across a block. 1 (no joining) where the patch folds over itself, as the outline
|
||||
/// of a block across the fold wouldn't fill it as its quads do.
|
||||
fn block_side(&self, r: usize, c: usize, n: usize, quads: &[MeshQuad]) -> usize {
|
||||
let corner = |dr: usize, dc: usize| self.points.get(self.idx(r + dr, c + dc));
|
||||
let (Some(p00), Some(p01), Some(p10), Some(p11)) = (corner(0, 0), corner(0, 1), corner(1, 0), corner(1, 1)) else {
|
||||
return 1;
|
||||
};
|
||||
// Corners all in CMYK mix in their inks ([`Self::color_at`]); the RGB they show is checked too.
|
||||
let inks = [p00, p01, p10, p11].iter().all(|p| matches!(p.color, Color::Cmyk { .. }));
|
||||
let channels = |p: &MeshPoint| -> Vec<f32> {
|
||||
let mut v = p.color.to_rgb().to_vec();
|
||||
if inks && let Color::Cmyk { c, m, y, k } = p.color {
|
||||
v.extend([c, m, y, k]);
|
||||
}
|
||||
v.push(p.opacity);
|
||||
v
|
||||
};
|
||||
let [a, b, cc, d] = [p00, p01, p10, p11].map(channels);
|
||||
// A bilinear patch changes across a block of side h (0..1 of the patch) by at most
|
||||
// h × (du + dv) in each channel, du and dv its largest change along each parameter.
|
||||
let mut steps = 0.0f32;
|
||||
for k in 0..a.len() {
|
||||
let ch = |v: &[f32]| v.get(k).copied().unwrap_or_default();
|
||||
let du = (ch(&a) - ch(&b)).abs().max((ch(&cc) - ch(&d)).abs());
|
||||
let dv = (ch(&a) - ch(&cc)).abs().max((ch(&b) - ch(&d)).abs());
|
||||
steps = steps.max((du + dv) * 255.0);
|
||||
}
|
||||
if !steps.is_finite() {
|
||||
return 1;
|
||||
}
|
||||
let side = (1..=n).rev().find(|&s| n.is_multiple_of(s) && (n / s) as f32 >= steps).unwrap_or(1);
|
||||
if side > 1 {
|
||||
// Every quad turning the same way: the patch doesn't fold, so a block's outline
|
||||
// encloses just what its quads cover.
|
||||
let turn = |q: &MeshQuad| {
|
||||
let [a, b, c, d] = q.pts;
|
||||
(b - a).cross(c - a) + (c - a).cross(d - a)
|
||||
};
|
||||
let first = quads.first().map(turn).unwrap_or_default();
|
||||
if !quads.iter().all(|q| turn(q) * first > 0.0) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
side
|
||||
}
|
||||
|
||||
/// The mesh's outer boundary as a closed path.
|
||||
pub fn outline(&self) -> PathData {
|
||||
if !self.is_valid() {
|
||||
|
||||
@@ -339,6 +339,35 @@ fn transform_group_recurses() {
|
||||
assert_eq!(child.appearance.stroke_width(), 2.0);
|
||||
}
|
||||
|
||||
/// A symbol instance in perspective keeps its picture moved by a transform, and loses its
|
||||
/// projection when no invertible map of finite numbers draws the moved picture.
|
||||
#[test]
|
||||
fn a_projection_follows_the_transform_or_is_removed() {
|
||||
use vectorcraft_geom::{Homography, Point};
|
||||
let instance = |h: Homography| {
|
||||
let mut n = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY });
|
||||
n.extras_mut().perspective =
|
||||
Some(Box::new(crate::PerspectiveAttachment { projection: Some(h.to_array()), ..crate::PerspectiveAttachment::new("left", 0.0) }));
|
||||
n
|
||||
};
|
||||
let projection = |n: &Node| n.extras().perspective.as_deref().and_then(|p| p.projection);
|
||||
// A move by 10 scaled by 1e307: moved by a scale by 10, the product overflows unless scaled down.
|
||||
let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]);
|
||||
let mut n = instance(big);
|
||||
n.transform(Affine::scale(10.0), false);
|
||||
let p = Point::new(3.0, 7.0);
|
||||
assert!(n.projection().unwrap().apply(p).unwrap().distance(Point::new(103.0, 7.0)) < 1e-9);
|
||||
// A move by 1e200 moved by a scale by 1e150 sends every point to about (1e350, 0).
|
||||
let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
let mut n = instance(far);
|
||||
n.transform(Affine::scale(1e150), false);
|
||||
assert_eq!(projection(&n), None);
|
||||
// A transform that is not invertible leaves the projection as it is.
|
||||
let mut n = instance(far);
|
||||
n.transform(Affine::scale(0.0), false);
|
||||
assert_eq!(projection(&n), Some(far.to_array()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unplaced_gradients_pin_only_when_a_refit_would_differ() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
|
||||
|
||||
@@ -58,12 +58,14 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too).
|
||||
/// Keep a projection drawing the same picture moved by `a` (the flat art moves by `a` too). An
|
||||
/// invertible `a` that the projection cannot follow removes it, and the art is drawn flat.
|
||||
pub(crate) fn transform_projection(&mut self, a: vectorcraft_geom::Affine) {
|
||||
if let Some(p) = self.extras_if_any().and_then(|e| e.perspective.as_deref_mut())
|
||||
&& let Some(h) = p.homography().and_then(|h| h.conjugated(a))
|
||||
&& let Some(h) = p.homography()
|
||||
&& a.determinant().abs() >= 1e-15
|
||||
{
|
||||
p.projection = Some(h.to_array());
|
||||
p.projection = h.conjugated(a).map(|h| h.to_array());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-8
@@ -107,14 +107,17 @@ impl Quotes {
|
||||
/// Single-quote pairs offered by Document Setup.
|
||||
pub const SINGLE_CHOICES: &[[char; 2]] = &[['‘', '’'], ['‚', '‘'], ['‚', '’'], ['’', '’'], ['‹', '›'], ['›', '‹'], ['『', '』'], ['\'', '\'']];
|
||||
|
||||
/// Is `c` one of these opening quotes?
|
||||
/// Is `c` one of these opening quotes? A typographic glyph that is also a closing quote does not
|
||||
/// count; a straight quote has no direction, so it still opens.
|
||||
fn is_open(&self, c: char) -> bool {
|
||||
c == self.double[0] || c == self.single[0]
|
||||
(c == self.double[0] || c == self.single[0]) && (c.is_ascii() || (c != self.double[1] && c != self.single[1]))
|
||||
}
|
||||
|
||||
/// A quote opens after nothing, whitespace, an opening bracket, a dash or another opening quote.
|
||||
fn opens(&self, prev: Option<char>) -> bool {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || self.is_open(p))
|
||||
/// `quote_opened` says whether `prev` is a quote this conversion just produced, and if so whether
|
||||
/// it opened; that settles glyphs which serve as both opening and closing quote.
|
||||
fn opens(&self, prev: Option<char>, quote_opened: Option<bool>) -> bool {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
|
||||
}
|
||||
|
||||
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
|
||||
@@ -122,11 +125,16 @@ impl Quotes {
|
||||
/// and is updated to its last character, so text can be converted in pieces.
|
||||
pub fn apply(&self, text: &str, prev: &mut Option<char>) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut quote_opened = None;
|
||||
for c in text.chars() {
|
||||
let r = match c {
|
||||
'"' => self.double[usize::from(!self.opens(*prev))],
|
||||
'\'' => self.single[usize::from(!self.opens(*prev))],
|
||||
c => c,
|
||||
quote_opened = match c {
|
||||
'"' | '\'' => Some(self.opens(*prev, quote_opened)),
|
||||
_ => None,
|
||||
};
|
||||
let r = match (c, quote_opened) {
|
||||
('"', Some(open)) => self.double[usize::from(!open)],
|
||||
('\'', Some(open)) => self.single[usize::from(!open)],
|
||||
(c, _) => c,
|
||||
};
|
||||
out.push(r);
|
||||
*prev = Some(r);
|
||||
@@ -326,6 +334,18 @@ mod tests {
|
||||
assert_eq!((a.as_str(), b.as_str()), ("“", "“"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_quote_equal_to_other_kinds_opening_quote_still_closes() {
|
||||
let q = Quotes { double: ['”', '”'], single: ['‘', '’'] };
|
||||
let mut prev = None;
|
||||
assert_eq!(q.apply("'\"a\"'", &mut prev), "‘”a”’");
|
||||
let q = Quotes { double: ['"', '"'], single: ['‘', '’'] };
|
||||
let mut prev = None;
|
||||
assert_eq!(q.apply("'\"a\"'", &mut prev), "‘\"a\"’");
|
||||
let mut prev = None;
|
||||
assert_eq!(q.apply("\"'a'\"", &mut prev), "\"‘a’\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bleed_grows_the_artboard() {
|
||||
let s = DocSetup { bleed: [9.0, 9.0, 4.0, 2.0], ..Default::default() };
|
||||
|
||||
@@ -144,3 +144,10 @@ fn mixed_mul_div() {
|
||||
assert_eq!(Unit::Points.parse("10*2/5"), Some(4.0));
|
||||
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readout_has_no_negative_zero() {
|
||||
assert_eq!(Unit::Points.readout(-0.001), "0.00 pt");
|
||||
assert_eq!(Unit::Points.readout(-0.0), "0.00 pt");
|
||||
assert_eq!(Unit::Points.readout(-12.5), "-12.50 pt");
|
||||
}
|
||||
|
||||
@@ -161,7 +161,10 @@ impl Unit {
|
||||
/// A point value as the canvas measurement labels show it: two decimals and the suffix
|
||||
/// (`12.50 mm`).
|
||||
pub fn readout(self, pt: f64) -> String {
|
||||
format!("{:.2} {}", self.from_pt(pt), self.suffix())
|
||||
let s = format!("{:.2}", self.from_pt(pt));
|
||||
// A small negative value rounds to `-0.00`; show it as `0.00`.
|
||||
let s = if s == "-0.00" { "0.00" } else { &s };
|
||||
format!("{} {}", s, self.suffix())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! VectorCraft's built-in add-ons: extra tools beyond the standard menus, each a module of its own
|
||||
//! with commands under `addon.<name>.*` (see `docs/mcp.md`).
|
||||
|
||||
mod arrange;
|
||||
mod cleanup;
|
||||
mod color;
|
||||
mod pathtools;
|
||||
@@ -10,7 +11,8 @@ use super::CommandSpec;
|
||||
|
||||
/// The commands of every built-in add-on.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
let mut v = cleanup::specs();
|
||||
let mut v = arrange::specs();
|
||||
v.extend(cleanup::specs());
|
||||
v.extend(color::specs());
|
||||
v.extend(pathtools::specs());
|
||||
v.extend(pen::specs());
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Arrange Tools (`addon.arrange.*`), a built-in add-on: Arrange in Grid lays the selected objects
|
||||
//! out in rows and columns, a row or a column; Swap Objects swaps the places (and sizes) of two;
|
||||
//! Replace with Top Object puts a copy of the topmost selected object in place of each of the
|
||||
//! others; and Set Size of Each gives every selected object an exact width and height. Places and
|
||||
//! sizes are measured and set as `object.setBounds` does. Its submenu (Object › Plug-ins › Arrange
|
||||
//! Tools), its dialogs and Zoom to Selection (a view command) are the UI's.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, OrientedBox};
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::super::edit::selected_roots;
|
||||
use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns};
|
||||
use super::super::typecmd::refresh_bounds_of;
|
||||
use super::super::{CommandSpec, bad, cmd, has_doc, has_multi, has_selection, number_or, switch_or};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The largest gap, width or height (pt) the commands take.
|
||||
const LIMIT: f64 = 1e6;
|
||||
/// The smallest width or height (pt) Set Size of Each takes.
|
||||
const LEAST: f64 = 0.001;
|
||||
/// The most columns Arrange in Grid takes.
|
||||
const MAX_COLUMNS: u64 = 10_000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.arrange.grid",
|
||||
"Arrange in Grid",
|
||||
["Object", "Plug-ins", "Arrange Tools"],
|
||||
None,
|
||||
"{layout?: \"grid\" (rows and columns) | \"row\" (one row) | \"column\" (one column), columns?: 1..10000 (grid; default ⌈√count⌉; never more than the count), gapX?: pt −1000000..1000000 (10), gapY?: pt −1000000..1000000 (gapX), centerOnArtboard?: false (true: the grid is centred on the artboard under the selection's centre, else the first; false: it starts at the selection's top-left)} lay the selected objects (the top-level ones) out in their stacking order from the back, left to right and top to bottom, as one undo step. Each cell is as wide as the widest object in its column and as tall as the tallest in its row, each object is centred in its cell, and everything is measured by visual bounds (strokes and effects as drawn) → {count}",
|
||||
has_selection,
|
||||
grid
|
||||
),
|
||||
cmd!(
|
||||
"addon.arrange.swap",
|
||||
"Swap Objects",
|
||||
["Object", "Plug-ins", "Arrange Tools"],
|
||||
None,
|
||||
"{size?: false (true: each also takes the other's width and height), scaleStrokes?: the Scale Strokes & Effects preference} swap the places (bounds centres) of the two selected objects, measured as object.setBounds measures them (Use Preview Bounds), as one undo step. Off unless exactly two objects are selected",
|
||||
exactly_two,
|
||||
swap
|
||||
),
|
||||
cmd!(
|
||||
"addon.arrange.replace",
|
||||
"Replace with Top Object",
|
||||
["Object", "Plug-ins", "Arrange Tools"],
|
||||
None,
|
||||
"{fit?: \"keep\" (the copy keeps its size) | \"bounds\" (scaled to the replaced object's width and height) | \"proportional\" (scaled to fit inside them, in proportion) (\"keep\"), scaleStrokes?: the Scale Strokes & Effects preference, keep?: false (true: the replaced objects stay, under their copies)} put a copy of the topmost selected object centred on each of the other selected objects, in that object's place in the stacking order, as one undo step; sizes and centres are measured as object.setBounds measures them, and the copies are selected → {ids}",
|
||||
has_multi,
|
||||
replace
|
||||
),
|
||||
cmd!(
|
||||
"addon.arrange.size",
|
||||
"Set Size of Each",
|
||||
["Object", "Plug-ins", "Arrange Tools"],
|
||||
None,
|
||||
"{width?: pt, height?: pt (at least one; each 0.001..1000000), proportional?: false (with only one of them given, the other follows in proportion; else it stays), reference?: 0..8 (the 9-point grid row by row from the top-left; 4, the centre: the point of each object that stays put), scaleStrokes?: the Scale Strokes & Effects preference} give each selected object (the top-level ones) that width and height, measured as object.setBounds measures them (Use Preview Bounds), as one undo step → {count}",
|
||||
has_selection,
|
||||
size
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Exactly two objects are selected (Swap Objects).
|
||||
fn exactly_two(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let n = selected_roots(s).map_err(|e| e.to_string())?.len();
|
||||
if n == 2 { Ok(()) } else { Err("select exactly two objects".into()) }
|
||||
}
|
||||
|
||||
/// Number param `key` (`default` when left out) within `lo..=hi`.
|
||||
fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str) -> Result<f64> {
|
||||
let v = number_or(p, key, default, cmd)?;
|
||||
if (lo..=hi).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
|
||||
}
|
||||
|
||||
/// Whole-number param `key` within `lo..=hi`, `None` when left out.
|
||||
fn whole_in(p: &Value, key: &str, (lo, hi): (u64, u64), cmd: &str) -> Result<Option<u64>> {
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let n = v.as_u64().ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number")))?;
|
||||
if (lo..=hi).contains(&n) { Ok(Some(n)) } else { Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))) }
|
||||
}
|
||||
|
||||
/// A width or height param of Set Size of Each, `None` when left out.
|
||||
fn size_param(p: &Value, key: &str, cmd: &str) -> Result<Option<f64>> {
|
||||
p.get(key).filter(|v| !v.is_null()).map(|_| number_in(p, key, 0.0, (LEAST, LIMIT), cmd)).transpose()
|
||||
}
|
||||
|
||||
/// `object.setBounds` params that centre an object on `at`, with Scale Strokes & Effects `strokes`
|
||||
/// and, when given, a width and height.
|
||||
fn place(at: Point, size: Option<(f64, f64)>, strokes: bool) -> Value {
|
||||
let mut q = json!({ "x": at.x, "y": at.y, "reference": 4, "strokes": strokes });
|
||||
if let Some((w, h)) = size {
|
||||
q["width"] = json!(w);
|
||||
q["height"] = json!(h);
|
||||
}
|
||||
q
|
||||
}
|
||||
|
||||
/// Transform each object of `xfs` by its own transform as undo step `label`, strokes and effects
|
||||
/// scaling with Scale Strokes & Effects `strokes`, pattern tiles as Transform Pattern Tiles says.
|
||||
/// When every transform is (within rounding) the identity, nothing changes.
|
||||
fn transform_each(s: &mut Session, label: &str, strokes: bool, xfs: &[(NodeId, Affine)]) -> Result<()> {
|
||||
let still = |xf: &Affine| xf.as_coeffs().iter().zip(Affine::IDENTITY.as_coeffs()).all(|(a, b)| (a - b).abs() <= 1e-9);
|
||||
if xfs.iter().all(|(_, xf)| still(xf)) {
|
||||
return Ok(());
|
||||
}
|
||||
let p = json!({ "strokes": strokes });
|
||||
let ids: Vec<NodeId> = xfs.iter().map(|(id, _)| *id).collect();
|
||||
let mut sc = scaling(s, &p);
|
||||
sc.patterns = transform_patterns(s, &p, &ids)?;
|
||||
s.edit(label, |d, _| {
|
||||
for (id, xf) in xfs {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
transform_node(n, *xf, sc, true);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The box of object `id` as `object.setBounds` measures it.
|
||||
fn box_of(s: &Session, id: NodeId) -> Result<OrientedBox> {
|
||||
s.transform_box(&[id]).ok_or_else(|| EngineError::Other("no bounds".into()))
|
||||
}
|
||||
|
||||
fn grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.arrange.grid";
|
||||
let layout = choice(p, C, "layout", &["grid", "row", "column"])?.unwrap_or("grid");
|
||||
let columns = whole_in(p, "columns", (1, MAX_COLUMNS), C)?;
|
||||
let gap_x = number_in(p, "gapX", 10.0, (-LIMIT, LIMIT), C)?;
|
||||
let gap_y = number_in(p, "gapY", gap_x, (-LIMIT, LIMIT), C)?;
|
||||
let center = switch_or(p, "centerOnArtboard", false, C)?;
|
||||
let roots = selected_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let items: Vec<(NodeId, Rect)> = roots.iter().filter_map(|id| Some((*id, effects::preview::bounds_of(d, &[*id], true)?))).collect();
|
||||
let Some(all) = items.iter().map(|(_, b)| *b).reduce(|a, b| a.union(b)) else { return Ok(json!({ "count": 0 })) };
|
||||
let n = items.len();
|
||||
let cols = match layout {
|
||||
"row" => n,
|
||||
"column" => 1,
|
||||
_ => columns.map_or_else(|| (n as f64).sqrt().ceil() as usize, |c| usize::try_from(c).unwrap_or(n)),
|
||||
}
|
||||
.clamp(1, n);
|
||||
let mut widths = vec![0.0_f64; cols];
|
||||
let mut heights = vec![0.0_f64; n.div_ceil(cols)];
|
||||
for (i, (_, b)) in items.iter().enumerate() {
|
||||
if let Some(w) = widths.get_mut(i % cols) {
|
||||
*w = w.max(b.width());
|
||||
}
|
||||
if let Some(h) = heights.get_mut(i / cols) {
|
||||
*h = h.max(b.height());
|
||||
}
|
||||
}
|
||||
let total = |sizes: &[f64], gap: f64| sizes.iter().sum::<f64>() + gap * sizes.len().saturating_sub(1) as f64;
|
||||
let origin = if center {
|
||||
let board = d.artboards.get(d.artboard_at(all.center()).unwrap_or(0)).map_or(all, |a| a.rect);
|
||||
Point::new(board.center().x - total(&widths, gap_x) / 2.0, board.center().y - total(&heights, gap_y) / 2.0)
|
||||
} else {
|
||||
Point::new(all.x0, all.y0)
|
||||
};
|
||||
// The middle of each column (row): the sizes and gaps before it, and half its own size.
|
||||
let middles = |sizes: &[f64], gap: f64, from: f64| -> Vec<f64> {
|
||||
sizes
|
||||
.iter()
|
||||
.scan(from, |at, size| {
|
||||
let mid = *at + size / 2.0;
|
||||
*at += size + gap;
|
||||
Some(mid)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (xs, ys) = (middles(&widths, gap_x, origin.x), middles(&heights, gap_y, origin.y));
|
||||
let xfs: Vec<(NodeId, Affine)> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, (id, b))| Some((*id, Affine::translate(Point::new(*xs.get(i % cols)?, *ys.get(i / cols)?) - b.center()))))
|
||||
.collect();
|
||||
transform_each(s, "Arrange in Grid", false, &xfs)?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
fn swap(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.arrange.swap";
|
||||
let size = switch_or(p, "size", false, C)?;
|
||||
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
|
||||
let roots = selected_roots(s)?;
|
||||
let &[a, b] = roots.as_slice() else { return Err(bad(C, "select exactly two objects")) };
|
||||
let (ba, bb) = (box_of(s, a)?, box_of(s, b)?);
|
||||
let to = |o: &OrientedBox| place(o.center(), size.then(|| (o.rect.width(), o.rect.height())), strokes);
|
||||
let xfs = [(a, object_bounds_transform(s, &to(&bb), a)?), (b, object_bounds_transform(s, &to(&ba), b)?)];
|
||||
transform_each(s, "Swap Objects", strokes, &xfs)?;
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.arrange.replace";
|
||||
let fit = choice(p, C, "fit", &["keep", "bounds", "proportional"])?.unwrap_or("keep");
|
||||
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
|
||||
let keep = switch_or(p, "keep", false, C)?;
|
||||
let roots = selected_roots(s)?;
|
||||
let Some((&top, others)) = roots.split_last().filter(|(_, others)| !others.is_empty()) else {
|
||||
return Err(bad(C, "select at least two objects"));
|
||||
};
|
||||
let tb = box_of(s, top)?;
|
||||
let (tw, th) = (tb.rect.width(), tb.rect.height());
|
||||
let mut xfs = Vec::with_capacity(others.len());
|
||||
for &id in others {
|
||||
let Some(b) = s.transform_box(&[id]) else { continue };
|
||||
let (w, h) = (b.rect.width(), b.rect.height());
|
||||
let size = match fit {
|
||||
"bounds" => Some((w, h)),
|
||||
// The smaller of the two scales that fit; a side the copy doesn't have doesn't count.
|
||||
"proportional" => {
|
||||
[(w, tw), (h, th)].into_iter().filter(|(_, t)| *t > 1e-9).map(|(v, t)| v / t).reduce(f64::min).map(|k| (tw * k, th * k))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
xfs.push((id, object_bounds_transform(s, &place(b.center(), size, strokes), top)?));
|
||||
}
|
||||
let p = json!({ "strokes": strokes });
|
||||
let mut sc = scaling(s, &p);
|
||||
sc.patterns = transform_patterns(s, &p, &[top])?;
|
||||
let ids = s.edit("Replace with Top Object", |d, sel| {
|
||||
let Some(n) = d.node(top).cloned() else { return Ok(vec![]) };
|
||||
let mut items = Vec::with_capacity(xfs.len());
|
||||
for (id, xf) in &xfs {
|
||||
let mut c = d.reid(&n);
|
||||
transform_node(&mut c, *xf, sc, true);
|
||||
items.push((*id, vec![c]));
|
||||
}
|
||||
let out: Vec<NodeId> = items.iter().flat_map(|(_, c)| c.iter().map(|n| n.id)).collect();
|
||||
d.insert_beside(items, !keep)?;
|
||||
refresh_bounds_of(d, &out);
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
fn size(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.arrange.size";
|
||||
let width = size_param(p, "width", C)?;
|
||||
let height = size_param(p, "height", C)?;
|
||||
if width.is_none() && height.is_none() {
|
||||
return Err(bad(C, "give `width` or `height`"));
|
||||
}
|
||||
let proportional = switch_or(p, "proportional", false, C)?;
|
||||
let reference = whole_in(p, "reference", (0, 8), C)?.unwrap_or(4);
|
||||
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
|
||||
let mut q = json!({ "reference": reference, "strokes": strokes, "proportional": proportional && (width.is_none() || height.is_none()) });
|
||||
if let Some(w) = width {
|
||||
q["width"] = json!(w);
|
||||
}
|
||||
if let Some(h) = height {
|
||||
q["height"] = json!(h);
|
||||
}
|
||||
let mut xfs = vec![];
|
||||
for id in selected_roots(s)? {
|
||||
if s.transform_box(&[id]).is_some() {
|
||||
xfs.push((id, object_bounds_transform(s, &q, id)?));
|
||||
}
|
||||
}
|
||||
transform_each(s, "Set Size of Each", strokes, &xfs)?;
|
||||
Ok(json!({ "count": xfs.len() }))
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Arrange Tools' commands: the geometry each one leaves, one undo step each, when they are off,
|
||||
//! and bad params refused without a change.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A new document whose new art has no stroke, so visual and geometric bounds agree.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
fn bounds(s: &Session, id: NodeId) -> Rect {
|
||||
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: (f64, f64, f64, f64)) -> bool {
|
||||
[(a.x0, b.0), (a.y0, b.1), (a.width(), b.2), (a.height(), b.3)].iter().all(|(x, y)| (x - y).abs() < 1e-6)
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// Each of `bad` is refused and leaves the document as it was.
|
||||
fn refuses(s: &mut Session, cmd: &str, bad: &[Value]) {
|
||||
let (before, n) = (s.doc().unwrap().doc.clone(), steps(s));
|
||||
for p in bad {
|
||||
assert!(s.execute(cmd, p).is_err(), "{cmd} {p}");
|
||||
}
|
||||
assert_eq!(steps(s), n, "{cmd}");
|
||||
assert!(*s.doc().unwrap().doc == *before, "{cmd} changed the document");
|
||||
}
|
||||
|
||||
fn enabled(s: &Session, cmd: &str) -> bool {
|
||||
s.commands().into_iter().find(|c| c.id == cmd).unwrap().enabled
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_lays_objects_out_in_rows_and_columns_from_the_back() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 100.0, 100.0, 20.0, 10.0);
|
||||
let b = rect(&mut s, 300.0, 50.0, 40.0, 30.0);
|
||||
let c = rect(&mut s, 10.0, 200.0, 10.0, 10.0);
|
||||
let d = rect(&mut s, 50.0, 5.0, 30.0, 20.0);
|
||||
select(&mut s, &[d, c, b, a]);
|
||||
let n = steps(&s);
|
||||
// Four objects: two columns. The grid starts at the selection's top-left (10, 5).
|
||||
let r = s.execute("addon.arrange.grid", &json!({"gapX": 5, "gapY": 2})).unwrap();
|
||||
assert_eq!(r["count"], json!(4));
|
||||
assert_eq!(steps(&s), n + 1, "one undo step");
|
||||
// Columns 20 and 40 wide (the widest in each), rows 30 and 20 tall; each object centred.
|
||||
assert!(near(bounds(&s, a), (10.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, a));
|
||||
assert!(near(bounds(&s, b), (35.0, 5.0, 40.0, 30.0)), "{:?}", bounds(&s, b));
|
||||
assert!(near(bounds(&s, c), (15.0, 42.0, 10.0, 10.0)), "{:?}", bounds(&s, c));
|
||||
assert!(near(bounds(&s, d), (40.0, 37.0, 30.0, 20.0)), "{:?}", bounds(&s, d));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(bounds(&s, a), (100.0, 100.0, 20.0, 10.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_lays_out_a_row_or_a_column_and_centres_on_the_artboard() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 100.0, 100.0, 20.0, 20.0);
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("addon.arrange.grid", &json!({"layout": "row"})).unwrap();
|
||||
assert!(near(bounds(&s, b), (30.0, 0.0, 20.0, 20.0)), "gapX defaults to 10");
|
||||
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapX": 4})).unwrap();
|
||||
assert!(near(bounds(&s, b), (0.0, 24.0, 20.0, 20.0)), "gapY follows gapX");
|
||||
// 20 × 44 centred on the 400 × 300 artboard.
|
||||
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap();
|
||||
assert!(near(bounds(&s, a), (190.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, a));
|
||||
// In place already: no step.
|
||||
let n = steps(&s);
|
||||
s.execute("addon.arrange.grid", &json!({"layout": "column", "gapY": 4, "centerOnArtboard": true})).unwrap();
|
||||
assert_eq!(steps(&s), n);
|
||||
// More columns than objects: one row.
|
||||
s.execute("addon.arrange.grid", &json!({"columns": 50, "gapX": 0})).unwrap();
|
||||
assert!(near(bounds(&s, b), (210.0, 128.0, 20.0, 20.0)), "{:?}", bounds(&s, b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_measures_visual_bounds() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 20.0);
|
||||
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||||
s.execute("addon.arrange.grid", &json!({"layout": "row", "gapX": 0})).unwrap();
|
||||
// Visual boxes 24 wide, touching.
|
||||
assert!(near(bounds(&s, b), (24.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_trades_places_and_optionally_sizes() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
|
||||
let c = rect(&mut s, 200.0, 200.0, 5.0, 5.0);
|
||||
select(&mut s, &[a]);
|
||||
assert!(!enabled(&s, "addon.arrange.swap"));
|
||||
select(&mut s, &[a, b, c]);
|
||||
assert!(!enabled(&s, "addon.arrange.swap"));
|
||||
refuses(&mut s, "addon.arrange.swap", &[json!({})]);
|
||||
select(&mut s, &[a, b]);
|
||||
assert!(enabled(&s, "addon.arrange.swap"));
|
||||
let n = steps(&s);
|
||||
s.execute("addon.arrange.swap", &json!({})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// Centres (10, 5) and (120, 120) swap; sizes stay.
|
||||
assert!(near(bounds(&s, a), (110.0, 115.0, 20.0, 10.0)), "{:?}", bounds(&s, a));
|
||||
assert!(near(bounds(&s, b), (-10.0, -15.0, 40.0, 40.0)), "{:?}", bounds(&s, b));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("addon.arrange.swap", &json!({"size": true})).unwrap();
|
||||
assert!(near(bounds(&s, a), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, a));
|
||||
assert!(near(bounds(&s, b), (0.0, 0.0, 20.0, 10.0)), "{:?}", bounds(&s, b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_with_sizes_scales_strokes_as_asked() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 0.0, 20.0, 20.0);
|
||||
select(&mut s, &[a, b]);
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 2})).unwrap();
|
||||
let weight = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().appearance.stroke_width();
|
||||
s.execute("addon.arrange.swap", &json!({"size": true})).unwrap();
|
||||
assert_eq!((weight(&s, a), weight(&s, b)), (2.0, 2.0), "off by default (the preference)");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("addon.arrange.swap", &json!({"size": true, "scaleStrokes": true})).unwrap();
|
||||
assert!((weight(&s, a) - 4.0).abs() < 1e-9 && (weight(&s, b) - 1.0).abs() < 1e-9, "{} {}", weight(&s, a), weight(&s, b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_puts_a_copy_of_the_top_object_in_each_place() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0);
|
||||
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
|
||||
let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0);
|
||||
select(&mut s, &[top]);
|
||||
assert!(!enabled(&s, "addon.arrange.replace"));
|
||||
refuses(&mut s, "addon.arrange.replace", &[json!({})]);
|
||||
select(&mut s, &[a, b, top]);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.arrange.replace", &json!({})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.node(a).is_none() && st.doc.node(b).is_none(), "replaced objects deleted");
|
||||
assert!(st.doc.node(top).is_some(), "the top object stays");
|
||||
assert_eq!(st.selection.objects, ids, "the copies are selected");
|
||||
// Kept size, centred on (10, 20) and (120, 120); each in its object's place (bottom, then middle).
|
||||
assert!(near(bounds(&s, ids[0]), (5.0, 17.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[0]));
|
||||
assert!(near(bounds(&s, ids[1]), (115.0, 117.5, 10.0, 5.0)), "{:?}", bounds(&s, ids[1]));
|
||||
let layer = &s.doc().unwrap().doc.layers[0];
|
||||
let order: Vec<NodeId> = layer.children().unwrap().iter().map(|n| n.id).collect();
|
||||
assert_eq!(order, [ids[0], ids[1], top]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(a).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_fits_the_copies_and_can_keep_the_originals() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 40.0);
|
||||
let top = rect(&mut s, 200.0, 200.0, 10.0, 5.0);
|
||||
select(&mut s, &[a, top]);
|
||||
let r = s.execute("addon.arrange.replace", &json!({"fit": "bounds", "keep": true})).unwrap();
|
||||
let copy = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
assert!(near(bounds(&s, copy), (0.0, 0.0, 20.0, 40.0)), "{:?}", bounds(&s, copy));
|
||||
let order: Vec<NodeId> = s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||||
assert_eq!(order, [a, copy, top], "kept: the copy goes right above it");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
select(&mut s, &[a, top]);
|
||||
let r = s.execute("addon.arrange.replace", &json!({"fit": "proportional"})).unwrap();
|
||||
let copy = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
// 10 × 5 into 20 × 40: twice as large, centred on (10, 20).
|
||||
assert!(near(bounds(&s, copy), (0.0, 15.0, 20.0, 10.0)), "{:?}", bounds(&s, copy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_gives_each_object_a_width_and_height() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
|
||||
select(&mut s, &[a, b]);
|
||||
let n = steps(&s);
|
||||
s.execute("addon.arrange.size", &json!({"width": 30})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// About each centre; the height stays.
|
||||
assert!(near(bounds(&s, a), (-5.0, 0.0, 30.0, 10.0)), "{:?}", bounds(&s, a));
|
||||
assert!(near(bounds(&s, b), (105.0, 100.0, 30.0, 40.0)), "{:?}", bounds(&s, b));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("addon.arrange.size", &json!({"width": 40, "proportional": true, "reference": 0})).unwrap();
|
||||
assert!(near(bounds(&s, a), (0.0, 0.0, 40.0, 20.0)), "{:?}", bounds(&s, a));
|
||||
assert!(near(bounds(&s, b), (100.0, 100.0, 40.0, 40.0)), "{:?}", bounds(&s, b));
|
||||
s.execute("addon.arrange.size", &json!({"width": 10, "height": 12, "proportional": true, "reference": 8})).unwrap();
|
||||
assert!(near(bounds(&s, a), (30.0, 8.0, 10.0, 12.0)), "both given: both set, {:?}", bounds(&s, a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
|
||||
let b = rect(&mut s, 100.0, 100.0, 40.0, 40.0);
|
||||
select(&mut s, &[a, b]);
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.arrange.grid",
|
||||
&[
|
||||
json!({"layout": "spiral"}),
|
||||
json!({"columns": 0}),
|
||||
json!({"columns": 1.5}),
|
||||
json!({"columns": 1e12}),
|
||||
json!({"gapX": 1e308}),
|
||||
json!({"gapY": "10"}),
|
||||
json!({"centerOnArtboard": "yes"}),
|
||||
],
|
||||
);
|
||||
refuses(&mut s, "addon.arrange.swap", &[json!({"size": 1}), json!({"scaleStrokes": "no"})]);
|
||||
refuses(&mut s, "addon.arrange.replace", &[json!({"fit": "stretch"}), json!({"keep": 0})]);
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.arrange.size",
|
||||
&[
|
||||
json!({}),
|
||||
json!({"width": 0}),
|
||||
json!({"height": -5}),
|
||||
json!({"width": 1e308}),
|
||||
json!({"width": "10"}),
|
||||
json!({"width": 10, "reference": 9}),
|
||||
json!({"width": 10, "proportional": "yes"}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_without_a_selection() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 0.0, 0.0, 20.0, 10.0);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
for c in ["addon.arrange.grid", "addon.arrange.swap", "addon.arrange.replace", "addon.arrange.size"] {
|
||||
assert!(!enabled(&s, c), "{c}");
|
||||
}
|
||||
assert!(!enabled(&Session::new(), "addon.arrange.swap"));
|
||||
}
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Health Check",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found",
|
||||
"{scope?: \"selection\"|\"document\" (default: the selection, or the document while nothing is selected), kinds?: [kind…] (default: all: emptyText, strayPoints (paths of one anchor), unpainted, zeroOpacity, duplicateObjects (a path stacked on an identical one, or type on the same type within 1 pt), offArtboard, duplicateAnchors (neighbours within 0.01 pt), tinyHandles (under 0.01 pt), collinearAnchors (corners on a straight run, turning less than sine 0.005), nearPoints (anchors within 0.01 pt of other paths' anchors), nearlyClosed (ends within 0.01 pt), filledOpenPaths (filled, unstroked open paths), needlessClipGroups (an unpainted clip that clips nothing, hides everything, or is a rectangle round all its art), needlessCompounds (one path or none), needlessGroups (one member or none, nothing of their own), emptyArtboards (the document only))} find issues without changing anything; locked and hidden objects are left out → {scope, total, findings: [{kind, label, count, ids (the objects, at most 1000), artboards (indexes)}]} for the kinds found",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
|
||||
@@ -235,17 +235,46 @@ fn is_plain(n: &Node) -> bool {
|
||||
&& *n.extras() == NodeExtras::default()
|
||||
}
|
||||
|
||||
/// Is `n` a plain path (full opacity, normal blending) identical to the object just below it?
|
||||
/// Is `n` a plain path or type object (full opacity, normal blending) that doubles the object just
|
||||
/// below it?
|
||||
fn duplicates_the_one_below(d: &Document, n: &Node) -> bool {
|
||||
if !matches!(n.kind, NodeKind::Path { .. }) || n.opacity < 1.0 || n.blend != BlendMode::Normal {
|
||||
if n.opacity < 1.0 || n.blend != BlendMode::Normal {
|
||||
return false;
|
||||
}
|
||||
let below = d.position(n.id).and_then(|(parent, i, _)| d.children(parent)?.get(i.checked_sub(1)?).cloned());
|
||||
below.is_some_and(|b| {
|
||||
b.visible && b.kind == n.kind && b.appearance == n.appearance && b.opacity == n.opacity && b.blend == n.blend && b.extras() == n.extras()
|
||||
b.visible
|
||||
&& same_art(&b.kind, &n.kind)
|
||||
&& b.appearance == n.appearance
|
||||
&& b.opacity == n.opacity
|
||||
&& b.blend == n.blend
|
||||
&& b.extras() == n.extras()
|
||||
})
|
||||
}
|
||||
|
||||
/// Distance (pt) within which doubled type counts as stacked: text set twice from a PDF or an old
|
||||
/// file, or for a fake bold, is often a fraction of a point off.
|
||||
const DOUBLED_TYPE: f64 = 1.0;
|
||||
|
||||
/// The same path, or the same type (text, styles, size and turn) within [`DOUBLED_TYPE`] of it.
|
||||
fn same_art(a: &NodeKind, b: &NodeKind) -> bool {
|
||||
match (a, b) {
|
||||
(NodeKind::Path { .. }, NodeKind::Path { .. }) => a == b,
|
||||
(NodeKind::Text(a), NodeKind::Text(b)) => {
|
||||
let ([a0, a1, a2, a3, ax, ay], [b0, b1, b2, b3, bx, by]) = (a.xf.as_coeffs(), b.xf.as_coeffs());
|
||||
[a0, a1, a2, a3] == [b0, b1, b2, b3] && (ax - bx).hypot(ay - by) <= DOUBLED_TYPE && {
|
||||
// `b` placed on `a`, with `a`'s layout caches (they are in document space).
|
||||
let mut moved = (**b).clone();
|
||||
moved.xf = a.xf;
|
||||
moved.cached_bounds = a.cached_bounds;
|
||||
moved.cached_baselines.clone_from(&a.cached_baselines);
|
||||
**a == moved
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(a: Rect, b: Rect) -> bool {
|
||||
a.x0 >= b.x0 && a.y0 >= b.y0 && a.x1 <= b.x1 && a.y1 <= b.y1
|
||||
}
|
||||
|
||||
@@ -226,6 +226,26 @@ fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type set twice in the same place (from a PDF, or for a fake bold) is a duplicate within a point:
|
||||
/// Show selects the upper copy and Fix removes it. Other text, or the same text further off, stays.
|
||||
#[test]
|
||||
fn doubled_type_within_a_point_is_a_duplicate() {
|
||||
let mut s = session();
|
||||
let text = |s: &mut Session, x: f64, text: &str| id_of(&s.execute("text.create", &json!({"x": x, "y": 100, "text": text})).unwrap());
|
||||
let base = text(&mut s, 20.0, "Total");
|
||||
let doubled = text(&mut s, 20.4, "Total");
|
||||
let other = text(&mut s, 20.0, "Subtotal");
|
||||
let far = text(&mut s, 23.0, "Subtotal");
|
||||
assert_eq!(found(&mut s, "duplicateObjects", "document"), 1);
|
||||
let shown = s.execute("addon.cleanup.show", &json!({"kind": "duplicateObjects", "scope": "document"})).unwrap();
|
||||
assert_eq!(shown["ids"], json!([doubled.0]));
|
||||
s.execute("addon.cleanup.fix", &json!({"kinds": ["duplicateObjects"], "scope": "document"})).unwrap();
|
||||
assert!(node(&s, doubled).is_none());
|
||||
for id in [base, other, far] {
|
||||
assert!(node(&s, id).is_some(), "{id:?} stays");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fix_all_is_one_undo_step() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point
|
||||
//! Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset
|
||||
//! Text, Join Point Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset
|
||||
//! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters'
|
||||
//! styles and stay where the layout drew them: each is moved so the glyph of its first character
|
||||
//! lands where it was.
|
||||
@@ -57,6 +57,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
combine_text
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.joinText",
|
||||
"Join Point Text",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selection)} join one-line horizontal point type into one point type object, as one undo step: pieces turned and sheared like the top-left one are read as lines (baselines within 0.4 em of each other), top to bottom, each line's pieces left to right with a space between them; each line becomes a paragraph, left aligned, and keeps the distance between baselines as its leading. The text keeps its character styles; the object takes the top-left piece's place and paint, and the other pieces go → {id (selected), joined (pieces merged into it), lines}",
|
||||
has_selection,
|
||||
join_text
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.removeBreaks",
|
||||
"Remove Breaks",
|
||||
@@ -641,6 +650,85 @@ fn combine_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids, "combined": combined }))
|
||||
}
|
||||
|
||||
fn join_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.joinText";
|
||||
let d = &s.doc()?.doc;
|
||||
let pieces: Vec<Piece> =
|
||||
texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id).map(|p| p.0)).collect();
|
||||
// The piece top-left on the page sets the turn; the others turned like it are read in that frame.
|
||||
let on_page = |p: &Piece| Affine::new([p.turn[0], p.turn[1], p.turn[2], p.turn[3], 0.0, 0.0]) * p.at;
|
||||
let first = pieces.iter().min_by(|x, y| {
|
||||
let (a, b) = (on_page(x), on_page(y));
|
||||
a.y.total_cmp(&b.y).then(a.x.total_cmp(&b.x))
|
||||
});
|
||||
let mut kept: Vec<&Piece> = first.map(|f| pieces.iter().filter(|p| p.turned_like(f)).collect()).unwrap_or_default();
|
||||
kept.sort_by(|x, y| x.at.y.total_cmp(&y.at.y).then(x.at.x.total_cmp(&y.at.x)));
|
||||
// Lines, top to bottom, by baseline; each line's pieces left to right.
|
||||
let mut lines: Vec<Vec<&Piece>> = vec![];
|
||||
for piece in kept {
|
||||
match lines.last_mut().filter(|l| l.first().is_some_and(|f| (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))) {
|
||||
Some(l) => l.push(piece),
|
||||
None => lines.push(vec![piece]),
|
||||
}
|
||||
}
|
||||
for line in &mut lines {
|
||||
line.sort_by(|x, y| x.at.x.total_cmp(&y.at.x));
|
||||
}
|
||||
let joined: usize = lines.iter().map(Vec::len).sum();
|
||||
let (Some(top), true) = (lines.first().and_then(|l| l.first()).copied(), joined >= 2) else {
|
||||
return Err(EngineError::Other("Select two or more one-line point type objects to join".into()));
|
||||
};
|
||||
let mut runs: Vec<vectorcraft_doc::TextRun> = vec![];
|
||||
let mut prev_y = None;
|
||||
for line in &lines {
|
||||
let y = line.first().map_or(top.at.y, |p| p.at.y);
|
||||
// The line keeps the distance from the baseline above as its leading.
|
||||
let leading = prev_y.map(|above| y - above).filter(|l: &f64| l.is_finite() && *l > 0.0);
|
||||
prev_y = Some(y);
|
||||
let last = |runs: &[vectorcraft_doc::TextRun]| runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default();
|
||||
if !runs.is_empty() {
|
||||
runs.push(vectorcraft_doc::TextRun::new("\n", last(&runs)));
|
||||
}
|
||||
let start = runs.len();
|
||||
for (k, piece) in line.iter().enumerate() {
|
||||
let blank = runs.last().is_some_and(|r| r.text.ends_with(char::is_whitespace)) || piece.t.plain_text().starts_with(char::is_whitespace);
|
||||
if k > 0 && !blank {
|
||||
runs.push(vectorcraft_doc::TextRun::new(" ", last(&runs)));
|
||||
}
|
||||
runs.extend(piece.t.runs.iter().cloned());
|
||||
}
|
||||
if let Some(l) = leading {
|
||||
for r in runs.iter_mut().skip(start) {
|
||||
r.style.leading = Some(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut m = top.t.clone();
|
||||
m.set_all_paras(ParaStyle { justify: Justify::Left, ..top.t.para.clone() });
|
||||
m.runs = runs;
|
||||
edit::normalize(&mut m.runs);
|
||||
m.ruby.clear();
|
||||
// The top-left piece's first glyph stays where it was.
|
||||
let [a, b, c, dd] = top.turn;
|
||||
let base = Affine::new([a, b, c, dd, 0.0, 0.0]) * Affine::translate(top.at.to_vec2());
|
||||
m.xf = base;
|
||||
let x0 = glyph_starting(&layout(&m), 0).map_or(0.0, |g| g.x);
|
||||
m.xf = base * Affine::translate((-x0, 0.0));
|
||||
refresh_bounds(&mut m);
|
||||
let id = top.id;
|
||||
let gone: Vec<NodeId> = lines.iter().flatten().map(|p| p.id).filter(|g| *g != id).collect();
|
||||
let count = lines.len();
|
||||
s.edit("Join Point Text", |d, sel| {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.kind = NodeKind::Text(Box::new(m));
|
||||
}
|
||||
d.remove_many(&gone)?;
|
||||
sel.set([id]);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "joined": joined, "lines": count }))
|
||||
}
|
||||
|
||||
/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many
|
||||
/// went.
|
||||
fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
|
||||
|
||||
@@ -176,6 +176,40 @@ fn combine_joins_only_pieces_on_one_line_turned_alike() {
|
||||
assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine"));
|
||||
}
|
||||
|
||||
/// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs,
|
||||
/// pieces on a line left to right, each line's baseline where it was (its leading is the distance
|
||||
/// from the line above), in one undo step. A piece turned unlike the top-left one stays.
|
||||
#[test]
|
||||
fn join_stacks_lines_into_one_object_with_their_spacing() {
|
||||
let mut s = session();
|
||||
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "First", "size": 20}));
|
||||
let b = text(&mut s, json!({"x": 120, "y": 101, "text": "line", "size": 20}));
|
||||
let c = text(&mut s, json!({"x": 50, "y": 130, "text": "Second", "size": 20}));
|
||||
let d = text(&mut s, json!({"x": 50, "y": 166, "text": "Third", "size": 20}));
|
||||
let turned = text(&mut s, json!({"x": 300, "y": 300, "text": "Turned", "size": 20, "rotation": 45}));
|
||||
let was = glyphs(&s, &[a, c, d]);
|
||||
select(&mut s, &[d, b, turned, c, a]);
|
||||
let steps = undo_steps(&s);
|
||||
let r = s.execute("addon.cleanup.joinText", &json!({})).unwrap();
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
assert_eq!((r["id"].as_u64(), r["joined"].as_u64(), r["lines"].as_u64()), (Some(a.0), Some(4), Some(3)));
|
||||
let t = obj(&s, a);
|
||||
assert_eq!(t.plain_text(), "First line\nSecond\nThird");
|
||||
assert_eq!(t.runs.iter().filter(|r| r.text.contains("Third")).map(|r| r.style.leading).collect::<Vec<_>>(), [Some(36.0)]);
|
||||
// Each line's first glyph is where it was.
|
||||
let now = glyphs(&s, &[a]);
|
||||
for ch in ['F', 'S', 'T'] {
|
||||
let (p, q) = (was.iter().find(|g| g.0 == ch).unwrap().1, now.iter().find(|g| g.0 == ch).unwrap().1);
|
||||
assert!((p - q).hypot() < 0.05, "{ch}: {p:?} moved to {q:?}");
|
||||
}
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.node(b).is_none() && doc.node(c).is_none() && doc.node(d).is_none() && doc.node(turned).is_some());
|
||||
select(&mut s, &[turned]);
|
||||
assert!(other(s.execute("addon.cleanup.joinText", &json!({}))).contains("join"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(c).is_some(), "undo brings the pieces back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_line_and_paragraph_breaks() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add New Fill",
|
||||
["Window", "Appearance"],
|
||||
Some("Cmd+/"),
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill)",
|
||||
"{ids?|id?, target?: \"object\"|\"contents\"} add a fill on top of each selected object's own stack, on an image right below its Contents row (a copy of its top fill; type without one: its characters' fill; else the default fill). With a fill row active (appearance.setActiveItem), no `ids` and no `target: \"contents\"`, the new fill is a copy of that row, right above it, and becomes the active row",
|
||||
has_doc,
|
||||
|s, p| add_item(s, p, true)
|
||||
),
|
||||
@@ -71,7 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reduce to Basic Appearance",
|
||||
["Window", "Appearance"],
|
||||
None,
|
||||
"{ids?|id?, target?} keep only the topmost visible fill and stroke, without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
|
||||
"{ids?|id?, target?} keep only one visible fill and stroke (without ids: the Appearance panel's active row in place of the topmost one of its kind; else the topmost), without their own opacity, blend mode or effects (a group keeps no rows it lacked), and drop the object's effects",
|
||||
has_doc,
|
||||
reduce_basic
|
||||
),
|
||||
@@ -470,12 +470,45 @@ fn set_active_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
let ids = require_targets(s, p, if fill { "appearance.addFill" } else { "appearance.addStroke" })?;
|
||||
let defaults = s.paint.clone();
|
||||
// The panel's active Fill row, when the new fill goes on the selection: the new fill is a copy
|
||||
// of that row, right above it in each target that has it (others get a copy of their top fill).
|
||||
// Where the new item went in on the active item's object, when it is an image.
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
// The active row is a row of the active object's stack, which need not come first in `ids`
|
||||
// (they are in paint order): match the others against it.
|
||||
let anchor = active.and_then(|a| ids.iter().position(|id| *id == a));
|
||||
let selected = match (s.appearance_item(), anchor) {
|
||||
(Some(index), Some(_)) if fill && p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => {
|
||||
ItemTarget::Item { index, explicit: false, aligned: true }.of_kind(s, true)
|
||||
}
|
||||
_ => ItemTarget::Top,
|
||||
};
|
||||
let per: Vec<Option<usize>> = match (selected, anchor) {
|
||||
(ItemTarget::Item { index, .. }, Some(k)) => {
|
||||
let mut order = ids.clone();
|
||||
order.swap(0, k);
|
||||
let mut per: Vec<Option<usize>> =
|
||||
items_per_target(&s.doc()?.doc, &order, &[index], true).into_iter().map(|v| v.first().copied()).collect();
|
||||
per.swap(0, k);
|
||||
per
|
||||
}
|
||||
_ => vec![None; ids.len()],
|
||||
};
|
||||
let mut inserted = None;
|
||||
// Where the new fill went in on the active item's object, when it went above the active row.
|
||||
let mut above = None;
|
||||
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
|
||||
for id in &ids {
|
||||
for (id, row) in ids.iter().zip(&per) {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
if let Some(i) = n.appearance.item_of_kind(*row, true)
|
||||
&& let Some(paint) = n.appearance.paint_at(Some(i), true).cloned()
|
||||
{
|
||||
n.appearance.insert_item(i + 1, AppearanceItem::Fill(FillLayer::new(paint)));
|
||||
if active == Some(*id) {
|
||||
above = Some(i + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A copy of the top fill (stroke); without one, type's characters' and otherwise the
|
||||
// default paint, so a new fill on a group or type shows.
|
||||
let paint = match (n.appearance.paint_at(None, fill), &n.kind) {
|
||||
@@ -509,8 +542,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
// The rows above the new item move up by one.
|
||||
s.remap_appearance_item(&ids, |a| Some(a + usize::from(inserted.is_some_and(|k| k <= a))));
|
||||
// A fill added above the active row becomes the active row; otherwise the rows above the new
|
||||
// item move up by one.
|
||||
s.remap_appearance_item(&ids, |a| above.or(Some(a + usize::from(inserted.is_some_and(|k| k <= a)))));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -580,11 +614,30 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = require_targets(s, p, "appearance.reduceToBasic")?;
|
||||
// When every object comes out as it was, the document and its undo history stay as they are.
|
||||
let before = s.doc()?.doc.clone();
|
||||
// The Appearance panel's active row (a row of the first selected object's stack, and in each
|
||||
// other target the row equal to it there), when the command acts on that selection. The targets
|
||||
// are in paint order, so the first selected object can be any of them.
|
||||
let first = s.doc()?.selection.subjects().first().and_then(|f| ids.iter().position(|id| id == f));
|
||||
let active = match (s.appearance_item(), first) {
|
||||
(Some(index), Some(k)) if p.get("ids").is_none() && p.get("id").is_none() && !targets_contents(p)? => {
|
||||
let mut from = ids.clone();
|
||||
from.swap(0, k);
|
||||
let mut rows = items_per_target(&before, &from, &[index], true);
|
||||
rows.swap(0, k);
|
||||
rows
|
||||
}
|
||||
_ => vec![Vec::new(); ids.len()],
|
||||
};
|
||||
super::overprint::edit_counted(s, "Reduce to Basic Appearance", |d| {
|
||||
for id in &ids {
|
||||
for (id, at) in ids.iter().zip(&active) {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
// The topmost visible fill and stroke stay, without their own transparency or effects.
|
||||
let top = |fill: bool| n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned();
|
||||
// The active row stays when it is a visible fill or stroke; otherwise the topmost
|
||||
// visible one of each kind does. Both lose their own transparency and effects.
|
||||
let picked = at.first().and_then(|i| n.appearance.items.get(*i)).filter(|i| i.visible()).cloned();
|
||||
let top = |fill: bool| match &picked {
|
||||
Some(i) if i.is_fill() == fill => Some(i.clone()),
|
||||
_ => n.appearance.items.iter().rev().find(|i| i.visible() && i.is_fill() == fill).cloned(),
|
||||
};
|
||||
let (fill, stroke) = (top(true), top(false));
|
||||
// Other objects always keep a fill row (None when there was none); a group only
|
||||
// keeps the rows it had.
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
"{x, y, text, rotation?: deg (about x, y), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color?, keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -739,7 +739,17 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let made = insert_look_turned(s, "Type", NodeKind::Text(Box::new(t)), NewArt::plain(Appearance::default()), None, true, bbox_turn(rotation))?;
|
||||
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
|
||||
let made = super::typecmd::with_bounds(s, id, made);
|
||||
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
|
||||
let mut made = super::typecmd::with_overflow(s, id.as_slice(), made);
|
||||
if p.get("font").is_some() || p.get("style").is_some() {
|
||||
let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
if let Some(w) = super::fonts::set_font_warning(runs) {
|
||||
made["warning"] = json!(w);
|
||||
}
|
||||
}
|
||||
Ok(made)
|
||||
}
|
||||
|
||||
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move in Perspective",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid)} slide objects within their plane (unattached objects attach to `plane`/the active plane first); snapping lands the nearer edge of the objects' joint bounds on a gridline within a quarter cell; Transform Again repeats it",
|
||||
"{ids?|id?, from: [x,y], to: [x,y], plane?, copy?: bool (move copies, as Alt-dragging does), perpendicular?: bool (move along the plane's normal instead, as pressing 5 while dragging does), snap?: bool (default: View › Perspective Grid › Snap to Grid), constrain?: bool (default false; constrain each in-plane move to its plane's nearest axis or 45° diagonal)} slide objects within their own planes (unattached objects attach to `plane`/the active plane first); snapping pulls the nearer edge of the first plane's joint bounds toward a gridline within a quarter cell while preserving the constrained direction; Transform Again repeats it",
|
||||
has_doc,
|
||||
persp_move
|
||||
),
|
||||
|
||||
@@ -284,12 +284,42 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let fallback = plane_param(&grid_of(&s.doc()?.doc), p, C).ok();
|
||||
let (perp, copy) = (switch_or(p, "perpendicular", false, C)?, switch_or(p, "copy", false, C)?);
|
||||
let constrain = switch_or(p, "constrain", false, C)?;
|
||||
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
|
||||
check_switches(p, &["snap"], C)?;
|
||||
// Snap to Grid: a correction for the objects on the first one's plane and depth.
|
||||
let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, to)) };
|
||||
let snapped = move |pl: Plane, depth: f64, dv: vectorcraft_geom::Vec2| {
|
||||
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| f.2)
|
||||
let snap_to = if constrain && !perp {
|
||||
let doc = &s.doc()?.doc;
|
||||
let g = grid_of(doc);
|
||||
g.plane_bounds(doc, &ids)
|
||||
.and_then(|(plane, depth, _)| {
|
||||
let h = g.homography_at(plane, depth)?;
|
||||
let start = h.inverse()?.apply(from)?;
|
||||
let delta = g.plane_delta(plane, depth, from, to)?;
|
||||
h.apply(start + vectorcraft_tools::distort::perspective::constrain_move_delta(delta).1)
|
||||
})
|
||||
.unwrap_or(to)
|
||||
} else {
|
||||
to
|
||||
};
|
||||
let fix = if perp { None } else { crate::cmd::perspgrid::snap_fix(&s.doc()?.doc, &ids, p, (from, snap_to)) };
|
||||
let snapped = move |pl: Plane, depth: f64, raw: vectorcraft_geom::Vec2| {
|
||||
let (direction, dv) =
|
||||
if constrain { vectorcraft_tools::distort::perspective::constrain_move_delta(raw) } else { (vectorcraft_geom::Vec2::ZERO, raw) };
|
||||
dv + fix.filter(|f| f.0 == pl && f.1 == depth).map_or(vectorcraft_geom::Vec2::ZERO, |f| {
|
||||
if !constrain {
|
||||
return f.2;
|
||||
}
|
||||
if direction.x.abs() < 1e-12 {
|
||||
return vectorcraft_geom::Vec2::new(0.0, f.2.y);
|
||||
}
|
||||
if direction.y.abs() < 1e-12 {
|
||||
return vectorcraft_geom::Vec2::new(f.2.x, 0.0);
|
||||
}
|
||||
let (correction, axis) =
|
||||
if f.2.x == 0.0 || (f.2.y != 0.0 && f.2.y.abs() < f.2.x.abs()) { (f.2.y, direction.y) } else { (f.2.x, direction.x) };
|
||||
direction * (correction / axis)
|
||||
})
|
||||
};
|
||||
// In-plane: the plane-space offset under the pointer; perpendicular: the depth it reaches.
|
||||
let step = |g: &PerspectiveGrid, pl: Plane, depth: f64| -> Result<(Homography, f64)> {
|
||||
|
||||
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Blob Brush",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}",
|
||||
"{points: [[x,y]…] or [[x,y,pressure]…] (pen pressure 0..1), size?: pt (10), sizeMode?: \"fixed\" (default) | \"pressure\" (Blob Brush Tool Options' Size set to Pressure: the brush is size − sizeVariation wide at the lightest pen pressure and size + sizeVariation at the heaviest, at least 0.1 pt; a stroke without pressure keeps the size), sizeVariation?: pt (0), angle?: ° (0, kept within −180–180: the tip's long axis counter-clockwise from horizontal, kept on the page along the stroke), roundness?: % (100, kept within 0–100: the tip's short axis in percent of its long one, 100 round; 0 is drawn as 0.1), fidelity?: pt (0.01–100; the brush follows the stroke fitted at this tolerance as path.freehand fits a Pencil stroke, and a higher value gives a smoother shape; without it the brush follows every point), merge?: true, mergeOnlySelection?: bool (false: merge with any touching same-color blob; true: only with selected ones), keepSelected?: bool (false: the drawn or merged path is not selected, and the rest of the selection stays; true: it is selected)} filled brush shape, merged with the topmost touching same-color blob and the touching ones next to it in stacking order in its group or layer → {id}",
|
||||
has_doc,
|
||||
blob
|
||||
),
|
||||
|
||||
@@ -373,8 +373,22 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !(size.is_finite() && size > 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
let pressure = size_pressure(p, &samples, C)?;
|
||||
let angle = number_or(p, "angle", 0.0, C)?;
|
||||
let roundness = number_or(p, "roundness", 100.0, C)?;
|
||||
if !angle.is_finite() || !roundness.is_finite() {
|
||||
return Err(bad(C, "angle and roundness must be finite"));
|
||||
}
|
||||
let angle = angle.clamp(-180.0, 180.0);
|
||||
let roundness = roundness.clamp(0.0, 100.0);
|
||||
let tip = vectorcraft_tools::draw2::blob_tip(angle, roundness);
|
||||
let squash = tip.inverse();
|
||||
// Width profiles use distance along the spine, so measure pressure on the same squashed
|
||||
// points that the outline reads for a non-round tip.
|
||||
let mapped_samples: Vec<(Point, Option<f64>)> =
|
||||
if roundness < 100.0 { samples.iter().map(|(pt, pressure)| (squash * *pt, *pressure)).collect() } else { samples.clone() };
|
||||
let pressure = size_pressure(p, &mapped_samples, C)?;
|
||||
let pts: Vec<Point> = samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
let mapped_pts: Vec<Point> = mapped_samples.into_iter().map(|(pt, _)| pt).collect();
|
||||
// The widest the brush gets: the area merges look in.
|
||||
let reach = pressure.as_ref().map_or(size, |(_, v)| size + v);
|
||||
// With `fidelity` (pt), the brush follows the stroke fitted by [`fit_freehand`], as a Pencil
|
||||
@@ -384,9 +398,12 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(number_or(p, "fidelity", 0.0, C)?.clamp(vectorcraft_tools::draw2::MIN_FIDELITY, 100.0)),
|
||||
};
|
||||
let spine = |tol: Option<f64>| match tol {
|
||||
Some(tol) => PathData::single(fit_freehand(&pts, tol, false)),
|
||||
None => PathData::single(SubPath::polyline(&pts, false)),
|
||||
let spine = |tol: Option<f64>| {
|
||||
let path = match tol {
|
||||
Some(tol) => PathData::single(fit_freehand(&pts, tol, false)),
|
||||
None => PathData::single(SubPath::polyline(&pts, false)),
|
||||
};
|
||||
if roundness < 100.0 { path.transformed(squash) } else { path }
|
||||
};
|
||||
let region = match (&pressure, pts.len() > 1) {
|
||||
(Some((prof, variation)), true) => {
|
||||
@@ -400,15 +417,16 @@ pub(super) fn blob(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
// A click: one dab as wide as the pen pressed.
|
||||
(Some((prof, variation)), false) => brush_region(&pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))),
|
||||
(Some((prof, variation)), false) => brush_region(&mapped_pts, vectorcraft_tools::draw2::pressure_size(size, *variation, prof.at(0.0))),
|
||||
(None, _) => {
|
||||
let fitted = fidelity
|
||||
.filter(|_| pts.len() > 1)
|
||||
.map(|tol| po::outline_stroke(&spine(Some(tol)), size, Cap::Round, Join::Round, 4.0))
|
||||
.filter(|r| !r.is_empty());
|
||||
fitted.unwrap_or_else(|| brush_region(&pts, size))
|
||||
fitted.unwrap_or_else(|| brush_region(&mapped_pts, size))
|
||||
}
|
||||
};
|
||||
let region = if roundness < 100.0 { region.transformed(tip) } else { region };
|
||||
if region.is_empty() {
|
||||
return Err(EngineError::Other("empty brush stroke".into()));
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
"{folder?, zip?: false (one .zip of every file, each deflated or stored, whichever is smaller: no folder → {name, dataBase64, bytes, files: [name…]}; with a folder it is written there → {path, bytes, files}), artboards?: [index…] | range?: \"1-3\" (default all), assets?: [asset id…] (instead of artboards: each asset's art alone, cropped to its bounds and named after the asset; see assets.list), layers?: [layer id…] (instead of artboards: each layer's art alone, sublayers included, named after the layer), selection?: one|each (instead of artboards: the selected art as one file, or a file per selected object, named after it), fullDocument?: false (one file per format instead: a PDF of every artboard, other formats the bounds of all visible art, named after the document), includeBleed?: false (artboards grown by the document's bleed), pdfs?: multiple|single (Export PDFs As: with several artboards, single writes them as the pages of one PDF per PDF row, named after the document), exportPreset?: (a preset of document.exportPresets.list: its settings, under these params), subfolders?: false (each row's files in a sub-folder: its folder, else its size for raster (1x, 2x, 100w…) or its format (SVG, PDF)), folders?: none|rows (as subfolders)|format (a folder per extension: png, svg, pdf…)|item (a folder per artboard, asset, layer or object)|itemFormat (item/extension) (default: rows with subfolders or a preset, else none), name?: (a file name pattern instead of the item's name, with <filename> for the document, <name> for the artboard, asset, layer or object, <index> from 1, #### for the counter zero-padded to the run of #, <format>, <scale>, <suffix> (else the row's suffix ends the name), YYYYMMDD, YYYY-MM-DD, YYYY, <date>, <time>, <YYYY>, <MM>, <DD>, <hh>, <mm> and <ss>; a trailing .ext is dropped, as in `COMPANY-Logo-<filename>-####-YYYYMMDD`; different files it names alike get -2, -3…), counterStart?: 1, counterPer?: item (every file of an item has its number)|file, date?: YYYY-MM-DD (the date the pattern uses; default today, UTC), preset?: mobile (PNG 1x, 2x, 3x) | density (PNG 0.75x–4x in ldpi…xxxhdpi sub-folders) instead of formats (turns subfolders on), formats?: [{format: png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft, scale?: 1 | \"2x\" | \"100w\" | \"100h\" | \"72ppi\", width?: px | height?: px | ppi? (raster only; width, then height, then ppi win over scale), suffix?: (raster default: @2x, @100w, @100h, none at 1x; vector formats drop size suffixes), folder?: (its sub-folder), quality?: (jpg 0–100), …the format's options (antiAlias, background, preset (pdf)…)}], settings?: {png|png8|jpg|webp|gif|tiff|bmp|tga|psd|svg|svgz|pdf|eps|ai|vectorcraft: {…options for every row of that format}} (rows' own options win), prefix?, antiAlias?: none|art|type (raster rows without their own), openLocation?: (remembered; the app shows the files after exporting)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]} (names include sub-folders: 2x/Icon@2x.png); every reply has warnings: [the encoders' warnings for the files, each once: an image that couldn't be decoded, type written in the fallback font…]. eps, ai and vectorcraft rows write a document of the item alone (its one artboard). The params (but zip and assets) are remembered in the document (document.exportSettings; not an undo step)",
|
||||
has_doc,
|
||||
screens::export_for_screens
|
||||
),
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export DXF",
|
||||
[],
|
||||
None,
|
||||
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (every stroke as its filled outline), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
|
||||
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
|
||||
has_doc,
|
||||
export_dxf
|
||||
)]
|
||||
@@ -64,7 +64,7 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
name: "alterPaths",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "Alter Paths for Appearance: every stroke as its filled outline",
|
||||
description: "Alter Paths for Appearance: strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14",
|
||||
},
|
||||
FormatOption { name: "outlineText", ty: "boolean", default: "false", description: "type as glyph outlines (always with preserve: appearance)" },
|
||||
FormatOption { name: "selectedOnly", ty: "boolean", default: "false", description: "only the selected objects, in their layers" },
|
||||
|
||||
@@ -309,6 +309,9 @@ struct ScreenParams {
|
||||
/// Where files go: `none` (all in the folder), `rows` (the rows' sub-folders, as
|
||||
/// `subfolders`), `format` (a folder per extension), `item`, or `itemFormat`.
|
||||
folders: Option<String>,
|
||||
/// Export PDFs As: `single` (the artboards a PDF row writes are the pages of one file) or
|
||||
/// `multiple` (a file per artboard, the default).
|
||||
pdfs: Option<String>,
|
||||
}
|
||||
|
||||
/// How exported files are sorted into folders.
|
||||
@@ -389,6 +392,8 @@ struct Naming {
|
||||
/// The counter counts files rather than items.
|
||||
per_file: bool,
|
||||
folders: Folders,
|
||||
/// Export PDFs As › Single file.
|
||||
single_pdf: bool,
|
||||
}
|
||||
|
||||
impl Naming {
|
||||
@@ -406,9 +411,14 @@ impl Naming {
|
||||
Some("file") => true,
|
||||
Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))),
|
||||
};
|
||||
let single_pdf = match o.pdfs.as_deref() {
|
||||
None | Some("multiple") => false,
|
||||
Some("single") => true,
|
||||
Some(m) => return Err(bad(C, format!("pdfs `{m}`: single or multiple"))),
|
||||
};
|
||||
let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into());
|
||||
let pattern = o.name.clone().filter(|n| !n.trim().is_empty());
|
||||
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders })
|
||||
Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders, single_pdf })
|
||||
}
|
||||
|
||||
/// The sub-folder (`None` for the export folder itself) and the file name of the file row `r`
|
||||
@@ -781,15 +791,34 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
|
||||
// One step per item and row, the files a name collision skips included.
|
||||
let steps = (items.len() * rows.len()) as u64;
|
||||
let mut step = 0u64;
|
||||
// Export PDFs As › Single file: with several artboards, a PDF row writes them all as the pages
|
||||
// of one file, named as the document, with the first of them.
|
||||
let artboards: Option<Vec<usize>> = items.iter().map(|i| i.artboard).collect::<Option<Vec<_>>>().filter(|a| naming.single_pdf && a.len() > 1);
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
for r in &rows {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
vectorcraft_doc::cancel::progress(step, steps, &format!("Exporting file {} of {steps}", step + 1));
|
||||
step += 1;
|
||||
let (sub, file) = naming.file(item, index, files, r);
|
||||
let single = artboards.as_ref().filter(|_| r.format.id == "pdf");
|
||||
if single.is_some() && index > 0 {
|
||||
continue;
|
||||
}
|
||||
let document = single.map(|_| Item {
|
||||
name: naming.filename.clone(),
|
||||
stem: naming.filename.clone(),
|
||||
doc: item.doc.clone(),
|
||||
artboard: None,
|
||||
region: item.region,
|
||||
every_artboard: None,
|
||||
});
|
||||
let (sub, file) = naming.file(document.as_ref().unwrap_or(item), index, files, r);
|
||||
let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}"));
|
||||
let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue };
|
||||
let bytes = if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
|
||||
let bytes = if let Some(boards) = single {
|
||||
let mut options = r.options.clone();
|
||||
options["range"] = json!(boards.iter().map(|b| (b + 1).to_string()).collect::<Vec<_>>().join(", "));
|
||||
write(&item.doc, "pdf", &options)?
|
||||
} else if let (Some(all), "pdf") = (&item.every_artboard, r.format.id) {
|
||||
// One page per artboard.
|
||||
let mut options = r.options.clone();
|
||||
options["range"] = json!("all");
|
||||
|
||||
@@ -93,7 +93,7 @@ fn selected_only_exports_the_selection_in_its_layers() {
|
||||
let line = s.execute("shape.line", &json!({"x1": 0, "y1": 90, "x2": 90, "y2": 90})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [line]})).unwrap();
|
||||
let dxf = text(&s.execute("document.export", &json!({"format": "dxf", "selectedOnly": true})).unwrap());
|
||||
assert_eq!(entities(&dxf), ["LWPOLYLINE"]);
|
||||
assert_eq!(entities(&dxf), ["LINE"]);
|
||||
assert!(dxf.contains("\n 2\nTop\n") && !dxf.contains("\n 2\nLayer 1\n"), "only the selection's layer");
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let e = s.execute("document.export", &json!({"format": "dxf", "selectedOnly": true})).unwrap_err().to_string();
|
||||
|
||||
@@ -811,3 +811,25 @@ fn the_document_remembers_its_export_settings() {
|
||||
assert!(!plain.contains("export_settings") && !plain.contains("exportSettings"), "not written when empty");
|
||||
assert!(vectorcraft_format::load(plain.as_bytes()).unwrap().export_settings.is_empty());
|
||||
}
|
||||
|
||||
/// Export PDFs As › Single file: with several artboards, a PDF row writes them as the pages of one
|
||||
/// file named after the document (raster rows still write a file per artboard); with one artboard
|
||||
/// the PDF is that artboard's, as Multiple files writes it.
|
||||
#[test]
|
||||
fn single_file_pdfs_hold_every_chosen_artboard() {
|
||||
let mut s = session(3);
|
||||
let title = s.doc().unwrap().doc.title.clone();
|
||||
let rows = json!([{"format": "png", "scale": "1x"}, {"format": "pdf"}]);
|
||||
let r = export(&mut s, json!({"range": "1-2", "pdfs": "single", "formats": rows}));
|
||||
let got = files(&r);
|
||||
let pdf: Vec<&(String, Vec<u8>)> = got.iter().filter(|(n, _)| n.ends_with(".pdf")).collect();
|
||||
assert_eq!(pdf.len(), 1, "{:?}", names(&r));
|
||||
assert_eq!(pdf[0].0, format!("{title}.pdf"));
|
||||
assert_eq!(vectorcraft_pdf::import(&pdf[0].1).unwrap().artboards.len(), 2, "one page per artboard");
|
||||
assert_eq!(got.iter().filter(|(n, _)| n.ends_with(".png")).count(), 2);
|
||||
let r = export(&mut s, json!({"range": "2", "pdfs": "single", "formats": [{"format": "pdf"}]}));
|
||||
assert_eq!(names(&r), ["Artboard-2.pdf"]);
|
||||
let r = export(&mut s, json!({"range": "1-2", "pdfs": "multiple", "formats": [{"format": "pdf"}]}));
|
||||
assert_eq!(names(&r), ["Artboard-1.pdf", "Artboard-2.pdf"]);
|
||||
assert!(s.execute("document.exportForScreens", &json!({"pdfs": "both", "formats": [{"format": "pdf"}]})).is_err());
|
||||
}
|
||||
|
||||
+211
-11
@@ -29,7 +29,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Font",
|
||||
[],
|
||||
None,
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style) → {runs}",
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); text that is hidden or locked, in a hidden or locked group or layer, or on a locked artboard keeps its font → {runs, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Text by Font",
|
||||
[],
|
||||
None,
|
||||
"{family, style?} select the text objects using the font → {count}",
|
||||
"{family, style?} select the text objects using the font that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}",
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
@@ -318,6 +318,24 @@ pub(super) fn substitution_warning(d: &Document) -> Option<String> {
|
||||
|
||||
/// [`substitution_warning`] for type set in `fonts`.
|
||||
pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<String> {
|
||||
substitutions(fonts, ("were written", "was drawn"))
|
||||
}
|
||||
|
||||
/// The font (family, style, version) of character style `st`.
|
||||
pub(super) fn font_of(st: &vectorcraft_doc::CharStyle) -> UsedFont {
|
||||
(st.font_family.clone(), st.font_style.clone(), st.font_version.clone())
|
||||
}
|
||||
|
||||
/// What `text.create`, `text.setStyle`, `text.setRangeStyle` and `text.replaceFont` say when the
|
||||
/// fonts they set type in (`fonts`) don't draw it: the family isn't available (the fallback font
|
||||
/// is drawn) or doesn't have that style (its closest style is drawn). `None` when all draw it.
|
||||
pub(super) fn set_font_warning(fonts: BTreeSet<UsedFont>) -> Option<String> {
|
||||
substitutions(fonts, ("are drawn", "is drawn"))
|
||||
}
|
||||
|
||||
/// The warning for type set in `fonts`, with the verbs `written` and `drawn` (an export's are
|
||||
/// `were written` and `was drawn`).
|
||||
fn substitutions(fonts: BTreeSet<UsedFont>, (written, drawn): (&str, &str)) -> Option<String> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut missing: Vec<String> = Vec::new();
|
||||
let mut styles: BTreeSet<String> = BTreeSet::new();
|
||||
@@ -325,7 +343,7 @@ pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<Strin
|
||||
match db.resolve(&family, &style) {
|
||||
None | Some((_, vectorcraft_text::FontMatch::Missing)) => missing.push(family),
|
||||
Some((face, vectorcraft_text::FontMatch::Style)) => {
|
||||
styles.insert(format!("{family} {style} was drawn in {} {}", face.family, face.style));
|
||||
styles.insert(format!("{family} {style} {drawn} in {} {}", face.family, face.style));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
@@ -335,14 +353,14 @@ pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<Strin
|
||||
let mut parts = Vec::new();
|
||||
if !missing.is_empty() {
|
||||
parts.push(format!(
|
||||
"fonts that aren't available were written in the fallback font, {}: {} (install them, or replace them with Find Font)",
|
||||
"fonts that aren't available {written} in the fallback font, {}: {} (install them, or replace them with Find Font)",
|
||||
vectorcraft_text::FALLBACK_FAMILY,
|
||||
missing.join(", ")
|
||||
));
|
||||
}
|
||||
if !styles.is_empty() {
|
||||
parts.push(format!(
|
||||
"font styles that aren't available were written in the closest style: {} (pick a style the family has)",
|
||||
"font styles that aren't available {written} in the closest style: {} (pick a style the family has)",
|
||||
styles.into_iter().collect::<Vec<_>>().join(", ")
|
||||
));
|
||||
}
|
||||
@@ -414,10 +432,20 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),
|
||||
_ => return Err(bad(C, format!("font family `{tf}` is not available"))),
|
||||
};
|
||||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||||
let matches = |st: &vectorcraft_doc::CharStyle| {
|
||||
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
|
||||
};
|
||||
// Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked
|
||||
// group or layer, or on a locked artboard. The text left in the font is counted.
|
||||
let (ids, skipped) = {
|
||||
let d = &s.doc()?.doc;
|
||||
let editable = d.editable();
|
||||
let (ids, other): (Vec<NodeId>, Vec<NodeId>) = scope(s, bool_or(p, "selectionOnly", false))?.into_iter().partition(|id| editable(*id));
|
||||
let skipped = other.into_iter().filter(|id| text(d, *id).is_some_and(|t| t.runs.iter().any(|r| matches(&r.style)))).count();
|
||||
(ids, skipped)
|
||||
};
|
||||
// The fonts the replaced runs are set in, for the warning.
|
||||
let mut set = BTreeSet::new();
|
||||
let n = s.edit("Replace Font", |d, _| {
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
@@ -435,6 +463,7 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Another font: none of the old one's versions or Variable Font settings.
|
||||
r.style.font_version = None;
|
||||
r.style.font_axes.clear();
|
||||
set.insert(font_of(&r.style));
|
||||
changed = true;
|
||||
n += 1;
|
||||
}
|
||||
@@ -446,24 +475,42 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({ "runs": n }))
|
||||
let mut out = json!({ "runs": n });
|
||||
let mut warnings = Vec::new();
|
||||
if skipped > 0 {
|
||||
out["skipped"] = json!(skipped);
|
||||
warnings.push(REPLACE_SKIPPED.to_string());
|
||||
}
|
||||
warnings.extend(set_font_warning(set));
|
||||
if !warnings.is_empty() {
|
||||
out["warning"] = json!(warnings.join("; "));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What Replace Font says when it left text in the font that it can't reach.
|
||||
const REPLACE_SKIPPED: &str = "some text is hidden or locked, so its font wasn't replaced";
|
||||
|
||||
fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let family = str_param(p, "family").ok_or_else(|| bad("select.font", "missing `family`"))?.to_string();
|
||||
let style = str_param(p, "style").map(str::to_string);
|
||||
let ids = scope(s, false)?;
|
||||
let d = &s.doc()?.doc;
|
||||
// Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group
|
||||
// or layer, or on a locked artboard.
|
||||
let editable = d.editable();
|
||||
let hits: Vec<NodeId> = ids
|
||||
.into_iter()
|
||||
.filter(|id| {
|
||||
text(d, *id).is_some_and(|t| {
|
||||
t.runs.iter().any(|r| {
|
||||
r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x))
|
||||
editable(*id)
|
||||
&& text(d, *id).is_some_and(|t| {
|
||||
t.runs.iter().any(|r| {
|
||||
r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x))
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
drop(editable);
|
||||
let n = hits.len();
|
||||
s.select(|_, sel| sel.set(hits))?;
|
||||
Ok(json!({ "count": n }))
|
||||
@@ -568,6 +615,117 @@ mod tests {
|
||||
assert!(s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Nope"}})).is_err());
|
||||
let _ = a;
|
||||
}
|
||||
|
||||
/// Select Text by Font leaves out hidden and locked text, and text inside a hidden or locked
|
||||
/// group or layer. Text on a visible, unlocked template layer is selected.
|
||||
#[test]
|
||||
fn select_font_skips_hidden_and_locked_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let text = |s: &mut Session, y: i32, font: &str| {
|
||||
s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": font})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let mut ids = vec![];
|
||||
for y in [20, 40, 60, 80, 100] {
|
||||
ids.push(text(&mut s, y, "Source Serif 4"));
|
||||
}
|
||||
// Inter is used only by hidden or locked text.
|
||||
let only_hidden = text(&mut s, 120, "Inter");
|
||||
for (id, group, cmd) in [
|
||||
(ids[1], false, "object.hide"),
|
||||
(ids[2], false, "object.lock"),
|
||||
(ids[3], true, "object.hide"),
|
||||
(ids[4], true, "object.lock"),
|
||||
(only_hidden, false, "object.hide"),
|
||||
] {
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
if group {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
}
|
||||
let mut layer_text = |props: &[Value]| {
|
||||
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let id = text(&mut s, 140, "Source Serif 4");
|
||||
for p in props {
|
||||
let mut p = p.clone();
|
||||
p["id"] = json!(layer);
|
||||
s.execute("layer.setProps", &p).unwrap();
|
||||
}
|
||||
id
|
||||
};
|
||||
layer_text(&[json!({"visible": false})]);
|
||||
layer_text(&[json!({"locked": true})]);
|
||||
// Template on locks the layer; unlocking it makes its text selectable again.
|
||||
let template = layer_text(&[json!({"template": true}), json!({"locked": false})]);
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Source Serif 4"})).unwrap()["count"], 2);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(ids[0]), NodeId(template)]);
|
||||
assert_eq!(s.execute("select.font", &json!({"family": "Inter"})).unwrap()["count"], 0);
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty());
|
||||
}
|
||||
|
||||
/// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked
|
||||
/// group or layer, and says so. Text on a visible, unlocked template layer is changed.
|
||||
#[test]
|
||||
fn replace_font_skips_hidden_and_locked_text() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let text = |s: &mut Session, y: i32| {
|
||||
s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let mut ids = vec![];
|
||||
for y in [20, 40, 60, 80, 100] {
|
||||
ids.push(text(&mut s, y));
|
||||
}
|
||||
for (id, group, cmd) in
|
||||
[(ids[1], false, "object.hide"), (ids[2], false, "object.lock"), (ids[3], true, "object.hide"), (ids[4], true, "object.lock")]
|
||||
{
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
if group {
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
}
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
}
|
||||
let mut layer_text = |props: &[Value]| {
|
||||
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let id = text(&mut s, 140);
|
||||
for p in props {
|
||||
let mut p = p.clone();
|
||||
p["id"] = json!(layer);
|
||||
s.execute("layer.setProps", &p).unwrap();
|
||||
}
|
||||
id
|
||||
};
|
||||
ids.push(layer_text(&[json!({"visible": false})]));
|
||||
ids.push(layer_text(&[json!({"locked": true})]));
|
||||
// Template on locks the layer; unlocking it makes its text editable again.
|
||||
let template = layer_text(&[json!({"template": true}), json!({"locked": false})]);
|
||||
let family = |s: &Session, id: u64| text_family(s, id);
|
||||
let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}})).unwrap();
|
||||
assert_eq!(r["runs"], 2, "{r}");
|
||||
assert_eq!(r["skipped"], 6, "{r}");
|
||||
assert_eq!(r["warning"], REPLACE_SKIPPED, "{r}");
|
||||
assert_eq!(family(&s, ids[0]), "Source Sans 3");
|
||||
assert_eq!(family(&s, template), "Source Sans 3");
|
||||
for id in &ids[1..] {
|
||||
assert_eq!(family(&s, *id), "Source Serif 4", "object {id}");
|
||||
}
|
||||
// The font stays in the document's list while the skipped text uses it.
|
||||
let fams: Vec<String> =
|
||||
s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().map(|f| f["family"].as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(fams, ["Source Sans 3", "Source Serif 4"]);
|
||||
// Only skipped text left: nothing changes, and the warning says why.
|
||||
let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Source Sans 3"}})).unwrap();
|
||||
assert_eq!((r["runs"].clone(), r["skipped"].clone(), r["warning"].clone()), (json!(0), json!(6), json!(REPLACE_SKIPPED)), "{r}");
|
||||
// A plain replacement carries no warning.
|
||||
let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Sans 3"}, "to": {"family": "Inter"}})).unwrap();
|
||||
assert_eq!(r, json!({"runs": 2}));
|
||||
}
|
||||
|
||||
/// The font family of text object `id`'s first run.
|
||||
fn text_family(s: &Session, id: u64) -> String {
|
||||
text(&s.doc().unwrap().doc, NodeId(id)).unwrap().runs[0].style.font_family.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -619,6 +777,48 @@ mod open_tests {
|
||||
assert!(warnings.contains("Source Sans 3 Black Wide was drawn in Source Sans 3"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_type_in_a_font_it_cant_draw_warns() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let warning = |out: &Value| out["warning"].as_str().map(str::to_string);
|
||||
let made = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Hi", "font": "Source Sans 3", "style": "Black Wide"})).unwrap();
|
||||
assert!(warning(&made).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{made}");
|
||||
let gone = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Gone", "font": "No Such Font Family"})).unwrap();
|
||||
let w = warning(&gone).unwrap_or_default();
|
||||
assert!(w.contains("No Such Font Family") && w.contains(vectorcraft_text::FALLBACK_FAMILY), "{gone}");
|
||||
let plain = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Fine", "font": "Source Sans 3", "style": "Semibold"})).unwrap();
|
||||
assert_eq!(warning(&plain), None, "{plain}");
|
||||
let id = plain["id"].as_u64().unwrap();
|
||||
let set = s.execute("text.setStyle", &json!({"ids": [id], "style": "Black Wide"})).unwrap();
|
||||
assert!(warning(&set).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{set}");
|
||||
let back = s.execute("text.setStyle", &json!({"ids": [id], "style": "Bold"})).unwrap();
|
||||
assert_eq!(warning(&back), None, "{back}");
|
||||
// Only the font the command set: the other objects' substitutes aren't its to report.
|
||||
let size = s.execute("text.setStyle", &json!({"ids": [id], "size": 30})).unwrap();
|
||||
assert_eq!(warning(&size), None, "{size}");
|
||||
// Only the characters the command styles: with "Fi" in Black Wide, styling "ne" doesn't warn.
|
||||
let first = s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "style": "Black Wide"})).unwrap();
|
||||
assert!(warning(&first).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{first}");
|
||||
let rest = s.execute("text.setRangeStyle", &json!({"id": id, "start": 2, "end": 4, "font": "Source Sans 3"})).unwrap();
|
||||
assert_eq!(warning(&rest), None, "{rest}");
|
||||
let range = s.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 2, "font": "No Such Font Family"})).unwrap();
|
||||
assert!(warning(&range).is_some_and(|w| w.contains("No Such Font Family")), "{range}");
|
||||
let replaced = s
|
||||
.execute(
|
||||
"text.replaceFont",
|
||||
&json!({"from": {"family": "No Such Font Family"}, "to": {"family": "Source Sans 3", "style": "Black Wide"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(warning(&replaced).is_some_and(|w| w.contains("Source Sans 3 Black Wide is drawn in Source Sans 3")), "{replaced}");
|
||||
// Empty type: the font set is the one the next characters typed get.
|
||||
let empty = s.execute("text.create", &json!({"x": 10, "y": 140, "text": ""})).unwrap()["id"].as_u64().unwrap();
|
||||
let set = s.execute("text.setStyle", &json!({"ids": [empty], "style": "Black Wide"})).unwrap();
|
||||
assert!(warning(&set).is_some_and(|w| w.contains("Black Wide")), "{set}");
|
||||
let range = s.execute("text.setRangeStyle", &json!({"id": empty, "font": "No Such Font Family"})).unwrap();
|
||||
assert!(warning(&range).is_some_and(|w| w.contains("No Such Font Family")), "{range}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_documents_have_exact_text_bounds() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -115,7 +115,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rasterize…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded PNG image; each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
"{ppi?, background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art|type (text snapped to pixels)|bool, padding?|addAround?: pt (0–1000), colorModel?: \"rgb\"|\"cmyk\"|\"grayscale\"|\"bitmap\", clippingMask?: bool (a clip group with the art's outline)} replace the selection with an embedded image (PNG; in a CMYK document with its own colour model, a CMYK image of the art's inks, its transparency as its opacity mask); each defaults to document.rasterEffectsSettings; a frame of threaded text that is rasterized leaves its thread with the text it shows → {id (the image, or its clip group), width, height, warning?: \"an image couldn't be decoded and is missing from the picture\" (as raster exports say)}",
|
||||
has_selection,
|
||||
rasterize
|
||||
),
|
||||
@@ -513,15 +513,15 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` rendered over `region` with `opts` as a PNG in colour model `color`, with the note that an
|
||||
/// image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]).
|
||||
fn render_png(
|
||||
/// `doc` rendered over `region` with `opts` in colour model `color` (premultiplied RGBA), with the
|
||||
/// note that an image in it couldn't be decoded ([`super::rasterfx::undecoded_warning`]).
|
||||
fn render_pixels(
|
||||
doc: &vectorcraft_doc::Document,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
color: RasterColorModel,
|
||||
) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> {
|
||||
) -> Result<(vectorcraft_render::Rendered, Option<&'static str>)> {
|
||||
if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 {
|
||||
return Err(EngineError::Other("image would be too large; lower the resolution".into()));
|
||||
}
|
||||
@@ -530,13 +530,57 @@ fn render_png(
|
||||
let mut img = r.render_region_with(doc, region, scale, opts);
|
||||
let warning = super::rasterfx::undecoded_warning(failed);
|
||||
RasterEffectsSettings { color_model: color, ..Default::default() }.finish_pixels(&mut img.pixels);
|
||||
Ok((img, warning))
|
||||
}
|
||||
|
||||
/// [`render_pixels`] as a PNG, with its width and height.
|
||||
fn render_png(
|
||||
doc: &vectorcraft_doc::Document,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
color: RasterColorModel,
|
||||
) -> Result<(Vec<u8>, u32, u32, Option<&'static str>)> {
|
||||
let (img, warning) = render_pixels(doc, region, scale, opts, color)?;
|
||||
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height, warning))
|
||||
}
|
||||
|
||||
/// `doc` over `region` as a CMYK image of its ink amounts (as CMYK exports draw them), with the
|
||||
/// transparency of `img` (the same art in screen colours) as a greyscale PNG when it has any: a
|
||||
/// CMYK image keeps no alpha of its own.
|
||||
fn cmyk_raster(
|
||||
doc: &vectorcraft_doc::Document,
|
||||
region: Rect,
|
||||
scale: f64,
|
||||
opts: &vectorcraft_render::RenderOptions,
|
||||
img: &vectorcraft_render::Rendered,
|
||||
) -> Result<(vectorcraft_doc::ImageBlob, Option<Vec<u8>>)> {
|
||||
let inks = vectorcraft_render::Renderer::new().render_region_inks(doc, region, scale, opts);
|
||||
let alpha: Vec<u8> = img.pixels.as_chunks::<4>().0.iter().map(|p| p[3]).collect();
|
||||
// Partly covered pixels carry that much ink: divided back out, as straight colour is, since
|
||||
// the mask applies the coverage.
|
||||
let straight = |v: u8, a: u8| if a == 0 { 0 } else { ((u32::from(v) * 255 + u32::from(a) / 2) / u32::from(a)).min(255) as u8 };
|
||||
let data = inks.as_chunks::<4>().0.iter().zip(&alpha).flat_map(|(p, &a)| p.map(|v| straight(v, a))).collect();
|
||||
let blob = vectorcraft_doc::cmyk::Inks::new(img.width, img.height, data)
|
||||
.and_then(|i| vectorcraft_doc::ImageBlob::cmyk_tiff(&i))
|
||||
.ok_or_else(|| EngineError::Other("the CMYK image couldn't be written".into()))?;
|
||||
if alpha.iter().all(|&a| a == 255) {
|
||||
return Ok((blob, None));
|
||||
}
|
||||
let mask = image::GrayImage::from_raw(img.width, img.height, alpha)
|
||||
.ok_or_else(|| EngineError::Other("the image's transparency doesn't fit it".into()))?;
|
||||
let mut png = Vec::new();
|
||||
mask.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok((blob, Some(png)))
|
||||
}
|
||||
|
||||
/// The image Object › Rasterize makes of some art, before it goes in a document
|
||||
/// ([`rasterize_art`]).
|
||||
pub(crate) struct Rasterized {
|
||||
png: Vec<u8>,
|
||||
/// A PNG, or a CMYK image of the art's inks ([`cmyk_raster`]).
|
||||
blob: vectorcraft_doc::ImageBlob,
|
||||
/// A CMYK image's transparency, a greyscale PNG: it becomes the image's opacity mask.
|
||||
alpha: Option<Vec<u8>>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Image pixels → document.
|
||||
@@ -551,11 +595,18 @@ impl Rasterized {
|
||||
/// The image as a new object of `d` (its pixels in `d.images`), in a clip group with the art's
|
||||
/// outline when it has one.
|
||||
pub(crate) fn into_node(self, d: &mut vectorcraft_doc::Document) -> Node {
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", self.png));
|
||||
let id = d.alloc_id();
|
||||
let image = ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None };
|
||||
let node = Node::new(id, NodeKind::Image(Box::new(image)));
|
||||
let image = |d: &mut vectorcraft_doc::Document, blob: vectorcraft_doc::ImageBlob| {
|
||||
let key = unique_key(d, "raster");
|
||||
d.images.insert(key.clone(), blob);
|
||||
let image =
|
||||
ImageObject { key, width: self.width, height: self.height, xf: self.xf, link: None, placement: Default::default(), pixels: None };
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)))
|
||||
};
|
||||
let mut node = image(d, self.blob);
|
||||
if let Some(png) = self.alpha {
|
||||
let art = image(d, vectorcraft_doc::ImageBlob::new("image/png", png));
|
||||
node.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
|
||||
}
|
||||
match self.clip {
|
||||
Some((path, rule)) => super::rasterfx::clip_group(d, path, rule, node),
|
||||
None => node,
|
||||
@@ -565,8 +616,9 @@ impl Rasterized {
|
||||
|
||||
/// Object › Rasterize's image of `nodes` (drawn also where hidden) with the params `p` of command
|
||||
/// `cmd`, each defaulting to `doc`'s raster effects settings: over the bounds of what the art draws
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels.
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str) -> Result<Rasterized> {
|
||||
/// ([`vectorcraft_render::drawn_bounds`]) and the room around them, in whole pixels. With `inks`,
|
||||
/// a CMYK document's colour model makes a CMYK image ([`cmyk_raster`]); otherwise images are PNGs.
|
||||
pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p: &Value, cmd: &str, inks: bool) -> Result<Rasterized> {
|
||||
check_numbers(p, &["ppi"], cmd)?;
|
||||
let ppi = f64_or(p, "ppi", doc.raster_effects_ppi);
|
||||
if !(1.0..=2400.0).contains(&ppi) {
|
||||
@@ -630,9 +682,15 @@ pub(crate) fn rasterize_art(doc: &vectorcraft_doc::Document, nodes: Vec<Node>, p
|
||||
.flatten()
|
||||
.map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));
|
||||
let tmp = isolated_doc(doc, nodes);
|
||||
let (png, width, height, warning) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png), look.color_model)?;
|
||||
let opts = raster.render_options(RasterFormat::Png);
|
||||
let (img, warning) = render_pixels(&tmp, region, scale, &opts, look.color_model)?;
|
||||
let (blob, alpha) = if inks && doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && look.color_model == RasterColorModel::Document {
|
||||
cmyk_raster(&tmp, region, scale, &opts, &img)?
|
||||
} else {
|
||||
(vectorcraft_doc::ImageBlob::new("image/png", img.to_png().map_err(EngineError::Other)?), None)
|
||||
};
|
||||
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
|
||||
Ok(Rasterized { png, width, height, xf, clip, warning })
|
||||
Ok(Rasterized { blob, alpha, width: img.width, height: img.height, xf, clip, warning })
|
||||
}
|
||||
|
||||
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -640,7 +698,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
|
||||
let image = rasterize_art(&st.doc, nodes, p, C)?;
|
||||
let image = rasterize_art(&st.doc, nodes, p, C, true)?;
|
||||
let (w, h, warning) = (image.width, image.height, image.warning);
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "nothing selected"));
|
||||
|
||||
@@ -32,7 +32,7 @@ mod transform;
|
||||
|
||||
pub(crate) use align::choice;
|
||||
pub(crate) use arrange::group_nodes;
|
||||
pub(crate) use bounds::bounds_rewrite;
|
||||
pub(crate) use bounds::{bounds_rewrite, object_bounds_transform};
|
||||
pub use commands::specs;
|
||||
pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip};
|
||||
pub(super) use shape::recut;
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::{
|
||||
};
|
||||
|
||||
pub(super) fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0)? {
|
||||
match bounds_transform(s, p, selected_roots_to_transform(s)?, 0.0, true)? {
|
||||
Bounds::Anchors(ids, b) => set_anchor_bounds(s, p, ids, b),
|
||||
Bounds::Objects(ids, xf) => apply_transform(s, "Transform", ids, xf, p, false),
|
||||
}
|
||||
@@ -32,7 +32,7 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
|
||||
if cmd != "object.setBounds" {
|
||||
return None;
|
||||
}
|
||||
let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4) else { return None };
|
||||
let Ok(Bounds::Objects(_, xf)) = bounds_transform(s, params, selected_roots_to_transform(s).ok()?, 1e-4, true) else { return None };
|
||||
let mut p = params.as_object().cloned().unwrap_or_default();
|
||||
for k in ["x", "y", "width", "height", "reference", "proportional"] {
|
||||
p.remove(k);
|
||||
@@ -41,14 +41,26 @@ pub(crate) fn bounds_rewrite(s: &Session, cmd: &str, params: &Value) -> Option<(
|
||||
Some(("object.transform".to_string(), Value::Object(p)))
|
||||
}
|
||||
|
||||
/// The transform that gives object `id` on its own the place and size `p` asks for, as
|
||||
/// `object.setBounds` measures them (Use Preview Bounds, `strokes`, `reference`, `proportional`),
|
||||
/// whatever anchors are direct-selected. The art keeps at least 1e-4 of its width and height.
|
||||
pub(crate) fn object_bounds_transform(s: &Session, p: &Value, id: NodeId) -> Result<Affine> {
|
||||
match bounds_transform(s, p, vec![id], 1e-4, false)? {
|
||||
Bounds::Objects(_, xf) => Ok(xf),
|
||||
Bounds::Anchors(..) => Err(EngineError::Other("no bounds".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// How `object.setBounds` with params `p` changes the objects `ids`. A scale that would leave the
|
||||
/// art with a width or height below `least` of the one it has keeps `least` of it.
|
||||
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64) -> Result<Bounds> {
|
||||
/// art with a width or height below `least` of the one it has keeps `least` of it. With `anchors`,
|
||||
/// direct-selected anchors are what changes ([`Bounds::Anchors`]).
|
||||
fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchors: bool) -> Result<Bounds> {
|
||||
check_numbers(p, &["x", "y", "width", "height", "reference"], "object.setBounds")?;
|
||||
let st = s.doc()?;
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.setBounds")?;
|
||||
check_switches(p, &["proportional"], "object.setBounds")?;
|
||||
if transforms_objects(p)
|
||||
if anchors
|
||||
&& transforms_objects(p)
|
||||
&& !bool_or(p, "copy", false)
|
||||
&& let Some(b) = effects::preview::anchor_bounds(&st.selection, &st.doc, s.prefs.use_preview_bounds)
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}",
|
||||
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several, a layer among several by its art). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode and in pattern editing mode → {id, editing}",
|
||||
can_make,
|
||||
make
|
||||
),
|
||||
@@ -206,6 +206,24 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let invert = bool_or(p, "invert", s.menu.new_masks_inverted);
|
||||
// Several objects: the top one is the mask. One: an empty mask, drawn in mask editing.
|
||||
let (art, mask_id) = if below.is_empty() { (&ids[..], None) } else { (below, Some(top)) };
|
||||
// Several objects go into a new group, and a layer can't: a layer among them stands for its
|
||||
// visible, unlocked art, which the group takes from it. A hidden, locked or template layer
|
||||
// gives none.
|
||||
let contents;
|
||||
let art = if art.len() > 1 && art.iter().any(|id| d.node(*id).is_some_and(Node::is_layer)) {
|
||||
let objects = art.iter().flat_map(|id| match d.node(*id) {
|
||||
Some(n) if n.is_layer() && (!n.visible || n.locked || n.is_template()) => vec![],
|
||||
Some(n) if n.is_layer() => n.layer_art(true),
|
||||
_ => vec![*id],
|
||||
});
|
||||
contents = super::edit::outermost(d, d.paint_order(objects));
|
||||
if contents.is_empty() {
|
||||
return Err(bad(C, "the layers hold no art to mask"));
|
||||
}
|
||||
&contents[..]
|
||||
} else {
|
||||
art
|
||||
};
|
||||
let (id, layer) = s.edit("Make Opacity Mask", |d, sel| {
|
||||
let mask_art = match mask_id {
|
||||
Some(m) => (*d.remove(m)?).clone(),
|
||||
@@ -492,6 +510,45 @@ mod tests {
|
||||
assert!(!n.extras().mask.as_ref().unwrap().clip);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_masks_the_art_of_layers_under_a_mask_object() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let rect = |s: &mut Session, x: u64| {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let l1 = s.doc().unwrap().doc.layers[0].id.0;
|
||||
let (a, a2) = (rect(&mut s, 0), rect(&mut s, 30));
|
||||
let l2 = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let (b, b2) = (rect(&mut s, 60), rect(&mut s, 90));
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let top = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 150, "height": 50})).unwrap()["id"].as_u64().unwrap();
|
||||
let layer_ids = |s: &Session| s.doc().unwrap().doc.layers.iter().map(|l| l.id.0).collect::<Vec<_>>();
|
||||
let before = s.doc().unwrap().doc.layers.clone();
|
||||
// Two layers, and a layer with an object of another layer: the masked group takes the
|
||||
// layers' art, and the layers stay where they are.
|
||||
for (ids, grouped, left) in [(vec![l1, l2, top], vec![a, a2, b, b2], vec![]), (vec![l1, b, top], vec![a, a2, b], vec![b2])] {
|
||||
let g = s.execute("transparency.makeOpacityMask", &json!({ "ids": ids })).unwrap()["id"].as_u64().unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(layer_ids(&s), before.iter().map(|l| l.id.0).collect::<Vec<_>>(), "{ids:?}: no layer moved");
|
||||
let n = d.node(NodeId(g)).unwrap();
|
||||
assert!(!n.is_layer() && n.extras().mask.is_some(), "{ids:?}");
|
||||
assert!(d.parent_of(NodeId(g)).and_then(|p| d.node(p)).is_some_and(Node::is_layer), "{ids:?}: the group is in a layer");
|
||||
assert_eq!(n.children().unwrap().iter().map(|c| c.id.0).collect::<Vec<_>>(), grouped, "{ids:?}");
|
||||
for id in left {
|
||||
assert_eq!(d.parent_of(NodeId(id)), Some(NodeId(l2)), "{ids:?}: {id} stays in its layer");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.layers, before, "{ids:?}: undo");
|
||||
}
|
||||
// A hidden layer gives none of its art.
|
||||
s.execute("layer.setProps", &json!({"id": l2, "visible": false})).unwrap();
|
||||
let g = s.execute("transparency.makeOpacityMask", &json!({ "ids": [l1, l2, top] })).unwrap()["id"].as_u64().unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.node(NodeId(g)).unwrap().children().unwrap().iter().map(|c| c.id.0).collect::<Vec<_>>(), vec![a, a2]);
|
||||
assert_eq!((d.parent_of(NodeId(b)), d.parent_of(NodeId(b2))), (Some(NodeId(l2)), Some(NodeId(l2))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_restores_mask_art_and_undo_restores_mask() {
|
||||
let (mut s, a, b) = session_with_two();
|
||||
|
||||
@@ -112,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert Anchor Points",
|
||||
[],
|
||||
None,
|
||||
"{to: \"corner\"|\"smooth\"} convert the direct-selected anchors (all anchors of fully selected paths) as one undo step: corner retracts both handles, smooth pulls handles in line with the neighbours (smooth anchors keep theirs)",
|
||||
"{to: \"corner\"|\"smooth\"} convert the direct-selected anchors (all anchors of fully selected paths, every point of a selected blend's spine) as one undo step: corner retracts both handles, smooth pulls handles in line with the neighbours (smooth anchors keep theirs)",
|
||||
has_selection,
|
||||
convert_anchors
|
||||
),
|
||||
@@ -1123,7 +1123,11 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad(C, "`to` must be \"corner\" or \"smooth\"")),
|
||||
};
|
||||
let t = anchor_targets(s)?;
|
||||
if t.is_empty() {
|
||||
// A selected blend converts every point of its spine, as a path selected whole does.
|
||||
let st = s.doc()?;
|
||||
let blends: Vec<NodeId> =
|
||||
st.selection.objects.iter().copied().filter(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Blend { .. }))).collect();
|
||||
if t.is_empty() && blends.is_empty() {
|
||||
return Err(bad(C, "select paths or anchor points"));
|
||||
}
|
||||
s.edit("Convert Anchor Points", |d, _| {
|
||||
@@ -1138,6 +1142,24 @@ fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &blends {
|
||||
let Some(NodeKind::Blend { children, spec }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let Some((mut path, anchors)) = vectorcraft_doc::live::pin_spine(children, spec) else { continue };
|
||||
let pinned = path.clone();
|
||||
let Some(sp) = path.subpaths.first_mut() else { continue };
|
||||
for ai in 0..sp.anchors.len() {
|
||||
if smooth {
|
||||
sp.smooth_anchor(ai);
|
||||
} else if let Some(a) = sp.anchors.get_mut(ai) {
|
||||
a.retract();
|
||||
}
|
||||
}
|
||||
// A spine the conversion leaves as it was stays as the blend has it.
|
||||
if path != pinned {
|
||||
spec.key_anchors = anchors.iter().map(|a| u32::try_from(*a).unwrap_or(u32::MAX)).collect();
|
||||
spec.spine = Some(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
|
||||
@@ -888,11 +888,13 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
}
|
||||
|
||||
/// A plain copy of path `l` (new ids, no transparency, name or mask) painted with `items`.
|
||||
/// The copy is shown and unlocked.
|
||||
pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>) -> Node {
|
||||
let mut f = d.reid(l);
|
||||
f.appearance = Appearance { items, ..Default::default() }.into();
|
||||
f.opacity = 1.0;
|
||||
f.blend = BlendMode::Normal;
|
||||
(f.visible, f.locked, f.knockout_shape) = (true, false, false);
|
||||
f.name = None;
|
||||
f.extras_mut().mask = None;
|
||||
if let NodeKind::Path { live, .. } = &mut f.kind {
|
||||
@@ -903,7 +905,7 @@ pub(crate) fn styled_copy(d: &mut Document, l: &Node, items: Vec<AppearanceItem>
|
||||
|
||||
/// What stands for object `l` once its appearance became `parts` (paint order): the one part (in a
|
||||
/// group when it has effects of its own) or a group of them, with `l`'s transparency, name, opacity
|
||||
/// mask and effects. `None` without parts.
|
||||
/// mask, effects, and hidden, locked and knockout-shape states. `None` without parts.
|
||||
pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Option<Node> {
|
||||
let root = match parts.len() {
|
||||
0 => return None,
|
||||
@@ -911,6 +913,7 @@ pub(crate) fn assemble(d: &mut Document, l: &Node, mut parts: Vec<Node>) -> Opti
|
||||
_ => Node::group(d.alloc_id(), parts.into_iter().map(Arc::new).collect()),
|
||||
};
|
||||
let mut root = with_transparency(d, root, l.opacity, l.blend);
|
||||
(root.visible, root.locked, root.knockout_shape) = (l.visible, l.locked, l.knockout_shape);
|
||||
root.name = l.name.clone();
|
||||
root.extras_mut().mask = l.extras().mask.clone();
|
||||
root.appearance.effects = l.appearance.effects.clone();
|
||||
|
||||
@@ -351,7 +351,9 @@ fn rasterize_effect_image(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
whole.appearance.effects.remove(at);
|
||||
(whole.opacity, whole.blend) = (1.0, Default::default());
|
||||
whole.extras_mut().mask = None;
|
||||
let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize").ok()?;
|
||||
// shortcut: expanded Rasterize effects stay RGB, as the object keeps its own opacity mask and the
|
||||
// image's transparency would need one too; upgrade when CMYK images carry alpha.
|
||||
let image = super::menucmds::rasterize_art(d, vec![whole], &p, "object.rasterize", false).ok()?;
|
||||
Some(image.into_node(d))
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::BTreeSet;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_tools::builder::is_live_paint;
|
||||
|
||||
use super::wand::{fill_of, stroke_of};
|
||||
use super::{
|
||||
@@ -106,11 +107,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill Color",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path",
|
||||
"{} the objects filled with the first selected object's fill colour (any tint of its global or spot swatch, the same tint with prefs selectSameTintPercent); a member path of a compound path selected first compares as its compound path, except in isolation mode inside that compound path; from a Live Paint face, the faces of its Live Paint group only, and from any other object, no Live Paint face",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
same(s, "select.same.fillColor", |a, b| same_paint(&fill_of(a), &fill_of(b), tint))
|
||||
same_fill(s, "select.same.fillColor", |a, b| same_paint(&fill_of(a), &fill_of(b), tint))
|
||||
}
|
||||
),
|
||||
cmd!(
|
||||
@@ -135,11 +136,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill & Stroke",
|
||||
["Select", "Same"],
|
||||
None,
|
||||
"{} the objects with the first selected object's fill and stroke colours (tints and compound paths as select.same.fillColor)",
|
||||
"{} the objects with the first selected object's fill and stroke colours (tints, compound paths and Live Paint faces as select.same.fillColor)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let tint = s.prefs.select_same_tint_percent;
|
||||
same(s, "select.same.fillAndStroke", |a, b| {
|
||||
same_fill(s, "select.same.fillAndStroke", |a, b| {
|
||||
same_paint(&fill_of(a), &fill_of(b), tint) && same_paint(&stroke_of(a).0, &stroke_of(b).0, tint)
|
||||
})
|
||||
}
|
||||
@@ -684,9 +685,30 @@ fn same_reference<'a>(st: &'a DocState, cmd: &str) -> Result<(&'a Node, bool)> {
|
||||
/// [`paint_reported`] accepts. A compound path matches as a whole. In isolation mode inside a
|
||||
/// compound path, its member paths are matched instead.
|
||||
fn same(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let (refn, members) = same_reference(s.doc()?, cmd)?;
|
||||
same_in(s, cmd, false, eq)
|
||||
}
|
||||
|
||||
/// Select > Same > Fill Color and Fill & Stroke: [`same`], kept to one side of a Live Paint group.
|
||||
/// From a face or edge of a Live Paint group, only the faces and edges of that group match; from
|
||||
/// any other object, no face or edge of a Live Paint group does
|
||||
/// (<https://helpx.adobe.com/illustrator/desktop/paint-and-fill/learn-painting-basics/select-objects-with-same-fill-and-stroke.html>).
|
||||
fn same_fill(s: &mut Session, cmd: &str, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
same_in(s, cmd, true, eq)
|
||||
}
|
||||
|
||||
fn same_in(s: &mut Session, cmd: &str, live_paint: bool, eq: impl Fn(&Node, &Node) -> bool) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (refn, members) = same_reference(st, cmd)?;
|
||||
let group = live_paint_group(&st.doc, refn.id);
|
||||
let refn = refn.clone();
|
||||
select_where(s, cmd, &json!({}), |d, n| (if members { !n.is_container() } else { paint_reported(d, n) }) && eq(n, &refn))
|
||||
select_where(s, cmd, &json!({}), |d, n| {
|
||||
(!live_paint || live_paint_group(d, n.id) == group) && (if members { !n.is_container() } else { paint_reported(d, n) }) && eq(n, &refn)
|
||||
})
|
||||
}
|
||||
|
||||
/// The Live Paint group `id` is a face or edge of.
|
||||
fn live_paint_group(d: &Document, id: NodeId) -> Option<NodeId> {
|
||||
d.parent_of(id).filter(|p| d.node(*p).is_some_and(is_live_paint))
|
||||
}
|
||||
|
||||
/// Select the objects [`walk_selectable`] visits that `f` accepts (not looking inside accepted
|
||||
|
||||
@@ -331,11 +331,21 @@ fn library_tab_name(file: &str, lib_name: &str) -> String {
|
||||
}
|
||||
|
||||
/// The file name of library `name` with extension `ext`: characters file systems reject become
|
||||
/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`.
|
||||
/// `-`, and dots and spaces at either end are dropped. A name with nothing left is `Library`, and
|
||||
/// one that is a Windows device name (`CON`, `NUL`, `COM1`…, also with an extension) gets a `-`
|
||||
/// after its first part (`CON-.json`, `CON-.txt.json`).
|
||||
pub fn library_file_name(name: &str, ext: &str) -> String {
|
||||
let base: String = name.chars().map(|c| if "/\\:*?\"<>|".contains(c) || c.is_control() { '-' } else { c }).collect();
|
||||
let base = base.trim_matches(['.', ' ']);
|
||||
format!("{}.{ext}", if base.is_empty() { "Library" } else { base })
|
||||
if base.is_empty() {
|
||||
format!("Library.{ext}")
|
||||
} else if crate::file_access::is_device_name(base) {
|
||||
// The part before the first dot is what makes it a device, so the `-` goes there.
|
||||
let (stem, rest) = base.split_once('.').map_or((base, String::new()), |(stem, rest)| (stem, format!(".{rest}")));
|
||||
format!("{stem}-{rest}.{ext}")
|
||||
} else {
|
||||
format!("{base}.{ext}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The library files with extensions `exts` in folder `dir`, sorted by name.
|
||||
|
||||
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs}",
|
||||
"{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, fill?: colour|\"none\", stroke?: colour|\"none\", keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_range_style
|
||||
),
|
||||
@@ -708,6 +708,9 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = text_ref(s, id)?;
|
||||
change.localize(&t, C)?;
|
||||
let (a, b) = range_of(p, edit::runs_len(&t.runs), C)?;
|
||||
// The fonts the styled characters are set in, for the warning.
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
let sets_font = change.font.is_some() || change.style.is_some();
|
||||
let n = s.edit("Character", |d, _| {
|
||||
let t = text_mut(d, id).ok_or(EngineError::NoNode(id))?;
|
||||
if a == b && !t.plain_text().is_empty() {
|
||||
@@ -719,10 +722,19 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(before) = before {
|
||||
protect_missing_glyphs(&before, &mut t.runs);
|
||||
}
|
||||
if sets_font {
|
||||
// Empty text has one run, which the empty range styles.
|
||||
let styled = if a == b { t.runs.clone() } else { edit::slice_runs(&t.runs, a, b) };
|
||||
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
Ok(t.runs.len())
|
||||
})?;
|
||||
Ok(json!({"id": id.0, "runs": n}))
|
||||
let mut out = json!({"id": id.0, "runs": n});
|
||||
if let Some(w) = super::fonts::set_font_warning(set) {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The em a laid-out glyph's kerning and tracking are measured in, in text space: the size it
|
||||
|
||||
@@ -123,7 +123,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, keepModel?: bool (as paint.setFill), features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -805,6 +805,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let range = TextRange::parse(p, C)?;
|
||||
let ids = text_targets(s, p, C)?;
|
||||
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
|
||||
// The fonts the styled characters are set in, for the warning.
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
s.edit("Character", |d, _| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
@@ -846,6 +848,17 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(before) = before {
|
||||
super::textedit::protect_missing_glyphs(&before, &mut t.runs);
|
||||
}
|
||||
if font.is_some() || style.is_some() {
|
||||
// All the text: every run, the one run of empty text included.
|
||||
let styled = match range {
|
||||
None => t.runs.clone(),
|
||||
Some(r) => {
|
||||
let (a, b) = r.bytes(t);
|
||||
vectorcraft_text::edit::slice_runs(&t.runs, a, b)
|
||||
}
|
||||
};
|
||||
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
|
||||
}
|
||||
if let Some(j) = justify {
|
||||
let span = para_span(range, t);
|
||||
t.edit_paras(span, |pa| pa.justify = j);
|
||||
@@ -888,7 +901,11 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
|
||||
let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
if let Some(w) = super::fonts::set_font_warning(set) {
|
||||
out["warning"] = json!(w);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reshape area type's frame with `f` ([`TextObject::transform_area`],
|
||||
|
||||
@@ -29,8 +29,8 @@ fn datasets_csv_bytes(p: &Value, cmd: &str) -> Result<Vec<u8>> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// A row of a datasets CSV: the file line it starts on, and its cells.
|
||||
type CsvRow = (usize, Vec<String>);
|
||||
/// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted.
|
||||
type CsvRow = (usize, Vec<String>, Vec<bool>);
|
||||
|
||||
/// Whether a datasets CSV separates its cells with tabs: its header row (the first line that
|
||||
/// isn't blank), read with tabs between cells, has a tab outside quotes and no comma outside
|
||||
@@ -80,7 +80,7 @@ fn tab_separated(text: &str) -> bool {
|
||||
fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<CsvRow>> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let sep = if tab_separated(text) { '\t' } else { ',' };
|
||||
let (mut rows, mut row, mut cur) = (Vec::new(), Vec::<String>::new(), String::new());
|
||||
let (mut rows, mut row, mut quoted, mut cur) = (Vec::new(), Vec::<String>::new(), Vec::<bool>::new(), String::new());
|
||||
// `closed`: where the closing quote of a quoted cell falls in `cur`. `start`: the line the
|
||||
// current row starts on.
|
||||
let (mut in_quotes, mut closed, mut line, mut start) = (false, None, 1usize, 1usize);
|
||||
@@ -98,18 +98,23 @@ fn parse_datasets_csv(text: &str, cmd: &str) -> Result<Vec<CsvRow>> {
|
||||
}
|
||||
}
|
||||
Some('"') if cur.is_empty() => in_quotes = true,
|
||||
Some(ch) if ch == sep && !in_quotes => row.push(end_csv_cell(&mut cur, &mut closed)),
|
||||
Some(ch) if ch == sep && !in_quotes => {
|
||||
quoted.push(closed.is_some());
|
||||
row.push(end_csv_cell(&mut cur, &mut closed));
|
||||
}
|
||||
Some('\r') if !in_quotes => {}
|
||||
Some('\n') | None if !in_quotes => {
|
||||
quoted.push(closed.is_some());
|
||||
row.push(end_csv_cell(&mut cur, &mut closed));
|
||||
if row.iter().any(|c| !c.trim().is_empty()) {
|
||||
rows.push((start, std::mem::take(&mut row)));
|
||||
rows.push((start, std::mem::take(&mut row), std::mem::take(&mut quoted)));
|
||||
// +1 for the header.
|
||||
if rows.len() > MAX_CSV_ROWS + 1 {
|
||||
return Err(bad(cmd, format!("more than {MAX_CSV_ROWS} data rows")));
|
||||
}
|
||||
}
|
||||
row.clear();
|
||||
quoted.clear();
|
||||
if c.is_none() {
|
||||
return Ok(rows);
|
||||
}
|
||||
@@ -143,7 +148,8 @@ fn end_csv_cell(cur: &mut String, closed: &mut Option<usize>) -> String {
|
||||
}
|
||||
|
||||
fn csv_escape(s: &str) -> String {
|
||||
if s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
|
||||
// An empty string is quoted so that it reads back as an empty value, not as a missing one.
|
||||
if s.is_empty() || s.contains([',', '"', '\n', '\r']) || s != s.trim() { format!("\"{}\"", s.replace('"', "\"\"")) } else { s.to_string() }
|
||||
}
|
||||
|
||||
/// A visibility cell: true/false, 1/0, yes/no (ASCII, case-insensitive).
|
||||
@@ -155,12 +161,13 @@ fn parse_visible_cell(s: &str, var: &str, cmd: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_to_value(kind: VariableKind, cell: &str, var: &str, cmd: &str) -> Result<Option<DataValue>> {
|
||||
/// The value of a cell. A quoted empty text cell is an empty text value; any other empty cell is no value.
|
||||
fn cell_to_value(kind: VariableKind, cell: &str, quoted: bool, var: &str, cmd: &str) -> Result<Option<DataValue>> {
|
||||
// A text cell is not trimmed here (the parser trims unquoted cells); image, graph and
|
||||
// visibility cells are trimmed.
|
||||
let t = if kind == VariableKind::Text { cell } else { cell.trim() };
|
||||
if t.is_empty() {
|
||||
return Ok(None);
|
||||
return Ok((quoted && kind == VariableKind::Text).then(|| DataValue::Text(String::new())));
|
||||
}
|
||||
Ok(Some(match kind {
|
||||
VariableKind::Text => DataValue::Text(t.to_string()),
|
||||
@@ -250,7 +257,7 @@ pub(super) fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bytes = datasets_csv_bytes(p, C)?;
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| bad(C, "CSV is not UTF-8"))?;
|
||||
let rows = parse_datasets_csv(text, C)?;
|
||||
let Some((_, header)) = rows.first() else {
|
||||
let Some((_, header, _)) = rows.first() else {
|
||||
return Err(bad(C, "CSV is empty"));
|
||||
};
|
||||
let name_column = header.first().is_some_and(|h| h.trim().eq_ignore_ascii_case("name"));
|
||||
@@ -280,7 +287,7 @@ pub(super) fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut parsed: Vec<DataSet> = Vec::new();
|
||||
// The names of the rows read so far, and the last number given to a row without a name.
|
||||
let (mut seen, mut number) = (BTreeSet::new(), 0usize);
|
||||
for &(line, ref row) in rows.iter().skip(1) {
|
||||
for &(line, ref row, ref quoted) in rows.iter().skip(1) {
|
||||
let raw_name = if name_column { row.first().map_or("", |c| c.trim()) } else { "" };
|
||||
let name = if raw_name.is_empty() {
|
||||
// Same numbering as Capture Data Set when a row leaves the name blank or the file has
|
||||
@@ -302,7 +309,8 @@ pub(super) fn dataset_import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut values = BTreeMap::new();
|
||||
for &(col, ref var, kind) in &columns {
|
||||
let cell = row.get(col).map(String::as_str).unwrap_or("");
|
||||
match cell_to_value(kind, cell, var, C) {
|
||||
let was_quoted = quoted.get(col).copied().unwrap_or(false);
|
||||
match cell_to_value(kind, cell, was_quoted, var, C) {
|
||||
Ok(Some(v)) => {
|
||||
values.insert(var.clone(), v);
|
||||
}
|
||||
|
||||
@@ -213,8 +213,10 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||
if cfg!(windows)
|
||||
&& let Some(why) = windows_name_refusal(path)
|
||||
{
|
||||
return Err(format!("automation path rejected: {why}: {path}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
@@ -274,12 +276,24 @@ fn same_component(a: Component, b: Component) -> bool {
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
pub(crate) fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
}
|
||||
|
||||
/// Why Windows would not take `path` as an ordinary file name, for the first of its components
|
||||
/// that is a device name, or holds a `:` (a drive letter's own colon belongs to the prefix, not to
|
||||
/// a name, so only a stream such as `x.svg:evil` has one). (Judged by name alone, so it can be
|
||||
/// tested on any platform; `resolve` applies it on Windows only.)
|
||||
fn windows_name_refusal(path: &str) -> Option<&'static str> {
|
||||
Path::new(path).components().find_map(|c| match c {
|
||||
Component::Normal(n) if is_device_name(&n.to_string_lossy()) => Some("reserved device names are not allowed"),
|
||||
Component::Normal(n) if n.to_string_lossy().contains(':') => Some("alternate data streams are not allowed"),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -497,4 +511,29 @@ mod tests {
|
||||
assert!(!is_device_name(f), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3604: on Windows `x.svg:evil` writes a hidden NTFS alternate data stream of `x.svg`, which
|
||||
/// stays inside the root yet shows in no folder listing, so a `:` in any component after the
|
||||
/// drive is refused (a drive letter's own colon is not a component).
|
||||
#[test]
|
||||
fn alternate_data_streams_are_refused() {
|
||||
for p in ["x.svg:evil", "out/x.svg:evil", "out/x.svg:evil:$DATA", "out/sub:evil/x.svg", "out/x.svg::$DATA", "out/:evil"] {
|
||||
assert_eq!(windows_name_refusal(p), Some("alternate data streams are not allowed"), "{p}");
|
||||
}
|
||||
for p in ["x.svg", "out/x.svg", "out/a b.svg", "out/x.svg.evil", "out/NUL"] {
|
||||
assert_ne!(windows_name_refusal(p), Some("alternate data streams are not allowed"), "{p}");
|
||||
}
|
||||
assert_eq!(windows_name_refusal("out/NUL"), Some("reserved device names are not allowed"));
|
||||
assert_eq!(windows_name_refusal("out/x.svg"), None);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_refuses_a_stream_inside_a_write_root() {
|
||||
let (base, roots) = folders("streams");
|
||||
let e = roots.check(&s(base.join("out").join("x.svg:evil")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("alternate data streams"), "{e}");
|
||||
assert_eq!(roots.check(&s(base.join("out/x.svg")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,27 +199,30 @@ impl Session {
|
||||
r
|
||||
}
|
||||
|
||||
/// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and the active
|
||||
/// document goes back to how it was before the command, instead of crashing the frontend.
|
||||
/// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and every open
|
||||
/// document goes back to how it was before the command (its art, selection, interaction and
|
||||
/// undo history), and the same document stays active, instead of crashing the frontend.
|
||||
pub(crate) fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
|
||||
let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone()));
|
||||
let active = self.active;
|
||||
let snapshot: Vec<_> =
|
||||
self.docs.iter().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone(), d.history.clone())).collect();
|
||||
let active = self.active().map(|d| d.uid);
|
||||
let r = match guard::catch_panic(|| self.run_nested(f)) {
|
||||
Ok(r) => r,
|
||||
Err(msg) => {
|
||||
self.depth = 0;
|
||||
self.journal_note.clear();
|
||||
if let Some((uid, doc, selection, interaction)) = snapshot
|
||||
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
{
|
||||
for (uid, doc, selection, interaction, history) in snapshot {
|
||||
// A document the command closed stays closed.
|
||||
let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid) else { continue };
|
||||
st.doc = doc;
|
||||
st.selection = selection;
|
||||
st.interaction = interaction;
|
||||
st.history = history;
|
||||
cmd::globaledit::follow_history(st);
|
||||
st.revision += 1;
|
||||
}
|
||||
if active.is_some_and(|i| i < self.docs.len()) {
|
||||
self.active = active;
|
||||
if let Some(i) = active.and_then(|uid| self.docs.iter().position(|d| d.uid == uid)) {
|
||||
self.active = Some(i);
|
||||
}
|
||||
Err(EngineError::Internal { cmd: id.to_string(), msg })
|
||||
}
|
||||
|
||||
@@ -3521,6 +3521,46 @@ fn a_panicking_command_rolls_back_instead_of_crashing() {
|
||||
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("shape.rectangle"));
|
||||
}
|
||||
|
||||
/// A command that recorded an undo step and then panicked leaves Undo and Redo as they were.
|
||||
#[test]
|
||||
fn a_panic_after_an_edit_leaves_undo_and_redo_as_they_were() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
|
||||
rect(&mut s, 200.0, 10.0, 50.0, 50.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let (undo, redo) = (s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len());
|
||||
assert_eq!((undo, redo), (1, 1));
|
||||
let r = s.run_guarded("test.panic", |s| {
|
||||
s.execute("edit.clear", &json!({"ids": [a.0]}))?;
|
||||
s.run_nested(|_| panic!("boom"))
|
||||
});
|
||||
assert!(matches!(r, Err(EngineError::Internal { .. })));
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, redo));
|
||||
assert!(st.doc.node(a).is_some());
|
||||
}
|
||||
|
||||
/// A panic rolls back every document the command touched, and the active one stays the same.
|
||||
#[test]
|
||||
fn a_panic_rolls_back_other_documents_and_keeps_the_active_one() {
|
||||
let mut s = session();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let second = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
let first_uid = s.doc().unwrap().uid;
|
||||
let second_before = s.docs[1].doc.clone();
|
||||
let steps = s.docs[1].history.undo.len();
|
||||
let r = s.run_guarded("test.panic", |s| {
|
||||
s.docs[1].history.undo.clear();
|
||||
Arc::make_mut(&mut s.docs[1].doc).remove(second)?;
|
||||
s.run_nested(|_| panic!("boom"))
|
||||
});
|
||||
assert!(matches!(r, Err(EngineError::Internal { .. })));
|
||||
assert!(Arc::ptr_eq(&s.docs[1].doc, &second_before));
|
||||
assert_eq!(s.docs[1].history.undo.len(), steps);
|
||||
assert_eq!(s.doc().unwrap().uid, first_uid);
|
||||
}
|
||||
|
||||
/// New layers and sublayers share one "Layer N" numbering, whatever art the document holds, and
|
||||
/// never repeat a name.
|
||||
#[test]
|
||||
|
||||
@@ -288,6 +288,74 @@ fn add_fill_and_stroke_copy_the_top_ones() {
|
||||
assert_eq!(node(&s, id).appearance.items.len(), 3);
|
||||
}
|
||||
|
||||
/// With a fill row active, Add New Fill puts a copy of that row right above it and makes the copy
|
||||
/// the active row; the rows above it stay where they are.
|
||||
#[test]
|
||||
fn add_fill_copies_the_active_fill_row_right_above_it() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s, 100.0);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [red, Stroke, blue]
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!(n.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke", "fill"]);
|
||||
let hex = |i: usize| fill_paint(&n, i).color().unwrap().to_hex();
|
||||
assert_eq!((hex(0), hex(1), hex(3)), ("#ff0000".to_string(), "#ff0000".to_string(), "#0000ff".to_string()));
|
||||
assert_eq!(s.appearance_item(), Some(1), "the new fill is the active row");
|
||||
// The next edit goes to the new fill.
|
||||
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
|
||||
let n = node(&s, id);
|
||||
assert_eq!((fill_paint(&n, 0).color().unwrap().to_hex(), fill_paint(&n, 1).color().unwrap().to_hex()), ("#ff0000".into(), "#00ff00".into()));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(node(&s, id).appearance.items.len(), 3);
|
||||
// An active stroke row, or ids, leave the new fill on top.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
assert_eq!(node(&s, id).appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill", "fill"]);
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
run(&mut s, "appearance.addFill", json!({"ids": [id.0]}));
|
||||
assert_eq!(fill_paint(&node(&s, id), 4).color().unwrap().to_hex(), "#0000ff");
|
||||
assert_eq!(s.appearance_item(), Some(0));
|
||||
}
|
||||
|
||||
/// In a mixed selection the active fill row goes above itself in the first object; an object that
|
||||
/// doesn't share that row gets a copy of its top fill on top, as without an active row.
|
||||
#[test]
|
||||
fn add_fill_above_an_unshared_active_row_tops_the_other_objects() {
|
||||
let mut s = session();
|
||||
let (a, b) = mixed_circles(&mut s, true); // a [yellow, Stroke], b [red, green, Stroke]
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
let (na, nb) = (node(&s, a), node(&s, b));
|
||||
assert_eq!(na.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke"]);
|
||||
assert_eq!(fill_paint(&na, 1).color().unwrap().to_hex(), "#ffff00");
|
||||
assert_eq!(nb.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "stroke", "fill"]);
|
||||
assert_eq!(fill_paint(&nb, 3).color().unwrap().to_hex(), "#00ff00");
|
||||
assert_eq!(s.appearance_item(), Some(1));
|
||||
}
|
||||
|
||||
/// The active row is a row of the first selected object, also when that object paints above the
|
||||
/// others.
|
||||
#[test]
|
||||
fn add_fill_goes_above_the_active_row_of_the_first_selected_object() {
|
||||
let mut s = session();
|
||||
let (a, b) = mixed_circles(&mut s, true); // a [yellow, Stroke], b [red, green, Stroke]
|
||||
run(&mut s, "select.set", json!({"ids": [b.0, a.0]}));
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1})); // b's green fill
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
let (na, nb) = (node(&s, a), node(&s, b));
|
||||
assert_eq!(nb.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "fill", "fill", "stroke"]);
|
||||
assert_eq!(fill_paint(&nb, 2).color().unwrap().to_hex(), "#00ff00");
|
||||
assert_eq!(na.appearance.items.iter().map(AppearanceItem::kind_name).collect::<Vec<_>>(), ["fill", "stroke", "fill"]);
|
||||
assert_eq!(fill_paint(&na, 2).color().unwrap().to_hex(), "#ffff00");
|
||||
assert_eq!(s.appearance_item(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_item_and_remove_item() {
|
||||
let mut s = session();
|
||||
@@ -332,6 +400,69 @@ fn clear_and_reduce_to_basic() {
|
||||
assert_eq!(node(&s, id).opacity, 0.4);
|
||||
}
|
||||
|
||||
/// Reduce to Basic Appearance keeps the Appearance panel's active fill or stroke row instead of the
|
||||
/// topmost one of its kind, in each selected object with the same stack. With `ids` it keeps the
|
||||
/// topmost ones.
|
||||
#[test]
|
||||
fn reduce_to_basic_keeps_the_active_row() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 50.0);
|
||||
let b = rect(&mut s, 200.0);
|
||||
let c = rect(&mut s, 300.0);
|
||||
let stack = |s: &mut Session| {
|
||||
run(s, "appearance.addFill", json!({}));
|
||||
run(s, "appearance.addStroke", json!({})); // [Fill, Stroke, Fill, Stroke]
|
||||
run(s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"}));
|
||||
run(s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"}));
|
||||
run(s, "appearance.setItem", json!({"index": 1, "weight": 7}));
|
||||
};
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
stack(&mut s);
|
||||
// The lower, green fill row is active: both objects keep it and their top stroke.
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
run(&mut s, "appearance.reduceToBasic", json!({}));
|
||||
for id in [a, b] {
|
||||
let n = node(&s, id);
|
||||
assert!(n.appearance.is_basic(), "{:?}", n.appearance);
|
||||
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#00ff00");
|
||||
assert_eq!(stroke(&n, 1).width, 1.0);
|
||||
}
|
||||
// The lower stroke row is active: it stays, with the top fill.
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 1}));
|
||||
run(&mut s, "appearance.reduceToBasic", json!({}));
|
||||
let n = node(&s, a);
|
||||
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(stroke(&n, 1).width, 7.0);
|
||||
// Given ids, the topmost fill and stroke stay.
|
||||
run(&mut s, "select.set", json!({"ids": [c.0]}));
|
||||
stack(&mut s);
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
run(&mut s, "appearance.reduceToBasic", json!({"ids": [c.0]}));
|
||||
let n = node(&s, c);
|
||||
assert_eq!(fill_paint(&n, 0).color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(stroke(&n, 1).width, 1.0);
|
||||
}
|
||||
|
||||
/// The active row is a row of the first selected object even when that object paints above the
|
||||
/// others; another object without an equal row keeps its topmost fill.
|
||||
#[test]
|
||||
fn reduce_to_basic_keeps_the_active_row_of_the_first_selected_object() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 50.0);
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#0000ff"})); // [Fill, Stroke, Fill blue]
|
||||
let b = rect(&mut s, 200.0);
|
||||
run(&mut s, "appearance.addFill", json!({}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 0, "color": "#00ff00"}));
|
||||
run(&mut s, "appearance.setItem", json!({"index": 2, "color": "#ff0000"})); // [Fill green, Stroke, Fill red]
|
||||
run(&mut s, "select.set", json!({"ids": [b.0, a.0]}));
|
||||
run(&mut s, "appearance.setActiveItem", json!({"index": 0}));
|
||||
run(&mut s, "appearance.reduceToBasic", json!({}));
|
||||
assert_eq!(fill_paint(&node(&s, b), 0).color().unwrap().to_hex(), "#00ff00");
|
||||
assert_eq!(fill_paint(&node(&s, a), 0).color().unwrap().to_hex(), "#0000ff");
|
||||
}
|
||||
|
||||
/// How many steps there are to undo and to redo, and whether the document has unsaved changes.
|
||||
fn history(s: &Session) -> (usize, usize, bool) {
|
||||
let st = s.doc().unwrap();
|
||||
|
||||
@@ -115,6 +115,35 @@ fn spine_conversion_rejects_invalid_arguments_without_mutation() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #4819: Convert Selected Anchor Points to Smooth or Corner (the Control bar's and the Contextual
|
||||
/// Task Bar's buttons) converts the points of a selected blend's spine, as it converts every
|
||||
/// anchor of a path selected whole, in one undo step, the keys staying put.
|
||||
#[test]
|
||||
fn converting_anchors_of_a_selected_blend_converts_its_spine() {
|
||||
let mut s = session();
|
||||
let (a, b, g) = blend(&mut s);
|
||||
s.execute("object.blend.spine.addAnchor", &json!({"id": g.0, "x": 55, "y": 5})).unwrap();
|
||||
s.execute("object.blend.spine.moveAnchor", &json!({"id": g.0, "anchor": 1, "x": 55, "y": 105})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let keys = spec(&s, g).key_anchors;
|
||||
s.execute("path.convertAnchors", &json!({"to": "smooth"})).unwrap();
|
||||
let sp = spec(&s, g);
|
||||
assert_eq!(sp.key_anchors, keys);
|
||||
let middle = sp.spine.as_ref().unwrap().subpaths[0].anchors[1];
|
||||
assert_eq!(middle.p, Point::new(55.0, 105.0));
|
||||
assert!(middle.has_in() && middle.has_out(), "{middle:?}");
|
||||
assert!((middle.h_in.y - 105.0).abs() < 1e-9 && (middle.h_out.y - 105.0).abs() < 1e-9, "tangent to the chord between its neighbors: {middle:?}");
|
||||
assert!(middle.h_in.x < 55.0 && middle.h_out.x > 55.0, "{middle:?}");
|
||||
assert_eq!(center(&node(&s, a)), Point::new(5.0, 5.0));
|
||||
assert_eq!(center(&node(&s, b)), Point::new(105.0, 5.0));
|
||||
|
||||
s.execute("path.convertAnchors", &json!({"to": "corner"})).unwrap();
|
||||
let corner = spec(&s, g).spine.unwrap().subpaths[0].anchors.clone();
|
||||
assert!(corner.iter().all(|a| !a.has_in() && !a.has_out()), "{corner:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, g).spine.unwrap().subpaths[0].anchors[1], middle, "one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_key_point_moves_the_key_and_the_other_way_round() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -38,6 +38,74 @@ fn paths(s: &Session) -> Vec<(NodeId, PathData)> {
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_tip_angle_changes_horizontal_extent_and_stays_page_oriented() {
|
||||
let bounds = |points: serde_json::Value, angle: i32, roundness: i32| {
|
||||
let mut s = session();
|
||||
s.execute("path.blob", &json!({"points": points, "size": 40, "angle": angle, "roundness": roundness, "merge": false})).unwrap();
|
||||
paths(&s)[0].1.control_bounds().unwrap()
|
||||
};
|
||||
let flat = bounds(json!([[100, 200], [300, 200]]), 0, 25);
|
||||
let tilted = bounds(json!([[100, 200], [300, 200]]), 45, 25);
|
||||
assert!(flat.height() < 15.0, "flat tip height: {}", flat.height());
|
||||
assert!(tilted.height() > 25.0, "tilted tip height: {}", tilted.height());
|
||||
let vertical = bounds(json!([[200, 100], [200, 300]]), 45, 25);
|
||||
assert!((vertical.width() - tilted.height()).abs() < 2.0);
|
||||
let round = bounds(json!([[100, 200], [300, 200]]), 45, 100);
|
||||
assert!((round.height() - 40.0).abs() < 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_pressure_peak_stays_at_the_sample_with_a_flat_tip() {
|
||||
use kurbo::ParamCurveNearest;
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
let mut s = session();
|
||||
s.execute(
|
||||
"path.blob",
|
||||
&json!({"points": [[100, 100, 0.0], [200, 100, 1.0], [200, 200, 0.0]],
|
||||
"size": 10, "sizeMode": "pressure", "sizeVariation": 8,
|
||||
"roundness": 25, "merge": false}),
|
||||
)
|
||||
.unwrap();
|
||||
let outline = &paths(&s)[0].1;
|
||||
let near_peak = Point::new(200.0, 110.0);
|
||||
let past_peak = Point::new(200.0, 140.0);
|
||||
let distance = |p: Point| outline.to_bezpath().segments().map(|seg| seg.nearest(p, 0.01).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
|
||||
assert!(distance(near_peak) > distance(past_peak), "pressure must narrow after the corner sample");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_tip_zero_and_invalid_values_are_safe() {
|
||||
let mut s = session();
|
||||
s.execute("path.blob", &json!({"points": [[100, 100], [300, 100]], "size": 40, "roundness": 0})).unwrap();
|
||||
let b = paths(&s)[0].1.control_bounds().unwrap();
|
||||
assert!(b.height().is_finite() && b.height() > 0.0);
|
||||
for (key, value) in [("angle", json!("NaN")), ("roundness", json!("oops"))] {
|
||||
let mut p = json!({"points": [[100, 100], [300, 100]]});
|
||||
p[key] = value;
|
||||
assert!(s.execute("path.blob", &p).is_err(), "{key}: {p}");
|
||||
}
|
||||
s.execute("path.blob", &json!({"points": [[100, 200], [300, 200]], "angle": 1e300, "roundness": -1e300, "merge": false})).unwrap();
|
||||
assert!(paths(&s)[1].1.control_bounds().unwrap().height().is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_pressure_size_works_with_flat_tip() {
|
||||
let mut s = session();
|
||||
s.execute(
|
||||
"path.blob",
|
||||
&json!({"points": [[100, 100, 0], [200, 100, 1]], "size": 40, "sizeMode": "pressure", "sizeVariation": 15, "roundness": 25, "merge": false}),
|
||||
)
|
||||
.unwrap();
|
||||
let variable = paths(&s)[0].1.control_bounds().unwrap();
|
||||
let mut fixed = session();
|
||||
fixed.execute("path.blob", &json!({"points": [[100, 100], [200, 100]], "size": 40, "roundness": 25, "merge": false})).unwrap();
|
||||
let baseline = paths(&fixed)[0].1.control_bounds().unwrap();
|
||||
assert!(variable.height() > baseline.height(), "{} <= {}", variable.height(), baseline.height());
|
||||
assert!(variable.height() < 20.0, "flat tip height: {}", variable.height());
|
||||
}
|
||||
|
||||
fn path(s: &Session, id: NodeId) -> PathData {
|
||||
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
|
||||
}
|
||||
|
||||
@@ -1020,10 +1020,15 @@ fn mesh_expand_to_flat_pieces() {
|
||||
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
sel(&mut s, &[a]);
|
||||
s.execute("object.mesh.create", &json!({"rows": 1, "cols": 2})).unwrap();
|
||||
// The first patch shades from black to white; the second is one colour.
|
||||
for (index, color) in [(0, "#000000"), (3, "#000000"), (1, "#ffffff"), (2, "#ffffff"), (4, "#ffffff"), (5, "#ffffff")] {
|
||||
s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": index, "color": color})).unwrap();
|
||||
}
|
||||
s.execute("object.mesh.expand", &json!({})).unwrap();
|
||||
let g = node(&s, a);
|
||||
assert!(matches!(g.kind, NodeKind::Group { .. }));
|
||||
assert_eq!(g.children().unwrap().len(), 2 * 64);
|
||||
// 8 × 8 pieces for the shaded patch, one for the flat one.
|
||||
assert_eq!(g.children().unwrap().len(), 64 + 1);
|
||||
assert!(g.children().unwrap().iter().all(|c| c.id != NodeId(0)));
|
||||
}
|
||||
|
||||
@@ -1093,9 +1098,10 @@ fn live_objects_round_trip_through_native_format() {
|
||||
fn svg_export_expands_live_objects() {
|
||||
let s = live_doc();
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
// 4 blend objects + envelope content + 4 patches × 64 mesh pieces.
|
||||
// 4 blend objects + envelope content + a piece for each of the 4 mesh patches (the mesh is
|
||||
// white, which its highlight leaves white, so a patch is one piece rather than 64 alike).
|
||||
let paths = vectorcraft_testkit::svg_shapes(&svg);
|
||||
assert!(paths >= 4 + 1 + 256, "{paths}");
|
||||
assert!(paths >= 4 + 1 + 4, "{paths}");
|
||||
assert!(vectorcraft_svg::import(&svg).is_ok());
|
||||
}
|
||||
|
||||
|
||||
@@ -1813,6 +1813,48 @@ fn select_same_matches_compound_paths_on_their_own_paint() {
|
||||
assert_eq!(same(&mut s, outer, "select.same.shapeType"), vec![outer, inner]);
|
||||
}
|
||||
|
||||
/// Select > Same > Fill Color and Fill & Stroke from a Live Paint face select the faces of that
|
||||
/// Live Paint group with the same fill, and nothing outside it. From an object outside every Live
|
||||
/// Paint group, they select no face.
|
||||
#[test]
|
||||
fn select_same_fill_keeps_live_paint_faces_to_their_group() {
|
||||
let mut s = session();
|
||||
let live = |s: &mut Session, y: f64| {
|
||||
let a = rect(s, 0.0, y, 100.0, 100.0);
|
||||
let b = rect(s, 50.0, y, 100.0, 100.0);
|
||||
sel(s, &[a, b]);
|
||||
id_of(&s.execute("livePaint.make", &json!({})).unwrap())
|
||||
};
|
||||
let paint = |s: &mut Session, g: NodeId, x: f64, y: f64, hex: &str| {
|
||||
NodeId(s.execute("livePaint.fill", &json!({"group": g.0, "point": [x, y], "color": hex})).unwrap()["face"].as_u64().unwrap())
|
||||
};
|
||||
let g1 = live(&mut s, 0.0);
|
||||
let g2 = live(&mut s, 300.0);
|
||||
let [left, right] = [25.0, 125.0].map(|x| paint(&mut s, g1, x, 50.0, "#ff0000"));
|
||||
let middle = paint(&mut s, g1, 75.0, 50.0, "#0000ff");
|
||||
let other = paint(&mut s, g2, 25.0, 350.0, "#ff0000");
|
||||
let [r1, r2] = [400.0, 450.0].map(|x| rect(&mut s, x, 0.0, 10.0, 10.0));
|
||||
for r in [r1, r2] {
|
||||
fill(&mut s, r, "#ff0000");
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
|
||||
}
|
||||
let same = |s: &mut Session, from: NodeId, cmd: &str| {
|
||||
sel(s, &[from]);
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
let mut v = selected(s);
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
for cmd in ["select.same.fillColor", "select.same.fillAndStroke"] {
|
||||
let mut faces = vec![left, right];
|
||||
faces.sort();
|
||||
assert_eq!(same(&mut s, left, cmd), faces, "{cmd} from a face");
|
||||
assert_eq!(same(&mut s, middle, cmd), vec![middle], "{cmd} from the blue face");
|
||||
assert_eq!(same(&mut s, other, cmd), vec![other], "{cmd} from the other group's face");
|
||||
assert_eq!(same(&mut s, r1, cmd), vec![r1, r2], "{cmd} from a path outside");
|
||||
}
|
||||
}
|
||||
|
||||
/// Select by Color needs no object to start from: it matches fills and strokes as
|
||||
/// `document.inspect` reports them, `#rrggbb`.
|
||||
#[test]
|
||||
|
||||
@@ -159,6 +159,35 @@ fn eight_strips_match_the_gradient_samples() {
|
||||
assert!(matches!(selected(&s).appearance.fill_paint(), Paint::Gradient(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_a_group_keeps_its_hidden_and_locked_members_hidden_and_locked() {
|
||||
// A hidden stroked square with a gradient fill, a locked square with a pattern fill and a
|
||||
// plain square, grouped.
|
||||
let mut s = session();
|
||||
let pat = pattern_rect(&mut s, Rect::new(20.0, 100.0, 80.0, 160.0), false);
|
||||
let grad = gradient_rect(&mut s, Rect::new(20.0, 20.0, 80.0, 80.0), white_black(), true);
|
||||
let plain = NodeId(run(&mut s, "shape.rectangle", json!({"x": 100, "y": 20, "width": 40, "height": 40}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "select.set", json!({"ids": [pat.0, grad.0, plain.0]}));
|
||||
run(&mut s, "object.group", json!({}));
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(grad).unwrap().visible = false;
|
||||
d.node_mut(pat).unwrap().locked = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
run(&mut s, "object.expand", json!({}));
|
||||
let g = selected(&s);
|
||||
let ch = g.children().unwrap();
|
||||
assert_eq!(ch.len(), 3);
|
||||
assert!(!clip_parts(&ch[0]).1.is_empty() && ch[0].locked && ch[0].visible, "the pattern's tiles stay locked: {:?}", ch[0]);
|
||||
assert!(!ch[1].visible && !ch[1].locked, "the gradient square's art stays hidden: {:?}", ch[1]);
|
||||
assert!(ch[2].visible && !ch[2].locked);
|
||||
// Showing or unlocking the new art shows or unlocks all of it.
|
||||
for c in ch[..2].iter().flat_map(|c| c.children().unwrap().iter()) {
|
||||
c.walk(&mut |n| assert!(n.visible && !n.locked, "{n:?}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_radial_gradient_becomes_concentric_ellipses() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -122,6 +122,36 @@ fn every_stroke_is_outlined_in_paint_order() {
|
||||
assert_eq!(d.layers[0].children().unwrap()[0].appearance.items.len(), 3);
|
||||
}
|
||||
|
||||
/// The art that stands for an outlined path keeps the path's hidden, locked and knockout-shape
|
||||
/// states, so a hidden member of a selected group stays hidden.
|
||||
#[test]
|
||||
fn outlined_art_keeps_the_hidden_locked_and_knockout_shape_states() {
|
||||
let mut s = session();
|
||||
// A filled and stroked square (a fill copy and an outline) and a stroked line (one outline).
|
||||
let sq = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 100, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
let ln = line(&mut s, json!({"weight": 4}));
|
||||
s.execute("select.set", &json!({"ids": [sq.0, ln.0]})).unwrap();
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(sq).unwrap().visible = false;
|
||||
let l = d.node_mut(ln).unwrap();
|
||||
l.locked = true;
|
||||
l.knockout_shape = true;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let g = outline(&mut s);
|
||||
let ch = g.children().unwrap();
|
||||
assert_eq!(ch.len(), 2);
|
||||
let (sq, ln) = (&ch[0], &ch[1]);
|
||||
assert!(!sq.visible && !sq.locked, "the square's art stays hidden: {sq:?}");
|
||||
assert_eq!(sq.children().map(|c| c.len()), Some(2), "a fill copy and the outline");
|
||||
assert!(sq.children().unwrap().iter().all(|c| c.visible && !c.locked), "showing it shows all of it: {sq:?}");
|
||||
assert!(ln.visible && ln.locked && ln.knockout_shape, "the line's outline stays locked: {ln:?}");
|
||||
}
|
||||
|
||||
/// The live effect on a stroke item outlines that stroke (its own weight), not the top stroke.
|
||||
#[test]
|
||||
fn the_live_effect_outlines_the_stroke_it_sits_on() {
|
||||
|
||||
@@ -111,6 +111,238 @@ fn alt_drag_copies_and_the_copy_stays_attached() {
|
||||
assert!(s.doc().unwrap().doc.node(copy).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_moves_on_a_plane_axis() {
|
||||
let (mut s, id) = persp_session();
|
||||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
let start = bounds_of(&before).center();
|
||||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, &[from, to, to], Mods { shift: true, ..Default::default() });
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "shift move: {a:?} → {b:?}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||||
}
|
||||
|
||||
/// An attached rectangle with a horizontal edge close enough to a gridline to be pulled by snapping.
|
||||
fn near_gridline_session() -> (Session, NodeId) {
|
||||
let (mut s, id) = persp_session();
|
||||
let g = grid(&s);
|
||||
let b = bounds_of(&plane_pts(&s, id, Plane::Right, 0.0));
|
||||
let dy = (b.y0 / g.cell).round() * g.cell + g.cell * 0.1 - b.y0;
|
||||
let h = g.homography(Plane::Right).unwrap();
|
||||
let from = h.apply(b.center()).unwrap();
|
||||
let to = h.apply(b.center() + Vec2::new(0.0, dy)).unwrap();
|
||||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "snap": false})).unwrap();
|
||||
s.execute("perspective.grid.snap", &json!({"on": true})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn shift_move_near_gridline(diagonal: bool, copy: bool) {
|
||||
let (mut s, id) = near_gridline_session();
|
||||
s.select_tool("perspectiveSelection", ViewInfo::default()).unwrap();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
let start = bounds_of(&before).center();
|
||||
let raw = if diagonal { Vec2::new(40.0, 50.0) } else { Vec2::new(80.0, 20.0) };
|
||||
let from = h.apply(start).unwrap();
|
||||
let to = h.apply(start + raw).unwrap();
|
||||
drag(&mut s, &[from, to, to], Mods { shift: true, alt: copy, ..Default::default() });
|
||||
let target = if copy { *s.doc().unwrap().selection.objects.first().unwrap() } else { id };
|
||||
assert!(!copy || target != id);
|
||||
let after = plane_pts(&s, target, Plane::Right, 0.0);
|
||||
for (a, b) in before.iter().zip(after) {
|
||||
let d = b - *a;
|
||||
if diagonal {
|
||||
assert!((d.x - d.y).abs() < 1e-6, "diagonal move left its line: {d:?}");
|
||||
} else {
|
||||
assert!(d.y.abs() < 1e-6, "axis move left its line: {d:?}");
|
||||
}
|
||||
}
|
||||
if copy {
|
||||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_axis_move_stays_on_axis_with_grid_snap() {
|
||||
shift_move_near_gridline(false, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_axis_copy_stays_on_axis_with_grid_snap() {
|
||||
shift_move_near_gridline(false, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_diagonal_move_stays_on_diagonal_with_grid_snap() {
|
||||
shift_move_near_gridline(true, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_diagonal_copy_stays_on_diagonal_with_grid_snap() {
|
||||
shift_move_near_gridline(true, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_move_command_stays_on_axis_with_grid_snap() {
|
||||
let (mut s, id) = near_gridline_session();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
let start = bounds_of(&before).center();
|
||||
let from = h.apply(start).unwrap();
|
||||
let to = h.apply(start + Vec2::new(80.0, 0.0)).unwrap();
|
||||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true})).unwrap();
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||||
assert!((b.y - a.y).abs() < 1e-6, "constrained command left axis: {a:?} to {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagonal_shift_snap_lands_an_edge_on_a_gridline() {
|
||||
let (mut s, id) = near_gridline_session();
|
||||
let g = grid(&s);
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let b = bounds_of(&before);
|
||||
let (raw, correction) = (1..200)
|
||||
.map(|n| {
|
||||
let raw = Vec2::new(n as f64 * g.cell * 0.05, n as f64 * g.cell * 0.05);
|
||||
(raw, g.snap_offset(b, raw) - raw)
|
||||
})
|
||||
.find(|(_, fix)| fix.x.abs() < 1e-9 && fix.y.abs() > 1e-6)
|
||||
.unwrap();
|
||||
let h = g.homography(Plane::Right).unwrap();
|
||||
let start = b.center();
|
||||
let from = h.apply(start).unwrap();
|
||||
let to = h.apply(start + raw).unwrap();
|
||||
s.execute("perspective.move", &json!({"ids": [id.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true})).unwrap();
|
||||
let after = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let delta = after[0] - before[0];
|
||||
assert!((delta.x - delta.y).abs() < 1e-6, "{delta:?}");
|
||||
let edge = [b.y0 + delta.y, b.y1 + delta.y];
|
||||
assert!(edge.iter().any(|y| ((y / g.cell).round() * g.cell - y).abs() < 1e-6), "{edge:?}, correction {correction:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_move_uses_each_objects_plane() {
|
||||
let (mut s, right) = persp_session();
|
||||
let left_result = s.execute("shape.rectangle", &json!({"x": 240, "y": 380, "width": 60, "height": 60})).unwrap();
|
||||
let left = NodeId(left_result["id"].as_u64().unwrap());
|
||||
s.execute("perspective.attach", &json!({"ids": [left.0], "plane": "left"})).unwrap();
|
||||
let right_before = plane_pts(&s, right, Plane::Right, 0.0);
|
||||
let left_before = plane_pts(&s, left, Plane::Left, 0.0);
|
||||
let from = grid(&s).homography(Plane::Right).unwrap().apply(bounds_of(&right_before).center()).unwrap();
|
||||
let to = from + Vec2::new(70.0, 30.0);
|
||||
let g = grid(&s);
|
||||
for plane in [Plane::Right, Plane::Left] {
|
||||
let raw = g.plane_delta(plane, 0.0, from, to).unwrap();
|
||||
assert!(raw.x.abs() > 1e-3 && raw.y.abs() > 1e-3 && (raw.x.abs() - raw.y.abs()).abs() > 1e-3, "raw {plane:?}: {raw:?}");
|
||||
}
|
||||
s.execute("perspective.move", &json!({"ids": [right.0, left.0], "from": [from.x, from.y], "to": [to.x, to.y], "constrain": true, "snap": false}))
|
||||
.unwrap();
|
||||
for (id, plane, before) in [(right, Plane::Right, right_before), (left, Plane::Left, left_before)] {
|
||||
let after = plane_pts(&s, id, plane, 0.0);
|
||||
let delta = after[0] - before[0];
|
||||
assert!(delta.x.abs() < 1e-6 || delta.y.abs() < 1e-6 || (delta.x.abs() - delta.y.abs()).abs() < 1e-6, "{plane:?}: {delta:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constrained_move_rejects_non_boolean_switch() {
|
||||
let (mut s, id) = near_gridline_session();
|
||||
for direction in [json!([0.0, 0.0]), json!([1.0]), json!(1), json!("true")] {
|
||||
let error = s.execute("perspective.move", &json!({"ids": [id.0], "from": [1.0, 1.0], "to": [2.0, 2.0], "constrain": direction})).unwrap_err();
|
||||
assert!(error.to_string().contains("`constrain` must be true or false"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_drag_keeps_its_displacement_after_a_plane_key() {
|
||||
let (mut s, id) = persp_session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("perspectiveSelection", v).unwrap();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
let start = bounds_of(&before).center();
|
||||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
|
||||
s.tool_key(ToolKey::Digit(2), shift, v).unwrap();
|
||||
assert_eq!(grid(&s).plane, Plane::Ground);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y).with_mods(shift), v).unwrap();
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, id, Plane::Right, 0.0)) {
|
||||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "plane key: {a:?} → {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loose_art_move_survives_clearing_the_active_plane() {
|
||||
for (drag_after_key, switch_before_drag) in [(false, false), (true, false), (false, true)] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600, "created": null})).unwrap();
|
||||
s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap();
|
||||
s.execute("perspective.grid.snap", &json!({"on": false})).unwrap();
|
||||
s.execute("perspective.plane.set", &json!({"plane": "right"})).unwrap();
|
||||
let result = s.execute("shape.rectangle", &json!({"x": 450, "y": 380, "width": 60, "height": 60})).unwrap();
|
||||
let id = NodeId(result["id"].as_u64().unwrap());
|
||||
assert_eq!(attached(&s, id), None);
|
||||
let before = s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap();
|
||||
let (from, to) = (before.center(), before.center() + Vec2::new(30.0, 15.0));
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("perspectiveSelection", v).unwrap();
|
||||
let undo_before = undo_len(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.x, from.y), v).unwrap();
|
||||
if switch_before_drag {
|
||||
s.tool_key(ToolKey::Digit(2), Mods::default(), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x, to.y), v).unwrap();
|
||||
s.tool_key(ToolKey::Digit(4), Mods::default(), v).unwrap();
|
||||
assert_eq!(grid(&s).plane, Plane::None);
|
||||
if drag_after_key {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.x + 10.0, to.y), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.x, to.y), v).unwrap();
|
||||
assert_eq!(attached(&s, id), Some((if switch_before_drag { Plane::Ground } else { Plane::Right }, 0.0)));
|
||||
assert_ne!(s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(), before);
|
||||
assert_eq!(undo_len(&s), undo_before + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_alt_shift_drag_copies_on_a_plane_axis() {
|
||||
let (mut s, id) = persp_session();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
s.select_tool("perspectiveSelection", v).unwrap();
|
||||
let before = plane_pts(&s, id, Plane::Right, 0.0);
|
||||
let count = s.doc().unwrap().doc.node_count();
|
||||
let h = grid(&s).homography(Plane::Right).unwrap();
|
||||
let start = bounds_of(&before).center();
|
||||
let (from, to) = (h.apply(start).unwrap(), h.apply(start + Vec2::new(80.0, 20.0)).unwrap());
|
||||
let n = undo_len(&s);
|
||||
drag(&mut s, &[from, to, to], Mods { cmd: true, alt: true, shift: true, ..Default::default() });
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), count + 1);
|
||||
let copy = *s.doc().unwrap().selection.objects.first().unwrap();
|
||||
assert_ne!(copy, id);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![copy]);
|
||||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||||
assert_eq!(attached(&s, copy), Some((Plane::Right, 0.0)));
|
||||
for (a, b) in before.iter().zip(plane_pts(&s, copy, Plane::Right, 0.0)) {
|
||||
assert!((b - *a - Vec2::new(80.0, 0.0)).hypot() < 1e-6, "shift copy: {a:?} → {b:?}");
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node_count(), count);
|
||||
assert_eq!(plane_pts(&s, id, Plane::Right, 0.0), before);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_alt_drag_copies_on_the_perspective_plane() {
|
||||
let (mut s, id) = persp_session();
|
||||
|
||||
@@ -409,3 +409,49 @@ fn a_gradient_exports_at_the_resolution_the_file_opens_with() {
|
||||
assert_eq!(sizes, [(10, 10)], "saved as {saved}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterize in a CMYK document's colour model makes a CMYK image of the art's inks (#4882): the
|
||||
/// image says CMYK, CMYK colours keep their numbers, a CMYK export keeps them, and a transparent
|
||||
/// background becomes the image's opacity mask.
|
||||
#[test]
|
||||
fn rasterize_to_cmyk_makes_a_cmyk_image() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "colorMode": "cmyk"})).unwrap();
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap());
|
||||
s.execute("paint.setFill", &json!({"color": {"c": 0.2, "m": 0.4, "y": 0.6, "k": 0.1}, "ids": [r.0]})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true, "ids": [r.0]})).unwrap();
|
||||
let want = [51u8, 102, 153, 26];
|
||||
let near = |p: &[u8]| p.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 1);
|
||||
let rasterize = |s: &mut Session, background: &str| {
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
let p = json!({"ppi": 72, "colorModel": "cmyk", "background": background, "addAround": 5});
|
||||
let img = id(&s.execute("object.rasterize", &p).unwrap());
|
||||
assert_eq!(s.execute("image.info", &json!({"id": img.0})).unwrap()["colorMode"], "CMYK");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let n = d.node(img).unwrap().clone();
|
||||
let NodeKind::Image(im) = &n.kind else { panic!("an image") };
|
||||
let inks = d.images[&im.key].cmyk().expect("a CMYK image");
|
||||
assert_eq!((inks.width, inks.height), (50, 50));
|
||||
let at = |x: u32, y: u32| inks.data[((y * 50 + x) * 4) as usize..][..4].to_vec();
|
||||
assert!(near(&at(25, 25)), "the fill's inks: {:?}", at(25, 25));
|
||||
assert_eq!(at(1, 1), [0; 4], "no ink around the art");
|
||||
n
|
||||
};
|
||||
let opaque = rasterize(&mut s, "white");
|
||||
assert!(opaque.extras().mask.is_none(), "opaque: no mask");
|
||||
// A CMYK TIFF export keeps the image's inks.
|
||||
let tiff = s.execute("document.export", &json!({"format": "tiff", "colorModel": "cmyk", "ppi": 72})).unwrap();
|
||||
let tiff = vectorcraft_format::base64_decode(tiff["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let inks = vectorcraft_doc::ImageBlob::new("image/tiff", tiff).cmyk().expect("a CMYK TIFF");
|
||||
let at = ((30 * inks.width + 30) * 4) as usize;
|
||||
assert!(near(&inks.data[at..at + 4]), "exported: {:?}", &inks.data[at..at + 4]);
|
||||
// Transparent: the art's coverage is the image's opacity mask.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let masked = rasterize(&mut s, "transparent");
|
||||
let mask = masked.extras().mask.as_deref().expect("an opacity mask").clone();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let NodeKind::Image(m) = &mask.art.kind else { panic!("the mask is an image") };
|
||||
let alpha = image::load_from_memory(&d.images[&m.key].bytes).unwrap().to_luma8();
|
||||
assert_eq!((alpha.get_pixel(1, 1).0, alpha.get_pixel(25, 25).0), ([0], [255]));
|
||||
assert!(mask.clip);
|
||||
}
|
||||
|
||||
@@ -637,3 +637,22 @@ fn gradient_libraries_are_listed_and_gradient_swatches_rename_and_take_new_gradi
|
||||
run(&mut s, "swatch.edit", json!({"name": "Red", "paint": {"color": "#00ff00"}}));
|
||||
assert_eq!(doc(&s).swatch("Red").unwrap().paint.color().map(|c| c.to_hex()), Some("#00ff00".into()));
|
||||
}
|
||||
|
||||
/// #3602: a library titled with a Windows device name is saved under a name that is not one.
|
||||
#[test]
|
||||
fn a_library_file_name_is_never_a_windows_device_name() {
|
||||
use crate::cmd::swatchlib::library_file_name;
|
||||
for (title, ext) in
|
||||
[("CON", "json"), ("nul", "json"), ("Com1", "acb"), ("LPT9", "ase"), ("aux.", "json"), ("CON.txt", "json"), ("nul.tar.gz", "acb")]
|
||||
{
|
||||
let file = library_file_name(title, ext);
|
||||
assert!(!crate::file_access::is_device_name(&file), "{title}: {file}");
|
||||
assert!(
|
||||
file.ends_with(&format!(".{ext}"))
|
||||
&& file.to_ascii_lowercase().starts_with(&title.split('.').next().unwrap_or_default().to_ascii_lowercase()),
|
||||
"{title}: {file}"
|
||||
);
|
||||
}
|
||||
assert_eq!(library_file_name("Console", "json"), "Console.json");
|
||||
assert_eq!(library_file_name("COM10", "json"), "COM10.json");
|
||||
}
|
||||
|
||||
@@ -14,6 +14,21 @@ fn select(s: &mut Session, tool: &str) {
|
||||
s.select_tool(tool, ViewInfo::default()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blob_tip_options_report_and_persist() {
|
||||
let mut s = session();
|
||||
select(&mut s, "blobBrush");
|
||||
assert_eq!((s.tool_options()["angle"].as_f64(), s.tool_options()["roundness"].as_f64()), (Some(0.0), Some(100.0)));
|
||||
s.set_tool_option("angle", &json!(45));
|
||||
s.set_tool_option("roundness", &json!(0));
|
||||
select(&mut s, "selection");
|
||||
let saved = serde_json::to_string(&s.prefs).unwrap();
|
||||
let mut next = session();
|
||||
next.apply_prefs(serde_json::from_str(&saved).unwrap());
|
||||
select(&mut next, "blobBrush");
|
||||
assert_eq!((next.tool_options()["angle"].as_f64(), next.tool_options()["roundness"].as_f64()), (Some(45.0), Some(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_survive_a_tool_switch() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1103,6 +1103,35 @@ fn datasets_csv_keeps_line_breaks_in_quoted_cells() {
|
||||
assert!(s.execute("dataset.import", &json!({"csv": "name,Name\nX,\"open"})).is_err(), "unclosed quote");
|
||||
}
|
||||
|
||||
/// An empty text value is not a missing value: it comes back from the exported CSV as an empty
|
||||
/// text, so selecting the data set still clears the bound text.
|
||||
#[test]
|
||||
fn datasets_csv_keeps_an_empty_text_value() {
|
||||
let mut s = session();
|
||||
let t = text(&mut s, "One");
|
||||
s.execute("variable.define", &json!({"name": "Name", "kind": "text"})).unwrap();
|
||||
s.execute("variable.bind", &json!({"variable": "Name", "ids": [t.0]})).unwrap();
|
||||
s.execute("dataset.new", &json!({"name": "A", "values": {"Name": ""}})).unwrap();
|
||||
let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
|
||||
s.execute("dataset.delete", &json!({"names": ["A"]})).unwrap();
|
||||
s.execute("dataset.import", &json!({"csv": csv})).unwrap();
|
||||
let value = s.doc().unwrap().doc.variables.dataset("A").and_then(|d| d.values.get("Name")).cloned();
|
||||
assert_eq!(value, Some(vectorcraft_doc::DataValue::Text(String::new())));
|
||||
s.execute("dataset.import", &json!({"csv": "name,Name\nB,\nC,\"\"\n"})).unwrap();
|
||||
let value = |name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Name")).cloned();
|
||||
assert_eq!(value("C"), Some(vectorcraft_doc::DataValue::Text(String::new())));
|
||||
// Compatibility rule for files written before empty text was quoted: a bare empty cell stays "no value".
|
||||
assert_eq!(value("B"), None);
|
||||
// A data set with no value for the variable exports a bare cell and stays without a value.
|
||||
s.execute("dataset.new", &json!({"name": "D", "values": {}})).unwrap();
|
||||
let csv = s.execute("dataset.export", &json!({})).unwrap()["csv"].as_str().unwrap().to_string();
|
||||
assert!(csv.contains("\nD,\n"), "{csv:?}");
|
||||
s.execute("dataset.delete", &json!({"names": ["D"]})).unwrap();
|
||||
s.execute("dataset.import", &json!({"csv": csv})).unwrap();
|
||||
let value = |name: &str| s.doc().unwrap().doc.variables.dataset(name).and_then(|d| d.values.get("Name")).cloned();
|
||||
assert_eq!(value("D"), None);
|
||||
}
|
||||
|
||||
/// A quoted text cell keeps its leading and trailing spaces, so a padded value comes back from
|
||||
/// the exported CSV as it went out; an unquoted cell, and spaces after a closing quote, are trimmed.
|
||||
#[test]
|
||||
|
||||
@@ -457,6 +457,11 @@ impl Session {
|
||||
// 1–4 pick the active perspective plane while the grid is shown.
|
||||
if let Some(plane) = self.plane_key(key) {
|
||||
self.execute("perspective.plane.set", &serde_json::json!({ "plane": plane.id() }))?;
|
||||
if self.tool_id() == "perspectiveSelection" {
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
|
||||
self.take_tool_panic()?;
|
||||
return self.apply_actions(acts);
|
||||
}
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
|
||||
|
||||
@@ -579,6 +579,15 @@ fn structured_junk() {
|
||||
("addon.pathtools.movePointsTogether", json!({"enabled": true})),
|
||||
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})),
|
||||
("addon.pathtools.movePointsTogether", json!({"enabled": false})),
|
||||
// Arrange Tools add-on.
|
||||
("addon.arrange.grid", json!({"columns": u64::MAX, "gapX": 1e6, "gapY": -1e6, "centerOnArtboard": true})),
|
||||
("addon.arrange.grid", json!({"layout": "row", "gapX": f64::MAX, "columns": -1})),
|
||||
("addon.arrange.grid", json!({"layout": "column", "gapX": -1e6})),
|
||||
("addon.arrange.swap", json!({"size": true, "scaleStrokes": true})),
|
||||
("addon.arrange.replace", json!({"fit": "proportional", "keep": true})),
|
||||
("addon.arrange.replace", json!({"fit": "bounds", "scaleStrokes": true})),
|
||||
("addon.arrange.size", json!({"width": 1e6, "height": 0.001, "reference": 8, "scaleStrokes": true})),
|
||||
("addon.arrange.size", json!({"width": 0.001, "proportional": true, "reference": u64::MAX})),
|
||||
// Smart Pen add-on.
|
||||
("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})),
|
||||
("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})),
|
||||
@@ -601,6 +610,8 @@ fn structured_junk() {
|
||||
("addon.cleanup.breakText", json!({"by": "words", "ids": "all"})),
|
||||
("addon.cleanup.combineText", json!({"ids": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]})),
|
||||
("addon.cleanup.combineText", json!({"ids": [-1, 1e308]})),
|
||||
("addon.cleanup.joinText", json!({"ids": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]})),
|
||||
("addon.cleanup.joinText", json!({"ids": [-1, 1e308, "x"]})),
|
||||
("addon.cleanup.removeBreaks", json!({"kind": "paragraph", "ids": [1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})),
|
||||
("addon.cleanup.removeBreaks", json!({"kind": 3})),
|
||||
("addon.cleanup.removeBreaks", json!({"kind": "line", "ids": null})),
|
||||
|
||||
@@ -24,6 +24,7 @@ impl<'a> Reader<'a> {
|
||||
base,
|
||||
clips: Vec::new(),
|
||||
compound_members: false,
|
||||
state: None,
|
||||
});
|
||||
self.after_object = false;
|
||||
Ok(())
|
||||
@@ -260,13 +261,16 @@ impl<'a> Reader<'a> {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(f) = self.frames.pop() else { return Ok(()) };
|
||||
let Frame { kind, children, base, hidden, gs, after, mesh_ended, .. } = f;
|
||||
let Frame { kind, children, base, hidden, gs, after, mesh_ended, state, .. } = f;
|
||||
let Kind::Obj(mut o) = kind else { return Ok(()) };
|
||||
obj::close(&mut o, &mut self.stack, base);
|
||||
self.obj_hidden = hidden;
|
||||
// Art made inside a dictionary (type's paths, a mask's art) isn't kept, and doesn't end what
|
||||
// follows the object the dictionary is about: a mesh's state comes after its mask.
|
||||
self.gs = gs;
|
||||
if let Some(state) = state {
|
||||
self.keep_state(state);
|
||||
}
|
||||
self.after_object = after;
|
||||
self.mesh_ended = mesh_ended;
|
||||
if matches!(self.frames.last(), Some(Frame { kind: Kind::Obj(_), .. })) {
|
||||
@@ -334,23 +338,26 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the object just read when it is an envelope's own mesh: a group on `%_` lines holding
|
||||
/// only gradient meshes. Whether it was dropped.
|
||||
pub(super) fn drop_envelope_mesh(&mut self) -> bool {
|
||||
fn only_meshes(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Mesh(_) => true,
|
||||
NodeKind::Group { children, .. } => !children.is_empty() && children.iter().all(|c| only_meshes(c)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Drop the object just read when it is an envelope's own: a group on `%_` lines, before the
|
||||
/// envelope's dictionary. It holds the mesh that shapes the envelope, or, while the envelope's
|
||||
/// contents are being edited, those contents (the mesh then is in the dictionary); either way the
|
||||
/// distorted art follows on plain lines. The source text in the contents stays, hidden, as the
|
||||
/// dictionary's does. Whether it was dropped.
|
||||
pub(super) fn drop_envelope_art(&mut self) -> bool {
|
||||
let Some(n) = self.last_object() else { return false };
|
||||
let id = n.id;
|
||||
if !matches!(n.kind, NodeKind::Group { .. }) || !only_meshes(n) || !self.hidden_ids.contains(&id) {
|
||||
if !matches!(n.kind, NodeKind::Group { .. }) || !self.hidden_ids.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
if let Some(f) = self.frames.last_mut() {
|
||||
f.children.pop();
|
||||
let mut slots = vec![];
|
||||
if let Some(f) = self.frames.last_mut()
|
||||
&& let Some(group) = f.children.pop()
|
||||
{
|
||||
envelope_text_slots(&group, &mut slots);
|
||||
for mut slot in slots {
|
||||
slot.visible = false;
|
||||
f.children.push(slot);
|
||||
}
|
||||
}
|
||||
self.hidden_ids.remove(&id);
|
||||
self.after_object = false;
|
||||
|
||||
@@ -229,9 +229,10 @@ impl<'a> Reader<'a> {
|
||||
self.blend_roles.insert(id, (kind, o.text("ShapeBlendPathID")));
|
||||
}
|
||||
// An envelope (a distortion, `AIDeformStyle` / `DeformValue`) is written on `%_` lines as
|
||||
// a group holding the mesh that shapes it; the distorted art the file's PDF part draws
|
||||
// follows on plain lines. That mesh isn't art, so it goes (#2405).
|
||||
if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_mesh() {
|
||||
// a group holding the mesh that shapes it (or its contents, while they are being edited);
|
||||
// the distorted art the file's PDF part draws follows on plain lines. That group isn't
|
||||
// art, so it goes (#2405).
|
||||
if (o.get("AIDeformStyle").is_some() || o.get("DeformValue").is_some()) && self.drop_envelope_art() {
|
||||
return;
|
||||
}
|
||||
if let Some(mask) = o.obj("AI9_Mask").or_else(|| o.obj("Mask")) {
|
||||
|
||||
@@ -446,6 +446,28 @@ impl<'a> Reader<'a> {
|
||||
// art moves on, so a path's state before its paint isn't read as its layer's.
|
||||
self.layer_state = in_layer.then_some((gs.opacity, gs.blend, gs.isolate, gs.knockout));
|
||||
}
|
||||
self.note_state();
|
||||
}
|
||||
|
||||
/// Inside a dictionary in the art (a text's frame, a mask's art), the transparency just set
|
||||
/// stays after the dictionary: the writer goes on from it, so the objects after an area text
|
||||
/// whose frame sets one have no `Xy` of their own.
|
||||
fn note_state(&mut self) {
|
||||
let gs = &self.gs;
|
||||
let state = (gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape);
|
||||
if self.frames.iter().any(|f| matches!(f.kind, Kind::Layer(_)))
|
||||
&& let Some(f) = self.frames.iter_mut().rev().find(|f| matches!(f.kind, Kind::Obj(_)))
|
||||
{
|
||||
f.state = Some(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// A dictionary's art set transparency `state` (see [`Self::note_state`]): it is the current
|
||||
/// one now, and the enclosing dictionary's.
|
||||
pub(super) fn keep_state(&mut self, state: (f32, BlendMode, bool, Knockout, bool)) {
|
||||
let gs = &mut self.gs;
|
||||
(gs.opacity, gs.blend, gs.isolate, gs.knockout, gs.knockout_shape) = state;
|
||||
self.note_state();
|
||||
}
|
||||
|
||||
/// `n (style) XW` after an object: `1 (style)` after the object written on `%_` lines, its
|
||||
|
||||
@@ -153,6 +153,8 @@ pub(super) struct Frame {
|
||||
pub(super) clips: Vec<usize>,
|
||||
/// A compound member group explicitly declares its subpaths' winding with `D`.
|
||||
pub(super) compound_members: bool,
|
||||
/// A dictionary in the art: the transparency (`Xy`) its art set last, which stays after it.
|
||||
pub(super) state: Option<(f32, BlendMode, bool, Knockout, bool)>,
|
||||
}
|
||||
|
||||
/// What a reader of a pattern's definition has of its head, `(name) llx lly urx ury`: the operands
|
||||
|
||||
@@ -508,6 +508,21 @@ fn a_masked_gradient_mesh_takes_the_transparency_written_after_its_mask() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transparency_set_in_a_dictionary_s_art_stays_after_it() {
|
||||
// GUARDS: an area text's frame (its `/ConfiningPath` art) sets its own transparency; the objects
|
||||
// after the text, with no `Xy` of their own, draw with it. Read as the dictionary's alone, they
|
||||
// drew with the Lighten of the path before the text and vanished on a light background.
|
||||
let text = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n0 Ap\n0 1 0 0 0 Xy\n0 w\n100 0 m\n100 10 L\nn\nX+\n\
|
||||
; /ConfiningPath ,\n2 /TextAntialiasing ,\n;\n";
|
||||
let body = format!("9 0.5 0 0 0 Xy\n{RED}{}f\n{text}{RED}{}f\n", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0));
|
||||
let a = art(&read_ok(&layer(&body)).doc);
|
||||
let paths: Vec<_> = a.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).collect();
|
||||
assert_eq!(paths.len(), 2, "{a:#?}");
|
||||
assert_eq!((paths[0].opacity, paths[0].blend), (0.5, BlendMode::Lighten), "the state before the text");
|
||||
assert_eq!((paths[1].opacity, paths[1].blend), (1.0, BlendMode::Normal), "the state the text's art set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients() {
|
||||
let defs = "%AI5_BeginGradient: (G)\n(G) 1 2 Bd\n[\n<00FF>\n0\n0 0 0 0 1 0 0 2 1 6 %_BS\n\
|
||||
@@ -1672,6 +1687,33 @@ fn an_envelope_mesh_is_not_read_as_art() {
|
||||
assert!(art(&doc).iter().any(|n| has_mesh(n)), "{:#?}", art(&doc));
|
||||
}
|
||||
|
||||
/// An envelope whose contents are being edited is written the other way round: its contents, a
|
||||
/// group on `%_` lines, then its dictionary, which holds the mesh that shapes them. The contents
|
||||
/// aren't drawn as they are, only the distorted art on the plain lines after them.
|
||||
#[test]
|
||||
fn an_envelope_s_contents_being_edited_are_not_read_as_art() {
|
||||
let corner = |x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
|
||||
format!("%_[0.82 0.83 0.83 1 {x} {y} {} {} 1 {} {} 1] /N X#\n", next.0, next.1, prev.0, prev.1)
|
||||
};
|
||||
let mesh = format!(
|
||||
"%_/Mesh X!\n%_(DeviceRGB) /CS X#\n%_[0 0] /P X#\n{}{}{}{}%_0 /E X#\n%_/End X!\n%_0 0 Xd\n%_6 () XW\n",
|
||||
corner(0, 0, (0, 10), (10, 0)),
|
||||
corner(0, 40, (10, 40), (0, 30)),
|
||||
corner(40, 40, (40, 30), (30, 40)),
|
||||
corner(40, 0, (30, 0), (40, 10)),
|
||||
);
|
||||
let contents: String = rect_path(0.0, 0.0, 80.0, 20.0).lines().map(|l| format!("%_{l}\n")).collect();
|
||||
let body = format!(
|
||||
"%_u\n%_u\n%_0 0 1 0 0 0 1 Xa\n{contents}%_f\n%_U\n%_9 () XW\n%_U\n%_/ArtDictionary :\n%_1 /Bool (AIDeformEditModeKey) ,\n\
|
||||
%_3 /Int (AIDeformStyle) ,\n%_X=\n{mesh}%_X+\n%_ (AIDeformArtKey) ,\n%_;\n%_\n%_9 () XW\n{RED}{}f\n",
|
||||
rect_path(0.0, 0.0, 40.0, 40.0)
|
||||
);
|
||||
let doc = read_ok(&layer(&body)).doc;
|
||||
let a = art(&doc);
|
||||
assert_eq!(a.len(), 1, "{a:#?}");
|
||||
assert_eq!(fill(&a[0]).color(), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_mesh_reads_as_a_patch() {
|
||||
let corner = |rgb: &str, x: i32, y: i32, next: (i32, i32), prev: (i32, i32)| {
|
||||
|
||||
+121
-12
@@ -235,6 +235,11 @@ fn read(bytes: &[u8]) -> Option<Val> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most character styles read of a text document, and most styles a run's style is followed
|
||||
/// through to the ones they are based on.
|
||||
const MAX_SHEETS: usize = 4096;
|
||||
const MAX_SHEETS_BASED_ON: usize = 16;
|
||||
|
||||
/// Most frames read of one story, and most path segments of a frame.
|
||||
const MAX_FRAMES: usize = 4096;
|
||||
const MAX_SEGMENTS: usize = 1 << 16;
|
||||
@@ -309,6 +314,9 @@ pub(super) struct Texts {
|
||||
/// The character and paragraph styles a story starts from.
|
||||
defaults: Val,
|
||||
para_defaults: Val,
|
||||
/// The document's character styles (`[Normal Character Style]` first), each a name, the
|
||||
/// character style it is based on (key 5, an index into these) and its attributes (key 6).
|
||||
char_sheets: Vec<Val>,
|
||||
frames: Vec<Val>,
|
||||
}
|
||||
|
||||
@@ -352,6 +360,16 @@ impl Texts {
|
||||
fonts,
|
||||
defaults: main.get("2")?.clone(),
|
||||
para_defaults: main.get("3").cloned().unwrap_or(Val::Dict(BTreeMap::new())),
|
||||
char_sheets: doc
|
||||
.get("0")?
|
||||
.get("5")
|
||||
.and_then(|s| s.get("0"))
|
||||
.map(Val::list)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.take(MAX_SHEETS)
|
||||
.map(|s| s.get("0").cloned().unwrap_or(Val::Dict(BTreeMap::new())))
|
||||
.collect(),
|
||||
frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(),
|
||||
})
|
||||
}
|
||||
@@ -440,8 +458,7 @@ impl Texts {
|
||||
let mut runs = vec![];
|
||||
for run in s.get("0")?.get("6").map(|p| p.get("0").map(Val::list).unwrap_or_default()).unwrap_or_default() {
|
||||
let n = run.get("1").and_then(Val::num)? as usize;
|
||||
let props = run.get("0").and_then(|r| r.get("0")).and_then(|r| r.get("6"));
|
||||
runs.push((n, self.style(props)));
|
||||
runs.push((n, self.style(run.get("0").and_then(|r| r.get("0")))));
|
||||
}
|
||||
if runs.is_empty() {
|
||||
runs.push((text.chars().count(), self.style(None)));
|
||||
@@ -465,9 +482,19 @@ impl Texts {
|
||||
}
|
||||
}
|
||||
|
||||
/// A character style: its own keys, else the document's.
|
||||
fn style(&self, s: Option<&Val>) -> Style {
|
||||
let pick = |k: &str| s.and_then(|s| s.get(k)).or_else(|| self.defaults.get(k));
|
||||
/// A run's character style (`run`: its attributes, key 6, and the character style it is based
|
||||
/// on, key 5): its own keys, else those of its character style and the ones that is based on,
|
||||
/// else the document's.
|
||||
fn style(&self, run: Option<&Val>) -> Style {
|
||||
// The run's attributes, then its character style's, then the styles each is based on.
|
||||
let mut chain = vec![];
|
||||
let mut at = run;
|
||||
for _ in 0..=MAX_SHEETS_BASED_ON {
|
||||
let Some(style) = at else { break };
|
||||
chain.extend(style.get("6"));
|
||||
at = style.get("5").and_then(Val::num).filter(|i| i.is_finite() && *i >= 0.0).and_then(|i| self.char_sheets.get(i as usize));
|
||||
}
|
||||
let pick = |k: &str| chain.iter().find_map(|s| s.get(k)).or_else(|| self.defaults.get(k));
|
||||
let num = |k: &str| pick(k).and_then(Val::num);
|
||||
let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false)));
|
||||
let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned();
|
||||
@@ -776,6 +803,35 @@ impl Story {
|
||||
!self.lines.is_empty() && vectorcraft_text::fit_first_line(vectorcraft_text::FontDb::global(), t)
|
||||
}
|
||||
|
||||
/// Area type (`node`, the story's first frame as [`Self::node`] makes it with `template` and
|
||||
/// `to_doc`) whose first line the file lays out higher or lower than it is laid out here, as its
|
||||
/// page draws it: its first baseline is fixed where the file has it. The font's ascent below the
|
||||
/// frame's top, the first baseline here, isn't where the app that wrote the file puts it for
|
||||
/// every font. Whether it was.
|
||||
pub(super) fn place_first_line(&self, node: &mut Node, template: (f64, f64), to_doc: Affine) -> bool {
|
||||
// Closer than this (points of the document) and the line is where the file has it.
|
||||
const SAME: f64 = 0.5;
|
||||
let NodeKind::Text(t) = &mut node.kind else { return false };
|
||||
if !matches!(t.kind, TextKind::Area { .. }) || t.area.vertical_align != vectorcraft_doc::VerticalAlign::Top {
|
||||
return false;
|
||||
}
|
||||
let Some((_, (x, y))) = self.line_starts(template).into_iter().next() else { return false };
|
||||
let want = (to_doc * Point::new(x, y)).y;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// A frame too short for the line lays nothing out: as the line needs it (see `fit_first_line`).
|
||||
let mut probe = TextObject::clone(t);
|
||||
vectorcraft_text::fit_first_line(db, &mut probe);
|
||||
let laid = vectorcraft_text::layout(db, &probe);
|
||||
let Some(first) = laid.lines.first().filter(|l| l.region == 0) else { return false };
|
||||
let min = first.first_baseline + (want - first.baseline);
|
||||
if (want - first.baseline).abs() <= SAME || !min.is_finite() || min <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
t.area.first_baseline = vectorcraft_doc::FirstBaseline::Fixed;
|
||||
t.area.first_baseline_min = min;
|
||||
true
|
||||
}
|
||||
|
||||
/// The leading the file's own line positions imply: the distance from one line to the next, less
|
||||
/// the space between paragraphs that is part of it (a paragraph's space after and the next one's
|
||||
/// space before; a layout's lines are where that space has put them, and the text object adds it
|
||||
@@ -899,8 +955,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs_swap_left_and_right_alignment() {
|
||||
let texts =
|
||||
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] };
|
||||
let texts = Texts {
|
||||
stories: vec![],
|
||||
fonts: vec![],
|
||||
defaults: Val::Dict(BTreeMap::new()),
|
||||
para_defaults: read(b"/33 1").unwrap(),
|
||||
char_sheets: vec![],
|
||||
frames: vec![],
|
||||
};
|
||||
let justify = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).justify;
|
||||
// Right to left from the document's defaults, or the paragraph's own.
|
||||
assert_eq!(justify(b"/0 0"), Justify::Right);
|
||||
@@ -915,9 +977,15 @@ mod tests {
|
||||
/// dimension's is) lies in a tall box, not a wide one.
|
||||
#[test]
|
||||
fn point_type_lies_along_its_baseline() {
|
||||
let para =
|
||||
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: Val::Dict(BTreeMap::new()), frames: vec![] }
|
||||
.para(None);
|
||||
let para = Texts {
|
||||
stories: vec![],
|
||||
fonts: vec![],
|
||||
defaults: Val::Dict(BTreeMap::new()),
|
||||
para_defaults: Val::Dict(BTreeMap::new()),
|
||||
char_sheets: vec![],
|
||||
frames: vec![],
|
||||
}
|
||||
.para(None);
|
||||
let mut s = story(" 80 mm ", &[0.0], ¶);
|
||||
let size = |s: &Story| s.region((0.0, 0.0), Affine::IDENTITY).map(|r| (r.width(), r.height())).unwrap();
|
||||
let (w, h) = size(&s);
|
||||
@@ -933,8 +1001,14 @@ mod tests {
|
||||
/// that doesn't say, goes by its letters.
|
||||
#[test]
|
||||
fn right_to_left_paragraphs_set_their_direction() {
|
||||
let texts =
|
||||
Texts { stories: vec![], fonts: vec![], defaults: Val::Dict(BTreeMap::new()), para_defaults: read(b"/33 1").unwrap(), frames: vec![] };
|
||||
let texts = Texts {
|
||||
stories: vec![],
|
||||
fonts: vec![],
|
||||
defaults: Val::Dict(BTreeMap::new()),
|
||||
para_defaults: read(b"/33 1").unwrap(),
|
||||
char_sheets: vec![],
|
||||
frames: vec![],
|
||||
};
|
||||
let direction = |own: &[u8]| para_style(&texts.para(Some(&read(own).unwrap())), 1.0).direction;
|
||||
assert_eq!(direction(b"/0 2"), Some(ParaDirection::RightToLeft), "from the document's defaults");
|
||||
assert_eq!(direction(b"/0 2 /33 0"), None, "the paragraph's own");
|
||||
@@ -973,6 +1047,7 @@ mod tests {
|
||||
fonts: vec![],
|
||||
defaults: Val::Dict(BTreeMap::new()),
|
||||
para_defaults: Val::Dict(BTreeMap::new()),
|
||||
char_sheets: vec![],
|
||||
frames: vec![read(doc.as_bytes()).unwrap()],
|
||||
};
|
||||
texts.frame(0)
|
||||
@@ -1203,4 +1278,38 @@ mod tests {
|
||||
let mut t = area(13.0);
|
||||
assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_s_first_line_goes_where_the_file_lays_it_out() {
|
||||
// GUARDS: area type's first baseline was the font's ascent below the frame's top, where the
|
||||
// file's own layout (and its page) can have it higher: a heading came in lower than the page's.
|
||||
let line = |a: (f64, f64), b: (f64, f64)| [a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1];
|
||||
let frame = vec![
|
||||
line((10.0, 20.0), (210.0, 20.0)),
|
||||
line((210.0, 20.0), (210.0, 120.0)),
|
||||
line((210.0, 120.0), (10.0, 120.0)),
|
||||
line((10.0, 120.0), (10.0, 20.0)),
|
||||
];
|
||||
// The canvas is the document here: the art's y up, the document's down.
|
||||
let (template, to_doc) = ((CANVAS_CENTRE, -CANVAS_CENTRE), Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]));
|
||||
let first = |s: &Story| {
|
||||
let mut node = s.node(NodeId(0), 0, template, to_doc).unwrap();
|
||||
let placed = s.place_first_line(&mut node, template, to_doc);
|
||||
let NodeKind::Text(t) = node.kind else { panic!("area type") };
|
||||
let l = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
(placed, l.lines.first().map(|l| l.baseline), t.area.first_baseline)
|
||||
};
|
||||
let mut s = story("Heading\r", &[], &Para::default());
|
||||
s.frames = vec![FrameKind::Area(frame)];
|
||||
// Where this lays the line out on its own: the file agrees, and nothing changes.
|
||||
s.lines = vec![(10.0, 20.0)];
|
||||
let (_, Some(own), _) = first(&s) else { panic!("a line") };
|
||||
s.lines = vec![(10.0, own)];
|
||||
assert_eq!(first(&s), (false, Some(own), vectorcraft_doc::FirstBaseline::Ascent));
|
||||
// Higher in the file: there.
|
||||
s.lines = vec![(10.0, own - 3.0)];
|
||||
let (placed, at, fb) = first(&s);
|
||||
assert!(placed && fb == vectorcraft_doc::FirstBaseline::Fixed, "{fb:?}");
|
||||
assert!(at.is_some_and(|y| (y - (own - 3.0)).abs() < 1e-6), "{at:?} for {}", own - 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,6 +333,12 @@ impl<'a> Interp<'a> {
|
||||
self.spend(1)
|
||||
}
|
||||
|
||||
/// Charge for work on `n` bytes or objects in one operator: one operation per 256, as `search`
|
||||
/// charges per 64 bytes, so a loop of big copies runs out of the budget.
|
||||
fn spend_bytes(&mut self, n: usize) -> Res {
|
||||
self.spend((n / 256) as u64)
|
||||
}
|
||||
|
||||
/// Count `ops` operations' worth of work against [`MAX_OPS`]: what an operator that costs
|
||||
/// far more than one operation (laying out type) does in a loop has to end too.
|
||||
pub fn spend(&mut self, ops: u64) -> Res {
|
||||
@@ -661,6 +667,7 @@ impl<'a> Interp<'a> {
|
||||
let n = self.pop_count()?;
|
||||
let at = self.stack.len().checked_sub(n).ok_or(PsError::Ps("rangecheck", String::new()))?;
|
||||
if n > 0 {
|
||||
self.spend_bytes(n)?;
|
||||
let k = j.rem_euclid(n as i64) as usize;
|
||||
if let Some(s) = self.stack.get_mut(at..) {
|
||||
s.rotate_right(k);
|
||||
@@ -687,6 +694,7 @@ impl<'a> Interp<'a> {
|
||||
self.pop_to_mark()?;
|
||||
}
|
||||
CountToMark => {
|
||||
self.spend_bytes(self.stack.len())?;
|
||||
let n = self.stack.iter().rev().position(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?;
|
||||
self.push(Obj::Int(n as i64))?;
|
||||
}
|
||||
@@ -860,6 +868,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Cvn => {
|
||||
let s = self.pop_str()?;
|
||||
self.spend_bytes(s.len())?;
|
||||
// The name is a copy of the string (a bigger one when bytes are replaced).
|
||||
self.alloc(s.len().saturating_mul(3))?;
|
||||
let n: Rc<str> = Rc::from(String::from_utf8_lossy(&s.borrow()).as_ref());
|
||||
@@ -966,8 +975,14 @@ impl<'a> Interp<'a> {
|
||||
let src = self.pop()?;
|
||||
let at = self.pop_count()?;
|
||||
let written = match (self.pop()?, src) {
|
||||
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => items.write(at, &from.to_vec()),
|
||||
(Obj::Str(s), Obj::Str(from)) => s.write(at, &from.to_vec()),
|
||||
(Obj::Array { items, .. }, Obj::Array { items: from, .. }) => {
|
||||
self.spend_bytes(from.len())?;
|
||||
items.write(at, &from.to_vec())
|
||||
}
|
||||
(Obj::Str(s), Obj::Str(from)) => {
|
||||
self.spend_bytes(from.len())?;
|
||||
s.write(at, &from.to_vec())
|
||||
}
|
||||
_ => return ps_err("typecheck", ""),
|
||||
};
|
||||
if !written {
|
||||
@@ -1171,6 +1186,7 @@ impl<'a> Interp<'a> {
|
||||
|
||||
/// The objects above the topmost mark, which is removed.
|
||||
fn pop_to_mark(&mut self) -> Res<Vec<Obj>> {
|
||||
self.spend_bytes(self.stack.len())?;
|
||||
let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?;
|
||||
let items = self.stack.split_off(at + 1);
|
||||
self.stack.pop();
|
||||
@@ -1202,6 +1218,7 @@ impl<'a> Interp<'a> {
|
||||
}
|
||||
Obj::Str(s) => {
|
||||
let from = self.pop_str()?.to_vec();
|
||||
self.spend_bytes(from.len())?;
|
||||
if !s.write(0, &from) {
|
||||
return ps_err("rangecheck", "copy");
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ pub(super) fn plan(
|
||||
let Some(Some(story)) = stories.get(k) else { continue };
|
||||
let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue };
|
||||
let mut region = story.region(t, to_doc);
|
||||
// The first line where the file has it, in the text document's frame (that of a
|
||||
// copied object is its original's, as its lines are).
|
||||
story.place_first_line(&mut node, t, to_doc);
|
||||
// Area type and type on a path in the object's own frame: the text document's frame
|
||||
// of a copied object keeps its original's path.
|
||||
frame_text_from_art(info, &mut node, &mut region);
|
||||
|
||||
@@ -1307,6 +1307,21 @@ fn search_in_a_loop_runs_out_of_the_budget() {
|
||||
runs_out_of_budget(&format!("{make} {{ h n search {{ pop pop pop }} {{ pop }} ifelse }} loop"));
|
||||
}
|
||||
|
||||
/// #3584: operators that touch up to 4 MiB or 100,000 objects are charged for it, so a loop of them
|
||||
/// runs out of the operation budget instead of keeping the importer busy for hours.
|
||||
#[test]
|
||||
fn big_operators_in_a_loop_run_out_of_the_budget() {
|
||||
let strings = "/a 4194304 string def /b 4194304 string def";
|
||||
for body in [
|
||||
format!("{strings} {{ a 0 b putinterval }} loop"),
|
||||
format!("{strings} {{ b a copy pop }} loop"),
|
||||
"99990 { 0 } repeat { 99990 1 roll } loop".to_string(),
|
||||
"mark 99990 { 0 } repeat { counttomark pop } loop".to_string(),
|
||||
] {
|
||||
runs_out_of_budget(&body);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3568: an image dictionary with more data sources than colour components is a `rangecheck`.
|
||||
#[test]
|
||||
fn an_image_with_more_data_sources_than_components_is_refused() {
|
||||
|
||||
@@ -841,6 +841,46 @@ fn type_on_a_hidden_layer_comes_from_the_text_document() {
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A run that sets no colour of its own takes its character style's (here `[Normal Character
|
||||
/// Style]`, black), not the text document's base attributes (here white, which hid the type).
|
||||
/// A style takes what it doesn't set from the style it is based on, and a run's own setting wins.
|
||||
#[test]
|
||||
fn type_from_the_text_document_takes_its_character_styles_attributes() {
|
||||
let own_fill = "/53 << /99 /CAITextPaint /0 << /0 2 /1 [ 1.0 0.0 1.0 0.0 0.0 ] >> >> ";
|
||||
let run = |style: u8| format!("/5 {style} /6 << /0 0 /1 14.0 >>");
|
||||
let doc = text_document(&[
|
||||
("Normal\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0),
|
||||
("Based\r", 0, (8200.0, 8170.0), (0.0, 0.0), 0.0),
|
||||
("Own\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0),
|
||||
])
|
||||
// The first two runs set no colour, the second of the style based on the normal one.
|
||||
.replacen(own_fill, "", 2)
|
||||
.replacen(&run(0), "/5 X", 1)
|
||||
.replacen(&run(0), &run(1), 1)
|
||||
.replacen("/5 X", &run(0), 1)
|
||||
// The document's base attributes: grey level 1, white, with auto leading.
|
||||
.replacen("/2 << /1 12.0 >>", "/2 << /1 12.0 /4 true /53 << /99 /SimplePaint /0 << /0 0 /1 [ 1.0 1.0 ] >> >> >>", 1)
|
||||
// Its character styles: the normal one black with 18-point leading, and one based on it in red.
|
||||
.replacen(
|
||||
"/0 << /1 <<",
|
||||
"/0 << /5 << /0 [ << /0 << /0 ([Normal Character Style]) /6 << /4 false /5 18.0 \
|
||||
/53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 0.0 0.0 0.0 ] >> >> >> >> >> \
|
||||
<< /0 << /0 (Red) /5 0 /6 << /53 << /99 /SimplePaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >> >> >> >> ] >> /1 <<",
|
||||
1,
|
||||
);
|
||||
let spare = text_object(0) + &text_object(1) + &text_object(2);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &spare)), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["Normal", "Based", "Own"], "{:?}", r.warnings);
|
||||
let style = |i: usize| (texts[i].runs[0].style.fill.clone(), texts[i].runs[0].style.leading);
|
||||
let solid = vectorcraft_color::Paint::solid;
|
||||
use vectorcraft_color::Color;
|
||||
assert_eq!(style(0), (solid(Color::rgb(0.0, 0.0, 0.0)), Some(18.0)), "the normal style's");
|
||||
assert_eq!(style(1), (solid(Color::rgb(1.0, 0.0, 0.0)), Some(18.0)), "its style's colour, the leading of the one it's based on");
|
||||
assert_eq!(style(2), (solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Some(18.0)), "the run's own colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_from_the_text_document_keeps_its_case_setting() {
|
||||
// Key 12 of a character style is its case: 1 Small Caps, 2 All Caps. The characters stay as
|
||||
|
||||
@@ -40,7 +40,8 @@ impl Homography {
|
||||
[m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], m[1][2], m[2][0], m[2][1], m[2][2]]
|
||||
}
|
||||
|
||||
/// Map a point; `None` when it lands on or beyond the horizon (w ≤ 0).
|
||||
/// Map a point; `None` when it lands on or beyond the horizon (w ≤ 0), or when it does not map
|
||||
/// to a finite page point.
|
||||
pub fn apply(&self, p: Point) -> Option<Point> {
|
||||
let m = &self.0;
|
||||
let x = m[0][0] * p.x + m[0][1] * p.y + m[0][2];
|
||||
@@ -49,7 +50,8 @@ impl Homography {
|
||||
if w <= 1e-9 || !x.is_finite() || !y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(Point::new(x / w, y / w))
|
||||
let q = Point::new(x / w, y / w);
|
||||
q.is_finite().then_some(q)
|
||||
}
|
||||
|
||||
pub fn det(&self) -> f64 {
|
||||
@@ -58,7 +60,8 @@ impl Homography {
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0])
|
||||
}
|
||||
|
||||
/// Inverse.
|
||||
/// Inverse; `None` when the map is not invertible or its inverse has an entry that is not
|
||||
/// finite.
|
||||
pub fn inverse(&self) -> Option<Self> {
|
||||
let m = &self.0;
|
||||
let d = self.det();
|
||||
@@ -77,6 +80,9 @@ impl Homography {
|
||||
out[r][k] = v / d;
|
||||
}
|
||||
}
|
||||
if out.iter().flatten().any(|v| !v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// H⁻¹·(x, y, 1) = (u, v, 1)/w_H, so points in front of the camera keep w > 0.
|
||||
Some(Self(out))
|
||||
}
|
||||
@@ -94,16 +100,51 @@ impl Homography {
|
||||
}
|
||||
|
||||
/// `self` seen through `a`: the map `a ∘ self ∘ a⁻¹` (what `self` becomes when everything it
|
||||
/// maps from and to is moved by `a`). `None` when `a` isn't invertible.
|
||||
/// maps from and to is moved by `a`). When the product overflows, every entry is formed with
|
||||
/// its powers of two kept apart, and the product is returned divided by the largest magnitude
|
||||
/// in its `w` row (the same map, with a `w` row whose largest entry is 1). `None` when `a`
|
||||
/// isn't invertible, or when the product overflows and the result at that scale has an entry
|
||||
/// that is not finite or is not invertible.
|
||||
pub fn conjugated(&self, a: Affine) -> Option<Self> {
|
||||
if a.determinant().abs() < 1e-15 {
|
||||
return None;
|
||||
}
|
||||
Some(Self::from_affine(a).then_after(self).then_after(&Self::from_affine(a.inverse())))
|
||||
let (to, from) = (Self::from_affine(a), Self::from_affine(a.inverse()));
|
||||
let c = to.then_after(self).then_after(&from);
|
||||
if c.is_finite() {
|
||||
return Some(c);
|
||||
}
|
||||
// Each entry of the product is a sum of nine terms to[r][i]·self[i][j]·from[j][k]. Every
|
||||
// factor is split into a mantissa and a power of two, so each term is a mantissa product
|
||||
// and an exponent sum, and the whole product is scaled by one power of two at the end.
|
||||
let split = |h: &Self| h.0.map(|row| row.map(frexp));
|
||||
let (t, s, f) = (split(&to), split(self), split(&from));
|
||||
let term = |r: usize, i: usize, j: usize, k: usize| {
|
||||
let (a, b, c) = (t[r][i], s[i][j], f[j][k]);
|
||||
(a.0 * b.0 * c.0, a.1 + b.1 + c.1)
|
||||
};
|
||||
let ijk = (0..27).map(|n| (n / 9, n / 3 % 3, n % 3));
|
||||
let e = ijk.map(|(i, j, k)| term(2, i, j, k)).filter(|(m, _)| *m != 0.0).map(|(_, e)| e).max()?;
|
||||
let mut c = [[0.0; 3]; 3];
|
||||
for (r, row) in c.iter_mut().enumerate() {
|
||||
for (k, v) in row.iter_mut().enumerate() {
|
||||
*v = (0..9).map(|n| term(r, n / 3, n % 3, k)).map(|(m, x)| ldexp(m, x - e)).sum();
|
||||
}
|
||||
}
|
||||
let k = c[2].iter().fold(0.0_f64, |k, v| k.max(v.abs()));
|
||||
if k <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let c = Self(c.map(|row| row.map(|v| v / k)));
|
||||
(c.is_finite() && c.det().abs() > 1e-15).then_some(c)
|
||||
}
|
||||
|
||||
fn is_finite(&self) -> bool {
|
||||
self.0.iter().flatten().all(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// The affine map that matches `self` to first order at `p` (its derivative there), `None`
|
||||
/// beyond the horizon.
|
||||
/// beyond the horizon or when the derivative is not finite.
|
||||
pub fn affine_at(&self, p: Point) -> Option<Affine> {
|
||||
let m = &self.0;
|
||||
let w = m[2][0] * p.x + m[2][1] * p.y + m[2][2];
|
||||
@@ -111,7 +152,8 @@ impl Homography {
|
||||
// d(x/w) = (dx − (x/w)·dw) / w, and the same for y.
|
||||
let j = |r: usize, c: usize| (m[r][c] - [q.x, q.y][r] * m[2][c]) / w;
|
||||
let lin = Affine::new([j(0, 0), j(1, 0), j(0, 1), j(1, 1), 0.0, 0.0]);
|
||||
Some(Affine::translate(q.to_vec2()) * lin * Affine::translate(-p.to_vec2()))
|
||||
let out = Affine::translate(q.to_vec2()) * lin * Affine::translate(-p.to_vec2());
|
||||
out.is_finite().then_some(out)
|
||||
}
|
||||
|
||||
/// The bounding box of the image of `r` (lines stay lines: its corners bound it), `None` when
|
||||
@@ -127,6 +169,36 @@ impl Homography {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as `m·2^e` with `0.5 ≤ |m| < 1`; 0, infinities and NaN come back as `(v, 0)`.
|
||||
fn frexp(v: f64) -> (f64, i32) {
|
||||
if v == 0.0 || !v.is_finite() {
|
||||
return (v, 0);
|
||||
}
|
||||
// A subnormal is scaled into the normal range first.
|
||||
let (v, bias) = if v.abs() < f64::MIN_POSITIVE { (v * pow2(64), -64) } else { (v, 0) };
|
||||
let bits = v.to_bits();
|
||||
let e = ((bits >> 52) & 0x7ff) as i32 - 1022;
|
||||
(f64::from_bits((bits & !(0x7ff << 52)) | (1022 << 52)), e + bias)
|
||||
}
|
||||
|
||||
/// `m·2^e`, multiplied in steps of powers of two that are each within the normal range.
|
||||
fn ldexp(mut m: f64, mut e: i32) -> f64 {
|
||||
while e > 1023 && m.is_finite() {
|
||||
m *= pow2(1023);
|
||||
e -= 1023;
|
||||
}
|
||||
while e < -1022 && m != 0.0 {
|
||||
m *= pow2(-1022);
|
||||
e += 1022;
|
||||
}
|
||||
m * pow2(e.clamp(-1022, 1023))
|
||||
}
|
||||
|
||||
/// `2^e` for `-1022 ≤ e ≤ 1023`.
|
||||
fn pow2(e: i32) -> f64 {
|
||||
f64::from_bits(((e + 1023) as u64) << 52)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -175,6 +247,93 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_point_that_overflows_maps_to_nothing() {
|
||||
// w = 1e-8 is in front of the horizon, but 1e305 / 1e-8 overflows.
|
||||
let near = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1e-8]]);
|
||||
assert_eq!(near.apply(Point::new(1e305, 0.0)), None);
|
||||
assert_eq!(near.apply(Point::new(0.0, -1e305)), None);
|
||||
assert_eq!(near.map_rect_bbox(Rect::new(0.0, 0.0, 1e305, 1.0)), None);
|
||||
assert!(near.apply(Point::new(2.0, 3.0)).unwrap().distance(Point::new(2e8, 3e8)) < 1e-6);
|
||||
// A NaN w is not caught by the horizon test.
|
||||
let nan = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, f64::NAN]]);
|
||||
assert_eq!(nan.apply(Point::new(1.0, 1.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inverse_that_overflows_is_none() {
|
||||
let big = Homography([[1e300, 0.0, 0.0], [0.0, 1e300, 0.0], [0.0, 0.0, 1e-310]]);
|
||||
assert_eq!(big.inverse(), None);
|
||||
assert!(h().inverse().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_derivative_that_overflows_is_none() {
|
||||
// The point maps to 1e300, but the derivative there is 1e302 / 1e-8.
|
||||
let steep = Homography([[1e302, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1e-8]]);
|
||||
let p = Point::new(1e-10, 0.0);
|
||||
assert!(steep.apply(p).is_some());
|
||||
assert_eq!(steep.affine_at(p), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conjugate_that_overflows_is_scaled_down() {
|
||||
// A move by 10, scaled by 1e307: seen through a scale by 10 it moves by 100, but the
|
||||
// product's corner entry is 1e309.
|
||||
let big = Homography([[1e307, 0.0, 1e308], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]);
|
||||
let t = Affine::scale(10.0);
|
||||
let c = big.conjugated(t).unwrap();
|
||||
assert!(c.is_finite());
|
||||
assert_eq!(c.0[2], [0.0, 0.0, 1.0]);
|
||||
assert_eq!(Homography::from_array(c.to_array()), Some(c));
|
||||
for p in [Point::new(3.0, 7.0), Point::new(-4.0, 1.5)] {
|
||||
assert!(c.apply(t * p).unwrap().distance(t * big.apply(p).unwrap()) < 1e-9);
|
||||
assert!(c.apply(p).unwrap().distance(p + kurbo::Vec2::new(100.0, 0.0)) < 1e-9);
|
||||
}
|
||||
// A move by 1 scaled by 1e307, seen through a scale by 1e7: a move by 1e7, whose
|
||||
// determinant is 1 at a w row of 1 but 1e-21 at a largest entry of 1.
|
||||
let one = Homography([[1e307, 0.0, 1e307], [0.0, 1e307, 0.0], [0.0, 0.0, 1e307]]);
|
||||
assert!(Homography::from_array(one.to_array()).is_some());
|
||||
let c = one.conjugated(Affine::scale(1e7)).unwrap();
|
||||
assert_eq!(Homography::from_array(c.to_array()), Some(c));
|
||||
let p = Point::new(3.0, -2.0);
|
||||
assert!(c.apply(p).unwrap().distance(p + kurbo::Vec2::new(1e7, 0.0)) < 1e-6);
|
||||
// A scale by 1e10 seen through a scale by 1e10: still a scale by 1e10, which every point
|
||||
// reaches only when w is not left at 1e-10.
|
||||
let wide = Homography([[1e300, 0.0, 0.0], [0.0, 1e300, 0.0], [0.0, 0.0, 1e290]]);
|
||||
let c = wide.conjugated(Affine::scale(1e10)).unwrap();
|
||||
assert_eq!(Homography::from_array(c.to_array()), Some(c));
|
||||
for p in [Point::new(1.0, 2.0), Point::new(-5.0, 0.5)] {
|
||||
assert!(c.apply(p).unwrap().distance(Point::new(p.x * 1e10, p.y * 1e10)) < 1e-3);
|
||||
}
|
||||
// A scale by 2 seen through a move by 1e308: p goes to 2p - 1e308, though the product's
|
||||
// corner entry is 2 * -1e308 + 1e308 and the moves scaled to a largest entry of 1 leave
|
||||
// their w rows at 1e-308.
|
||||
let two = Homography([[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
let c = two.conjugated(Affine::translate((1e308, 0.0))).unwrap();
|
||||
let want = [2.0, 0.0, -1e308, 0.0, 2.0, 0.0, 0.0, 0.0, 1.0];
|
||||
for (v, w) in c.to_array().iter().zip(want) {
|
||||
assert!((v - w).abs() <= 1e-9 * w.abs().max(1.0), "{c:?}");
|
||||
}
|
||||
assert_eq!(Homography::from_array(c.to_array()), Some(c));
|
||||
// A scale by (1e160, 1e-160) seen through the same scale is itself, though the product's
|
||||
// first entry is 1e320 and a scale to a largest entry of 1 takes its second to 1e-480.
|
||||
let thin = Homography([[1e160, 0.0, 0.0], [0.0, 1e-160, 0.0], [0.0, 0.0, 1.0]]);
|
||||
assert!(Homography::from_array(thin.to_array()).is_some());
|
||||
let c = thin.conjugated(Affine::scale_non_uniform(1e160, 1e-160)).unwrap();
|
||||
for (v, w) in c.to_array().iter().zip(thin.to_array()) {
|
||||
assert!((v - w).abs() <= 1e-9 * w.abs(), "{c:?}");
|
||||
}
|
||||
assert_eq!(Homography::from_array(c.to_array()), Some(c));
|
||||
// A move by 1e200 seen through a scale by 1e150 sends every point to about (1e350, 0),
|
||||
// which no invertible map of finite numbers draws.
|
||||
let far = Homography([[1.0, 0.0, 1e200], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
|
||||
assert_eq!(far.conjugated(Affine::scale(1e150)), None);
|
||||
assert!(far.conjugated(Affine::scale(2.0)).is_some());
|
||||
// A product that does not overflow is kept as it is.
|
||||
assert_eq!(far.conjugated(Affine::scale(2.0)).unwrap().0[0][2], 2e200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_bbox_is_the_corners_box() {
|
||||
let b = h().map_rect_bbox(Rect::new(0.0, 0.0, 10.0, 20.0)).unwrap();
|
||||
|
||||
@@ -185,7 +185,7 @@ pub fn add_anchors(sp: &SubPath, which: SegmentKind, spacing: AnchorSpacing, cap
|
||||
};
|
||||
let c = sp.segment(seg);
|
||||
let len = c.arclen(ACCURACY);
|
||||
if !picked || !(len > TINY && len.is_finite()) {
|
||||
if !(picked && len > TINY && len.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let (count, step) = match spacing {
|
||||
|
||||
@@ -634,11 +634,11 @@ impl Walker<'_, '_> {
|
||||
fn note_fonts(&mut self, res: &Resources<'_>) {
|
||||
for key in res.fonts.keys() {
|
||||
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
|
||||
&& let Some(name) = f.get::<Name<'_>>(b"BaseFont")
|
||||
&& let Some(name) = font_name(&f)
|
||||
{
|
||||
self.fonts.entry(f.cache_key()).or_insert_with(|| {
|
||||
let embedded = embeds_glyphs(&f);
|
||||
FontNote { name: String::from_utf8_lossy(name.as_ref()).into_owned(), embedded, vertical: vertical_metrics(&f, embedded) }
|
||||
FontNote { name, embedded, vertical: vertical_metrics(&f, embedded) }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1009,6 +1009,24 @@ fn cid_ranges(a: &Array<'_>, per: usize, mut each: impl FnMut(u32, &[f64])) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The PostScript name of font `f`: its `BaseFont`. A composite (Type 0) font's `BaseFont` is its
|
||||
/// CIDFont's name, a hyphen and its CMap's (`Name-Identity-H`, ISO 32000-1 §9.7.6.1), so its
|
||||
/// descendant CIDFont's `BaseFont` is taken, the font program's own name (§9.7.4.1); without one,
|
||||
/// the `BaseFont` without the CMap's name on its end.
|
||||
fn font_name(f: &Dict<'_>) -> Option<String> {
|
||||
let base = String::from_utf8_lossy(f.get::<Name<'_>>(b"BaseFont")?.as_ref()).into_owned();
|
||||
if f.get::<Name<'_>>(b"Subtype").is_none_or(|s| s.as_ref() != b"Type0") {
|
||||
return Some(base);
|
||||
}
|
||||
let descendant = f.get::<Array<'_>>(b"DescendantFonts").and_then(|a| a.iter::<Dict<'_>>().next());
|
||||
if let Some(name) = descendant.and_then(|d| d.get::<Name<'_>>(b"BaseFont")).filter(|n| !n.as_ref().is_empty()) {
|
||||
return Some(String::from_utf8_lossy(name.as_ref()).into_owned());
|
||||
}
|
||||
let cmap = f.get::<Name<'_>>(b"Encoding").map(|n| String::from_utf8_lossy(n.as_ref()).into_owned());
|
||||
let stripped = cmap.and_then(|c| base.strip_suffix(c.as_str())?.strip_suffix('-').filter(|b| !b.is_empty()).map(str::to_string));
|
||||
Some(stripped.unwrap_or(base))
|
||||
}
|
||||
|
||||
/// Whether font `f` carries its own glyphs: a font program in its descriptor (a composite font's in
|
||||
/// its descendant's), or a Type 3 font's glyph procedures. A font that doesn't is drawn with a
|
||||
/// stand-in, whose glyphs say nothing about which installed face the document means.
|
||||
|
||||
@@ -965,6 +965,12 @@ impl Families {
|
||||
Self(vectorcraft_text::FontDb::global().families().into_iter().map(|f| (norm(&f), f)).collect())
|
||||
}
|
||||
|
||||
/// Just the families `names`, for tests that mustn't depend on the fonts installed.
|
||||
#[cfg(test)]
|
||||
pub fn of(names: &[&str]) -> Self {
|
||||
Self(names.iter().map(|f| (norm(f), f.to_string())).collect())
|
||||
}
|
||||
|
||||
/// The family and style of a font with base (PostScript) name `name`, or of weight `weight`
|
||||
/// and slant `italic` when the name has no style; whether the family is available.
|
||||
pub fn resolve(&self, name: &str, weight: Option<u32>, italic: bool) -> (String, String, bool) {
|
||||
@@ -977,22 +983,74 @@ impl Families {
|
||||
if let Some((family, style)) = vectorcraft_text::FontDb::global().by_postscript_name(name) {
|
||||
return (family, style, true);
|
||||
}
|
||||
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
|
||||
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
|
||||
let style = match spaced(strip_ps(style)).as_str() {
|
||||
"" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) {
|
||||
(true, true) => "Bold Italic".to_string(),
|
||||
(true, false) => "Bold".to_string(),
|
||||
(false, true) => "Italic".to_string(),
|
||||
(false, false) => "Regular".to_string(),
|
||||
// The longest part of the name before a hyphen (or the comma of `Arial,Bold`, ISO 32000-1
|
||||
// §9.6.3) that names an available family, the rest its style: a family can hold hyphens
|
||||
// (`Noto-Sans-Bold` is Noto Sans, Bold). The name is the file's: a few cuts from its end are
|
||||
// enough for any family, and each costs a pass over the name.
|
||||
let cuts = std::iter::once(name.len()).chain(name.rmatch_indices(['-', ',']).map(|(i, _)| i).take(16));
|
||||
for cut in cuts {
|
||||
let (fam, style) = (name.get(..cut).unwrap_or(name), name.get(cut + 1..).unwrap_or(""));
|
||||
if let Some(f) = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned()) {
|
||||
return (f, style_of(style, weight, italic), true);
|
||||
}
|
||||
}
|
||||
// Not available: split at a comma, or before the first part that is a style
|
||||
// (`Helvetica-BoldOblique`, `Noto-Sans-Bold`, `AGaramondPro-Bold-SC700`); otherwise the
|
||||
// whole name is the family (`Noto-Sans`, `MS-PGothic`).
|
||||
let style_at = |i: usize| name.get(i + 1..).and_then(|rest| rest.split('-').next()).is_some_and(|part| is_style(strip_ps(part)));
|
||||
let (fam, style) = match name.split_once(',') {
|
||||
Some(split) => split,
|
||||
None => match name.match_indices('-').map(|(i, _)| i).find(|&i| style_at(i)) {
|
||||
Some(i) => (name.get(..i).unwrap_or(name), name.get(i + 1..).unwrap_or("")),
|
||||
None => (name, ""),
|
||||
},
|
||||
s => s.to_string(),
|
||||
};
|
||||
match found {
|
||||
Some(f) => (f, style, true),
|
||||
None => (spaced(strip_ps(fam)).trim().to_string(), style, false),
|
||||
let family = spaced(strip_ps(fam)).split(['-', ' ']).filter(|w| !w.is_empty()).collect::<Vec<_>>().join(" ");
|
||||
(family, style_of(style, weight, italic), false)
|
||||
}
|
||||
}
|
||||
|
||||
/// The style a PostScript name's `style` part names, or the one of weight `weight` and slant
|
||||
/// `italic` when the part names none (`Roman`, `Book`…).
|
||||
fn style_of(style: &str, weight: Option<u32>, italic: bool) -> String {
|
||||
match spaced(strip_ps(style)).as_str() {
|
||||
"" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) {
|
||||
(true, true) => "Bold Italic".to_string(),
|
||||
(true, false) => "Bold".to_string(),
|
||||
(false, true) => "Italic".to_string(),
|
||||
(false, false) => "Regular".to_string(),
|
||||
},
|
||||
s => s.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `s` is made of style words and their usual abbreviations (`BoldOblique`, `SemiboldIt`,
|
||||
/// `XBold`, `W6`), any case: the last part of a font name that names its style.
|
||||
fn is_style(s: &str) -> bool {
|
||||
const WORDS: &str = "bd bk black blk bold book cn cnd compressed con condensed demi ex expanded ext extended extra hairline heavy hv it ita italic light lt md \
|
||||
med medium narrow normal obl oblique plain reg regular rg roman sb semi slanted smb super th thin ul ultra w wide x xb xl";
|
||||
let s = s.to_ascii_lowercase();
|
||||
let s = s.as_bytes();
|
||||
// Which starts of `s` the words and digit runs before them reach (a word break, so
|
||||
// `XBold` is X, Bold even though `xb` also starts it).
|
||||
let mut reached = vec![false; s.len() + 1];
|
||||
if let Some(r) = reached.first_mut() {
|
||||
*r = !s.is_empty();
|
||||
}
|
||||
for i in 0..s.len() {
|
||||
if !reached.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let rest = s.get(i..).unwrap_or_default();
|
||||
let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count();
|
||||
let ends = WORDS.split_ascii_whitespace().filter(|w| rest.starts_with(w.as_bytes())).map(|w| i + w.len());
|
||||
for end in ends.chain((digits > 0).then_some(i + digits)) {
|
||||
if let Some(r) = reached.get_mut(end) {
|
||||
*r = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
reached.last().copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text
|
||||
|
||||
@@ -406,6 +406,74 @@ fn font_names_resolve_to_available_families() {
|
||||
assert_eq!((family.as_str(), style.as_str(), found), ("Missing Sans Pro", "Bold Italic", false));
|
||||
}
|
||||
|
||||
/// A family can hold hyphens (`Noto-Sans-Bold` is Noto Sans, Bold): the longest part of the name
|
||||
/// before a hyphen that names an available family is the family, the rest its style. Synthetic
|
||||
/// family names, so the host's fonts don't matter.
|
||||
#[test]
|
||||
fn hyphenated_font_names_resolve_to_the_longest_available_family() {
|
||||
let f = crate::import_text::Families::of(&["Vctest", "Vctest Sans", "Vctest Sans CJK JP", "Vctest Mono"]);
|
||||
let ok = |family: &str, style: &str| (family.to_string(), style.to_string(), true);
|
||||
assert_eq!(f.resolve("HUHQIP+Vctest-Sans", None, false), ok("Vctest Sans", "Regular"));
|
||||
assert_eq!(f.resolve("Vctest-Sans-Bold", None, false), ok("Vctest Sans", "Bold"));
|
||||
assert_eq!(f.resolve("Vctest-Sans-Italic", None, false), ok("Vctest Sans", "Italic"));
|
||||
assert_eq!(f.resolve("Vctest-Sans", Some(700), true), ok("Vctest Sans", "Bold Italic"));
|
||||
assert_eq!(f.resolve("Vctest-Sans-CJK-JP", None, false), ok("Vctest Sans CJK JP", "Regular"));
|
||||
// The family is there but not the style: the family, which then draws its closest style.
|
||||
assert_eq!(f.resolve("Vctest-Mono-Bold", None, false), ok("Vctest Mono", "Bold"));
|
||||
// A shorter family still takes the name when no longer one is available.
|
||||
assert_eq!(f.resolve("Vctest-BoldOblique", None, false), ok("Vctest", "Bold Oblique"));
|
||||
assert_eq!(f.resolve("Vctest,Bold", None, false), ok("Vctest", "Bold"));
|
||||
}
|
||||
|
||||
/// A missing font's name is split only at a comma (`Arial,Bold`) or before the first part that is
|
||||
/// a style (`Family-BoldOblique`); otherwise the whole name is the family, so the missing font is
|
||||
/// reported as `Vcmissing Sans`, not `Vcmissing`.
|
||||
#[test]
|
||||
fn a_missing_font_is_split_only_before_a_style() {
|
||||
let f = crate::import_text::Families::of(&[]);
|
||||
let missing = |family: &str, style: &str| (family.to_string(), style.to_string(), false);
|
||||
assert_eq!(f.resolve("Vcmissing-Sans", None, false), missing("Vcmissing Sans", "Regular"));
|
||||
assert_eq!(f.resolve("Vcmissing-Sans-CJK-JP", None, false), missing("Vcmissing Sans CJK JP", "Regular"));
|
||||
assert_eq!(f.resolve("Vcmissing-Sans-BoldOblique", None, false), missing("Vcmissing Sans", "Bold Oblique"));
|
||||
assert_eq!(f.resolve("VcmissingPro-SemiboldIt", None, false), missing("Vcmissing Pro", "Semibold It"));
|
||||
assert_eq!(f.resolve("VcmissingPSMT-XBold", None, false), missing("Vcmissing", "XBold"));
|
||||
assert_eq!(f.resolve("Vcmissing-Bold-SC700", None, false), missing("Vcmissing", "Bold-SC 700"));
|
||||
assert_eq!(f.resolve("Vcmissing-Regular-Regular", None, false), missing("Vcmissing", "Regular-Regular"));
|
||||
assert_eq!(f.resolve("Vcmissing,Bold", None, false), missing("Vcmissing", "Bold"));
|
||||
assert_eq!(f.resolve("-Vcmissing", None, false), missing("Vcmissing", "Regular"));
|
||||
assert_eq!(f.resolve("Vcmissing-Roman", Some(700), false), missing("Vcmissing", "Bold"));
|
||||
assert_eq!(f.resolve("Vcmissing-", None, true), missing("Vcmissing", "Italic"));
|
||||
assert_eq!(f.resolve("-Bold", None, false), missing("", "Bold"));
|
||||
}
|
||||
|
||||
/// A composite (Type 0) font's base name is its CIDFont's name, a hyphen and its CMap's
|
||||
/// (`SomeFont-Italic-Identity-H`, ISO 32000-1 §9.7.6.1): the font is noted by its CIDFont's name,
|
||||
/// the PostScript name of the font program (§9.7.4.1), so it is found among the installed fonts.
|
||||
#[test]
|
||||
fn a_composite_font_is_noted_by_its_cid_font_name() {
|
||||
let noted = |font: &str| {
|
||||
let resources = format!("/Font << /F1 {font} >>");
|
||||
let bytes = one_page("BT /F1 24 Tf 10 30 Td <0001> Tj ET", &resources, &[]);
|
||||
let pdf = craft_pdf::syntax::Pdf::new(bytes).unwrap();
|
||||
let mut fonts = crate::import_scan::FontNames::new();
|
||||
let mut ocgs = crate::import_scan::Ocgs::read(&pdf);
|
||||
crate::import_scan::scan_page(&pdf.pages()[0], &mut ocgs, false, &mut fonts, pdf.decode_limits().max_decoded_stream()).unwrap();
|
||||
fonts.into_values().map(|f| f.name).collect::<Vec<_>>()
|
||||
};
|
||||
let type0 = |base: &str, enc: &str, descendant: &str| {
|
||||
let info = "/CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >>";
|
||||
format!(
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /{base} /Encoding /{enc} /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 {descendant} {info} >>] >>"
|
||||
)
|
||||
};
|
||||
assert_eq!(noted(&type0("MGZJFV+Vctest-Italic-Identity-H", "Identity-H", "/BaseFont /MGZJFV+Vctest-Italic")), ["MGZJFV+Vctest-Italic"]);
|
||||
// No name in the descendant: the CMap's name comes off the end of the base name.
|
||||
assert_eq!(noted(&type0("Vctest-Italic-UniJIS-UCS2-H", "UniJIS-UCS2-H", "")), ["Vctest-Italic"]);
|
||||
// A base name without the CMap's is kept, as is a simple font's.
|
||||
assert_eq!(noted(&type0("Vctest-Italic", "Identity-H", "")), ["Vctest-Italic"]);
|
||||
assert_eq!(noted("<< /Type /Font /Subtype /Type1 /BaseFont /Vctest-Identity-H >>"), ["Vctest-Identity-H"]);
|
||||
}
|
||||
|
||||
/// A value of the page's font dictionary that the reader can't parse (a name with a broken `#`
|
||||
/// escape, a number with a stray `-`) is left out when the page's fonts are noted, and the fonts
|
||||
/// after it are noted.
|
||||
|
||||
@@ -27,6 +27,8 @@ weezl = "0.2"
|
||||
# PNG rows read one at a time (MIT OR Apache-2.0; already used by the image crate's PNG reader).
|
||||
png = "0.18"
|
||||
log = { workspace = true }
|
||||
# Combining marks inside a lam-alef cluster (MIT OR Apache-2.0; already used by vectorcraft-text).
|
||||
unicode-general-category = "1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
@@ -212,7 +212,7 @@ impl Renderer {
|
||||
self.draw_kept_node(ctx, f, p);
|
||||
self.fx_owner = owner;
|
||||
} else {
|
||||
self.draw_node(ctx, f, p, true);
|
||||
self.draw_forced_arc(ctx, f, p);
|
||||
}
|
||||
}
|
||||
(self.made_again, self.cur) = outer;
|
||||
@@ -260,6 +260,36 @@ mod tests {
|
||||
assert_eq!(again.pixels, img.pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn art_brush_piece_applies_its_root_mask() {
|
||||
use vectorcraft_brush::{ArtBrush, ArtScale, Brush, BrushKind, Colorization};
|
||||
use vectorcraft_doc::OpacityMask;
|
||||
|
||||
let mut d = doc_with_line(Some("Masked art"), 1.0);
|
||||
let mut art = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(0.0, -5.0, 10.0, 5.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
let black = Node::path(
|
||||
d.alloc_id(),
|
||||
shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 100.0)),
|
||||
Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0),
|
||||
);
|
||||
art.extras_mut().mask = Some(Box::new(OpacityMask::new(black, true)));
|
||||
let brush = Brush {
|
||||
name: "Masked art".into(),
|
||||
kind: BrushKind::Art(ArtBrush { art, scale: ArtScale::Stretch, colorization: Colorization::None, ..Default::default() }),
|
||||
};
|
||||
vectorcraft_brush::store(&mut d, &[brush]);
|
||||
|
||||
let mut renderer = Renderer::new();
|
||||
for _ in 0..2 {
|
||||
let img = render(&mut renderer, &d);
|
||||
assert_eq!(img.pixel(100, 50), [255, 255, 255, 255]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Object opacity fades a brushed stroke's art as a whole, with or without a dash and a fill:
|
||||
/// the same as the object at full opacity inside a group at that opacity.
|
||||
#[test]
|
||||
|
||||
@@ -227,6 +227,6 @@ pub(crate) use text_geom::is_opentype_alternate;
|
||||
#[cfg(test)]
|
||||
use text_geom::text_geom;
|
||||
pub(crate) use threads::single_threaded_context;
|
||||
pub use threads::{default_threads, set_default_threads};
|
||||
pub use threads::{default_threads, set_default_threads, set_usable_cores};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! ([`PatternDef::period`]) is rasterized at the device scale it is painted at, rounded up to a
|
||||
//! power of two and capped ([`raster_scale`]), and used as an image paint with `Extend::Repeat`,
|
||||
//! with the paint transform mapping pixels → pattern space → document. Tiles are cached per
|
||||
//! pattern name and raster scale (and ink plane, see [`crate::ink`]) within
|
||||
//! pattern name, raster scale, undecoded-image mark setting and ink plane (see [`crate::ink`]) within
|
||||
//! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was
|
||||
//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and,
|
||||
//! when its art shows more than itself (a symbol, another pattern, an image, type, a brush), while
|
||||
@@ -33,9 +33,9 @@ use crate::{Frame, RenderOptions, Rendered, Renderer};
|
||||
/// Largest super-tile raster side (pixels).
|
||||
const MAX_TILE_PX: f64 = 2048.0;
|
||||
|
||||
/// What a tile is kept under: the pattern's name and the bits of its raster scale
|
||||
/// ([`raster_scale`]).
|
||||
type TileKey = (String, u64, Ink);
|
||||
/// What a tile is kept under: the pattern's name, the bits of its raster scale
|
||||
/// ([`raster_scale`]), ink plane and undecoded-image mark setting.
|
||||
type TileKey = (String, u64, Ink, bool);
|
||||
|
||||
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
|
||||
/// the art of every pattern and symbol and the pixels of every image in the document, the
|
||||
@@ -169,12 +169,20 @@ pub(crate) fn raster_scale(s: f64, period: (f64, f64)) -> f64 {
|
||||
|
||||
/// Rasterize `region` (pattern space) of the infinite tiling into a `w`×`h` pixmap, colours as
|
||||
/// `ink` paints them.
|
||||
fn rasterize(r: &mut Renderer, doc: &Document, def: &PatternDef, region: Rect, w: u16, h: u16, ink: Ink) -> (Pixmap, bool) {
|
||||
let (w, h) = (w.max(1), h.max(1));
|
||||
fn rasterize(
|
||||
r: &mut Renderer,
|
||||
doc: &Document,
|
||||
def: &PatternDef,
|
||||
region: Rect,
|
||||
size: (u16, u16),
|
||||
ink: Ink,
|
||||
mark_undecoded_images: bool,
|
||||
) -> (Pixmap, bool) {
|
||||
let (w, h) = (size.0.max(1), size.1.max(1));
|
||||
let mut ctx = crate::single_threaded_context(w, h);
|
||||
let view = Affine::scale_non_uniform(w as f64 / region.width().max(1e-9), h as f64 / region.height().max(1e-9))
|
||||
* Affine::translate(-region.origin().to_vec2());
|
||||
let opts = RenderOptions::default();
|
||||
let opts = RenderOptions { mark_undecoded_images, ..Default::default() };
|
||||
r.image_left_out = Some(false);
|
||||
let pattern_images_left_out = images_left_out();
|
||||
r.check_plugins();
|
||||
@@ -254,7 +262,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let (pw, ph) = def.period();
|
||||
let scale = raster_scale(s, (pw, ph));
|
||||
let params = (def.tile, def.tile_type, def.overlap);
|
||||
let key = (name.to_string(), scale.to_bits(), ink);
|
||||
let key = (name.to_string(), scale.to_bits(), ink, f.opts.mark_undecoded_images);
|
||||
let cms = vectorcraft_color::cms::active();
|
||||
let hit = CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
@@ -283,7 +291,9 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine,
|
||||
let w = (pw * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let h = (ph * scale).ceil().clamp(1.0, 4096.0) as u16;
|
||||
let before = crate::paint::decode_failures();
|
||||
let Some((pm, image_left_out)) = with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), w, h, ink)) else {
|
||||
let Some((pm, image_left_out)) =
|
||||
with_tile_renderer(|r| rasterize(r, doc, def, Rect::new(0.0, 0.0, pw, ph), (w, h), ink, f.opts.mark_undecoded_images))
|
||||
else {
|
||||
ctx.set_paint(missing());
|
||||
return true;
|
||||
};
|
||||
@@ -322,7 +332,7 @@ pub fn render_pattern_swatch(doc: &Document, name: &str, size: u32) -> Option<Re
|
||||
let side = w.max(h);
|
||||
let region = Rect::from_center_size((w / 2.0, h / 2.0), (side, side));
|
||||
let px = size.clamp(1, 1024) as u16;
|
||||
let (pm, _) = with_tile_renderer(|r| rasterize(r, doc, def, region, px, px, Ink::Display))?;
|
||||
let (pm, _) = with_tile_renderer(|r| rasterize(r, doc, def, region, (px, px), Ink::Display, false))?;
|
||||
Some(Rendered { width: px as u32, height: px as u32, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
|
||||
|
||||
@@ -361,7 +361,7 @@ impl Renderer {
|
||||
self.begin_frame();
|
||||
(self.knockout, self.nested, self.backdrop, self.picture) = (false, 0, None, None);
|
||||
self.clip_paths.clear();
|
||||
self.draw_node(&mut ctx, &frame, n, true);
|
||||
self.draw_forced_arc(&mut ctx, &frame, &Arc::new(n.clone()));
|
||||
let pm = self.finish(&mut ctx, vello_cpu::RasterizerSettings::default());
|
||||
self.end_frame();
|
||||
Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
|
||||
@@ -157,23 +157,39 @@ impl Renderer {
|
||||
self.stats.drawn += 1;
|
||||
return;
|
||||
}
|
||||
if let Some(m) = a.extras().mask.as_deref()
|
||||
&& !m.disabled
|
||||
&& !f.opts.outline
|
||||
&& !self.is_shape(a)
|
||||
{
|
||||
// A masked object is a non-isolated group of its own, drawn over its own pixels: the
|
||||
// mask is rendered over those only.
|
||||
let bounds = self.bounds_of(f.symbols, a);
|
||||
let Some((r, sub)) = group::area(ctx, f, bounds) else { return };
|
||||
let Some(mask) = self.mask_values(&sub, m, r.w, r.h) else { return };
|
||||
let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a));
|
||||
self.group(ctx, f, Composite { mask: Some(mask), blends, bounds, ..Default::default() }, &mut |r, c, fr| r.draw_arc_body(c, fr, a));
|
||||
if self.draw_masked_arc(ctx, f, a, false) {
|
||||
return;
|
||||
}
|
||||
self.draw_arc_body(ctx, f, a);
|
||||
}
|
||||
|
||||
/// Draw `a` through its opacity mask when it has one. Forced roots draw regardless of their
|
||||
/// visibility and bounds.
|
||||
fn draw_masked_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>, force: bool) -> bool {
|
||||
let Some(m) = a.extras().mask.as_deref().filter(|m| !m.disabled && !f.opts.outline && !self.is_shape(a)) else { return false };
|
||||
// A masked object is a non-isolated group of its own, drawn over its own pixels: the
|
||||
// mask is rendered over those only.
|
||||
let bounds = self.bounds_of(f.symbols, a);
|
||||
let Some((r, sub)) = group::area(ctx, f, bounds) else { return true };
|
||||
let Some(mask) = self.mask_values(&sub, m, r.w, r.h) else { return true };
|
||||
let blends = a.blend != vectorcraft_color::BlendMode::Normal || (!a.isolate && self.blends_through(a));
|
||||
self.group(ctx, f, Composite { mask: Some(mask), blends, bounds, ..Default::default() }, &mut |r, c, fr| {
|
||||
if force {
|
||||
r.draw_node(c, fr, a, true);
|
||||
} else {
|
||||
r.draw_arc_body(c, fr, a);
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Draw an `Arc` regardless of its visibility and bounds, applying its opacity mask.
|
||||
pub(crate) fn draw_forced_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
|
||||
if !self.draw_masked_arc(ctx, f, a, true) {
|
||||
self.draw_node(ctx, f, a, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen,
|
||||
/// as luminance ([`mask_value`]).
|
||||
pub(super) fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Option<Vec<u8>> {
|
||||
@@ -191,8 +207,6 @@ impl Renderer {
|
||||
let failures = crate::paint::decode_failures();
|
||||
let pattern_images_left_out = crate::pattern::images_left_out();
|
||||
self.draw_kept_node(&mut mctx, &Frame { mt: false, ink: Ink::Display, ..*f }, &m.art);
|
||||
// Pattern tiles are drawn without undecoded-image marks, so an image a tile left out counts
|
||||
// with the marks on too.
|
||||
let incomplete = self.image_left_out.unwrap_or(false)
|
||||
|| crate::pattern::images_left_out() > pattern_images_left_out
|
||||
|| (!f.opts.mark_undecoded_images && crate::paint::decode_failures() > failures);
|
||||
@@ -489,7 +503,7 @@ impl Renderer {
|
||||
if kept {
|
||||
self.draw_kept_node(ctx, f, art);
|
||||
} else {
|
||||
self.draw_node(ctx, f, art, true);
|
||||
self.draw_forced_arc(ctx, f, art);
|
||||
}
|
||||
self.made_again = outer;
|
||||
}
|
||||
@@ -500,7 +514,7 @@ impl Renderer {
|
||||
pub(crate) fn draw_kept_node(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
|
||||
let live = vectorcraft_doc::live::is_live(a).then(|| a.clone());
|
||||
let outer = std::mem::replace(&mut self.live_root, live);
|
||||
self.draw_node(ctx, f, a, true);
|
||||
self.draw_forced_arc(ctx, f, a);
|
||||
self.live_root = outer;
|
||||
}
|
||||
|
||||
@@ -518,7 +532,7 @@ impl Renderer {
|
||||
fn draw_trace_view(&mut self, ctx: &mut RenderContext, f: &Frame, children: &[Arc<Node>], view: TraceView, knockout: bool) {
|
||||
let Some((source, shapes)) = children.split_first() else { return };
|
||||
if view.shows_source() && matches!(source.kind, NodeKind::Image(_)) {
|
||||
self.draw_node(ctx, f, source, true);
|
||||
self.draw_forced_arc(ctx, f, source);
|
||||
}
|
||||
if view.shows_result() {
|
||||
self.draw_children(ctx, f, shapes, knockout);
|
||||
|
||||
@@ -109,8 +109,11 @@ impl Renderer {
|
||||
}
|
||||
ctx.set_transform(f.view * xf);
|
||||
let closed = effects::stroke::is_closed(bp);
|
||||
// Keep hairlines visible when zoomed far out (at least ~1 device pixel).
|
||||
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9));
|
||||
// Keep hairlines visible when zoomed far out (at least ~1 device pixel). A width profile
|
||||
// multiplies the weight, so it is the profile's widest point that is kept visible: raising
|
||||
// the weight further would swell the whole stroke while the view zooms out (#995).
|
||||
let widest = st.profile.as_ref().map_or(1.0, |p| p.widest().max(1e-3));
|
||||
let width = effects::stroke::aligned_width(st, closed).max(f.px * 0.5 / xf.determinant().abs().sqrt().max(1e-9) / widest);
|
||||
let inside = st.align == StrokeAlign::Inside && closed;
|
||||
if inside {
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
|
||||
+187
-3
@@ -29,6 +29,7 @@ mod setup;
|
||||
mod strokegradient;
|
||||
mod tileedge;
|
||||
mod viewraster;
|
||||
mod widthzoom;
|
||||
|
||||
fn rect_node(d: &mut Document, r: Rect, fill: Paint, stroke: Paint, w: f64) -> Node {
|
||||
let id = d.alloc_id();
|
||||
@@ -662,6 +663,39 @@ fn trace_source_draws_the_source_image_of_the_listed_objects() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_source_applies_the_hidden_image_mask() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("trace-source".into(), ImageBlob::new("image/png", png));
|
||||
let image = ImageObject {
|
||||
key: "trace-source".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
xf: Affine::translate((10.0, 10.0)) * Affine::scale(80.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut source = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
source.visible = false;
|
||||
let mask = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
source.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true)));
|
||||
let result = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0);
|
||||
let mut trace = Node::group(d.alloc_id(), vec![Arc::new(source), Arc::new(result)]);
|
||||
trace.extras_mut().trace = Some(Box::new(serde_json::json!({ "view": "tracingResult" })));
|
||||
let trace_id = trace.id;
|
||||
d.insert(Some(d.layers[0].id), 0, trace).unwrap();
|
||||
|
||||
let opts = RenderOptions { trace_views: true, trace_source: Some(Arc::new(vec![trace_id])), background: Some([255; 4]), ..Default::default() };
|
||||
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(img.pixel(30, 50), [255, 0, 0, 255]);
|
||||
assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artboards_white_on_pasteboard() {
|
||||
let d = Document::new(50.0, 50.0);
|
||||
@@ -747,6 +781,94 @@ fn opacity_mask_without_clip_and_inverted() {
|
||||
assert_eq!(img.pixel(80, 50), [255, 0, 0, 255], "inverted clip background → visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mask_art_root_applies_its_own_mask() {
|
||||
use vectorcraft_doc::OpacityMask;
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let mut art = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
let inner = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
art.extras_mut().mask = Some(Box::new(OpacityMask::new(inner, true)));
|
||||
art.visible = false;
|
||||
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true)));
|
||||
let object_id = object.id;
|
||||
d.insert(Some(d.layers[0].id), 0, object).unwrap();
|
||||
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let mut renderer = Renderer::new();
|
||||
let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(img.pixel(30, 50), [255, 0, 0, 255]);
|
||||
assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]);
|
||||
let view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts });
|
||||
assert_eq!(view.pixel(30, 50), [255, 255, 255, 255]);
|
||||
assert_eq!(view.pixel(70, 50), [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_image_in_mask_art_root_mask_leaves_out_outer_object() {
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("broken-root-mask-image".into(), ImageBlob::new("image/png", b"not a PNG".to_vec()));
|
||||
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let mut art = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
let image = ImageObject {
|
||||
key: "broken-root-mask-image".into(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
xf: Affine::translate((30.0, 30.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
art.extras_mut().mask = Some(Box::new(OpacityMask::new(Node::new(d.alloc_id(), NodeKind::Image(Box::new(image))), false)));
|
||||
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, true)));
|
||||
let object_id = object.id;
|
||||
d.insert(Some(d.layers[0].id), 0, object).unwrap();
|
||||
|
||||
let opts = RenderOptions { background: Some([255; 4]), ..Default::default() };
|
||||
let mut renderer = Renderer::new();
|
||||
let img = renderer.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(img.pixel(70, 50), [255, 255, 255, 255]);
|
||||
let view = renderer.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mask_view: Some(object_id), ..opts });
|
||||
assert_eq!(view.pixel(70, 50), [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_symbol_art_root_applies_its_own_mask() {
|
||||
use vectorcraft_doc::{NodeKind, OpacityMask};
|
||||
|
||||
for grouped in [true, false] {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let red = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let mut art = if grouped { Node::group(d.alloc_id(), vec![Arc::new(red)]) } else { red };
|
||||
let inner = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
art.extras_mut().mask = Some(Box::new(OpacityMask::new(inner, true)));
|
||||
d.symbols.push(vectorcraft_doc::Symbol { name: "Masked".into(), art: Arc::new(art) });
|
||||
let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Masked".into(), xf: Affine::IDENTITY });
|
||||
d.insert(Some(d.layers[0].id), 0, instance).unwrap();
|
||||
|
||||
let img = render(&d);
|
||||
assert_eq!(img.pixel(30, 50), [255, 0, 0, 255], "grouped {grouped}");
|
||||
assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "grouped {grouped}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_thumbnail_applies_the_root_mask() {
|
||||
use vectorcraft_doc::OpacityMask;
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let mut node = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let mask = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), Paint::solid(Color::WHITE), Paint::None, 0.0);
|
||||
node.extras_mut().mask = Some(Box::new(OpacityMask::new(mask, true)));
|
||||
|
||||
let img = Renderer::new().render_node_thumbnail(&d, &node, 100, Some([255; 4])).unwrap();
|
||||
assert_eq!(img.pixel(25, 50), [255, 0, 0, 255]);
|
||||
assert_eq!(img.pixel(75, 50), [255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mask_with_undecodable_image_leaves_out_object() {
|
||||
use vectorcraft_doc::{Effect, ImageBlob, ImageObject, NodeKind, OpacityMask};
|
||||
@@ -823,9 +945,33 @@ fn opacity_mask_with_missing_image_in_pattern_leaves_out_object() {
|
||||
let img = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(img.pixel(70, 50), [255, 255, 255, 255], "frame {frame}: incomplete pattern mask leaves out the object");
|
||||
}
|
||||
// Pattern tiles draw no undecoded-image mark, so the object stays out with the marks on too.
|
||||
let marked = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts.clone() });
|
||||
assert_eq!(marked.pixel(70, 50), [255, 255, 255, 255], "a tile without the mark leaves out the object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_mask_with_marked_image_in_pattern_draws_object() {
|
||||
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind, OpacityMask};
|
||||
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
d.images.insert("cut-mask".into(), ImageBlob::new("image/png", b"not a PNG: pattern mask".to_vec()));
|
||||
let image =
|
||||
ImageObject { key: "cut-mask".into(), width: 20, height: 20, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
let mut pattern = PatternDef::new("Cut mask", vec![Arc::new(image)]);
|
||||
pattern.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
d.patterns.push(pattern);
|
||||
let art = rect_node(&mut d, Rect::new(10.0, 10.0, 50.0, 90.0), pattern_paint("Cut mask"), Paint::None, 0.0);
|
||||
let mut object = rect_node(&mut d, Rect::new(10.0, 10.0, 90.0, 90.0), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
object.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false)));
|
||||
d.insert(Some(d.layers[0].id), 0, object).unwrap();
|
||||
|
||||
let mut r = Renderer::new();
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let off = r.render(&d, 100, 100, Affine::IDENTITY, &opts);
|
||||
assert_eq!(off.pixel(70, 50), [255, 255, 255, 255], "an unmarked missing image leaves out the object");
|
||||
let on = r.render(&d, 100, 100, Affine::IDENTITY, &RenderOptions { mark_undecoded_images: true, ..opts });
|
||||
assert_eq!(on.pixel(70, 50), [255, 0, 0, 255], "the marked image keeps the mask complete");
|
||||
assert!(on.pixel(15, 15)[1] < 255, "the marked pattern contributes mask coverage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1367,6 +1513,44 @@ fn an_undecodable_image_is_tried_once_and_marked_on_screen() {
|
||||
assert_eq!((attempts() - tried, decode_failures() - failed), (1, 3), "and counts it without decoding it again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_tile_follows_undecoded_image_mark_in_both_cache_orders() {
|
||||
use vectorcraft_doc::pattern::{PatternDef, pattern_paint};
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject, NodeKind};
|
||||
|
||||
let mut d = Document::new(80.0, 80.0);
|
||||
d.images.insert("cut-pattern".into(), ImageBlob::new("image/png", b"not a PNG: pattern fill".to_vec()));
|
||||
let image = ImageObject {
|
||||
key: "cut-pattern".into(),
|
||||
width: 20,
|
||||
height: 20,
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
let mut pattern = PatternDef::new("Cut pattern", vec![Arc::new(image)]);
|
||||
pattern.tile = Rect::new(0.0, 0.0, 20.0, 20.0);
|
||||
d.patterns.push(pattern);
|
||||
let shape = rect_node(&mut d, Rect::new(10.0, 10.0, 70.0, 70.0), pattern_paint("Cut pattern"), Paint::None, 0.0);
|
||||
d.insert(Some(d.layers[0].id), 0, shape).unwrap();
|
||||
|
||||
let mut r = Renderer::new();
|
||||
for order in [[false, true, false], [true, false, true]] {
|
||||
for mark in order {
|
||||
let opts = RenderOptions { mark_undecoded_images: mark, ..Default::default() };
|
||||
let pixel = r.render(&d, 80, 80, Affine::IDENTITY, &opts).pixel(15, 15);
|
||||
assert_eq!(pixel[3] > 0, mark, "mark_undecoded_images {mark}: {pixel:?}");
|
||||
if mark {
|
||||
assert_eq!(pixel[0], pixel[1], "the pattern draws a gray mark");
|
||||
assert_eq!(pixel[1], pixel[2], "the pattern draws a gray mark");
|
||||
assert!((120..=140).contains(&pixel[0]), "the pattern draws a gray mark: {pixel:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rasters a renderer keeps of an object's raster effects are made again when
|
||||
/// `mark_undecoded_images` changes: a blurred image whose pixels can't be decoded shows its blurred
|
||||
/// mark only while the option is on.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user