Merge main (11862f61) into feat/native-save-precision

This commit is contained in:
dotCooCoo
2026-11-02 16:29:45 -08:00
537 changed files with 54516 additions and 4735 deletions
+22 -22
View File
@@ -13,27 +13,27 @@ on:
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
# A newer push to a PR branch cancels its older run.
concurrency:
group: print-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# Desktop printing on Windows, through the suite's shared gate. Pull requests restore the cache
# but never save one; a manual run on main saves the cache they restore.
windows:
runs-on: windows-2022
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # master
with:
toolchain: stable
components: clippy
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: desktop-print
- run: cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
- run: cargo test --locked -p vectorcraft
- name: Check pending submission and queue refusal feedback
shell: bash
# A filter that matches nothing still passes, so fail unless some test ran.
run: |
cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee printps.log
grep -Eq 'test result: ok\. [1-9][0-9]* passed' printps.log || { echo "::error::tests::printps matched no tests"; exit 1; }
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
os: '["windows-2022"]'
timeout-minutes: 90
# As before: printing builds and tests without craft-fonts, nextest or the wasm target.
craft-fonts-ref: ""
tools: ""
targets: ""
# A filter that matches nothing still passes, so the printps run fails unless some test ran.
command: |
cargo clippy --locked -p vectorcraft --all-targets -- -D warnings
cargo test --locked -p vectorcraft
log="$RUNNER_TEMP/printps.log"
cargo test --locked -p vectorcraft-ui-egui --lib -- tests::printps | tee "$log"
grep -Eq 'test result: ok\. [1-9][0-9]* passed' "$log" || { echo "::error::tests::printps matched no tests"; exit 1; }
+1
View File
@@ -63,6 +63,7 @@ jobs:
needs: git-sources
permissions:
contents: write
packages: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
+7 -4
View File
@@ -224,13 +224,16 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-swap.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-swatch-kinds.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-symbol-sprayer.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-touch-type.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-touch-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-transform-panel.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-transparency.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-twirl.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type-area.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type-vertical.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type-vertical-area.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-type-vertical-path.svg` | VectorCraft contributors (designed by ham9zah) | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-width-profile.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-width.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-wrinkle.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
Generated
+54 -29
View File
@@ -705,6 +705,17 @@ dependencies = [
"shlex",
]
[[package]]
name = "cfb"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b"
dependencies = [
"fnv",
"uuid",
"web-time",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
@@ -1070,6 +1081,17 @@ dependencies = [
"zip",
]
[[package]]
name = "craft-vectorcraft-format"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=14d5dbb50c317b8747a9cf9eb35ab8a22515f137#14d5dbb50c317b8747a9cf9eb35ab8a22515f137"
dependencies = [
"flate2",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "craft-window"
version = "0.1.0"
@@ -2841,7 +2863,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=fb08be5fa19eee8d37e624397fcdc96df6035e92#fb08be5fa19eee8d37e624397fcdc96df6035e92"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6050,7 +6072,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -6095,7 +6117,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"crc32fast",
"flate2",
@@ -6106,7 +6128,7 @@ dependencies = [
[[package]]
name = "vectorcraft-brush"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -6118,7 +6140,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6134,7 +6156,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"log",
"objc2-core-foundation",
@@ -6156,7 +6178,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -6168,7 +6190,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"chrono",
"hayro-ccitt",
@@ -6187,7 +6209,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6203,8 +6225,10 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"cfb",
"chrono",
"craft-docpath",
"crc32fast",
"flate2",
@@ -6243,7 +6267,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"fax",
"flate2",
@@ -6267,8 +6291,9 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"craft-vectorcraft-format",
"flate2",
"proptest",
"rustix 1.1.6",
@@ -6282,7 +6307,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -6294,7 +6319,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"image",
"log",
@@ -6313,7 +6338,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6327,7 +6352,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -6339,7 +6364,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"aes",
"chrono",
@@ -6371,7 +6396,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"serde",
"serde_json",
@@ -6385,7 +6410,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"flate2",
"gif 0.14.2",
@@ -6415,14 +6440,14 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"proptest",
]
[[package]]
name = "vectorcraft-svg"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"craft-docpath",
"flate2",
@@ -6447,7 +6472,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"fax",
"image",
@@ -6465,7 +6490,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -6486,7 +6511,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -6494,7 +6519,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6511,7 +6536,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6523,7 +6548,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"bytemuck",
"craft-keymap",
@@ -6561,7 +6586,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"eframe",
"egui",
@@ -7692,7 +7717,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.19.0"
version = "0.21.0"
dependencies = [
"serde_json",
]
+6 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.19.0"
version = "0.21.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -19,6 +19,8 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "92d392c
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "14d5dbb50c317b8747a9cf9eb35ab8a22515f137" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# internal
@@ -150,7 +152,9 @@ hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "92d3
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# The PDF writer takes craft-libs#417 (CMYK, gray and spot colors keep their exact inks); the other
# craft-libs crates stay at 92d392cf. craft-pdf's sources are the same at both revisions.
krilla = { git = "https://github.com/storytold/craft-libs", rev = "fb08be5fa19eee8d37e624397fcdc96df6035e92" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+10 -1
View File
@@ -328,12 +328,21 @@ stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | **Vector illustration · you are here** | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+11 -1
View File
@@ -9,7 +9,7 @@
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Document Tools add-on's New Symbols from Layers; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Color Effects add-on's Find Nearest Swatch; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
@@ -129,6 +129,11 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
## Progress log
- 2026-10-31: Pattern libraries: Window › Swatch Libraries › Patterns and the Swatches panel's library menu open Geometric Patterns, a built-in library of eleven pattern swatches (dots, stripes, checks, a grid and shapes); clicking one adds its pattern and swatch to the document, once, and applies it to the active fill or stroke (`swatch.library.add`).
- 2026-11-01: Find Nearest Swatch joins the Color Effects add-on (Object › Plug-ins › Color Effects): for each colour of the selection, the closest swatches of the document, a built-in library or a library or colour book the user loaded, with their CIEDE2000 difference; Apply gives each colour its nearest swatch as one undo step, linking global and spot swatches and adding a library's to the document first, so a process colour becomes the closest spot colour (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`, #4923).
- 2026-11-01: Expand Dashes into Paths joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): each selected path's dashed stroke becomes separate open paths, one per dash, laid out exactly as the stroke draws them (pattern, offset, Align Dashes to Corners and Path Ends), each keeping the stroke with the dashes off, so the gaps are real gaps for cutters and plotters, as one undo step (`addon.pathtools.expandDashes`, #4922).
- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925).
- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917).
- 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged.
- 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `<name>_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged.
- 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged.
@@ -145,6 +150,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
- 2026-10-22: Effect › Sketch › Conté Crayon: a black crayon in the dark areas and a white one in the light areas, on mid-grey paper whose texture (Brick, Burlap, Canvas, Sandstone; Scaling, Relief, Light Direction, Invert) breaks the strokes, with Foreground Level and Background Level; an original approximation, defaults unverified. Every Photoshop-style filter is now in (57/57); the Effect Gallery remains (4–6 h).
- 2026-10-22: Ruby (ふりがな) for live text: `type.ruby` sets or removes a reading over a byte range of the selected type — above it in horizontal type, right of the column in vertical type (or below/left), start/centre/end aligned, at 1–100% of the base size (default half), overhanging or squeezed to the base width. It is saved in the native file, laid out with its line growing (following lines move on), drawn on the canvas, and written in SVG and PDF as outlines where ruby is present. UI panel and type on a path are next. Headline numbers unchanged.
- 2026-10-22: PDF accessibility: titled exports set `/ViewerPreferences << /DisplayDocTitle true >>`, so viewers show the document title rather than the file name and title checks can pass (#3646). Headline numbers unchanged.
- 2026-10-22: Pen tilt, bearing and barrel rotation (part of #764): Calligraphic and Scatter brushes take Tilt, Bearing and Rotation beside Pressure, the Paintbrush records them along each stroke, and the app reads a pen's tilt and an Art Pen's barrel rotation from XInput2 on Linux under X11 and XWayland and from AppKit's tablet events on macOS. Headline numbers unchanged.
- 2026-10-14: Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged.
@@ -208,6 +214,10 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | minor | Progress log row for the Color Effects add-on's Find Nearest Swatch (#4923); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Path Precision Tools add-on's Expand Dashes into Paths (#4922); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Document Tools add-on's New Symbols from Layers (#4927); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for the Document Tools add-on's Save a Timestamped Copy (#4926); headline numbers unchanged. |
| 2026-11-01 | minor | Progress log row for Divide into Dashes (Path Precision Tools add-on, #4950); headline numbers unchanged. |
+10 -2
View File
@@ -116,7 +116,8 @@ fn edit_drags(n: usize) -> Result<(f64, f64), String> {
let view = vectorcraft_engine::ViewInfo { zoom: 8.0, ..Default::default() };
let err = |e: vectorcraft_engine::EngineError| e.to_string();
let mut drag = |down: Point| -> Result<f64, String> {
let ev = |kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0 };
let ev =
|kind, y: f64| PointerEvent { kind, pos: down + Vec2::new(0.0, y), mods: Mods::default(), backquote: false, pressure: 1.0, pen: None };
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 400]]})).map_err(err)?;
s.select_tool("directSelection", view).map_err(err)?;
let undo = s.doc().map_err(err)?.history.undo.len();
@@ -215,7 +216,14 @@ fn deep(n: usize) -> Result<(), String> {
let first = s.doc().map_err(err)?.selection.objects.first().copied();
let at = first.and_then(|id| s.doc().ok()?.doc.node(id)?.geometric_bounds()).map(|b| b.center());
if let Some(down) = at {
let ev = |kind, dx: f64| PointerEvent { kind, pos: down + Vec2::new(dx, 0.0), mods: Mods::default(), backquote: false, pressure: 1.0 };
let ev = |kind, dx: f64| PointerEvent {
kind,
pos: down + Vec2::new(dx, 0.0),
mods: Mods::default(),
backquote: false,
pressure: 1.0,
pen: None,
};
s.select_tool("selection", view).map_err(err)?;
s.pointer(&ev(PointerKind::Down, 0.0), view).map_err(err)?;
let mut dx = 0.0;
+2 -2
View File
@@ -23,7 +23,7 @@ use std::cell::RefCell;
use std::rc::Rc;
use egui::{Event, PointerButton, Pos2, TouchDeviceId, TouchId, TouchPhase};
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
use vectorcraft_ui_egui::touch::{self, Source};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast as _;
@@ -95,7 +95,7 @@ impl Pointers {
/// A pen moved or pressed with `pressure`, with the end it last pressed with.
fn pen_sample(&self, pressure: f32) {
self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser }));
self.stylus.set_sample(Some(PenSample { pressure, eraser: self.eraser, pose: PenPose::default() }));
}
fn touch(&mut self, device: TouchDeviceId, id: i32, phase: TouchPhase, pos: Pos2, force: Option<f32>) {
+64 -18
View File
@@ -20,7 +20,7 @@ use std::io::{Cursor, Read};
use std::path::PathBuf;
use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour};
use vectorcraft_ui_egui::SystemClipboard;
use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard};
/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where
/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11).
@@ -172,7 +172,8 @@ mod win {
use clipboard_win::{formats, options::NoClear, raw};
use super::{
BITMAP, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of, windows_formats,
BITMAP, ClipboardRead, Cursor, Flavour, PNG, SVG, SystemClipboard, TEXT, copied_file, decode_png, dib_image, png_flavour, text_of,
windows_formats,
};
/// The device-independent bitmaps, the one that carries alpha first. Each is there whenever the
@@ -249,10 +250,16 @@ mod win {
// A clipboard manager may have copied our data again as its own.
let Some(ours) = self.text.as_deref() else { return false };
let Ok(_open) = clipboard_win::Clipboard::new_attempts(10) else { return false };
read_one(TEXT).is_some_and(|f| f.data == ours.as_bytes())
read_one(TEXT).and_then(ClipboardRead::flavour).is_some_and(|f| f.data == ours.as_bytes())
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
self.read_deferred(mimes)?.flavour()
}
/// A device-independent bitmap is copied off the clipboard here and decoded and encoded
/// as PNG by the work that comes back.
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
let mut files = vec![];
{
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
@@ -260,7 +267,7 @@ mod win {
let _ = raw::get_file_list_path(&mut files);
}
// The files are read with the clipboard closed again, not keeping other apps waiting.
copied_file(files, mimes).or_else(|| {
copied_file(files, mimes).map(ClipboardRead::Now).or_else(|| {
let _open = clipboard_win::Clipboard::new_attempts(10).ok()?;
mimes.iter().find_map(|m| read_one(m))
})
@@ -284,16 +291,28 @@ mod win {
})
}
/// One flavour, while the clipboard is open.
fn read_one(mime: &'static str) -> Option<Flavour> {
/// A device-independent bitmap as a PNG flavour.
fn dib_png(dib: &[u8]) -> Option<Flavour> {
png_flavour(&dib_image(dib)?)
}
/// Copy both bitmap formats while the clipboard is open, then decode them in preference order.
pub(super) fn read_dibs(mut get: impl FnMut(u32) -> Option<Vec<u8>>) -> Option<ClipboardRead> {
let dibs: Vec<_> = DIBS.into_iter().filter_map(&mut get).collect();
(!dibs.is_empty()).then_some(ClipboardRead::Later(Box::new(move || dibs.iter().find_map(|dib| dib_png(dib)))))
}
/// One flavour, while the clipboard is open. A device-independent bitmap is copied here, and
/// the work that comes back decodes each captured format in preference order.
fn read_one(mime: &'static str) -> Option<ClipboardRead> {
let mut data = vec![];
let mime = match mime {
BITMAP if read_registered(PNG, &mut data) => PNG,
BITMAP => {
return DIBS.into_iter().find_map(|id| {
data.clear();
raw::get_vec(id, &mut data).ok()?;
png_flavour(&dib_image(&data)?)
return read_dibs(|id| {
let mut dib = vec![];
raw::get_vec(id, &mut dib).ok()?;
Some(dib)
});
}
TEXT => {
@@ -309,7 +328,7 @@ mod win {
{
data.truncate(end);
}
(!data.is_empty()).then_some(Flavour { mime, data })
(!data.is_empty()).then_some(ClipboardRead::Now(Flavour { mime, data }))
}
}
@@ -322,7 +341,7 @@ mod portable {
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
use super::{BITMAP, ClipboardRead, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
/// What our last write put on the clipboard.
enum Ours {
@@ -580,22 +599,27 @@ mod portable {
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
self.read_deferred(mimes)?.flavour()
}
/// arboard's image is read here and encoded as PNG by the work that comes back.
fn read_deferred(&mut self, mimes: &[&'static str]) -> Option<ClipboardRead> {
let cb = self.cb(Asker::User).ok()?;
if let Some(f) = copied_file(cb.file_list(), mimes) {
return Some(f);
return Some(ClipboardRead::Now(f));
}
mimes.iter().find_map(|m| {
#[cfg(target_os = "macos")]
if let Some(f) = super::mac::read(m) {
return Some(f);
return Some(ClipboardRead::Now(f));
}
match *m {
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| Flavour { mime: TEXT, data: t.into_bytes() }),
TEXT => cb.get_text().ok().filter(|t| !t.is_empty()).map(|t| ClipboardRead::Now(Flavour { mime: TEXT, data: t.into_bytes() })),
BITMAP => {
let img = cb.get_image().ok()?;
let rgba =
image::RgbaImage::from_raw(u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?, img.bytes.into_owned())?;
png_flavour(&rgba)
let (width, height) = (u32::try_from(img.width).ok()?, u32::try_from(img.height).ok()?);
let bytes = img.bytes.into_owned();
Some(ClipboardRead::Later(Box::new(move || png_flavour(&image::RgbaImage::from_raw(width, height, bytes)?))))
}
_ => None,
}
@@ -1057,6 +1081,28 @@ mod tests {
img.pixels().map(|p| p.0).collect()
}
#[test]
#[cfg(windows)]
fn dib_fallback_captures_both_formats_before_decoding() {
use clipboard_win::formats::{CF_DIB, CF_DIBV5};
let valid = dib(40, 1, 1, 32, 0, &[], &bgra(&[RED]));
let mut captured = Vec::new();
let read = win::read_dibs(|id| {
captured.push(id);
match id {
CF_DIBV5 => Some(vec![40, 0, 0]),
CF_DIB => Some(valid.clone()),
_ => None,
}
})
.unwrap();
assert_eq!(captured, [CF_DIBV5, CF_DIB]);
let flavour = read.flavour().unwrap();
assert_eq!(flavour.mime, PNG);
assert_eq!(pixels(&decode_png(&flavour.data).unwrap()), [RED]);
}
#[test]
fn a_screenshot_dib_pastes_opaque_and_upright() {
// CF_DIB as screenshots copy it: 32-bit BI_RGB, bottom row first, the fourth byte zero.
+116 -21
View File
@@ -1,6 +1,7 @@
//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a
//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's
//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]).
//! macOS: a drawing tablet's pen pressure (#852), tilt and barrel rotation (#764). winit 0.30
//! hands a pen to the window there as a mouse, without them, so the app reads them from AppKit
//! itself and writes them into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas
//! reads, as on X11 ([`crate::tablet`]).
//!
//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with
//! each event it is about to dispatch" in this app:
@@ -9,9 +10,14 @@
//! it on unchanged:
//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure`
//! (0..1) goes into the feed, kept for the pointer move winit makes of the event
//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event
//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move.
//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. Its `tilt`
//! (−1..1 on each axis, ±[`TILT_DEGREES`] at the ends) and its `rotation` go into the feed as the
//! pen's pose, kept for that move too. AppKit gives a rotation of 0 for a pen that does not read
//! one and a tilt of 0 for an upright pen, so one event cannot tell a pen without tilt or rotation
//! from one held upright and unturned. Each reads as none until the pen reports a value other
//! than 0 after it comes near ([`Pen`]).
//! A mouse event of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
//! - An `NSEventTypeTabletPoint` event carries the same pressure, tilt and rotation without a move.
//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says
//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so,
//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck
@@ -20,12 +26,14 @@
//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which
//! merges the drags that queue up between two reads into one: every report of the pen arrives.
//!
//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the
//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor
//! reads them.
//! The event mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`). The tilt
//! and rotation follow Apple's `NSEvent` documentation, as [`RawEvent::pose`] describes. The mapping
//! works on plain values and is tested on every platform; the monitor reads them.
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
use crate::tablet::TILT_DEGREES;
/// `NSEventType` values (AppKit headers).
mod event_type {
@@ -56,6 +64,10 @@ struct RawEvent {
subtype: i16,
/// `pressure` (tablet point events and mouse events of that subtype only).
pressure: f32,
/// `tilt` (x, y), each −1..1 (the same events as `pressure`).
tilt: (f64, f64),
/// `rotation`, in degrees (the same events as `pressure`).
rotation: f32,
/// `pointingDeviceType` (proximity events only).
device: usize,
/// `isEnteringProximity` (proximity events only).
@@ -74,6 +86,40 @@ impl RawEvent {
fn point(&self) -> bool {
self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT)
}
/// The pen's tilt and rotation in [`PenPose`]'s units.
///
/// AppKit's `tilt` is −1.0..1.0 on each axis and 0.0 on both with the pen perpendicular to the
/// tablet. A negative x is a tilt to the left and a positive x a tilt to the right; a negative y
/// is a tilt to the top and a positive y a tilt to the bottom
/// (<https://developer.apple.com/documentation/appkit/nsevent/tilt>). [`PenPose`]'s x is positive
/// to the right and its y positive down the page, the same signs, so each axis keeps its sign.
/// Apple gives the ends of the range as maximum tilt and does not give them in degrees
/// (<https://developer.apple.com/documentation/coregraphics/cgeventfield/tableteventtiltx>), so
/// ±1.0 is scaled to ±[`TILT_DEGREES`], the degrees the X11 reader ([`crate::tablet`]) gives the
/// ends of a tilt range that is not in degrees. A value past ±1.0 is clamped.
///
/// AppKit's `rotation` is in degrees, and a device without rotation reports 0.0
/// (<https://developer.apple.com/documentation/appkit/nsevent/rotation>). Apple's tablet pages give
/// no direction for rotation; the Cocoa Event Handling Guide gives the `rotation` accessor's
/// degrees as counterclockwise for the rotate gesture
/// (<https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/EventOverview/HandlingTouchEvents/HandlingTouchEvents.html>),
/// and the pen's rotation is taken the same way. [`PenPose`]'s rotation runs clockwise, so the
/// angle is negated and brought into 0..360.
///
/// The tilt is left out unless `tilts`, and the rotation unless `turns` ([`Pen`]).
fn pose(&self, tilts: bool, turns: bool) -> PenPose {
let degrees = |v: f64| (v.clamp(-1.0, 1.0) * TILT_DEGREES) as f32;
// `0.0 - r` rather than `-r`: a rotation of 0 gives 0, not −0.
let rotation = turns.then(|| (0.0 - self.rotation).rem_euclid(360.0));
PenPose { tilt: tilts.then(|| [degrees(self.tilt.0), degrees(self.tilt.1)]), rotation }
}
/// Whether the event reports a tilt other than 0, and a rotation other than 0.
fn reads(&self) -> (bool, bool) {
let read = |v: f64| v.is_finite() && v != 0.0;
(read(self.tilt.0) || read(self.tilt.1), read(f64::from(self.rotation)))
}
}
/// The events the monitor reads (`NSEventMask` bits are `1 << type`).
@@ -86,7 +132,7 @@ fn event_mask() -> u64 {
enum Update {
/// Nothing: an event the monitor doesn't map.
Keep,
/// The pressure and end of a pen, or `None` for a mouse.
/// The sample of a pen, or `None` for a mouse.
Set(Option<PenSample>),
/// A pen's sample that comes with a pointer move.
Move(PenSample),
@@ -102,10 +148,14 @@ impl Update {
}
}
/// Which end of the pen is near the tablet.
/// Which end of the pen is near the tablet, and whether it has reported a tilt or a rotation other
/// than 0 since it came near: until it has, its tilt or rotation reads as none, since AppKit gives
/// 0 for a pen without one and for one held upright or unturned.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Pen {
eraser: bool,
tilts: bool,
turns: bool,
}
impl Pen {
@@ -113,10 +163,16 @@ impl Pen {
if e.proximity() {
// Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse.
self.eraser = e.entering && e.device == device::ERASER;
(self.tilts, self.turns) = (false, false);
let pen = e.entering && e.device != device::CURSOR;
return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser }));
return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser, ..PenSample::default() }));
}
let sample = PenSample { pressure: e.pressure, eraser: self.eraser };
if e.point() {
let (tilts, turns) = e.reads();
self.tilts |= tilts;
self.turns |= turns;
}
let sample = PenSample { pressure: e.pressure, eraser: self.eraser, pose: e.pose(self.tilts, self.turns) };
match (e.point(), e.mouse()) {
(true, true) => Update::Move(sample),
(true, false) => Update::Set(Some(sample)),
@@ -187,6 +243,9 @@ mod monitor {
}
if raw.point() {
raw.pressure = e.pressure();
let tilt = e.tilt();
raw.tilt = (tilt.x, tilt.y);
raw.rotation = e.rotation();
}
if raw.proximity() {
raw.device = e.pointingDeviceType().0;
@@ -211,8 +270,14 @@ mod tests {
RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() }
}
/// A sample of the tip of a pen that has reported no tilt or rotation.
fn tip(pressure: f32) -> PenSample {
PenSample { pressure, eraser: false }
PenSample { pressure, eraser: false, pose: PenPose::default() }
}
/// A sample of the pen near the tablet but not yet read: no pose.
fn near(eraser: bool) -> PenSample {
PenSample { pressure: 0.0, eraser, pose: PenPose::default() }
}
#[test]
@@ -240,16 +305,45 @@ mod tests {
#[test]
fn proximity_says_which_end_is_near() {
let mut p = Pen::default();
let eraser = |pressure| PenSample { pressure, eraser: true };
assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0))));
let eraser = |pressure| PenSample { pressure, eraser: true, pose: PenPose::default() };
assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(near(true))));
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4)));
assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left");
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4)));
assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip");
assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip");
assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(near(false))), "the tip");
assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(near(false))), "an unknown tool draws as the tip");
assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse");
let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };
assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype");
assert_eq!(p.handle(&e), Update::Set(Some(near(true))), "as a mouse event's subtype");
}
/// #764: AppKit's tilt (−1..1, x positive to the right, y positive to the bottom) reaches the
/// feed in degrees with the same signs, and its rotation, taken as counterclockwise, reaches it
/// clockwise.
#[test]
fn a_pens_tilt_and_rotation_go_into_the_feed() {
let mut p = Pen::default();
let sample = |pressure, pose| PenSample { pressure, eraser: false, pose };
assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)), "no tilt or rotation reported yet");
let leaning = RawEvent { tilt: (0.5, -0.25), rotation: 30.0, ..pen(DRAG, 0.5) };
let pose = PenPose { tilt: Some([30.0, -15.0]), rotation: Some(330.0) };
assert_eq!(p.handle(&leaning), Update::Move(sample(0.5, pose)));
let past_the_ends = RawEvent { tilt: (-4.0, 1.5), rotation: 270.0, ..pen(event_type::TABLET_POINT, 0.8) };
let pose = PenPose { tilt: Some([-60.0, 60.0]), rotation: Some(90.0) };
assert_eq!(p.handle(&past_the_ends), Update::Set(Some(sample(0.8, pose))), "clamped");
let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
let Update::Move(upright) = p.handle(&pen(DRAG, 0.6)) else { panic!("a pen's drag is a move") };
assert_eq!(upright, sample(0.6, pose), "once reported, 0 is upright and unturned");
assert!(upright.pose.rotation.is_some_and(f32::is_sign_positive), "{upright:?}");
let feed = StylusFeed::default();
p.handle(&RawEvent { tilt: (f64::NAN, 0.0), rotation: -90.0, ..pen(DRAG, 0.3) }).apply(&feed);
assert_eq!(feed.pose(), Some(PenPose { tilt: None, rotation: Some(90.0) }), "a tilt that is not a number is left out");
p.handle(&proximity(1, true));
let tilted = RawEvent { tilt: (0.0, 0.5), ..pen(event_type::TABLET_POINT, 0.8) };
let pose = PenPose { tilt: Some([0.0, 30.0]), rotation: None };
assert_eq!(p.handle(&tilted), Update::Set(Some(sample(0.8, pose))), "no rotation reported since the pen came near");
p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
assert_eq!(feed.pose(), None, "a mouse moved");
}
#[test]
@@ -259,7 +353,8 @@ mod tests {
for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] {
p.handle(&e).apply(&feed);
}
assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
let pressures: Vec<f32> = feed.take_moves().into_iter().map(|(p, _)| p).collect();
assert_eq!((pressures, feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
assert_eq!(feed.held(), None, "a mouse moved");
}
+198 -1
View File
@@ -171,6 +171,11 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
let (Some(base), Some(mine), Value::Object(mut out)) = (base.as_object(), mine.as_object(), on_disk) else {
return mine.clone();
};
// The current Actions set (`action_set`, an index) is this window's when this window chose
// another one or the other window did not; otherwise it is the other window's.
let use_mine = chose_another_action_set(base, mine) || !chose_another_action_set(base, &out);
// The list the saved current set was chosen from.
let chosen_from = if use_mine { mine.get("action_sets").cloned() } else { out.get("action_sets").cloned() };
let keys: std::collections::BTreeSet<&String> = base.keys().chain(mine.keys()).collect();
for key in keys {
if PASSING.contains(&key.as_str()) {
@@ -180,7 +185,7 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
continue;
}
let (was, now) = (base.get(key), mine.get(key));
if was == now {
if was == now || key == "action_set" {
continue;
}
if key == "engine_prefs"
@@ -209,9 +214,95 @@ fn merge_changes(base: &serde_json::Value, mine: &serde_json::Value, on_disk: se
}
}
}
if use_mine {
match mine.get("action_set") {
Some(v) => {
out.insert("action_set".to_string(), v.clone());
}
None => {
out.remove("action_set");
}
}
}
keep_current_action_set(&mut out, chosen_from.as_ref());
Value::Object(out)
}
/// Point the saved current Actions set (`action_set`) at the same set in the merged
/// `action_sets` when the merge took the index from one window and the list from another
/// ([`find_action_set`]). With no such set, no set is current.
fn keep_current_action_set(out: &mut serde_json::Map<String, serde_json::Value>, chosen_from: Option<&serde_json::Value>) {
use serde_json::Value;
let Some(i) = out.get("action_set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()) else { return };
let merged = out.get("action_sets");
if merged == chosen_from {
return;
}
match find_action_set(chosen_from, i, merged) {
Some(j) => {
out.insert("action_set".to_string(), Value::from(j));
}
None => {
out.remove("action_set");
}
}
}
/// Was another Actions set made current from `base` to `now`? Recording into the current set,
/// renaming it, deleting it, or deleting a set before it changes the list or the index without
/// choosing another set.
fn chose_another_action_set(base: &serde_json::Map<String, serde_json::Value>, now: &serde_json::Map<String, serde_json::Value>) -> bool {
use serde_json::Value;
let index = |v: &serde_json::Map<String, Value>| v.get("action_set").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
let (was, sets) = (base.get("action_sets"), now.get("action_sets"));
let (Some(i), Some(k)) = (index(base), index(now)) else {
return match (index(base), index(now)) {
(None, None) => false,
// No set is current: another set was chosen only if the current one is still there.
(Some(i), None) => find_action_set(was, i, sets).is_some(),
_ => true,
};
};
if was == sets {
return i != k;
}
if let Some(j) = find_action_set(was, i, sets) {
return j != k;
}
// The set at the same place changed while every other set stayed as it was: it was edited.
let edited_in_place = was
.and_then(Value::as_array)
.zip(sets.and_then(Value::as_array))
.is_some_and(|(was, sets)| was.len() == sets.len() && was.iter().zip(sets).enumerate().all(|(j, (a, b))| j == i || a == b));
!(i == k && edited_in_place)
}
/// Where the Actions set at index `i` of `old` is in `new` (two `action_sets` lists): found by
/// its contents when no other set in either list has the same contents, otherwise by its name when
/// no other set in either list has it.
fn find_action_set(old: Option<&serde_json::Value>, i: usize, new: Option<&serde_json::Value>) -> Option<usize> {
use serde_json::Value;
let (old, new) = (old?.as_array()?, new?.as_array()?);
let set = old.get(i)?;
let only = |list: &[Value], same: &dyn Fn(&Value) -> bool| {
let mut found = list.iter().enumerate().filter(|(_, s)| same(s));
match (found.next(), found.next()) {
(Some((j, _)), None) => Some(j),
_ => None,
}
};
let same_contents = |s: &Value| s == set;
if only(old, &same_contents).is_some()
&& let Some(j) = only(new, &same_contents)
{
return Some(j);
}
let name = set.get("name")?;
let same_name = |s: &Value| s.get("name") == Some(name);
only(old, &same_name)?;
only(new, &same_name)
}
/// The preferences file's contents: the UI state with the engine preferences.
#[cfg(test)]
pub(crate) fn prefs_bytes(app: &VectorcraftApp) -> serde_json::Result<Vec<u8>> {
@@ -274,6 +365,112 @@ mod tests {
assert!(!same_lasting(&base, &mine));
}
/// One window deleted the first Actions set and saved; this one made the second set current.
/// The saved index follows that set to its place in the merged list.
#[test]
fn the_current_action_set_follows_its_set_in_the_merged_list() {
let set = |name: &str| json!({"name": name, "actions": []});
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
let on_disk = json!({"action_sets": [set("B"), set("C")]});
let mine = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 0}));
// The other window also recorded into B: B is found by its name.
let on_disk = json!({"action_sets": [{"name": "B", "actions": [{"name": "Action 1", "steps": []}]}, set("C")]});
let merged = merge_changes(&base, &mine, on_disk);
assert_eq!(merged["action_set"], json!(0));
// The other window deleted B: no set is current.
let on_disk = json!({"action_sets": [set("A"), set("C")]});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("A"), set("C")]}));
}
/// This window deleted the first set while the index saved by the other window still stands.
#[test]
fn the_other_windows_current_action_set_follows_its_set() {
let set = |name: &str| json!({"name": name, "actions": []});
let base = json!({"action_sets": [set("A"), set("B"), set("C")]});
let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
let mine = json!({"action_sets": [set("B"), set("C")]});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
}
/// The other window replaced or renamed the set this one made current (the saved sets do not
/// tell the two apart): no set is current.
#[test]
fn a_set_in_the_place_of_the_current_set_is_not_made_current() {
let set = |name: &str| json!({"name": name, "actions": []});
let base = json!({"action_sets": [set("A"), set("B"), set("C")]});
let on_disk = json!({"action_sets": [set("A"), set("D"), set("C")]});
let mine = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
assert_eq!(merge_changes(&base, &mine, on_disk).get("action_set"), None);
let on_disk = json!({"action_sets": [set("A"), set("C"), set("D")]});
assert_eq!(merge_changes(&base, &mine, on_disk).get("action_set"), None);
}
/// This window deleted a set and made the set now at the old index current: its choice is
/// saved, though the index is the same number.
#[test]
fn a_new_current_set_at_the_same_index_is_saved() {
let set = |name: &str| json!({"name": name, "actions": []});
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
let on_disk = json!({"action_sets": [{"name": "A", "actions": [{"name": "Action 1", "steps": []}]}, set("B"), set("C")], "action_set": 1});
let mine = json!({"action_sets": [set("B"), set("C")], "action_set": 1});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
}
/// Recording into the current set does not choose it again: the other window's choice stays.
#[test]
fn recording_into_the_current_set_keeps_the_other_windows_choice() {
let set = |name: &str| json!({"name": name, "actions": []});
let recorded = json!({"name": "A", "actions": [{"name": "Action 1", "steps": []}]});
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
let mine = json!({"action_sets": [recorded.clone(), set("B"), set("C")], "action_set": 0});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [recorded, set("B"), set("C")], "action_set": 2}));
}
/// Renaming the current set does not choose it again: the other window's choice stays. With no
/// other choice, the renamed set stays current.
#[test]
fn renaming_the_current_set_keeps_the_other_windows_choice() {
let set = |name: &str| json!({"name": name, "actions": []});
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 0});
let on_disk = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 1});
let mine = json!({"action_sets": [set("A2"), set("B"), set("C")], "action_set": 0});
assert_eq!(merge_changes(&base, &mine, on_disk)["action_set"], json!(1));
assert_eq!(merge_changes(&base, &mine, base.clone())["action_set"], json!(0));
}
/// Deleting the current set does not override the other window's choice.
#[test]
fn deleting_the_current_set_keeps_the_other_windows_choice() {
let set = |name: &str| json!({"name": name, "actions": []});
let base = json!({"action_sets": [set("A"), set("B")], "action_set": 0});
let on_disk = json!({"action_sets": [set("A"), set("B")], "action_set": 1});
let mine = json!({"action_sets": [set("B")]});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("B")], "action_set": 0}));
assert_eq!(merge_changes(&base, &mine, base.clone()), json!({"action_sets": [set("B")]}));
}
/// Deleting a set before the current one moves its index without choosing it again.
#[test]
fn deleting_a_set_before_the_current_one_keeps_it_current() {
let set = |name: &str| json!({"name": name, "actions": []});
let base = json!({"action_sets": [set("A"), set("B"), set("C")], "action_set": 2});
let mine = json!({"action_sets": [set("B"), set("C")], "action_set": 1});
assert_eq!(merge_changes(&base, &mine, base.clone()), json!({"action_sets": [set("B"), set("C")], "action_set": 1}));
}
/// Two sets with the same contents: when the other window deleted the one this window made
/// current, the one left is not made current in its place.
#[test]
fn a_set_with_the_same_contents_does_not_take_the_place_of_the_current_set() {
let set = |name: &str| json!({"name": name, "actions": []});
let base = json!({"action_sets": [set("A"), set("A"), set("C")]});
let on_disk = json!({"action_sets": [set("A"), set("C")]});
let mine = json!({"action_sets": [set("A"), set("A"), set("C")], "action_set": 1});
assert_eq!(merge_changes(&base, &mine, on_disk), json!({"action_sets": [set("A"), set("C")]}));
}
#[test]
fn a_file_that_is_not_an_object_is_replaced() {
let base = json!({"workspace": "Essentials"});
+214 -55
View File
@@ -1,17 +1,24 @@
//! Linux: a drawing tablet's pen pressure under X11, XWayland included (#764). winit 0.30 hands a
//! pen to the window there as a mouse, without its pressure, so the app reads the pressure from
//! XInput2 itself and writes it into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas
//! reads.
//! Linux: a drawing tablet's pen pressure, tilt and barrel rotation under X11, XWayland included
//! (#764). winit 0.30 hands a pen to the window there as a mouse, without any of them, so the app
//! reads them from XInput2 itself and writes them into the UI's [`StylusFeed`]
//! (`Services::stylus`), which the canvas reads.
//!
//! The reader opens a second connection to the X server (x11rb's `RustConnection`, without
//! `unsafe` or libxcb) and selects XInput2 raw events for every device on the root window. Raw
//! events reach every client that selects them, also while winit holds the pointer grab of a
//! drag, and name the device that moved. A pointer device with an "Abs Pressure" valuator is a pen
//! (xf86-input-wacom, xf86-input-libinput and XWayland give a pen's pressure axis that label),
//! unless its XInput type is a touchpad's or a touchscreen's: its pressure, scaled from the
//! valuator's range to 0..1, goes into the feed. Any other pointer
//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so
//! the last pressure of each pen is kept.
//! unless its XInput type is a touchpad's or a touchscreen's. Its valuators go into the feed:
//!
//! - "Abs Pressure", scaled from the valuator's range to 0..1;
//! - "Abs Tilt X" and "Abs Tilt Y" (a pen that has both), in degrees as they are when the range
//! fits in ±90, otherwise scaled to ±[`TILT_DEGREES`];
//! - "Abs Rotary Z" (the barrel rotation of an Art Pen), scaled to a turn with the middle of the
//! range at 0°.
//!
//! Any other pointer device moving sets the feed to `None`. Raw events carry only the valuators
//! that changed, so the last value of each axis of each pen is kept; an axis the pen has that no
//! event has carried yet reads 0 (upright, unturned), and an axis it lacks reads nothing.
//!
//! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains
//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's
@@ -22,13 +29,18 @@
//! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland
//! that receives none of the window's input.
//!
//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`), with
//! the pressure axis only.
//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`).
//! PhotoCraft maps the rotation to 0..360° and reads an axis a pen lacks as 0; here the middle of
//! the rotation's range is 0°, and an axis a pen lacks reads nothing.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::collections::HashMap;
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
/// The tilt of a valuator whose range does not fit in ±90 (so does not give degrees), at the ends
/// of its range.
pub(crate) const TILT_DEGREES: f64 = 60.0;
/// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer
/// than 16).
@@ -41,33 +53,76 @@ const MAX_DEVICES: usize = 256;
/// a touchpad's or a touchscreen's pressure axis "Abs Pressure" too, and they are not pens.
const FINGER_TYPES: &[&str] = &["TOUCHPAD", "TOUCHSCREEN"];
/// The "Abs Pressure" valuator of a pen: its number in a raw event's valuator mask, and its range.
/// Which pen axis a valuator carries.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AxisKind {
Pressure,
TiltX,
TiltY,
Rotation,
}
impl AxisKind {
/// The kind of the valuator labeled `label`, if it is one of a pen's.
fn from_label(label: &str) -> Option<Self> {
match label {
"Abs Pressure" => Some(Self::Pressure),
"Abs Tilt X" => Some(Self::TiltX),
"Abs Tilt Y" => Some(Self::TiltY),
"Abs Rotary Z" => Some(Self::Rotation),
_ => None,
}
}
/// Where [`Pens`] keeps the last value of this axis.
fn slot(self) -> usize {
match self {
Self::Pressure => 0,
Self::TiltX => 1,
Self::TiltY => 2,
Self::Rotation => 3,
}
}
}
/// A valuator of a pen: what it carries, its number in a raw event's valuator mask, and its range.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Axis {
kind: AxisKind,
number: u16,
min: f64,
max: f64,
}
impl Axis {
/// A raw value of this valuator as a pressure of 0..1; a value that is not a number is a
/// mouse's, 1.
fn pressure(&self, v: f64) -> f32 {
/// A raw value of this valuator as a pressure of 0..1, a tilt in degrees (−90..90) or a
/// rotation in degrees (−180..180, the middle of the range at 0). A value that is not a number
/// is a mouse's pressure, 1, or a tilt or rotation of 0.
fn map(&self, v: f64) -> f32 {
if !v.is_finite() {
return 1.0;
return if self.kind == AxisKind::Pressure { 1.0 } else { 0.0 };
}
let span = self.max - self.min;
let unit = if span.is_finite() && span > 0.0 { (v - self.min) / span } else { v };
unit.clamp(0.0, 1.0) as f32
let unit = unit.clamp(0.0, 1.0);
match self.kind {
AxisKind::Pressure => unit as f32,
AxisKind::TiltX | AxisKind::TiltY if self.min >= -90.0 && self.max <= 90.0 && span > 0.0 => v.clamp(-90.0, 90.0) as f32,
AxisKind::TiltX | AxisKind::TiltY => ((unit * 2.0 - 1.0) * TILT_DEGREES) as f32,
AxisKind::Rotation => ((unit - 0.5) * 360.0) as f32,
}
}
}
/// A pointer device the X server lists.
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
struct Device {
id: u16,
/// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none.
pressure: Option<Axis>,
/// The name the X server lists, so that another device under the same id compares unequal.
name: String,
/// The pen's valuators: a pen has a pressure valuator, a mouse, a touchpad or a touchscreen
/// has none (and keeps none).
axes: Vec<Axis>,
/// The eraser end of a pen: its name or its XInput type contains "eraser", in any case.
eraser: bool,
}
@@ -77,14 +132,31 @@ impl Device {
/// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`).
fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator<Item = (u16, String, f64, f64)>) -> Self {
let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k));
let pressure = valuators
.into_iter()
.filter(|(number, ..)| *number < MAX_AXES)
.find(|(_, label, ..)| label == "Abs Pressure")
.map(|(number, _, min, max)| Axis { number, min, max })
.filter(|_| !finger);
let mut axes: Vec<Axis> = Vec::new();
for (number, label, min, max) in valuators {
// The first valuator of each kind.
if let Some(kind) = AxisKind::from_label(&label)
&& number < MAX_AXES
&& !axes.iter().any(|a| a.kind == kind)
{
axes.push(Axis { kind, number, min, max });
}
}
if finger || !axes.iter().any(|a| a.kind == AxisKind::Pressure) {
axes.clear();
}
let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser"));
Self { id, pressure, eraser }
Self { id, name: name.to_string(), axes, eraser }
}
/// The valuator of `kind`, if the device is a pen that has one.
fn axis(&self, kind: AxisKind) -> Option<&Axis> {
self.axes.iter().find(|a| a.kind == kind)
}
/// A pen: a device with a pressure valuator.
fn is_pen(&self) -> bool {
!self.axes.is_empty()
}
}
@@ -93,36 +165,46 @@ impl Device {
enum Update {
/// Nothing: a device the last listing didn't have (a master, or one added since).
Keep,
/// The pressure and end of a pen, or `None` for another pointer.
/// The pressure, end, tilt and rotation of a pen, or `None` for another pointer.
Set(Option<PenSample>),
}
/// The pointer devices and the last pressure of each pen.
/// The pointer devices and the last pressure, tilt and rotation of each pen.
#[derive(Debug, Default)]
struct Pens {
devices: HashMap<u16, Device>,
last: HashMap<u16, f32>,
last: HashMap<u16, [Option<f32>; 4]>,
}
impl Pens {
/// Replace the device list (at the start, and when devices come and go or change).
/// A device keeps its last values only while it is listed unchanged: another device under the
/// same id starts without them.
fn set_devices(&mut self, devices: impl IntoIterator<Item = Device>) {
self.devices = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
self.last.retain(|id, _| self.devices.contains_key(id));
let devices: HashMap<u16, Device> = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
self.last.retain(|id, _| devices.get(id).is_some_and(|d| self.devices.get(id) == Some(d)));
self.devices = devices;
}
/// A raw motion or button event of device `id`, with the valuators that changed.
fn handle(&mut self, id: u16, valuators: impl IntoIterator<Item = (u16, f64)>) -> Update {
let Some(device) = self.devices.get(&id) else { return Update::Keep };
let Some(axis) = device.pressure else { return Update::Set(None) };
let eraser = device.eraser;
let last = self.last.entry(id).or_insert(0.0);
if !device.is_pen() {
return Update::Set(None);
}
let last = self.last.entry(id).or_default();
for (number, v) in valuators {
if number == axis.number {
*last = axis.pressure(v);
for axis in device.axes.iter().filter(|a| a.number == number) {
if let Some(slot) = last.get_mut(axis.kind.slot()) {
*slot = Some(axis.map(v));
}
}
}
Update::Set(Some(PenSample { pressure: *last, eraser }))
// An axis the pen has reads 0 until an event carries it; one it lacks reads nothing.
let value = |kind: AxisKind| device.axis(kind).map(|_| last.get(kind.slot()).copied().flatten().unwrap_or(0.0));
let tilt = value(AxisKind::TiltX).zip(value(AxisKind::TiltY)).map(|(x, y)| [x, y]);
let pose = PenPose { tilt, rotation: value(AxisKind::Rotation) };
Update::Set(Some(PenSample { pressure: value(AxisKind::Pressure).unwrap_or(0.0), eraser: device.eraser, pose }))
}
/// A raw event as the X server sends it: its valuator mask and the values of the valuators
@@ -149,8 +231,8 @@ fn fp3232(integral: i32, frac: u32) -> f64 {
f64::from(integral) + f64::from(frac) / 4_294_967_296.0
}
/// Start reading pen pressure for a window on X11: the feed the reader writes into, or `None`
/// (logged) when the X server can't be reached or has no XInput 2.1.
/// Start reading pen pressure, tilt and rotation for a window on X11: the feed the reader writes
/// into, or `None` (logged) when the X server can't be reached or has no XInput 2.1.
#[cfg(target_os = "linux")]
pub fn start() -> Option<StylusFeed> {
let feed = StylusFeed::default();
@@ -224,7 +306,7 @@ mod x11 {
}
}
/// The slave pointer devices with their names and pressure valuators (none on any error).
/// The slave pointer devices with their names and pen valuators (none on any error).
fn query_devices(conn: &RustConnection) -> Vec<Device> {
let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() };
let kinds = device_kinds(conn);
@@ -265,14 +347,16 @@ mod x11 {
mod tests {
use super::*;
/// The valuators of a Wacom pen as the X server lists them: x and y, then pressure from 0 to
/// 65,535.
/// The valuators of a Wacom pen as the X server lists them: x and y, pressure from 0 to
/// 65,535, tilt in degrees, and an Art Pen's barrel rotation.
fn pen_valuators() -> Vec<(u16, String, f64, f64)> {
vec![
(0, "Abs X".to_string(), 0.0, 44704.0),
(1, "Abs Y".to_string(), 0.0, 27940.0),
(2, "Abs Pressure".to_string(), 0.0, 65535.0),
(3, "Abs Tilt X".to_string(), -64.0, 63.0),
(4, "Abs Tilt Y".to_string(), -64.0, 63.0),
(5, "Abs Rotary Z".to_string(), -900.0, 899.0),
]
}
@@ -295,19 +379,22 @@ mod tests {
#[test]
fn a_device_with_an_abs_pressure_valuator_is_a_pen() {
assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 }));
assert_eq!(mouse(12).pressure, None);
assert_eq!(pen(9).axis(AxisKind::Pressure), Some(&Axis { kind: AxisKind::Pressure, number: 2, min: 0.0, max: 65535.0 }));
assert_eq!(pen(9).axes.len(), 4, "pressure, tilt and rotation");
assert!(!mouse(12).is_pen());
let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);
assert_eq!(pad.pressure, None, "a tablet's pad is no pen");
assert!(!pad.is_pen() && pad.axes.is_empty(), "a tablet's pad is no pen");
let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]);
assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored");
assert!(!far.is_pen(), "valuator numbers past the limit are ignored");
let tilt_only = Device::new(14, "", Some("STYLUS"), [(3, "Abs Tilt X".to_string(), -64.0, 63.0), (4, "Abs Tilt Y".to_string(), -64.0, 63.0)]);
assert!(!tilt_only.is_pen() && tilt_only.axes.is_empty(), "no pressure, no pen");
// The types libinput and XWayland give pens, and a device whose type wasn't listed.
for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] {
assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}");
assert!(Device::new(15, "", kind, pen_valuators()).is_pen(), "{kind:?}");
}
// xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure".
for kind in ["TOUCHPAD", "TOUCHSCREEN"] {
assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}");
assert!(Device::new(16, "", Some(kind), pen_valuators()).axes.is_empty(), "{kind}");
}
}
@@ -324,7 +411,7 @@ mod tests {
];
for (name, kind) in erasers {
let d = Device::new(10, name, kind, pen_valuators());
assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})");
assert!(d.eraser && d.is_pen(), "{name} ({kind:?})");
}
let tips = [
("Wacom Intuos Pro M Pen stylus", Some("STYLUS")),
@@ -343,13 +430,14 @@ mod tests {
let mut pens = Pens::default();
pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]);
let feed = StylusFeed::default();
let pose = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
pens.raw_event(&feed, 9, &[0b100], &[32767.5]);
assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false })));
assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false, pose })));
assert_eq!(feed.eraser(), Some(false));
// Turned over, the pen hovers with its eraser end: the eraser device's first event, with
// no pressure yet.
pens.raw_event(&feed, 10, &[0b1], &[100.0]);
assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true })));
assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true, pose })));
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0)));
pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)));
@@ -361,6 +449,71 @@ mod tests {
assert_eq!(feed.eraser(), None);
}
/// #764: a pen's tilt goes into the feed in degrees and its barrel rotation as a turn, the
/// middle of the valuator's range at 0°; a pen without those valuators reads neither.
#[test]
fn a_pens_tilt_and_rotation_go_into_the_feed() {
let mut pens = Pens::default();
let pressure_only = Device::new(10, "", Some("STYLUS"), pen_valuators().into_iter().take(3));
let one_tilt = Device::new(11, "", Some("STYLUS"), pen_valuators().into_iter().take(4));
pens.set_devices([pen(9), pressure_only, one_tilt, mouse(12)]);
let feed = StylusFeed::default();
// Pressure, tilt x, tilt y and rotation: valuators 2 to 5.
pens.raw_event(&feed, 9, &[0b11_1100], &[32767.5, -30.0, 45.0, 0.0]);
let pose = feed.pose().unwrap();
assert_eq!((feed.held(), pose.tilt), (Some(0.5), Some([-30.0, 45.0])));
assert!(pose.rotation.is_some_and(|r| r < 0.2), "{pose:?}");
// A quarter of the range back is a quarter turn back; the tilt stays as it was.
pens.raw_event(&feed, 9, &[0b10_0000], &[-450.0]);
let pose = feed.pose().unwrap();
assert_eq!(pose.tilt, Some([-30.0, 45.0]));
assert!(pose.rotation.is_some_and(|r| (r - 270.0).abs() < 0.2), "{pose:?}");
// A pen with no tilt or rotation valuators, or with one tilt valuator only, reads none.
pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
assert_eq!((feed.held(), feed.pose()), (Some(1.0), None));
pens.raw_event(&feed, 11, &[0b1100], &[65535.0, 20.0]);
assert_eq!(feed.pose(), None);
// The mouse: none.
pens.raw_event(&feed, 12, &[0b1], &[3.0]);
assert_eq!(feed.pose(), None);
// A pen whose tilt and rotation no event has carried yet is upright and unturned.
let mut fresh = Pens::default();
fresh.set_devices([pen(9)]);
let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
assert_eq!(fresh.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright })));
}
/// A device listed again unchanged keeps its last tilt and rotation; another device listed
/// under the same id starts upright and unturned.
#[test]
fn a_device_replaced_under_the_same_id_starts_without_the_old_pose() {
let mut pens = Pens::default();
pens.set_devices([pen(9)]);
pens.handle(9, [(3, -30.0), (4, 45.0), (5, -450.0)]);
pens.set_devices([pen(9)]);
let Update::Set(Some(PenSample { pose, .. })) = pens.handle(9, [(2, 0.0)]) else { panic!("no pen") };
assert_eq!(pose.tilt, Some([-30.0, 45.0]));
let other = Device::new(9, "HUION Kamvas Pen Pen (0x1)", Some("STYLUS"), pen_valuators());
pens.set_devices([other]);
let upright = PenPose { tilt: Some([0.0, 0.0]), rotation: Some(0.0) };
assert_eq!(pens.handle(9, [(2, 0.0)]), Update::Set(Some(PenSample { pressure: 0.0, eraser: false, pose: upright })));
}
/// Tilt valuators whose range does not fit in ±90 are scaled to ±60°.
#[test]
fn tilt_ranges_that_are_not_degrees_are_scaled() {
let d = Device::new(
1,
"",
None,
[(2, "Abs Pressure".into(), 0.0, 1023.0), (3, "Abs Tilt X".into(), 0.0, 127.0), (4, "Abs Tilt Y".into(), -1000.0, 1000.0)],
);
let mut pens = Pens::default();
pens.set_devices([d]);
let Update::Set(Some(PenSample { pose, .. })) = pens.handle(1, [(3, 127.0), (4, -1000.0)]) else { panic!("no pen") };
assert_eq!(pose.tilt, Some([60.0, -60.0]));
}
#[test]
fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() {
let mut pens = Pens::default();
@@ -389,13 +542,19 @@ mod tests {
#[test]
fn hostile_device_data_gives_a_pressure_within_0_to_1() {
let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)];
let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN), (90.0, -90.0)];
for (min, max) in ranges {
let mut pens = Pens::default();
pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]);
let labels = ["Abs Pressure", "Abs Tilt X", "Abs Tilt Y", "Abs Rotary Z"];
pens.set_devices([Device::new(1, "", None, (2..).zip(labels).map(|(n, l)| (n, l.to_string(), min, max)))]);
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] {
let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") };
let Update::Set(Some(PenSample { pressure: p, pose, .. })) = pens.handle(1, (2..6).map(|n| (n, v))) else {
panic!("{min}..{max}: {v} gave no pressure")
};
assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}");
let tilt = pose.tilt.unwrap_or_default();
assert!(tilt.iter().all(|t| (-90.0..=90.0).contains(t)), "{min}..{max}: {v} gave {pose:?}");
assert!(pose.rotation.is_some_and(|r| (-180.0..=180.0).contains(&r)), "{min}..{max}: {v} gave {pose:?}");
}
}
let mut pens = Pens::default();
+8 -4
View File
@@ -22,7 +22,7 @@ use std::cell::RefCell;
use std::sync::OnceLock;
use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase};
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
use vectorcraft_ui_egui::stylus::{PenPose, PenSample, StylusFeed};
/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's
/// `wParam`, a pen in contact with its barrel button pressed.
@@ -114,7 +114,7 @@ fn pen_sample(pressure: u32, pen_mask: u32, pen_flags: u32, in_contact: bool) ->
_ if in_contact => 1.0,
_ => 0.0,
};
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0 }
PenSample { pressure, eraser: pen_flags & (PEN_FLAG_INVERTED | PEN_FLAG_ERASER) != 0, pose: PenPose::default() }
}
/// Whether a mouse message with this extra information (`GetMessageExtraInfo`) comes from a mouse,
@@ -403,7 +403,11 @@ mod tests {
fn a_pens_pressure_is_the_one_winit_gives_its_touch() {
assert_eq!(pen_sample(512, PEN_MASK_PRESSURE, 0, true).pressure, 0.5);
assert_eq!(pen_sample(1024, PEN_MASK_PRESSURE, 0, true).pressure, 1.0);
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false), PenSample { pressure: 0.0, eraser: true }, "hovering");
assert_eq!(
pen_sample(0, PEN_MASK_PRESSURE, PEN_FLAG_INVERTED, false),
PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() },
"hovering"
);
assert_eq!(pen_sample(0, 0, 0, true).pressure, 1.0, "a pen in contact that reports no pressure presses fully");
assert_eq!(pen_sample(300, 0, 0, true).pressure, 1.0, "a pressure without the pressure mask is not one");
assert_eq!(pen_sample(0, PEN_MASK_PRESSURE, 0, true).pressure, 1.0, "as winit, which gives no force for 0");
@@ -420,7 +424,7 @@ mod tests {
#[test]
fn pen_messages_set_the_feed_and_other_pointers_clear_it() {
let pen = PenSample { pressure: 0.0, eraser: true };
let pen = PenSample { pressure: 0.0, eraser: true, pose: PenPose::default() };
// `POINTER_MESSAGE_FLAG_INRANGE` in the high word of `wParam`, pointer 7 in the low word.
let in_range = (POINTER_MESSAGE_FLAG_INRANGE << 16) | 7;
let no_mouse = || -> bool { panic!("a pointer message is not a mouse message") };
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 5h9M10.5 5v11"/><rect x="14" y="12" width="7" height="8" stroke-dasharray="1.5 1.5"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4h10M20 7v10M17 20H7M4 17V7" stroke-width="1"/><path d="M8 10V8h8v2M12 8v8"/><rect x="2.5" y="2.5" width="3" height="3" rx=".75" stroke-width="1"/><rect x="18.5" y="2.5" width="3" height="3" rx=".75" stroke-width="1"/><rect x="18.5" y="18.5" width="3" height="3" rx=".75" stroke-width="1"/><rect x="2.5" y="18.5" width="3" height="3" rx=".75" stroke-width="1"/></svg>

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 563 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" stroke-dasharray="2 2"/><path d="M8 8h8M12 8v9"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3.5" y="2.5" width="17" height="19" rx="2.5" stroke-width="1.25"/><path d="M8 8V6h8v2M12 6v7"/><path d="M7.5 16.5h9M7.5 18.5H13" stroke-width="1"/></svg>

Before

Width:  |  Height:  |  Size: 280 B

After

Width:  |  Height:  |  Size: 346 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 19 C8 9 16 9 21 19"/><path d="M8 5h8M12 5v8"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 5.5v-2h8v2M12 3.5v10"/><path d="M3 18.5c3-3.5 6-3.5 9 0s6 3.5 9 0" stroke-width="1.25"/></svg>

Before

Width:  |  Height:  |  Size: 248 B

After

Width:  |  Height:  |  Size: 290 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="2.5" width="13" height="19" rx="2.5" stroke-width="1.25"/><path d="M11.5 8V6h7v2M15 6v8"/><path d="M12 17h6" stroke-width="1"/><path d="M4 4v15.5M1.75 17.25 4 19.5l2.25-2.25" stroke-width="1.25"/></svg>

After

Width:  |  Height:  |  Size: 403 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 5.5v-2h10v2M15 3.5V13"/><path d="M4 3v10.5M1.75 11.25 4 13.5l2.25-2.25" stroke-width="1.25"/><path d="M3 18.5c3-3.5 6-3.5 9 0s6 3.5 9 0" stroke-width="1.25"/></svg>

After

Width:  |  Height:  |  Size: 361 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4h10M12 4v8"/><path d="M9 15h6M12 15v6"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 6.5v-2h10v2M15 4.5V19"/><path d="M4 4v15.5M1.75 17.25 4 19.5l2.25-2.25" stroke-width="1.25"/></svg>

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 296 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 6.5v-2h14v2M12 4.5v15"/></svg>

After

Width:  |  Height:  |  Size: 226 B

+2 -1
View File
@@ -23,6 +23,7 @@ Affinity files.
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
| A stroke's pressure curve (`LDeP`: position along the path against width) | the stroke's width profile, the curve sampled at 2 to 8 points per segment (256 segments at most), widths up to the full weight |
| Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
@@ -33,7 +34,7 @@ Affinity files.
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush strokes, pressure curves that can't be read or that go past the million points one document may hold,
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
+98
View File
@@ -169,6 +169,88 @@ fn curves(r: &mut Reader, crvs: ObjId, budget: &mut OutlineBudget) -> Option<Pat
Some(path)
}
/// Most segments a stroke's pressure curve may have; the curves in real documents have a handful.
const MAX_PRESSURE_SEGMENTS: usize = 256;
/// Most regular samples of a pressure curve: each segment gets an equal share, at least 2 (its
/// middle and its end) and 8 at most. The points where a segment's width stops rising or falling
/// come on top (at most two per segment), so a curve has at most 1 + 4 × [`MAX_PRESSURE_SEGMENTS`]
/// points; the document's limit counts all of them.
const PRESSURE_POINTS: usize = 512;
/// Most points one pressure curve can have (see [`PRESSURE_POINTS`]). A curve that can't be read
/// counts this many against the document's limit.
pub(crate) const MAX_PRESSURE_CURVE_POINTS: usize = 1 + 4 * MAX_PRESSURE_SEGMENTS;
/// A stroke's pressure curve (`LDsc.LDeP`, a `CrvD` whose positional `Data` holds an array of
/// 18-byte records as in [`curves`]): x is the position along the path (0 to 1) and y the width as
/// a fraction of the stroke's weight. Each segment is sampled at 2 to 8 points, at most
/// [`PRESSURE_POINTS`] in all, and also where its width stops rising or falling (at most two more
/// points), so a dip between samples is kept → (position, width) in order along the path,
/// positions clamped into 0..1 and never going back, widths clamped into 0..1. `None` when the
/// curve can't be read or has more than [`MAX_PRESSURE_SEGMENTS`] segments.
pub(crate) fn pressure(r: &Reader, crvd: ObjId) -> Option<Vec<(f64, f64)>> {
let s = r.s;
let data = s.obj(crvd, b"Data")?;
let records = s.positional(data).find_map(|v| if let Value::Array(a) = v { Some(a) } else { None })?;
// Every segment takes at least an anchor record.
if records.len() > MAX_PRESSURE_SEGMENTS * 3 + 1 {
return None;
}
let curve = subpath(&anchors(records)?, false, None)?;
if curve.segments.len() > MAX_PRESSURE_SEGMENTS {
return None;
}
let steps = u32::try_from((PRESSURE_POINTS / curve.segments.len().max(1)).clamp(2, 8)).unwrap_or(2);
let mut samples = vec![(curve.start.x, curve.start.y)];
let mut from = curve.start;
for [c1, c2, to] in &curve.segments {
let mut ts: Vec<f64> = (1..=steps).map(|i| f64::from(i) / f64::from(steps)).collect();
ts.extend(turning_points(from.y, c1.y, c2.y, to.y).into_iter().flatten());
ts.sort_by(f64::total_cmp);
ts.dedup_by(|a, b| (*a - *b).abs() < 1e-9);
for t in ts {
let u = 1.0 - t;
let at = |a: f64, b: f64, c: f64, d: f64| u * u * u * a + 3.0 * u * u * t * b + 3.0 * u * t * t * c + t * t * t * d;
samples.push((at(from.x, c1.x, c2.x, to.x), at(from.y, c1.y, c2.y, to.y)));
}
from = *to;
}
let mut last = 0.0f64;
Some(
samples
.into_iter()
.map(|(x, y)| {
last = x.clamp(last, 1.0);
(last, y.clamp(0.0, 1.0))
})
.collect(),
)
}
/// The parameters strictly between 0 and 1 where the cubic Bézier with control values `a`, `b`,
/// `c` and `d` stops rising or falling (where its derivative is 0). The values are scaled into
/// -1..1 first, so huge ones can't overflow the discriminant.
fn turning_points(a: f64, b: f64, c: f64, d: f64) -> [Option<f64>; 2] {
let scale = a.abs().max(b.abs()).max(c.abs()).max(d.abs());
if !(scale.is_finite() && scale > 0.0) {
return [None, None];
}
let (a, b, c, d) = (a / scale, b / scale, c / scale, d / scale);
let (d0, d1, d2) = (b - a, c - b, d - c);
let (qa, qb, qc) = (d0 - 2.0 * d1 + d2, 2.0 * (d1 - d0), d0);
let inside = |t: f64| (t.is_finite() && t > 0.0 && t < 1.0).then_some(t);
if qa.abs() < 1e-12 {
return [if qb.abs() < 1e-12 { None } else { inside(-qc / qb) }, None];
}
let disc = qb * qb - 4.0 * qa * qc;
if disc < 0.0 {
return [None, None];
}
let root = disc.sqrt();
[inside((-qb - root) / (2.0 * qa)), inside((-qb + root) / (2.0 * qa))]
}
struct Anchor {
p: Point,
inh: Option<Point>,
@@ -260,3 +342,19 @@ fn corner_radii(fields: Vec<&Value>) -> Option<Vec<(usize, f64)>> {
.collect(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn turning_points_of_huge_control_values_are_found() {
let small = turning_points(1.0, -3.0, 3.0, 1.0);
let huge = turning_points(1e200, -3e200, 3e200, 1e200);
assert!(small.iter().all(Option::is_some), "{small:?}");
for (s, h) in small.iter().zip(&huge) {
let (Some(s), Some(h)) = (s, h) else { panic!("{huge:?}") };
assert!((s - h).abs() < 1e-12, "{small:?} {huge:?}");
}
}
}
+12 -1
View File
@@ -26,6 +26,9 @@ const MAX_DOCUMENT_CHARS: usize = 16 << 20;
/// Table cells of all tables together (each becomes a text object with its edge lines).
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
/// documents are read with what their embedding document left, so linking or embedding the
/// same content many times can't multiply the work.
@@ -39,10 +42,18 @@ pub(crate) struct Budget {
pub(crate) chars: usize,
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
pub(crate) cells: usize,
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
pub(crate) pressure: usize,
}
impl Budget {
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
pub(crate) const FULL: Budget = Budget {
nodes: MAX_NODES,
pixels: MAX_LAYER_PIXELS,
chars: MAX_DOCUMENT_CHARS,
cells: MAX_DOCUMENT_CELLS,
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
};
}
/// Read a document: archive, `doc.dat` and the model.
+34 -3
View File
@@ -121,6 +121,9 @@ pub struct Stroke {
pub dash: Option<(Vec<f64>, f64)>,
/// Painted below the fill instead of above it.
pub behind: bool,
/// The pressure curve: (position 0..1 along the path, width as a fraction of `width`) in
/// order along the path, `None` for a stroke of the same width all along.
pub profile: Option<Vec<(f64, f64)>>,
}
/// Gradient stops per gradient.
@@ -350,10 +353,38 @@ pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, beh
}
_ => None,
};
if s.obj(lsty, b"Brus").is_some() || s.obj(ldsc, b"LDeP").is_some() {
r.warn("brush and pressure strokes (imported as plain strokes)");
if s.obj(lsty, b"Brus").is_some() {
r.warn("brush strokes (imported as plain strokes)");
}
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind })
// The pressure curve sets the width along the path; one at full width all along is a plain
// stroke. Every curve read counts against the document's points: one at full width counts its
// points, and one that can't be read counts the most a curve may have. Once the points are
// used up the rest are not read.
const BEYOND: &str = "pressure strokes beyond the pressure points one document may hold (imported as plain strokes)";
let ldep = s.obj(ldsc, b"LDeP");
if ldep.is_some() && r.budget.pressure == 0 {
r.warn(BEYOND);
}
let profile = match ldep.filter(|_| r.budget.pressure > 0).map(|p| crate::geometry::pressure(r, p)) {
Some(Some(points)) => match r.budget.pressure.checked_sub(points.len()) {
Some(left) => {
r.budget.pressure = left;
(!points.iter().all(|(_, w)| (w - 1.0).abs() < 1e-9)).then_some(points)
}
None => {
r.budget.pressure = 0;
r.warn(BEYOND);
None
}
},
Some(None) => {
r.budget.pressure = r.budget.pressure.saturating_sub(crate::geometry::MAX_PRESSURE_CURVE_POINTS);
r.warn("pressure strokes whose pressure curve can't be read (imported as plain strokes)");
None
}
None => None,
};
Some(Stroke { paint, width, cap, join, miter_limit, align, dash, behind, profile })
}
#[cfg(test)]
@@ -1,7 +1,7 @@
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=32b7cf3f;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=c10daaea;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=3bc13374;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=bc0c838d;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush strokes (imported as plain strokes)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
@@ -30,4 +30,4 @@ patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1,
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
patchy-vector.af dpi=72;spreads=1;model_crc32=3b16c7ed;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
+16 -19
View File
@@ -5,16 +5,16 @@
//! support point in direction `d` is `(a²(u·d)u + b²(v·d)v) / √(a²(u·d)² + b²(v·d)²)`, so the
//! stroke's local width across direction `n` is `2·√(a²(u·n)² + b²(v·n)²)`.
//!
//! A Calligraphic brush with Pressure variation changes its nib along the stroke with the pen
//! pressure recorded on it (`StrokeLayer::pressure`): the sweep is sampled densely and each
//! sample takes the nib for the pressure there.
//! A Calligraphic brush with Pressure, Tilt, Bearing or Rotation variation changes its nib along
//! the stroke with what the pen recorded on it (`StrokeLayer::pressure`, `tilt`, `bearing` and
//! `rotation`): the sweep is sampled densely and each sample takes the nib for the pen there.
use vectorcraft_color::Paint;
use vectorcraft_doc::{Node, PressureProfile, StrokeLayer};
use vectorcraft_doc::{Node, StrokeLayer};
use vectorcraft_geom::{BezPath, PathData, Point, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
use crate::{Bristle, BristleShape, Calligraphic, Variation, filled, tolerance};
use crate::{Bristle, BristleShape, Calligraphic, PenAt, Variation, filled, tolerance};
/// The most pieces a varying nib cuts one segment of a subpath into, and about how many samples
/// it takes along a whole subpath.
@@ -127,18 +127,16 @@ fn randomized(c: &Calligraphic, rng: &mut Rng) -> [f64; 3] {
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
}
/// The nib's angle, roundness and size at pen pressure `p` (0..1): each Pressure value goes from
/// the brush's value less its variation (lightest) to the value plus the variation (heaviest),
/// kept within its range; the others are `base`'s.
fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
/// The nib's angle, roundness and size where the pen recorded `pen`: each value on a mode that
/// follows the pen goes from the brush's value less its variation (where the pen reads 0 for it)
/// to the value plus the variation (where it reads 1), the angle turning round past ±180° and the
/// others kept within their ranges; the other values are `base`'s.
fn pressured(c: &Calligraphic, base: [f64; 3], pen: PenAt) -> [f64; 3] {
let values = [c.angle, c.roundness, c.size];
let mut out = base;
for (i, (v, mode)) in out.iter_mut().zip(c.modes()).enumerate() {
let (Some(&value), Some(&var), Some(&(lo, hi))) = (values.get(i), c.variation.get(i), Calligraphic::RANGES.get(i)) else { continue };
if mode == Variation::Pressure {
let x = value + var * (2.0 * p.clamp(0.0, 1.0) - 1.0);
*v = if x.is_finite() { x.clamp(lo, hi) } else { value.clamp(lo, hi) };
}
let (Some(&value), Some(&var), Some(p)) = (values.get(i), c.variation.get(i), pen.control(mode)) else { continue };
*v = Calligraphic::moved(i, value, var * (2.0 * p - 1.0));
}
let [angle, round, size] = out;
[angle, round.clamp(1.0, 100.0), size.max(0.05)]
@@ -147,14 +145,13 @@ fn pressured(c: &Calligraphic, base: [f64; 3], p: f64) -> [f64; 3] {
pub(crate) fn calligraphic(c: &Calligraphic, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
let mut rng = Rng::new(seed_of(bp, name));
let base = randomized(c, &mut rng);
let subs: Vec<SubPath> = if c.uses_pressure() {
let pressure = stroke.pressure.as_ref();
let subs: Vec<SubPath> = if c.uses_pen() {
let nib = |f: f64| {
let [angle, round, size] = pressured(c, base, pressure.map_or(PressureProfile::MID, |p| p.at(f)));
let [angle, round, size] = pressured(c, base, PenAt::of(stroke, f));
Nib::new(angle, round / 100.0, size * weight)
};
// Flattened for the largest nib the pressure gives.
let size = pressured(c, base, 0.0)[2].max(pressured(c, base, 1.0)[2]) * weight;
// Flattened for the largest nib the pen gives.
let size = pressured(c, base, PenAt::uniform(0.0))[2].max(pressured(c, base, PenAt::uniform(1.0))[2]) * weight;
tracks(bp, tolerance(size)).iter().flat_map(|t| sweep(t, &nib, Some((t.len() / MAX_STEPS).max(0.25)))).collect()
} else {
let [angle, round, size] = base;
+89 -4
View File
@@ -159,6 +159,24 @@ impl Calligraphic {
pub fn uses_pressure(&self) -> bool {
self.modes().contains(&Variation::Pressure)
}
/// Does the pen (its pressure, tilt, bearing or rotation) change the nib?
pub fn uses_pen(&self) -> bool {
self.modes().iter().any(|m| m.follows_pen())
}
/// Value `i` (0 angle, 1 roundness, 2 size) moved by `by`: the angle turns round past ±180°,
/// roundness and size stay within their [`Self::RANGES`]. A result that is not a number is
/// `value`, kept within its range.
pub fn moved(i: usize, value: f64, by: f64) -> f64 {
let (lo, hi) = Self::RANGES.get(i).copied().unwrap_or((f64::MIN, f64::MAX));
let x = value + by;
if !x.is_finite() {
value.clamp(lo, hi)
} else if i == 0 {
vectorcraft_geom::normalize_deg(x)
} else {
x.clamp(lo, hi)
}
}
}
/// How a Calligraphic brush's angle, roundness or size varies (Brush Options). Saved by name: new
@@ -174,16 +192,29 @@ pub enum Variation {
/// From the value less the variation at the lightest pen pressure to the value plus the
/// variation at the heaviest.
Pressure,
/// From the value less the variation with the pen lying flat to the value plus the variation
/// with it upright.
Tilt,
/// The value plus the variation times the pen's bearing over 180°: the bearing is which way the
/// pen leans, counter-clockwise on the page from leaning to the right (0°) round to ±180°.
Bearing,
/// The value plus the variation times the pen barrel's rotation over 180°, counter-clockwise
/// from where the pen reports 0° round to ±180°.
Rotation,
}
impl Variation {
pub const ALL: [Variation; 3] = [Variation::Fixed, Variation::Random, Variation::Pressure];
pub const ALL: [Variation; 6] =
[Variation::Fixed, Variation::Random, Variation::Pressure, Variation::Tilt, Variation::Bearing, Variation::Rotation];
/// The name brush definitions use.
pub fn id(self) -> &'static str {
match self {
Variation::Fixed => "fixed",
Variation::Random => "random",
Variation::Pressure => "pressure",
Variation::Tilt => "tilt",
Variation::Bearing => "bearing",
Variation::Rotation => "rotation",
}
}
pub fn label(self) -> &'static str {
@@ -191,11 +222,63 @@ impl Variation {
Variation::Fixed => "Fixed",
Variation::Random => "Random",
Variation::Pressure => "Pressure",
Variation::Tilt => "Tilt",
Variation::Bearing => "Bearing",
Variation::Rotation => "Rotation",
}
}
pub fn parse(id: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id() == id)
}
/// Does the value follow something the pen records along a stroke (Pressure, Tilt, Bearing,
/// Rotation)?
pub fn follows_pen(self) -> bool {
matches!(self, Variation::Pressure | Variation::Tilt | Variation::Bearing | Variation::Rotation)
}
}
/// What the pen recorded at one point of a stroke, each as the 0..1 that [`Variation`]'s pen modes
/// read: 0 gives the value less the variation (a Scatter brush's first value), 1 the value plus it
/// (the second value), and ½ the value itself, as a stroke without that recording does.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct PenAt {
/// The pressure, lightest 0.
pressure: f64,
/// 1 upright, 0 lying flat.
tilt: f64,
/// ½ plus the bearing over 360°.
bearing: f64,
/// ½ plus the counter-clockwise barrel rotation over 360°.
rotation: f64,
}
impl PenAt {
/// Every input at `c`.
pub(crate) fn uniform(c: f64) -> Self {
Self { pressure: c, tilt: c, bearing: c, rotation: c }
}
/// What `stroke` recorded at `f` (the fraction of its path's length).
pub(crate) fn of(stroke: &StrokeLayer, f: f64) -> Self {
let mid = vectorcraft_doc::PressureProfile::MID;
let turn = |p: Option<&vectorcraft_doc::AngleProfile>| 0.5 + p.map_or(0.0, |p| p.at(f)) / 360.0;
Self {
pressure: stroke.pressure.as_ref().map_or(mid, |p| p.at(f)),
tilt: 1.0 - stroke.tilt.as_ref().map_or(mid, |p| p.at(f)),
bearing: turn(stroke.bearing.as_ref()),
rotation: turn(stroke.rotation.as_ref()),
}
}
/// The 0..1 a value on `mode` takes, or `None` for a mode that doesn't follow the pen.
pub(crate) fn control(&self, mode: Variation) -> Option<f64> {
let c = match mode {
Variation::Fixed | Variation::Random => return None,
Variation::Pressure => self.pressure,
Variation::Tilt => self.tilt,
Variation::Bearing => self.bearing,
Variation::Rotation => self.rotation,
};
Some(if c.is_finite() { c.clamp(0.0, 1.0) } else { 0.5 })
}
}
/// Scatter brush: copies of the art along the path.
@@ -216,8 +299,10 @@ pub struct Scatter {
pub colorization: Colorization,
/// How size, spacing, scatter and rotation vary between their two values: Fixed takes the
/// first, Random any value between them, Pressure the first at the lightest pen pressure and
/// the second at the heaviest. `None` (brushes saved before there were modes): Random where
/// the two differ, else Fixed — see [`Scatter::modes`].
/// the second at the heaviest, Tilt the first with the pen lying flat and the second upright,
/// Bearing and Rotation the first at −180° and the second at 180° (half way at 0°). `None`
/// (brushes saved before there were modes): Random where the two differ, else Fixed (see
/// [`Scatter::modes`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub modes: Option<[Variation; 4]>,
}
@@ -719,7 +804,7 @@ pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &Strok
BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name),
BrushKind::Bristle(b) => calli::bristle(b, bp, w, &stroke.paint, &brush.name),
BrushKind::Art(a) => warp::art(a, bp, w, stroke.pressure.as_ref()),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke.pressure.as_ref(), &brush.name),
BrushKind::Scatter(s) => warp::scatter(s, bp, w, stroke, &brush.name),
BrushKind::Pattern(p) => warp::pattern(p, bp, w),
};
let colorization = brush.kind.colorization();
+158 -1
View File
@@ -889,6 +889,163 @@ fn pressure_varies_angle_and_roundness() {
};
assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0));
assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0));
// The angle turns on past 180°: 170 + 40 is 210°, the nib at 30° (not stopped at 180°).
let turned = Calligraphic { angle: 170.0, roundness: 10.0, variation: [40.0, 0.0, 0.0], ..c([Pressure, Fixed, Fixed]) };
let expected = 2.0 * (25.0 * 0.25 + 0.25 * 0.75f64).sqrt();
let h = draw(turned, 1.0);
assert!(near(h, expected, 0.05), "{h} vs {expected}");
}
/// A stroke that recorded the pen's tilt (`tilt`: 0 upright to 1 flat), bearing and barrel
/// rotation (degrees counter-clockwise), each the same all along it where given.
fn posed(tilt: Option<f64>, bearing: Option<f64>, rotation: Option<f64>) -> StrokeLayer {
let flat = |v: f64| vec![(0.0, v), (1.0, v)];
StrokeLayer {
tilt: tilt.map(|t| vectorcraft_doc::PressureProfile { points: flat(t) }),
bearing: bearing.map(|b| vectorcraft_doc::AngleProfile { points: flat(b) }),
rotation: rotation.map(|r| vectorcraft_doc::AngleProfile { points: flat(r) }),
..stroke(1.0)
}
}
/// #764: with Roundness on Tilt the nib goes from roundness − variation with the pen lying flat to
/// roundness + variation with it upright, along the stroke as the pen leaned.
#[test]
fn tilt_maps_to_roundness_along_the_stroke() {
let c = Calligraphic {
roundness: 50.0,
size: 10.0,
variation: [0.0, 50.0, 0.0],
modes: Some([Variation::Fixed, Variation::Tilt, Variation::Fixed]),
..Default::default()
};
let b = Brush { name: "t".into(), kind: BrushKind::Calligraphic(c) };
let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &upright_to_flat);
// Upright: round (half 5); half way: roundness 50 (half 2.5); flat: a hairline.
assert!(near(half_width(&out, 0.5, 1.5), 5.0, 0.1), "{}", half_width(&out, 0.5, 1.5));
assert!(near(half_width(&out, 49.5, 50.5), 2.5, 0.1), "{}", half_width(&out, 49.5, 50.5));
assert!(half_width(&out, 98.5, 99.5) < 0.2, "{}", half_width(&out, 98.5, 99.5));
}
/// #764: with Angle on Bearing or on Rotation and a variation of 180°, the nib turns with the way
/// the pen leans or with its barrel.
#[test]
fn bearing_and_rotation_turn_the_nib() {
let horizontal = line(0.0, 0.0, 100.0, 0.0);
let height = |mode: Variation, st: &StrokeLayer| {
let c = Calligraphic {
roundness: 10.0,
size: 10.0,
variation: [180.0, 0.0, 0.0],
modes: Some([mode, Variation::Fixed, Variation::Fixed]),
..Default::default()
};
let b = Brush { name: "b".into(), kind: BrushKind::Calligraphic(c) };
bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, st)).height()
};
// Leaning right (0°): the nib lies along the stroke (1 pt across); leaning up the page (90°)
// or down it (−90°), the nib stands across it (10 pt).
assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(0.0), None)), 1.0, 0.05));
assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(90.0), None)), 10.0, 0.05));
assert!(near(height(Variation::Bearing, &posed(Some(0.5), Some(-90.0), None)), 10.0, 0.05));
// The barrel turned a quarter turn stands it across the stroke too.
assert!(near(height(Variation::Rotation, &posed(None, None, Some(0.0))), 1.0, 0.05));
assert!(near(height(Variation::Rotation, &posed(None, None, Some(90.0))), 10.0, 0.05));
// Each reads only its own recording.
assert!(near(height(Variation::Rotation, &posed(None, Some(90.0), None)), 1.0, 0.05));
assert!(near(height(Variation::Bearing, &posed(None, None, Some(90.0))), 1.0, 0.05));
}
/// A stroke without the pen's tilt, bearing or rotation (a mouse, a pen without that input, or a
/// path the brush was applied to) draws Tilt, Bearing and Rotation values at the brush's own value.
#[test]
fn pen_modes_without_a_recording_draw_the_value_itself() {
let horizontal = line(0.0, 0.0, 100.0, 0.0);
for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] {
let c = Calligraphic { angle: 90.0, roundness: 50.0, size: 10.0, variation: [90.0, 50.0, 6.0], modes: Some([mode; 3]) };
let b = Brush { name: "n".into(), kind: BrushKind::Calligraphic(c) };
let h = bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &stroke(1.0))).height();
assert!(near(h, 10.0, 0.05), "{mode:?}: {h}");
let sc = Scatter {
size: (50.0, 300.0),
modes: Some([mode, Variation::Fixed, Variation::Fixed, Variation::Fixed]),
..scatter(&scatter_brush(100.0))
};
let s = Brush { name: "s".into(), kind: BrushKind::Scatter(sc) };
let heights: Vec<f64> =
stroke_pieces(&Document::new(1.0, 1.0), &s, &horizontal, &stroke(1.0)).iter().map(|n| n.geometric_bounds().unwrap().height()).collect();
assert!(!heights.is_empty() && heights.iter().all(|h| near(*h, 7.0, 1e-6)), "{mode:?}: {heights:?}");
}
}
/// A mouse stroke records no pressure, tilt, bearing or rotation. On it, a brush whose values are
/// on Tilt, Bearing or Rotation draws exactly what the same brush on Pressure draws, the output a
/// mouse stroke gave before these modes existed.
#[test]
fn a_mouse_stroke_draws_pen_modes_as_pressure_did() {
let doc = Document::new(1.0, 1.0);
let wavy = {
let mut bp = line(0.0, 0.0, 60.0, 0.0);
bp.curve_to((90.0, 0.0), (90.0, 40.0), (120.0, 40.0));
bp
};
let mouse = stroke(1.0);
assert!(mouse.pressure.is_none() && mouse.tilt.is_none() && mouse.bearing.is_none() && mouse.rotation.is_none());
let calli = |mode: Variation| {
let c = Calligraphic { angle: 30.0, roundness: 40.0, size: 8.0, variation: [60.0, 30.0, 4.0], modes: Some([mode; 3]) };
stroke_pieces(&doc, &Brush { name: "c".into(), kind: BrushKind::Calligraphic(c) }, &wavy, &mouse)
};
let scattered = |mode: Variation| {
let sc = Scatter {
size: (50.0, 300.0),
spacing: (80.0, 160.0),
scatter: (-40.0, 40.0),
rotation: (-90.0, 90.0),
modes: Some([mode; 4]),
..scatter(&scatter_brush(100.0))
};
stroke_pieces(&doc, &Brush { name: "s".into(), kind: BrushKind::Scatter(sc) }, &wavy, &mouse)
};
let (calli_before, scatter_before) = (calli(Variation::Pressure), scattered(Variation::Pressure));
assert!(!calli_before.is_empty() && !scatter_before.is_empty());
for mode in [Variation::Tilt, Variation::Bearing, Variation::Rotation] {
assert_eq!(calli(mode), calli_before, "{mode:?}");
assert_eq!(scattered(mode), scatter_before, "{mode:?}");
}
}
/// The scatter brush of `b`.
fn scatter(b: &Brush) -> Scatter {
let BrushKind::Scatter(sc) = &b.kind else { panic!("not a scatter brush") };
sc.clone()
}
/// #764: Scatter values on Tilt go from the first value with the pen lying flat to the second
/// upright; on Bearing, from the first at −180° to the second at 180°.
#[test]
fn scatter_values_follow_tilt_and_bearing() {
let bp = line(0.0, 0.0, 400.0, 0.0);
let boxes = |b: &Brush, st: &StrokeLayer| -> Vec<Rect> {
stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap()).collect()
};
// Size on Tilt, 50 % to 300 %: upright at the start (12 pt tall), flat at the end (2 pt).
let upright_to_flat = StrokeLayer { tilt: Some(vectorcraft_doc::PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }), ..stroke(1.0) };
let h: Vec<f64> = boxes(&sized_scatter(50.0, 300.0, Variation::Tilt), &upright_to_flat).iter().map(Rect::height).collect();
assert!(near(h[0], 12.0, 0.2) && h[h.len() - 1] < 3.0, "{h:?}");
assert!(h.windows(2).all(|w| w[1] <= w[0] + 1e-9), "shrinks as the pen tips over: {h:?}");
// Rotation on Bearing, −180° to 180°: the copies turn with the bearing, a quarter turn
// standing the 10 × 4 pt art on end.
let sc = Scatter {
rotation: (-180.0, 180.0),
modes: Some([Variation::Fixed, Variation::Fixed, Variation::Fixed, Variation::Bearing]),
..scatter(&scatter_brush(100.0))
};
let turning = Brush { name: "r".into(), kind: BrushKind::Scatter(sc) };
for (bearing, (w, h)) in [(0.0, (10.0, 4.0)), (90.0, (4.0, 10.0)), (-90.0, (4.0, 10.0))] {
let b = boxes(&turning, &posed(Some(0.5), Some(bearing), None));
assert!(!b.is_empty() && b.iter().all(|r| near(r.width(), w, 1e-6) && near(r.height(), h, 1e-6)), "{bearing}: {b:?}");
}
}
/// Brushes saved before the variation modes keep their look: a variation means Random, none
@@ -920,7 +1077,7 @@ fn brushes_without_modes_vary_randomly_as_before() {
stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))
);
// Modes are saved by name.
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"]));
assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure", "tilt", "bearing", "rotation"]));
assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v)));
}
+16 -15
View File
@@ -8,11 +8,11 @@
use std::sync::Arc;
use kurbo::ParamCurveNearest;
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile};
use vectorcraft_doc::{Appearance, Node, NodeId, NodeKind, PressureProfile, StrokeLayer};
use vectorcraft_geom::{Affine, BezPath, CubicBez, ParamCurve, PathData, PathEl, Point, Rect, SubPath, Vec2};
use crate::track::{Rng, Track, normal, seed_of, tracks};
use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, Scatter, Variation, tolerance};
use crate::{ArtBrush, ArtScale, CornerSource, Direction, PatternBrush, PatternFit, PenAt, Scatter, Variation, tolerance};
/// Longest mapped segment (points in document space).
const MAX_SEG: f64 = 1.5;
@@ -536,18 +536,19 @@ pub(crate) fn art(a: &ArtBrush, bp: &BezPath, weight: f64, pressure: Option<&Pre
}
/// One of a scatter brush's values for the next copy: Fixed takes the first of `(a, b)`, Random a
/// value between them, Pressure the first at pen pressure 0 and the second at 1. Every value
/// draws, so a copy's random values stay the same whatever the other modes.
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pressure: f64) -> f64 {
/// value between them, and a mode that follows the pen the first where `pen` reads 0 for it and
/// the second where it reads 1. Every value draws, so a copy's random values stay the same
/// whatever the other modes.
fn vary(rng: &mut Rng, (a, b): (f64, f64), mode: Variation, pen: PenAt) -> f64 {
let r = rng.range(a, b);
match mode {
Variation::Fixed => a,
Variation::Random => r,
Variation::Pressure => a + (b - a) * pressure.clamp(0.0, 1.0),
match pen.control(mode) {
Some(c) => a + (b - a) * c,
None if mode == Variation::Random => r,
None => a,
}
}
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<&PressureProfile>, name: &str) -> Vec<Node> {
pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, stroke: &StrokeLayer, name: &str) -> Vec<Node> {
let Some(b) = sc.art.geometric_bounds() else { return vec![] };
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
let mut rng = Rng::new(seed_of(bp, name));
@@ -558,8 +559,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
break;
}
let l = t.len();
// The pen pressure at arc length `at` (half way without a recorded one).
let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
// What the pen recorded at arc length `at` (the values themselves without a recording).
let pen_at = |at: f64| PenAt::of(stroke, if l > 1e-9 { at / l } else { 0.0 });
let mut s: Option<f64> = None;
let mut guard = 0;
loop {
@@ -567,8 +568,8 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
if guard > 20000 {
break;
}
// Size and spacing place the next copy, so they take the pressure at the last one.
let p = pressure_at(s.unwrap_or(0.0));
// Size and spacing place the next copy, so they take the pen at the last one.
let p = pen_at(s.unwrap_or(0.0));
let size = vary(&mut rng, sc.size, size_mode, p).max(1.0) / 100.0 * weight;
let spacing = vary(&mut rng, sc.spacing, spacing_mode, p).max(1.0) / 100.0;
let step = aw * size * spacing;
@@ -580,7 +581,7 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
break;
}
s = Some(pos);
let p = pressure_at(pos);
let p = pen_at(pos);
let off = vary(&mut rng, sc.scatter, scatter_mode, p) / 100.0 * ah * size;
let rot = vary(&mut rng, sc.rotation, rotation_mode, p).to_radians();
let (p, tg) = t.frame(pos);
+3 -2
View File
@@ -21,5 +21,6 @@ image = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
vectorcraft-text = { workspace = true, features = ["bundled-fonts"] }
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine)
# and in fonts built in code (`vectorcraft_text::test_fonts`).
vectorcraft-text = { workspace = true, features = ["bundled-fonts", "test-fonts"] }
+37 -4
View File
@@ -62,6 +62,33 @@ fn until_semicolon(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> Stri
arg
}
/// The character a `\U+XXXX` escape writes (`after` is the text just after its backslash), with
/// the escape's length; a UTF-16 surrogate pair written as two escapes is one character.
fn unicode_escape(after: &str) -> Option<(char, usize)> {
let hex = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+"))?.get(..4)?;
if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let value = u32::from_str_radix(hex, 16).ok()?;
if (0xD800..=0xDBFF).contains(&value) {
let next = after.get(6..)?;
let low = next.strip_prefix("\\U+").or_else(|| next.strip_prefix("\\u+"))?.get(..4)?;
if !low.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let low = u32::from_str_radix(low, 16).ok()?;
if !(0xDC00..=0xDFFF).contains(&low) {
return None;
}
let scalar = 0x1_0000 + ((value - 0xD800) << 10) + (low - 0xDC00);
return char::from_u32(scalar).map(|ch| (ch, 13));
}
if (0xDC00..=0xDFFF).contains(&value) {
return None;
}
char::from_u32(value).map(|ch| (ch, 6))
}
/// `\U+XXXX` Unicode escapes (how files before DXF 2007 hold characters outside their code page)
/// and caret escapes: `^ ` is a caret, `^I` a tab; other control characters are dropped. In
/// multiline text (`mtext`) `\\` is an escaped backslash and is kept as is, so that a `U+` after
@@ -77,12 +104,10 @@ fn escapes(s: &str, mtext: bool) -> String {
continue;
}
if c == '\\'
&& let Some(hex) = after.strip_prefix("U+").or_else(|| after.strip_prefix("u+")).and_then(|h| h.get(..4))
&& hex.bytes().all(|b| b.is_ascii_hexdigit())
&& let Some(ch) = u32::from_str_radix(hex, 16).ok().and_then(char::from_u32)
&& let Some((ch, consumed)) = unicode_escape(after)
{
out.push(ch);
rest = after.get(6..).unwrap_or("");
rest = after.get(consumed..).unwrap_or("");
continue;
}
if mtext
@@ -161,6 +186,14 @@ mod tests {
assert_eq!(plain("\\U+00C9"), "É");
}
#[test]
fn unicode_escape_decodes_surrogate_pairs() {
assert_eq!(plain("\\U+D83D\\U+DE00"), "😀");
assert_eq!(plain("\\U+D83D"), "\\U+D83D");
assert_eq!(plain("\\U+DE00"), "\\U+DE00");
assert_eq!(plain("\\U+D83D\\U+0041"), "\\U+D83DA");
}
#[test]
fn control_codes_and_escapes() {
assert_eq!(plain("45%%d %%p0.1 %%c20 100%%% %%uunder%%u"), "45° ±0.1 ⌀20 100% under");
+5 -3
View File
@@ -544,9 +544,8 @@ impl<'a> Scene<'a> {
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
&& let Some(art) = self.doc.place_symbol_art_unclipped(symbol, &vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n), *xf)
{
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
art.transform(*xf, false);
self.nest += 1;
self.node(&art, true);
self.nest -= 1;
@@ -1062,7 +1061,8 @@ impl<'a> Scene<'a> {
}
/// Type: glyph outlines (Preserve Appearance, Outline Text, type on a path), else one text
/// entity per run of each line.
/// entity per run of each line, whose height (group 40) is [`CAP_HEIGHT`] times the em size its
/// run is drawn at ([`vectorcraft_text::FontDb::font_heights_resolved`]).
fn text(&mut self, n: &Node, t: &TextObject) {
let outline = self.o.preserve == Preserve::Appearance || self.o.outline_text || matches!(t.kind, TextKind::OnPath { .. });
if outline {
@@ -1074,6 +1074,8 @@ impl<'a> Scene<'a> {
if !n.appearance.items.is_empty() {
self.warn(TYPE_ITEMS);
}
let resolved = vectorcraft_text::FontDb::global().font_heights_resolved(t);
let t = &*resolved;
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let plain = t.plain_text();
let xf = self.to_dxf * t.xf;
+17
View File
@@ -906,6 +906,23 @@ fn text_outside_ascii_is_escaped_before_2007() {
assert_eq!(text(DxfVersion::R2018), "Größe");
}
/// Type whose font size measures the cap height is written at the em size it is drawn at: 14 pt
/// measuring a 0.7 em cap height is 20 pt type, whose TEXT height is 14.
#[test]
fn a_font_height_reference_is_written_as_the_size_drawn() {
use vectorcraft_text::test_fonts::{HEIGHT_FAMILY, height_font};
vectorcraft_text::FontDb::global().add_font(height_font(true).unwrap());
let mut d = text_doc("Plan A", 14.0);
let id = d.layers[0].children().and_then(|c| c.first()).map(|n| n.id).unwrap();
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.runs[0].style.font_family = HEIGHT_FAMILY.into();
t.runs[0].style.font_height_reference = vectorcraft_doc::FontHeightReference::CapHeight;
}
let editable = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
let e = entities(&editable);
assert!((all(&e[0], 40)[0] - 14.0).abs() < 1e-6, "{:?}", all(&e[0], 40));
}
#[test]
fn images_link_png_or_jpeg_files() {
let mut png = vec![];
+121 -17
View File
@@ -78,6 +78,14 @@ const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0;
/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is
/// halved to keep within [`PERCEPTUAL_TOLERANCE`].
const PERCEPTUAL_DEPTH: u32 = 10;
/// How many times [`Gradient::ink_tints`] halves the range of tints it searches: to within
/// 1/65536 of a tint.
const TINT_STEPS: u32 = 16;
/// The least OKLab lightness by which an ink at 100% must differ from paper white for
/// [`Gradient::ink_tints`] to tell its tints apart by lightness.
const MIN_INK_LIGHTNESS_SPAN: f64 = 1.0 / 255.0;
/// Most stops on a gradient's curve that [`Gradient::ink_tints`] finds tints for.
const MAX_INK_TINTS: usize = 4096;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
@@ -100,6 +108,30 @@ fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out:
}
}
/// The mix positions (`v`, 0..1) of the stops a Perceptual gradient's curve has between stops `a`
/// and `b`, in order: [`perceptual_steps`] on either side of the midpoint, and the midpoint (½).
fn perceptual_mixes(a: &GradientStop, b: &GradientStop) -> Vec<f32> {
let mut vs = Vec::new();
perceptual_steps(&a.color, &b.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
perceptual_steps(&a.color, &b.color, (0.5, 1.0), 1, &mut vs);
vs
}
/// The offset of mix position `v` between stops `a` and `b`, placed through `a`'s midpoint
/// (`v` = ½ at the midpoint) as [`Gradient::sample`] maps it.
fn curve_offset(a: &GradientStop, b: &GradientStop, v: f32) -> f32 {
let m = a.midpoint.clamp(0.01, 0.99);
let u = if v == 0.5 {
a.midpoint
} else if v < 0.5 {
2.0 * v * m
} else {
m + (v - 0.5) * 2.0 * (1.0 - m)
};
a.offset + (b.offset - a.offset) * u
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
@@ -245,6 +277,11 @@ impl Gradient {
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
/// on the gradient's own stops and a Perceptual gradient's other in-between stops).
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
self.expanded_with_stops().map(|(t, c, o, mid, _)| (t, c, o, mid))
}
/// [`Self::expanded`], each of the gradient's own stops with its index in [`Self::stops`]
/// (`None` on the stops in between).
fn expanded_with_stops(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>, Option<usize>)> + '_ {
// Direct interpolation matches `sample` only for strictly increasing offsets.
let increasing = self.stops.windows(2).all(|w| w[0].offset < w[1].offset);
self.stops.iter().enumerate().flat_map(move |(i, s)| {
@@ -257,33 +294,100 @@ impl Gradient {
// stops again for each midpoint made many stops cost their square.
let mix = |a: &Color, b: &Color, v| self.method.mix(a, b, v);
let (c, o) = if increasing { between(s, n, t, &mix) } else { self.sample_with(t, mix) };
(t, c, o, Some(s.midpoint))
(t, c, o, Some(s.midpoint), None)
});
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
let mut vs = Vec::new();
perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs);
let m = s.midpoint.clamp(0.01, 0.99);
vs.into_iter().map(move |v| {
perceptual_mixes(s, n).into_iter().map(move |v| {
let mid = v == 0.5;
let u = if mid {
s.midpoint
} else if v < 0.5 {
2.0 * v * m
} else {
m + (v - 0.5) * 2.0 * (1.0 - m)
};
let t = s.offset + (n.offset - s.offset) * u;
let t = curve_offset(s, n, v);
let o = s.opacity + (n.opacity - s.opacity) * v;
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint))
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint), None)
})
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
std::iter::once((s.offset, s.color, s.opacity, None, Some(i))).chain(classic).chain(perceptual.into_iter().flatten())
})
}
/// A gradient of tints of one ink `ink` (its color at 100%; paper white is 0% of it) as
/// (offset, tint, opacity) at each of [`Self::expanded_stops`]: the tint (0..1) whose OKLab
/// lightness is the lightness of the color there, found to [`TINT_STEPS`] halvings, except at
/// the gradient's own stops, which take their tints from `stop_tints` (one per stop, in
/// order) where it has one. Mixed linearly between them, the tints follow a Perceptual
/// gradient's curve. `None` when the ink's lightness is within [`MIN_INK_LIGHTNESS_SPAN`] of
/// paper white's (a white or nearly white ink), so lightness does not tell its tints apart,
/// and when the curve has more than [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_tints(&self, ink: &Color, stop_tints: &[f32]) -> Option<Vec<(f32, f32, f32)>> {
use crate::libraries::srgb_to_oklab;
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
if lightness(&ink.tinted(0.0)) - lightness(ink) < MIN_INK_LIGHTNESS_SPAN {
return None;
}
let curve: Vec<_> = self.expanded_with_stops().take(MAX_INK_TINTS + 1).collect();
if curve.len() > MAX_INK_TINTS {
return None;
}
let tint = |c: &Color| {
let want = lightness(c);
let (mut lo, mut hi) = (0.0f32, 1.0f32);
for _ in 0..TINT_STEPS {
let mid = (lo + hi) / 2.0;
if lightness(&ink.tinted(mid)) > want {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
};
Some(curve.into_iter().map(|(t, c, o, _, i)| (t, i.and_then(|i| stop_tints.get(i).copied()).unwrap_or_else(|| tint(&c)), o)).collect())
}
/// Does the gradient's curve ([`Self::expanded`]) have at most [`MAX_INK_TINTS`] stops, so
/// [`Self::ink_tints`] finds tints for it?
pub fn fits_ink_tints(&self) -> bool {
self.expanded().nth(MAX_INK_TINTS).is_none()
}
/// A Perceptual gradient's curve ([`Self::expanded_stops`]) as mixes of its stops' inks:
/// (offset, `i`, weight, opacity) at each stop on it, where the inks there are those of stop
/// `i` and stop `i + 1` mixed linearly at the weight (0..1; 0 on the gradient's own stops).
/// The weight is the one at which the two stops' colors mixed in display RGB have the OKLab
/// lightness of the color on the curve there, found to [`TINT_STEPS`] halvings, or the mix
/// position itself when the two colors' lightness differs by less than
/// [`MIN_INK_LIGHTNESS_SPAN`]. `None` for a Classic gradient, and when the curve has more than
/// [`MAX_INK_TINTS`] stops ([`Self::fits_ink_tints`]).
pub fn ink_mixes(&self) -> Option<Vec<(f32, usize, f32, f32)>> {
use crate::libraries::srgb_to_oklab;
if self.method.is_classic() || !self.fits_ink_tints() {
return None;
}
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
let mut out = Vec::new();
for (i, s) in self.stops.iter().enumerate() {
out.push((s.offset, i, 0.0, s.opacity));
let Some(n) = self.stops.get(i + 1) else { continue };
let (l0, l1) = (lightness(&s.color), lightness(&n.color));
for v in perceptual_mixes(s, n) {
let weight = if (l1 - l0).abs() < MIN_INK_LIGHTNESS_SPAN {
v
} else {
let want = lightness(&self.method.mix(&s.color, &n.color, v));
let (mut lo, mut hi) = (0.0f32, 1.0f32);
for _ in 0..TINT_STEPS {
let mid = (lo + hi) / 2.0;
// The mix at `mid` has not yet gone from stop `i`'s lightness to the wanted one.
if (lightness(&GradientMethod::Classic.mix(&s.color, &n.color, mid)) - want) * (l1 - l0) < 0.0 {
lo = mid;
} else {
hi = mid;
}
}
(lo + hi) / 2.0
};
out.push((curve_offset(s, n, v), i, weight, s.opacity + (n.opacity - s.opacity) * v));
}
}
Some(out)
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
/// segment's midpoint mirrored with it.
pub fn reverse(&mut self) {
+72
View File
@@ -455,3 +455,75 @@ fn perceptual_gradients_draw_as_pixels() {
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
}
}
/// A Perceptual gradient of an ink's tints as tints: its stops keep their tints, and each tint on
/// the curve has the lightness of the color mixed there, so the tints between the stops are not
/// a straight line.
#[test]
fn ink_tints_follow_the_perceptual_curve() {
use crate::libraries::srgb_to_oklab;
let ink = Color::cmyk(0.0, 1.0, 0.0, 0.0);
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, ink.tinted(0.8)), GradientStop::new(1.0, Color::WHITE)]);
g.method = GradientMethod::Perceptual;
assert!(g.fits_ink_tints());
let tints = g.ink_tints(&ink, &[]).expect("a magenta ink's tints differ in lightness");
assert!(tints.len() > 2, "the curve's stops: {tints:?}");
let (first, last) = (tints[0], tints[tints.len() - 1]);
assert!((first.0, last.0) == (0.0, 1.0) && (first.1 - 0.8).abs() < 1e-4 && last.1 < 1e-4, "{tints:?}");
// The stops' own tints, when given, are kept exactly; the tints in between are the same.
let own = g.ink_tints(&ink, &[0.8, 0.0]).expect("tints");
assert!(own.len() == tints.len() && own[0].1 == 0.8 && own[own.len() - 1].1 == 0.0, "{own:?}");
assert!(own[1..own.len() - 1] == tints[1..tints.len() - 1], "{own:?}");
assert!(tints.windows(2).all(|w| w[0].0 <= w[1].0 && w[0].1 >= w[1].1), "in order, lighter along the way: {tints:?}");
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
for (t, tint, _) in &tints {
let want = lightness(&g.sample(*t).0);
assert!((lightness(&ink.tinted(*tint)) - want).abs() < 1e-3, "at {t}: tint {tint}");
}
let mid = tints.iter().find(|s| (s.0 - 0.5).abs() < 1e-6).expect("the midpoint");
assert!((mid.1 - 0.4).abs() > 0.01, "not halfway in tint: {mid:?}");
// A white ink's tints all have paper white's lightness: no tints by lightness.
assert_eq!(g.ink_tints(&Color::WHITE, &[]), None);
// More stops on the curve than MAX_INK_TINTS: no tints.
let n = MAX_INK_TINTS + 1;
g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { ink } else { Color::WHITE })).collect();
assert_eq!(g.ink_tints(&ink, &[]), None);
assert!(!g.fits_ink_tints());
}
/// A Perceptual gradient's curve as mixes of its stops' inks: the stops on the curve at their
/// offsets, each weighted so the two stops' colors mixed in display RGB have the lightness of
/// the color on the curve there.
#[test]
fn ink_mixes_follow_the_perceptual_curve() {
use crate::libraries::srgb_to_oklab;
let (magenta, yellow) = (Color::cmyk(0.0, 1.0, 0.0, 0.0), Color::cmyk(0.0, 0.0, 0.6, 0.0));
let mut g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, magenta), GradientStop::new(1.0, yellow)]);
g.stops[0].midpoint = 0.3;
assert_eq!(g.ink_mixes(), None, "a Classic gradient");
g.method = GradientMethod::Perceptual;
let mixes = g.ink_mixes().expect("a Perceptual gradient's curve");
let curve = g.expanded_stops();
assert_eq!(mixes.len(), curve.len(), "a mix for each stop on the curve");
assert_eq!((mixes[0], mixes[mixes.len() - 1]), ((0.0, 0, 0.0, 1.0), (1.0, 1, 0.0, 1.0)), "the gradient's own stops");
let lightness = |c: &Color| srgb_to_oklab(c.to_rgb())[0];
for ((t, i, w, _), (ct, c, _)) in mixes.iter().zip(&curve) {
assert!((t - ct).abs() < 1e-6, "{t} on the curve at {ct}");
if *i == 0 && *t > 0.0 {
let mixed = GradientMethod::Classic.mix(&magenta, &yellow, *w);
assert!((lightness(&mixed) - lightness(c)).abs() < 1e-3, "at {t}: weight {w}");
}
}
let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
assert!((mid.2 - 0.5).abs() > 0.01, "not halfway at the midpoint: {mid:?}");
assert!(mixes.windows(2).all(|w| w[0].0 <= w[1].0), "in order: {mixes:?}");
// Two colors of one lightness: the weights are the mix positions.
g.stops[1].color = magenta;
let mixes = g.ink_mixes().expect("the curve");
let mid = mixes.iter().find(|m| (m.0 - 0.3).abs() < 1e-6).expect("the midpoint");
assert!((mid.2 - 0.5).abs() < 1e-6, "{mid:?}");
// More stops on the curve than MAX_INK_TINTS: no mixes.
let n = MAX_INK_TINTS + 1;
g.stops = (0..n).map(|i| GradientStop::new(i as f32 / (n - 1) as f32, if i % 2 == 0 { magenta } else { Color::WHITE })).collect();
assert_eq!(g.ink_mixes(), None);
}
+148
View File
@@ -244,6 +244,12 @@ pub fn is_tint(c: Color, base: Color) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y * t).abs() <= 0.01)
}
/// Whether `a` and `b` carry the same ink in `a`'s model, within [`is_tint`]'s tolerance.
pub fn same_ink(a: Color, b: Color) -> bool {
let (x, y) = (ink(a, a.model()), ink(b, a.model()));
x.iter().zip(y).all(|(p, q)| (p - q).abs() <= 0.01)
}
/// `c` darkened by `f` (0..1) toward black: more K for CMYK and Gray, scaled RGB and Lab.
pub fn shaded(c: Color, f: f32) -> Color {
let f = f.clamp(0.0, 1.0);
@@ -255,6 +261,77 @@ pub fn shaded(c: Color, f: f32) -> Color {
}
}
/// Recolor Artwork's Global Adjust: the same change to every color, by four amounts from -1 to 1
/// (-100% to +100%), where 0 leaves a color as it is. The steps run in this order:
///
/// 1. Saturation multiplies HSB saturation by 1 + `saturation` (-1 gives a gray, 1 doubles it).
/// 2. Brightness moves HSB brightness that share of the way to its top (above 0) or to black
/// (below 0).
/// 3. Temperature adds 0.2 × `temperature` to display red and takes as much from blue (warmer
/// above 0, cooler below).
/// 4. Luminosity moves Lab lightness that share of the way to 100 (above 0) or to 0 (below 0),
/// with Lab a and b, and so the Lab hue, kept.
///
/// The result is in the model of the color it changes, with its components clamped to their
/// ranges. An amount that is not finite counts as 0.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct GlobalAdjust {
pub saturation: f32,
pub brightness: f32,
pub temperature: f32,
pub luminosity: f32,
}
impl GlobalAdjust {
/// The four amounts, in order, each finite and within -1..=1.
fn amounts(&self) -> [f32; 4] {
[self.saturation, self.brightness, self.temperature, self.luminosity].map(|v| if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 })
}
/// Whether every color stays as it is (all four amounts 0).
pub fn is_identity(&self) -> bool {
self.amounts().iter().all(|v| *v == 0.0)
}
/// `c` with the adjustment, in `c`'s model; `c` itself when [`GlobalAdjust::is_identity`].
pub fn apply(&self, c: Color) -> Color {
let [sat, bri, temp, lum] = self.amounts();
if [sat, bri, temp, lum].iter().all(|v| *v == 0.0) {
return c;
}
// `v` moved the share `a` of the way to `top` (a > 0) or to 0 (a < 0).
let toward = |v: f32, a: f32, top: f32| if a >= 0.0 { v + (top - v) * a } else { v * (1.0 + a) };
let mut out = c;
if sat != 0.0 || bri != 0.0 {
let [h, s, v] = out.to_hsb();
out = Color::from_hsb(h, (s * (1.0 + sat)).clamp(0.0, 1.0), toward(v.clamp(0.0, 1.0), bri, 1.0));
}
if temp != 0.0 {
let [r, g, b] = out.to_rgb();
out = Color::rgb((r + 0.2 * temp).clamp(0.0, 1.0), g.clamp(0.0, 1.0), (b - 0.2 * temp).clamp(0.0, 1.0));
}
if lum != 0.0 {
let Lab { l, a, b } = match out {
Color::Lab { l, a, b } => Lab::new(l, a, b),
_ => out.to_lab(),
};
out = Color::lab(toward(l.clamp(0.0, 100.0), lum, 100.0), a, b);
}
clamped(keep_model(c, out))
}
}
/// `c` with its components in their ranges: RGB, CMYK and Gray 0..1, Lab lightness 0..100.
fn clamped(c: Color) -> Color {
let u = |v: f32| v.clamp(0.0, 1.0);
match c {
Color::Rgb { r, g, b } => Color::rgb(u(r), u(g), u(b)),
Color::Cmyk { c, m, y, k } => Color::cmyk(u(c), u(m), u(y), u(k)),
Color::Gray { k } => Color::gray(u(k)),
Color::Lab { l, a, b } => Color::lab(l.clamp(0.0, 100.0), a, b),
}
}
/// One row of Recolor Artwork prepared for mapping: its current colours take `to` by `method`.
#[derive(Clone, Copy, Debug)]
pub struct RowMap {
@@ -615,6 +692,77 @@ mod tests {
assert!(row.iter().all(|c| kept.map(*c).model() == c.model()));
}
fn adjust(saturation: f32, brightness: f32, temperature: f32, luminosity: f32) -> GlobalAdjust {
GlobalAdjust { saturation, brightness, temperature, luminosity }
}
#[test]
fn global_adjust_at_zero_leaves_every_color_as_it_is() {
let zero = GlobalAdjust::default();
assert!(zero.is_identity());
assert!(adjust(f32::NAN, 0.0, f32::INFINITY, 0.0).is_identity(), "amounts that aren't finite count as 0");
for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.1234, 0.5, 0.0, 0.07), Color::gray(0.4), Color::lab(55.0, 60.5, -40.25)] {
assert_eq!(zero.apply(c), c);
}
assert!(!adjust(0.0, 0.0, 0.0, 0.01).is_identity());
}
#[test]
fn global_adjust_saturation_and_brightness_reach_their_ends() {
let c = Color::rgb(0.8, 0.4, 0.2);
let [h, s, v] = c.to_hsb();
let gray = adjust(-1.0, 0.0, 0.0, 0.0).apply(c).to_hsb();
assert!(gray[1].abs() < 1e-4 && (gray[2] - v).abs() < 1e-4, "-100% saturation gives a gray as bright: {gray:?}");
let more = adjust(0.5, 0.0, 0.0, 0.0).apply(c).to_hsb();
assert!((more[0] - h).abs() < 0.5 && more[1] > s && (more[1] - (s * 1.5).min(1.0)).abs() < 1e-3, "{more:?}");
assert!(adjust(-1.0, 0.0, 0.0, 0.0).apply(Color::rgb(0.5, 0.5, 0.5)) == Color::rgb(0.5, 0.5, 0.5), "a gray stays gray");
assert!(adjust(0.0, -1.0, 0.0, 0.0).apply(c).to_hsb()[2] < 1e-4, "-100% brightness is black");
assert!((adjust(0.0, 1.0, 0.0, 0.0).apply(c).to_hsb()[2] - 1.0).abs() < 1e-4, "+100% brightness is full brightness");
let half = adjust(0.0, 0.5, 0.0, 0.0).apply(c).to_hsb();
assert!((half[2] - (v + (1.0 - v) * 0.5)).abs() < 1e-3 && (half[1] - s).abs() < 1e-3, "{half:?}");
}
#[test]
fn global_adjust_temperature_warms_and_cools() {
let c = Color::rgb(0.5, 0.5, 0.5);
let warm = adjust(0.0, 0.0, 1.0, 0.0).apply(c).to_rgb();
let cool = adjust(0.0, 0.0, -1.0, 0.0).apply(c).to_rgb();
assert!(warm[0] > warm[1] && warm[1] > warm[2], "warmer: more red, less blue {warm:?}");
assert!(cool[2] > cool[1] && cool[1] > cool[0], "cooler: more blue, less red {cool:?}");
let edge = adjust(0.0, 0.0, 1.0, 0.0).apply(Color::rgb(1.0, 0.0, 0.0));
assert_eq!(edge, Color::rgb(1.0, 0.0, 0.0), "clamped at the ends of the channels");
}
#[test]
fn global_adjust_luminosity_keeps_the_hue() {
let lab = Color::lab(50.0, 30.0, -20.0);
assert_eq!(adjust(0.0, 0.0, 0.0, 0.5).apply(lab), Color::lab(75.0, 30.0, -20.0), "a Lab color keeps a and b exactly");
assert_eq!(adjust(0.0, 0.0, 0.0, -0.5).apply(lab), Color::lab(25.0, 30.0, -20.0));
let c = Color::rgb(0.6, 0.35, 0.3);
let (before, after) = (c.to_lab(), adjust(0.0, 0.0, 0.0, 0.2).apply(c).to_lab());
assert!(after.l > before.l, "lighter: {after:?}");
assert!((after.hue() - before.hue()).abs() < 2.0, "hue kept: {} vs {}", after.hue(), before.hue());
let darkest = adjust(0.0, 0.0, 0.0, -1.0).apply(c).to_lab();
assert!(darkest.l < before.l * 0.5, "-100% is far darker: {darkest:?}");
assert_eq!(adjust(0.0, 0.0, 0.0, -1.0).apply(Color::gray(0.4)), Color::gray(1.0), "-100% makes a gray black");
}
#[test]
fn global_adjust_keeps_the_model_and_the_ranges() {
let all = adjust(1.0, 1.0, 1.0, 1.0);
for c in [Color::rgb(0.3, 0.6, 0.9), Color::cmyk(0.2, 0.9, 0.1, 0.3), Color::gray(0.4), Color::lab(55.0, 60.0, -40.0)] {
let out = all.apply(c);
assert_eq!(out.model(), c.model(), "{c:?} → {out:?}");
let comps: Vec<f32> = match out {
Color::Rgb { r, g, b } => vec![r, g, b],
Color::Cmyk { c, m, y, k } => vec![c, m, y, k],
Color::Gray { k } => vec![k],
Color::Lab { l, .. } => vec![l / 100.0],
};
assert!(comps.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v)), "{out:?}");
}
}
#[test]
fn palette_snaps_to_the_nearest_colour() {
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
+29 -2
View File
@@ -10,10 +10,12 @@ mod stroke_style;
pub(crate) use layers::HEAD_REACH;
pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};
pub use profile::{PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
/// One entry of the appearance stack.
// A stroke takes 328 bytes and a fill 104; an appearance holds a few of each.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum AppearanceItem {
@@ -126,11 +128,15 @@ impl Appearance {
pub fn stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
self.items.iter_mut().rev().find_map(|i| if let AppearanceItem::Stroke(s) = i { Some(s) } else { None })
}
/// This appearance for other art: the pen pressure its strokes were drawn with stays with them.
/// This appearance for other art: the pen pressure, tilt, bearing and rotation its strokes
/// were drawn with stay with them.
pub fn without_pressure(mut self) -> Self {
for it in &mut self.items {
if let AppearanceItem::Stroke(s) = it {
s.pressure = None;
s.tilt = None;
s.bearing = None;
s.rotation = None;
}
}
self
@@ -504,6 +510,27 @@ impl Appearance {
self.set_contents_at(others_below + usize::from(now_below));
Some(to)
}
/// Move items `from` (ascending paint-order indices, no repeats) as one block, in their order,
/// so the lowest lands at `to` (clamped); returns it. The contents stay between the same unmoved
/// items; a block landing right at them keeps its lowest item's side. None (and nothing changed)
/// when an index is missing.
pub fn move_items(&mut self, from: &[usize], to: usize) -> Option<usize> {
let (&first, _) = from.split_first()?;
if from.iter().any(|i| *i >= self.items.len()) {
return None;
}
let below = first < self.contents_at();
// From the top down, so the lower indices stay valid; then back in order.
let mut block: Vec<AppearanceItem> = from.iter().rev().filter_map(|&i| self.remove_item(i)).collect();
block.reverse();
let others_below = self.contents_at();
let to = to.min(self.items.len());
let m = block.len();
self.items.splice(to..to, block);
let now_below = if below { to <= others_below } else { to < others_below };
self.set_contents_at(others_below + if now_below { m } else { 0 });
Some(to)
}
}
/// The most fills, strokes or effects a stack may have to be matched against another's
+19 -1
View File
@@ -1,4 +1,4 @@
use super::profile::{PressureProfile, WidthProfile};
use super::profile::{AngleProfile, PressureProfile, WidthProfile};
use super::stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
use super::{BlendMode, Deserialize, Paint, Serialize};
@@ -93,6 +93,21 @@ pub struct StrokeLayer {
/// art leave it behind ([`Appearance::without_pressure`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pressure: Option<PressureProfile>,
/// How far the pen leaned from upright along the stroke (Paintbrush with a tablet that reads
/// tilt): 0 upright to 1 lying flat (90°). Brushes with Tilt variation vary with it. Like the
/// pressure, it stays with this path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tilt: Option<PressureProfile>,
/// Which way the pen leaned along the stroke, in degrees counter-clockwise on the page from the
/// x axis. Brushes with Bearing variation vary with it. Like the pressure, it stays with this
/// path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bearing: Option<AngleProfile>,
/// How far the pen's barrel was turned along the stroke, in degrees counter-clockwise (a pen
/// that reads barrel rotation). Brushes with Rotation variation vary with it. Like the
/// pressure, it stays with this path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotation: Option<AngleProfile>,
}
/// How a gradient on a stroke is laid out (the Gradient panel's Stroke buttons). Saved by variant
@@ -167,6 +182,9 @@ impl StrokeLayer {
overprint: false,
gradient_mode: StrokeGradientMode::Within,
pressure: None,
tilt: None,
bearing: None,
rotation: None,
}
}
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
+146 -41
View File
@@ -1,3 +1,5 @@
use std::borrow::Cow;
use super::{Deserialize, Serialize};
/// Variable-width profile: (position 0..1 along the path, left width factor, right width factor),
@@ -101,7 +103,8 @@ impl WidthProfile {
/// Pen pressure recorded along a stroke (the Paintbrush with a graphics tablet): (position 0..1
/// along the path, pressure 0..1) samples in order, positions measured as a [`WidthProfile`]'s
/// are. Calligraphic brushes with Pressure variation read it; everything else ignores it.
/// are. The pen's tilt is kept the same way ([`StrokeLayer::tilt`]). Calligraphic and Scatter
/// brushes with Pressure or Tilt variation read them; everything else ignores them.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct PressureProfile {
pub points: Vec<(f64, f64)>,
@@ -118,18 +121,7 @@ impl PressureProfile {
/// The pressure at `t` (0..1), linear between samples; [`Self::MID`] without any.
pub fn at(&self, t: f64) -> f64 {
let p = &self.points;
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
let v = if t <= first.0 {
first.1
} else {
let i = p.partition_point(|q| q.0 < t);
match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
_ => last.1,
}
};
let v = interpolated(&self.points, t).unwrap_or(Self::MID);
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
}
@@ -139,44 +131,21 @@ impl PressureProfile {
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
let pts: Vec<(f64, f64)> =
samples.into_iter().filter(|(t, p)| t.is_finite() && p.is_finite()).map(|(t, p)| (t.clamp(0.0, 1.0), p.clamp(0.0, 1.0))).collect();
if pts.is_empty() {
return None;
}
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
let mut pts = thin(pts, 16 * Self::MAX_POINTS);
// Positions only ever go forward.
let mut prev = 0.0;
for p in &mut pts {
p.0 = p.0.max(prev);
prev = p.0;
}
let keep = simplify_1d(&pts, Self::TOLERANCE);
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), Self::MAX_POINTS);
let round = |v: f64| (v * 1e4).round() / 1e4;
Some(Self { points: pts.into_iter().map(|(t, p)| (round(t), round(p))).collect() })
simplified(pts, Self::TOLERANCE).map(|points| Self { points })
}
/// The pressure of a path `old_len` long (`old`; None: drawn without pressure) continued at its
/// end by a stroke `added_len` long (`added`). None when neither has pressure.
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
if old.is_none() && added.is_none() {
return None;
match extended_points(old.map(|p| p.points.as_slice()), old_len, added.map(|p| p.points.as_slice()), added_len, Self::MID)? {
Cow::Borrowed(points) => Some(Self { points: points.to_vec() }),
Cow::Owned(points) => Self::from_samples(points),
}
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
let total = old_len + added_len;
if !total.is_finite() || total <= 1e-9 {
return old.or(added).cloned();
}
let flat = Self { points: vec![(0.0, Self::MID), (1.0, Self::MID)] };
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
let o = old_len / total;
let old_pts = old.points.iter().map(|&(t, p)| (t * o, p));
Self::from_samples(old_pts.chain(added.points.iter().map(|&(u, p)| (o + u * (1.0 - o), p))))
}
/// The pressure of the path reversed.
pub fn reversed(&self) -> Self {
Self { points: self.points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect() }
Self { points: reversed_points(&self.points) }
}
/// A light–heavy–light stroke, for previews of pressure-sensitive brushes.
@@ -185,6 +154,142 @@ impl PressureProfile {
}
}
/// A pen angle recorded along a stroke (the Paintbrush with a tablet that reads it): (position 0..1
/// along the path, degrees) samples in order, positions measured as a [`PressureProfile`]'s are.
/// Each sample is joined to the one before it the shorter way round, so the degrees can run past
/// ±180° and the angle read between two samples turns through the smaller angle between them.
/// Calligraphic and Scatter brushes with Bearing or Rotation variation read it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct AngleProfile {
pub points: Vec<(f64, f64)>,
}
impl AngleProfile {
/// How far (in degrees) simplifying may move the recorded curve.
const TOLERANCE: f64 = 1.0;
/// The angle at `t` (0..1), linear between samples, in degrees above −180 up to 180; 0
/// without any samples.
pub fn at(&self, t: f64) -> f64 {
let v = interpolated(&self.points, t).unwrap_or(0.0);
if v.is_finite() { vectorcraft_geom::normalize_deg(v) } else { 0.0 }
}
/// A profile from (position, degrees) samples in order along the stroke: non-finite ones are
/// dropped, each sample is joined to the one before it the shorter way round, and the curve is
/// simplified to within 1° and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.
pub fn from_samples(samples: impl IntoIterator<Item = (f64, f64)>) -> Option<Self> {
let mut prev: Option<f64> = None;
let pts: Vec<(f64, f64)> = samples
.into_iter()
.filter(|(t, a)| t.is_finite() && a.is_finite())
.map(|(t, a)| {
let a = prev.map_or(vectorcraft_geom::normalize_deg(a), |p| p + vectorcraft_geom::normalize_deg(a - p));
prev = Some(a);
(t.clamp(0.0, 1.0), a)
})
.collect();
simplified(pts, Self::TOLERANCE).map(|points| Self { points })
}
/// The angle of a path `old_len` long (`old`; None: drawn without it, 0°) continued at its end
/// by a stroke `added_len` long (`added`). None when neither has one. Each part keeps the turns
/// it recorded, more than half a turn between two samples included; the added part is moved by
/// whole turns so that it starts the shorter way round from where the old part ends.
pub fn extended(old: Option<&Self>, old_len: f64, added: Option<&Self>, added_len: f64) -> Option<Self> {
let end = old.map_or(Some(0.0), |o| o.points.last().map(|p| p.1));
let added = added.map(|a| {
let shift = match (end, a.points.first()) {
(Some(end), Some(&(_, start))) => end + vectorcraft_geom::normalize_deg(start - end) - start,
_ => 0.0,
};
Self { points: a.points.iter().map(|&(t, v)| (t, v + shift)).collect() }
});
match extended_points(old.map(|p| p.points.as_slice()), old_len, added.as_ref().map(|p| p.points.as_slice()), added_len, 0.0)? {
Cow::Borrowed(points) => Some(Self { points: points.to_vec() }),
Cow::Owned(points) => {
let pts = points.into_iter().filter(|(t, a)| t.is_finite() && a.is_finite()).map(|(t, a)| (t.clamp(0.0, 1.0), a)).collect();
simplified(pts, Self::TOLERANCE).map(|points| Self { points })
}
}
}
/// The angle along the path reversed (the angles stay as they were on the page).
pub fn reversed(&self) -> Self {
Self { points: reversed_points(&self.points) }
}
/// One turn through every angle along the stroke, for previews of brushes that vary with one.
pub fn sample() -> Self {
Self { points: vec![(0.0, -179.0), (1.0, 179.0)] }
}
}
/// The value of samples `p` (position, value) at `t`, linear between them; `None` without any.
fn interpolated(p: &[(f64, f64)], t: f64) -> Option<f64> {
let (first, last) = (p.first()?, p.last()?);
if t <= first.0 {
return Some(first.1);
}
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
let i = p.partition_point(|q| q.0 < t);
Some(match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
_ => last.1,
})
}
/// Samples (position, value) with positions in 0..1 and finite values, simplified to within `tol`
/// of the value and kept to [`PressureProfile::MAX_POINTS`]. `None` without a sample.
fn simplified(pts: Vec<(f64, f64)>, tol: f64) -> Option<Vec<(f64, f64)>> {
if pts.is_empty() {
return None;
}
// Simplifying is quadratic at worst: a huge stroke is thinned evenly first.
let mut pts = thin(pts, 16 * PressureProfile::MAX_POINTS);
// Positions only ever go forward.
let mut prev = 0.0;
for p in &mut pts {
p.0 = p.0.max(prev);
prev = p.0;
}
let keep = simplify_1d(&pts, tol);
let pts = thin(pts.iter().zip(keep).filter(|(_, k)| *k).map(|(p, _)| *p).collect(), PressureProfile::MAX_POINTS);
let round = |v: f64| (v * 1e4).round() / 1e4;
Some(pts.into_iter().map(|(t, p)| (round(t), round(p))).collect())
}
/// The samples of a path `old_len` long (`old`) continued at its end by a stroke `added_len` long
/// (`added`), in order along the whole path, a side without samples (`None`) flat at `none`.
/// `None` when neither side has samples; the side that has them, as they are, when the two
/// lengths add up to no length.
fn extended_points<'a>(
old: Option<&'a [(f64, f64)]>,
old_len: f64,
added: Option<&'a [(f64, f64)]>,
added_len: f64,
none: f64,
) -> Option<Cow<'a, [(f64, f64)]>> {
if old.is_none() && added.is_none() {
return None;
}
let (old_len, added_len) = (old_len.max(0.0), added_len.max(0.0));
let total = old_len + added_len;
if !total.is_finite() || total <= 1e-9 {
return old.or(added).map(Cow::Borrowed);
}
let flat = [(0.0, none), (1.0, none)];
let (old, added) = (old.unwrap_or(&flat), added.unwrap_or(&flat));
let o = old_len / total;
let old_pts = old.iter().map(|&(t, p)| (t * o, p));
Some(Cow::Owned(old_pts.chain(added.iter().map(|&(u, p)| (o + u * (1.0 - o), p))).collect()))
}
/// Samples (position, value) of a path, for the path reversed.
fn reversed_points(points: &[(f64, f64)]) -> Vec<(f64, f64)> {
points.iter().rev().map(|&(t, p)| (1.0 - t, p)).collect()
}
/// At most `max` of `pts`, evenly spread (the ends kept).
fn thin(pts: Vec<(f64, f64)>, max: usize) -> Vec<(f64, f64)> {
let n = pts.len();
+53 -1
View File
@@ -125,11 +125,50 @@ fn pressure_profile_extends_and_reverses() {
assert_eq!(old.reversed().points, vec![(0.0, 1.0), (1.0, 0.0)]);
}
/// #764: a pen's bearing or barrel rotation is kept the shorter way round between samples, so
/// 170° then −170° turns through 180°, not through 0°.
#[test]
fn angle_profiles_join_samples_the_shorter_way_round() {
let across = AngleProfile::from_samples([(0.0, 170.0), (1.0, -170.0)]).unwrap();
assert_eq!(across.points, vec![(0.0, 170.0), (1.0, 190.0)]);
assert_eq!(across.at(0.5), 180.0);
assert!((across.at(0.75) + 175.0).abs() < 1e-9, "{}", across.at(0.75));
// Turns past a whole turn keep counting, and read back within −180..180°.
let spin = AngleProfile::from_samples((0..=40).map(|i| (f64::from(i) / 40.0, f64::from(i * 30) - 180.0))).unwrap();
assert!((spin.points.last().unwrap().1 - 1380.0).abs() < 1e-9, "{spin:?}");
assert!((spin.at(1.0) + 60.0).abs() < 1e-9 && spin.points.len() == 2, "{spin:?}");
// Junk is dropped; nothing at all is no profile, and no profile reads 0°.
let junk = AngleProfile::from_samples([(f64::NAN, 5.0), (0.5, f64::INFINITY), (0.2, 400.0)]).unwrap();
assert_eq!(junk.points, vec![(0.2, 40.0)]);
assert!(AngleProfile::from_samples([(0.5, f64::NAN)]).is_none());
assert_eq!(AngleProfile::default().at(0.3), 0.0);
let noisy = AngleProfile::from_samples((0..1_000_000).map(|i| (f64::from(i) / 1e6, f64::from(i % 2) * 90.0))).unwrap();
assert!(noisy.points.len() <= PressureProfile::MAX_POINTS);
// Continued: the old part, then the new one; a side without an angle is 0°. Reversed, the
// angles stay what they were.
let old = AngleProfile { points: vec![(0.0, 90.0), (1.0, 90.0)] };
let joined = AngleProfile::extended(Some(&old), 30.0, None, 10.0).unwrap();
assert!((joined.at(0.5) - 90.0).abs() < 1e-9 && joined.at(1.0) == 0.0, "{joined:?}");
assert!(AngleProfile::extended(None, 10.0, None, 10.0).is_none());
assert_eq!(AngleProfile::extended(Some(&old), 0.0, None, 0.0), Some(old.clone()));
assert_eq!(across.reversed().points, vec![(0.0, 190.0), (1.0, 170.0)]);
// A part that turned more than half a turn between two of its samples keeps that turn when
// the path is continued, and so does the new part.
let quarter_turns = AngleProfile::from_samples([(0.0, 0.0), (1.0 / 3.0, 90.0), (2.0 / 3.0, 180.0), (1.0, 270.0)]).unwrap();
assert_eq!(quarter_turns.points, vec![(0.0, 0.0), (1.0, 270.0)]);
let added = AngleProfile { points: vec![(0.0, -80.0), (1.0, 280.0)] };
let joined = AngleProfile::extended(Some(&quarter_turns), 10.0, Some(&added), 10.0).unwrap();
assert!((joined.at(0.25) - 135.0).abs() < 1e-9, "{joined:?}");
assert!((joined.at(0.75) - 100.0).abs() < 1e-9, "{joined:?}");
}
/// Strokes saved before pen pressure load as they were, and are written without it.
#[test]
fn strokes_without_pressure_round_trip_unchanged() {
let json = serde_json::to_value(StrokeLayer::new(Paint::None, 2.0)).unwrap();
assert!(json.get("pressure").is_none());
for key in ["pressure", "tilt", "bearing", "rotation"] {
assert!(json.get(key).is_none(), "{key}");
}
let st: StrokeLayer = serde_json::from_value(json).unwrap();
assert_eq!(st.pressure, None);
let pressed = StrokeLayer { pressure: Some(PressureProfile::sample()), ..st };
@@ -138,6 +177,19 @@ fn strokes_without_pressure_round_trip_unchanged() {
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
ap.stroke_mut().unwrap().pressure = Some(PressureProfile::sample());
assert_eq!(ap.without_pressure().stroke().unwrap().pressure, None);
// The pen's tilt, bearing and rotation load, save and stay behind the same way.
let posed = StrokeLayer {
tilt: Some(PressureProfile::sample()),
bearing: Some(AngleProfile::sample()),
rotation: Some(AngleProfile { points: vec![(0.0, -45.0)] }),
..back
};
let again: StrokeLayer = serde_json::from_value(serde_json::to_value(&posed).unwrap()).unwrap();
assert_eq!(again, posed);
let mut ap = Appearance::basic(Paint::None, Paint::None, 1.0);
*ap.stroke_mut().unwrap() = posed;
let st = ap.without_pressure().stroke().cloned().unwrap();
assert_eq!((st.pressure, st.tilt, st.bearing, st.rotation), (None, None, None, None));
}
#[test]
+22 -1
View File
@@ -20,7 +20,7 @@ use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, GradientStop, Paint};
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, SubPath, Vec2};
use vectorcraft_geom::{Affine, Anchor, BezPath, FillRule, PathData, Point, Rect, SubPath, Vec2};
use crate::appearance::{Appearance, AppearanceItem, Dash, StrokeLayer, WidthProfile};
use crate::live::{BlendOrientation, BlendSpacing, BlendSpec};
@@ -1029,6 +1029,27 @@ impl Rail {
}
}
/// A box round everything a blend of `keys` can paint once [`Rail::place`] puts its keys and steps
/// on its spine, turned to it with Align to Path: the spine's box grown by three times the farthest
/// any key paints from its centre. A step's shape lies within that distance of the point on the
/// spine it is moved to, and so does its own centre, which it turns about. `None` when nothing
/// moves.
pub(crate) fn rail_reach(keys: &[Arc<Node>], spec: &BlendSpec, symbols: Option<&crate::SymbolBoxes>) -> Option<Rect> {
if spec.spine.is_none() && spec.orientation != BlendOrientation::AlignToPath {
return None;
}
let spine = blend_spine(keys, spec)?.0.bounds()?;
let radius = keys
.iter()
.filter_map(|k| {
let (c, b) = (center_of(k), k.visual(symbols)?);
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)];
Some(corners.into_iter().map(|q| (q - c).hypot()).fold(0.0, f64::max))
})
.fold(0.0, f64::max);
Some(spine.inflate(3.0 * radius, 3.0 * radius))
}
// =====================================================================================
// Blend evaluation
// =====================================================================================
+3
View File
@@ -47,6 +47,7 @@ impl Document {
patterns,
pattern_edit,
mask_edit,
symbol_edit,
next_id: _,
unknown,
page_isolate,
@@ -113,6 +114,7 @@ impl Document {
(a, b) => a.is_none() && b.is_none(),
}
&& *mask_edit == other.mask_edit
&& *symbol_edit == other.symbol_edit
&& *unknown == other.unknown
&& *page_isolate == other.page_isolate
&& *page_knockout == other.page_knockout
@@ -221,6 +223,7 @@ impl Document {
patterns: vec![],
pattern_edit: None,
mask_edit: None,
symbol_edit: None,
next_id: 1,
unknown: Default::default(),
page_isolate: false,
+9 -5
View File
@@ -98,18 +98,22 @@ impl Document {
self.views = kept;
}
/// Leave opacity-mask editing: drop the temporary editing layer, a working copy of art the
/// mask already holds (the engine syncs it after every edit).
/// Leave opacity-mask editing and symbol editing: drop the temporary editing layer, a working
/// copy of art the mask or the symbol already holds (the engine syncs it after every edit).
pub fn drop_edit_modes(&mut self) {
if let Some(me) = self.mask_edit.take() {
self.layers.retain(|l| l.id != me.layer);
}
if let Some(se) = self.symbol_edit.take() {
self.layers.retain(|l| l.id != se.layer);
}
}
/// The document as saved and exported: without the opacity-mask editing layer. Borrowed when
/// no mask is being edited. (Pattern editing is saved: its tile layer holds unapplied edits.)
/// The document as saved and exported: without the opacity-mask or symbol editing layer.
/// Borrowed when neither mode is on. (Pattern editing is saved: its tile layer holds unapplied
/// edits.)
pub fn without_edit_modes(&self) -> std::borrow::Cow<'_, Document> {
if self.mask_edit.is_none() {
if self.mask_edit.is_none() && self.symbol_edit.is_none() {
return std::borrow::Cow::Borrowed(self);
}
let mut d = self.clone();
+161 -3
View File
@@ -1,7 +1,9 @@
//! Hit testing over the document tree.
use std::cell::RefCell;
use std::collections::HashSet;
use std::sync::OnceLock;
use std::rc::Rc;
use std::sync::{Arc, OnceLock};
use vectorcraft_geom::hit::{fill_contains, stroke_contains};
use vectorcraft_geom::{BezPath, FillRule, Point, Rect};
@@ -99,6 +101,128 @@ pub(crate) fn image_paint(n: &Node) -> Vec<(BezPath, FillRule, AppearanceItem)>
IMAGE_PAINT.get().map_or_else(Vec::new, |f| f(n))
}
/// One level of evaluation of a live object in a document, as the canvas draws it: type in an
/// envelope as its distorted glyph outlines, symbol instances as their art. Effects are evaluated
/// above this crate, so the engine registers one with [`set_live_art`]. It reads the object, the
/// document's symbols and patterns, and what [`LiveInputs`] stands for, and nothing else.
pub type LiveArt = fn(Option<&Document>, &Node) -> Vec<Node>;
/// A number that changes whenever something outside the document that [`LiveArt`] reads changes,
/// such as the installed fonts or the plug-ins.
pub type LiveInputs = fn() -> u64;
static LIVE_ART: OnceLock<(LiveArt, LiveInputs)> = OnceLock::new();
/// Register how a live object's art is evaluated for clicks, and the number that changes with what
/// else it reads (the first registration stays).
pub fn set_live_art(f: LiveArt, inputs: LiveInputs) {
// Already set: the same engine, registered by another session.
let _ = LIVE_ART.set((f, inputs));
}
/// The registered [`LiveInputs`] now, 0 while nothing is registered.
fn live_inputs() -> u64 {
LIVE_ART.get().map_or(0, |(_, inputs)| inputs())
}
/// A live object evaluated by [`live_art`] on this thread: the object, its document's symbols (by
/// name and art) and patterns and the [`LiveInputs`] as they were, the art and its [`art_size`].
struct EvaluatedLive {
node: Node,
symbols: Vec<(String, Arc<Node>)>,
patterns: Vec<crate::PatternDef>,
inputs: u64,
art: Rc<Vec<Node>>,
size: usize,
}
impl EvaluatedLive {
/// Is this the evaluation of `n` in `doc`, with inputs `inputs`, as they are now?
fn is_of(&self, doc: &Document, n: &Node, inputs: u64) -> bool {
self.inputs == inputs
&& self.symbols.len() == doc.symbols.len()
&& self.symbols.iter().zip(&doc.symbols).all(|((name, art), s)| *name == s.name && Arc::ptr_eq(art, &s.art))
&& self.node == *n
&& self.patterns == doc.patterns
}
}
/// How many evaluations [`live_art`] keeps on each thread: the pointer moving in a blend's or an
/// envelope's stroke band hits it again and again, and an envelope can hold a blend.
const LIVE_KEPT: usize = 4;
/// Most [`art_size`] the evaluations [`live_art`] keeps on one thread add up to. Art larger than
/// this is not kept.
const LIVE_KEPT_SIZE: usize = 250_000;
/// The size of `art` for [`LIVE_KEPT_SIZE`]: its objects and the anchors of their paths.
fn art_size(art: &[Node]) -> usize {
let mut size = 0usize;
for n in art {
n.walk(&mut |c| size = size.saturating_add(1 + c.path_data().map_or(0, |p| p.anchor_count())));
}
size
}
/// Does `n`, or the art of a symbol or a pattern of `doc`, hold a placed document? Its art changes
/// when the file is read, so [`live_art`] does not keep an evaluation of `n`.
fn holds_placed(doc: &Document, n: &Node) -> bool {
let mut placed = false;
let mut find = |c: &Node| placed |= matches!(c.kind, NodeKind::PlacedDocument(_));
n.walk(&mut find);
doc.symbols.iter().for_each(|s| s.art.walk(&mut find));
doc.patterns.iter().flat_map(|p| &p.art).for_each(|a| a.walk(&mut find));
placed
}
thread_local! {
/// The evaluations [`live_art`] keeps, the latest first.
static EVALUATED: RefCell<Vec<EvaluatedLive>> = const { RefCell::new(Vec::new()) };
}
/// The art live object `n` of `doc` draws ([`set_live_art`]); [`crate::live::expand_live`] while
/// nothing is registered. The art is evaluated again when `n`, a symbol of `doc`, a pattern of
/// `doc` or the [`LiveInputs`] have changed since, and on every click while it or the document's
/// symbols or patterns hold a placed document ([`holds_placed`]).
fn live_art(doc: &Document, n: &Node) -> Rc<Vec<Node>> {
let inputs = live_inputs();
let kept = EVALUATED.with_borrow_mut(|kept| {
let i = kept.iter().position(|e| e.is_of(doc, n, inputs))?;
let e = kept.remove(i);
let art = e.art.clone();
kept.insert(0, e);
Some(art)
});
if let Some(art) = kept {
return art;
}
#[cfg(test)]
LIVE_EVALUATED.with(|c| c.set(c.get() + 1));
let art = Rc::new(LIVE_ART.get().map_or_else(|| crate::live::expand_live(n), |(f, _)| f(Some(doc), n)));
let size = art_size(&art);
if size > LIVE_KEPT_SIZE || holds_placed(doc, n) {
return art;
}
let symbols = doc.symbols.iter().map(|s| (s.name.clone(), s.art.clone())).collect();
let e = EvaluatedLive { node: n.clone(), symbols, patterns: doc.patterns.clone(), inputs, art: art.clone(), size };
EVALUATED.with_borrow_mut(|kept| {
kept.insert(0, e);
kept.truncate(LIVE_KEPT);
let mut total = 0usize;
kept.retain(|e| {
total = total.saturating_add(e.size);
total <= LIVE_KEPT_SIZE
});
});
art
}
#[cfg(test)]
thread_local! {
/// How many live objects [`live_art`] evaluated on this thread.
static LIVE_EVALUATED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// Topmost editable object under `p`.
pub fn hit_test(doc: &Document, p: Point, opt: HitOptions) -> Option<Hit> {
hit_test_skipping(doc, p, opt, &|_| false, true)
@@ -565,7 +689,15 @@ fn hit_stroke(n: &Node, bp: &vectorcraft_geom::BezPath, rule: vectorcraft_geom::
stroke_contains(bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
#[cfg(test)]
thread_local! {
/// How many objects [`hit_leaf`] tested on this thread.
static LEAVES_TESTED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
#[cfg(test)]
LEAVES_TESTED.with(|c| c.set(c.get() + 1));
match &n.kind {
NodeKind::Path { path, rule, .. } => {
let bp = path.to_bezpath();
@@ -612,9 +744,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
AppearanceItem::Fill(_) => (!opt.path_only && fill_contains(bp, *rule, p)).then_some(HitKind::Fill),
})
}
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } => {
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
// A blend or an envelope is hit within its bounds, and outside them where its evaluated
// art paints: a blend's keys and steps on a spine can reach past the keys and the spine,
// and the strokes of an envelope's distorted contents past its bounds.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
return Some(HitKind::Bounds);
}
live_art(symbols.document(), n).iter().find_map(|g| hit_any(g, p, opt, symbols))
}
NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
@@ -651,8 +792,25 @@ fn on_type_path(n: &Node, t: &crate::TextObject, p: Point, tol: f64) -> bool {
/// Hit anywhere in an evaluated subtree (groups recurse; leaves use [`hit_leaf`]).
fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option<HitKind> {
// Hidden art does not hit.
if !n.visible {
return None;
}
// Nor art whose reach does not hold `p`, as in [`may_hit`]: a blend's steps far from `p` are
// passed by without being measured.
if !edits_contents(n) && n.extras().shaper.is_none() && n.reach_bounds_with(symbols).is_some_and(|b| !b.inflate(opt.tol, opt.tol).contains(p)) {
return None;
}
match &n.kind {
NodeKind::Group { children, .. } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
// A clip group only hits inside its clipping path.
NodeKind::Group { children, clip } => {
if *clip && children.first().is_some_and(|c| !clip_contains(c, p)) {
return None;
}
children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols))
}
// An intertwine group hits by its members, as on the canvas.
NodeKind::Intertwine { children } => children.iter().rev().find_map(|c| hit_any(c, p, opt, symbols)),
_ => hit_leaf(n, p, opt, symbols),
}
}
+145
View File
@@ -429,6 +429,151 @@ fn a_marquee_meets_blends_and_envelopes_by_their_box() {
}
}
/// A click just outside a blend's bounds, in the reach of its keys' strokes, tests only the steps
/// whose own reach holds the point, not every step of the evaluated blend.
#[test]
fn a_click_in_a_blends_stroke_band_tests_only_the_steps_that_reach_it() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
// 10 pt circles with 4 pt strokes, y 295 to 305, from x 10 to x 890: 200 steps about 4.3 pt apart.
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let count = |p: Point| {
LEAVES_TESTED.with(|c| c.set(0));
let h = hit_test(&d, p, HitOptions::default()).map(|h| h.leaf);
(h, LEAVES_TESTED.with(std::cell::Cell::get))
};
// On the steps' strokes halfway between the keys, 4 pt above the bounds: a few steps reach it.
let (h, tested) = count(Point::new(450.0, 291.0));
assert_eq!(h, Some(blend));
assert!(tested < 20, "{tested} objects tested");
// In the corner of the reach, outside every stroke: nothing hits.
let (h, tested) = count(Point::new(6.0, 291.0));
assert_eq!(h, None);
assert!(tested < 20, "{tested} objects tested");
}
/// An intertwine group in a live object's evaluated art (a blend key a spine moves) hits by its
/// members.
#[test]
fn evaluated_art_hits_an_intertwine_group_by_its_members() {
let d = Document::new(200.0, 200.0);
let square = Arc::new(Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art()));
let group = Node::new(NodeId(8), NodeKind::Intertwine { children: vec![square] });
let symbols = d.symbol_boxes();
assert_eq!(hit_any(&group, Point::new(10.0, 10.0), HitOptions::default(), &symbols), Some(HitKind::Fill));
assert_eq!(hit_any(&group, Point::new(50.0, 50.0), HitOptions::default(), &symbols), None);
}
/// The pointer moving in a blend's stroke band evaluates the blend once, and again after the blend
/// or a symbol of the document changes.
#[test]
fn clicks_in_a_blends_stroke_band_evaluate_it_once_until_it_changes() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0, 452.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 1);
// 100 steps 8.7 pt apart: a step still reaches (450, 291).
if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(blend).map(|n| &mut n.kind) {
spec.spacing = crate::live::BlendSpacing::Steps(100);
}
assert_eq!(hit_test(&d, Point::new(450.0, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
assert_eq!(evaluated(), before + 2);
let art = circle(d.alloc_id(), 0.0);
d.symbols.push(crate::Symbol { dynamic: false, name: "dot".into(), art });
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
assert_eq!(evaluated(), before + 3);
hit_test(&d, Point::new(450.0, 291.0), HitOptions::default());
assert_eq!(evaluated(), before + 3);
}
/// Evaluated art larger than [`LIVE_KEPT_SIZE`] is not kept: each click in the blend's stroke band
/// evaluates it again.
#[test]
fn evaluated_art_past_the_kept_size_is_not_kept() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
// A 10 pt circle drawn with 2,000 anchors: 202 objects of 2,000 anchors each with 200 steps.
let circle = |id, x: f64| {
let points: Vec<Point> = (0..2000)
.map(|i| {
let a = f64::from(i) * std::f64::consts::TAU / 2000.0;
Point::new(x + 5.0 + 5.0 * a.cos(), 300.0 + 5.0 * a.sin())
})
.collect();
let path = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
Arc::new(Node::path(id, path, stroked.clone()))
};
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 2);
}
/// While the document's symbols hold a placed document, whose art changes when its file is read,
/// each click in a blend's stroke band evaluates the blend again.
#[test]
fn evaluated_art_is_not_kept_while_a_placed_document_may_show_in_it() {
let mut d = Document::new(1000.0, 1000.0);
let l = d.layers[0].id;
let stroked = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 4.0);
let circle = |id, x: f64| Arc::new(Node::path(id, shapes::ellipse(Rect::new(x, 295.0, x + 10.0, 305.0)), stroked.clone()));
let blend = d.alloc_id();
let keys = vec![circle(d.alloc_id(), 10.0), circle(d.alloc_id(), 880.0)];
let spec = crate::live::BlendSpec { spacing: crate::live::BlendSpacing::Steps(200), ..Default::default() };
d.insert(Some(l), 0, Node::new(blend, NodeKind::Blend { children: keys, spec: Box::new(spec) })).unwrap();
let placed = crate::PlacedDocument {
link: crate::LinkInfo::new("/art/logo.vectorcraft"),
key: "logo".into(),
bounding: false,
page_box: crate::PageBox::Crop,
width: 80.0,
height: 40.0,
xf: vectorcraft_geom::Affine::IDENTITY,
placement: Default::default(),
};
let art = Arc::new(Node::new(d.alloc_id(), NodeKind::PlacedDocument(Box::new(placed))));
d.symbols.push(crate::Symbol { dynamic: false, name: "logo".into(), art });
let evaluated = || LIVE_EVALUATED.with(std::cell::Cell::get);
let before = evaluated();
for x in [448.0, 450.0] {
assert_eq!(hit_test(&d, Point::new(x, 291.0), HitOptions::default()).map(|h| h.leaf), Some(blend));
}
assert_eq!(evaluated(), before + 2);
}
/// A kept evaluation is the evaluation of the object only while the fonts and plug-ins
/// ([`LiveInputs`]) are as they were.
#[test]
fn a_kept_evaluation_is_stale_after_the_live_inputs_change() {
let d = Document::new(100.0, 100.0);
let n = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let e = EvaluatedLive { node: n.clone(), symbols: vec![], patterns: vec![], inputs: 7, art: std::rc::Rc::new(vec![]), size: 0 };
assert!(e.is_of(&d, &n, 7));
assert!(!e.is_of(&d, &n, 8));
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
+9 -4
View File
@@ -29,7 +29,7 @@ mod symbol_registration;
pub use inline::{SYMBOL_HALF, SYMBOL_SIZES};
pub use shared::Shared;
pub use symbol_export_type::{SYMBOL_EXPORT_TYPE, SymbolExportType};
pub use symbol_nine_slice::{SYMBOL_NINE_SLICE, SymbolNineSlice};
pub use symbol_nine_slice::{MAX_SLICE_GROWTH, NineSliceMap, PAINTED_EFFECTS, SYMBOL_NINE_SLICE, SymbolNineSlice, charge_symbol_art};
pub use symbol_overrides::SymbolOverride;
pub use symbol_registration::{SYMBOL_REGISTRATION, SymbolRegistration};
pub mod links;
@@ -75,8 +75,8 @@ pub(crate) mod skip;
pub use appearance::StrokeGradientMode;
pub use appearance::{
Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, SavedProfile,
StrokeAlign, StrokeLayer, WidthProfile,
AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset,
SavedProfile, StrokeAlign, StrokeLayer, WidthProfile,
};
pub use assets::ExportAsset;
pub use compound_shape::ShapeMode;
@@ -106,6 +106,7 @@ pub use selection::{AnchorRef, SegmentRef, Selection};
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
pub use style_libs::StyleLibrary;
pub use text::FontHeightReference;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
@@ -141,7 +142,7 @@ pub use guides::{GridPrefs, Guide, RulerMode};
#[cfg(test)]
use limits::MAX_NEXT_ID;
pub use limits::{DocError, FORMAT_VERSION, MAX_COORD};
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol};
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit};
pub use units::{ColorMode, Unit, parse_number};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -221,6 +222,10 @@ pub struct Document {
/// still read so files saved mid-edit by older versions load without the editing layer.
#[serde(default, skip_serializing)]
pub mask_edit: Option<MaskEdit>,
/// Symbol editing mode, while active. Never saved (see [`Document::without_edit_modes`]): the
/// symbol already holds the art of its editing layer.
#[serde(default, skip_serializing)]
pub symbol_edit: Option<SymbolEdit>,
next_id: u64,
/// Foreign data preserved on round-trip.
#[serde(default, skip_serializing_if = "skip::is_empty_map")]
+7 -1
View File
@@ -198,7 +198,8 @@ impl Node {
/// ([`Appearance::outset`]) instead of measuring along their shape. An image's box takes in
/// the shapes its own fills and strokes paint ([`crate::hit::set_image_paint`]). Groups, layers
/// without a clipping mask and Intertwine groups take in their members' boxes, grown by their
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`].
/// own strokes. Symbol instances are measured as in [`Node::visual_bounds`]. A blend's box
/// takes in where its spine can put its keys and steps.
pub fn reach_bounds(&self) -> Option<Rect> {
self.reach(None)
}
@@ -234,6 +235,11 @@ impl Node {
let o = if let crate::AppearanceItem::Stroke(s) = item { s.reach() } else { 0.0 };
vectorcraft_geom::union_opt(acc, (!bp.elements().is_empty()).then(|| bp.bounding_box().inflate(o, o)))
}),
// Keys and steps placed on a spine, turned to it with Align to Path, can reach past the
// keys and the spine.
NodeKind::Blend { children, spec } => {
vectorcraft_geom::union_opt(self.visual(symbols), crate::blend::rail_reach(children, spec, symbols))
}
_ => self.visual(symbols),
}
}
+24 -1
View File
@@ -1,4 +1,4 @@
use super::{Arc, Deserialize, Node, NodeId, Point, Serialize};
use super::{Affine, Arc, Deserialize, Node, NodeId, Point, Serialize};
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
/// place, so a selection change that sets it doesn't replace the document.
@@ -44,6 +44,29 @@ pub struct MaskEdit {
pub layer: NodeId,
}
/// Symbol editing mode: the art of symbol `symbol` lives on the temporary `layer` while editing,
/// placed by `xf` (from the symbol's coordinates to the document's), and goes back into the symbol
/// after each edit, so every instance follows it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SymbolEdit {
pub symbol: String,
pub layer: NodeId,
/// The instance editing was entered from (a double-click on it), selected again on leaving after an edit;
/// `None` when it was entered from the Symbols panel.
pub instance: Option<NodeId>,
pub xf: Affine,
/// The symbol's art is a group whose members are on the layer: the group takes them back.
pub group: bool,
/// An edit changed the art since editing was entered.
pub changed: bool,
/// The isolated object from before editing was entered, isolated again on leaving.
#[serde(default)]
pub back_isolation: Option<NodeId>,
/// The current layer from before editing was entered, current again on leaving.
#[serde(default)]
pub back_layer: Option<NodeId>,
}
/// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SavedView {
+22
View File
@@ -164,6 +164,14 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[
("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }),
// „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN
// 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR
// (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`).
("Serbian: Cyrillic", "sr-Cyrl", Quotes { double: ['„', '”'], single: ['’', '’'] }),
("Serbian: Latin", "sr-Latn", Quotes { double: ['„', '”'], single: ['’', '’'] }),
// „…“ outside, ‚…‘ inside: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml,
// `delimiters`).
("Croatian", "hr", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Turkish", "tr", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Japanese", "ja", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", "zh", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
@@ -373,12 +381,19 @@ mod tests {
(char_language_name(None), char_language_name(Some("gu")), char_language_name(Some("x-unknown"))),
(Some("English: USA"), Some("Gujarati"), None)
);
// Serbian in either script, and Croatian.
assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl")));
assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn")));
assert_eq!(char_language("Croatian"), Ok(Some("hr")));
assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic"));
// Every entry has a tag of its own, and a name of its own.
for (i, (n, t, _)) in LANGUAGES.iter().enumerate() {
assert!(LANGUAGES.iter().skip(i + 1).all(|(n2, t2, _)| n2 != n && t2 != t), "{n} {t}");
}
}
/// Straight quotes become the language's: opening or closing by what comes before, inner
/// quotes as single ones, and the context carried across pieces converted apart.
#[test]
fn quotes_open_and_close_by_context() {
let mut prev = None;
@@ -387,6 +402,13 @@ mod tests {
let mut prev = Some('(');
assert_eq!(german.apply("\"a\"", &mut prev), "„a“");
assert_eq!(prev, Some('“'));
// Serbian closes high (”), with ’ on both sides of an inner quote; Croatian as German.
for name in ["Serbian: Cyrillic", "Serbian: Latin"] {
let mut prev = None;
assert_eq!(language_quotes(name).unwrap().apply("\"a 'b' c\"", &mut prev), "„a ’b’ c”", "{name}");
}
let mut prev = None;
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
// Converting in pieces keeps the context.
let mut prev = None;
let a = Quotes::ENGLISH.apply("\"", &mut prev);
File diff suppressed because it is too large Load Diff
+18 -7
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use vectorcraft_geom::{Affine, Rect};
use crate::{AppearanceItem, Document, Node, NodeKind, SYMBOL_HALF};
use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF};
/// The box a symbol instance is measured by when its symbol's art has no box
/// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin
@@ -303,17 +303,28 @@ impl<'a> SymbolBoxes<'a> {
}
}
/// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it,
/// its strokes at their own weight, the instances in it measured by their art boxes. `None`
/// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], and while symbol art or a
/// click's box is measured.
/// The visual bounds of symbol `name`'s art drawn as an instance with transform `xf` draws it
/// (through its 9-slice guides when it has them, [`Document::place_symbol_art`]), its strokes
/// at their own weight, the instances in it measured by their art boxes. `None`
/// when [`Self::depth`] is `None`, in boxes made by [`Self::quick`], while symbol art or a
/// click's box is measured, and when placing the art through its guides could make more than
/// [`MAX_SYMBOL_NODES`] objects ([`crate::NineSliceMap::most_placed`]).
pub(crate) fn placed_visual(&self, name: &str, xf: Affine) -> Option<Rect> {
self.placed_visual_within(name, xf, MAX_SYMBOL_NODES)
}
/// [`Self::placed_visual`], `None` when placing the art through its guides could make more
/// than `most` objects.
pub(crate) fn placed_visual_within(&self, name: &str, xf: Affine, most: u64) -> Option<Rect> {
if self.quick.get() {
return None;
}
self.measure_art(name, |art| {
let mut art = (**art).clone();
art.transform(xf, false);
let art = match self.doc.symbol_slice_map(name, xf) {
Some(m) if m.most_placed(art, self.doc) > most => return None,
Some(m) => m.place(art, self.doc),
None => self.doc.place_symbol_art(name, art, xf)?,
};
self.in_art(|| art.visual(Some(self)))
})
.flatten()
+77
View File
@@ -145,9 +145,86 @@ fn mixed_mul_div() {
assert_eq!(Unit::Points.parse("10*2/5 px"), Some(Unit::Pixels.to_pt(4.0)));
}
/// Q and Ha are a quarter of a millimetre each, written back and read in any field, with or
/// without their suffix.
#[test]
fn q_and_ha_are_a_quarter_millimetre() {
for u in [Unit::Q, Unit::Ha] {
assert!((u.points() - Unit::Millimeters.points() / 4.0).abs() < 1e-12, "{u:?}");
assert!((u.to_pt(4.0) - Unit::Millimeters.to_pt(1.0)).abs() < 1e-12, "{u:?}");
let tol = 0.0005 * u.points() + 1e-9;
for pt in [0.0, 1.0, 12.5, 72.0, 100.0, 595.28, 1234.5678, -36.0] {
let shown = u.format(pt);
let back = u.parse(&shown).unwrap_or_else(|| panic!("{u:?} can't read back `{shown}`"));
assert!((back - pt).abs() <= tol, "{u:?}: {pt} → `{shown}` → {back}");
let bare = u.parse(&u.number(pt)).unwrap();
assert!((bare - pt).abs() <= tol, "{u:?}: bare `{}` → {bare}", u.number(pt));
let pts = Unit::Points.parse(&shown).unwrap();
assert!((pts - pt).abs() <= tol, "{u:?}: `{shown}` in a points field → {pts}");
}
}
assert_eq!(Unit::Q.format(72.0 / 25.4), "4 Q");
assert_eq!(Unit::Ha.format(Unit::Millimeters.to_pt(2.5)), "10 H");
// 13 Q is 3.25 mm, however it is typed.
let want = Unit::Millimeters.to_pt(3.25);
for typed in ["13q", "13 Q", "13Q+0", "26/2 q"] {
let got = Unit::Points.parse(typed).unwrap_or_else(|| panic!("`{typed}` reads as nothing"));
assert!((got - want).abs() < 1e-9, "`{typed}` → {got}");
}
assert!((Unit::Q.parse("13").unwrap() - want).abs() < 1e-9, "a bare number is in the field's unit");
assert!((Unit::Millimeters.parse("13 h").unwrap() - want).abs() < 1e-9, "a typed unit wins over the field's");
for typed in ["13h", "13 H", "13Ha", "13 ha"] {
assert!((Unit::Points.parse(typed).unwrap() - want).abs() < 1e-9, "`{typed}`");
}
// Mixed with other units.
assert!((Unit::Points.parse("4q+1mm").unwrap() - Unit::Millimeters.to_pt(2.0)).abs() < 1e-9);
}
/// Only Preferences › Units › East Asian Type offers Q and Ha: the list every other place shares
/// leaves them out, and `named` answers for it alone.
#[test]
fn only_the_east_asian_type_list_names_q_and_ha() {
assert_eq!(Unit::EAST_ASIAN[..10], Unit::ALL);
assert_eq!(Unit::EAST_ASIAN[10..], [Unit::Q, Unit::Ha]);
for u in Unit::EAST_ASIAN {
assert_eq!(Unit::named_east_asian(u.label()), Some(u));
assert_eq!(Unit::named_east_asian(u.key()), Some(u));
assert_eq!(Unit::named_east_asian(&u.label().to_uppercase()), Some(u));
assert_eq!(Unit::named_east_asian(u.suffix()), if u == Unit::FeetInches { Some(Unit::Feet) } else { Some(u) });
}
for name in ["q", "Q", "ha", "Ha", "H", "h"] {
assert_eq!(Unit::named(name), None, "`{name}`");
}
assert_eq!(Unit::named_east_asian("Q"), Some(Unit::Q));
assert_eq!(Unit::named_east_asian("h"), Some(Unit::Ha));
assert_eq!(Unit::named_east_asian("Ha"), Some(Unit::Ha));
assert_eq!(Unit::named_east_asian("furlongs"), None);
}
#[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");
}
/// Q and Ha take the suffixes `q` and `h` (`ha` too) without taking anyone else's: every unit's
/// own suffix still reads as that unit in a points field, a Q or Ha value is never read as
/// another unit, and `h`, `ha` and `q` mean nothing to a unit that is not offered Q and Ha when
/// they are typed as words that are not suffixes (`1hr`, `1hq`, `1qt`).
#[test]
fn q_and_ha_suffixes_do_not_take_other_units_suffixes() {
for u in Unit::EAST_ASIAN {
if matches!(u, Unit::FeetInches) {
continue;
}
let typed = format!("3{}", u.suffix());
let got = Unit::Points.parse(&typed).unwrap_or_else(|| panic!("`{typed}` reads as nothing"));
assert!((got - u.to_pt(3.0)).abs() < 1e-9, "`{typed}` is 3 {u:?}: {got}");
}
for typed in ["1hr", "1hq", "1qt", "1qq", "1hh", "1hm", "1hectare"] {
assert_eq!(Unit::Points.parse(typed), None, "`{typed}`");
}
// A suffix after math covers the whole expression, as for the other units.
assert!((Unit::Points.parse("26/2 q").unwrap() - Unit::Q.to_pt(13.0)).abs() < 1e-9);
}
+3 -1
View File
@@ -12,16 +12,18 @@ mod object;
mod para_style;
mod runs;
mod tabs_lists;
mod wrap;
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
use char_style::yes;
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, ScriptMetrics};
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, ScriptMetrics};
pub use para_style::{
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
ParaStyle, SingleWordJustify,
};
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop};
pub use wrap::{resolve_wrap, wrap_work};
/// A text object. `runs` split into paragraphs at `\n`.
///
+5
View File
@@ -141,6 +141,10 @@ pub struct AreaOptions {
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
/// Gutter between rows in points when it differs from [`Self::gutter`], which is then the
/// gutter between columns (left out when saved while unset).
#[serde(skip_serializing_if = "Option::is_none")]
pub row_gutter: Option<f64>,
/// Inset from the frame edges in points.
pub inset: f64,
pub first_baseline: FirstBaseline,
@@ -162,6 +166,7 @@ impl Default for AreaOptions {
rows: 1,
columns: 1,
gutter: 18.0,
row_gutter: None,
inset: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
+48 -2
View File
@@ -104,10 +104,33 @@ pub struct CharStyle {
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")]
pub tate_chu_yoko: bool,
/// Tate-chu-yoko Settings › Up/Down (Character panel menu, East Asian options), in points: a
/// block of marked characters in vertical type moves up by a positive value and down by a
/// negative one. The block takes the value of its first character; automatic digit blocks and
/// horizontal type do not move. Saved only when not 0.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, rename = "tcyUpDown", skip_serializing_if = "crate::skip::is_default")]
pub tcy_up_down: f64,
/// Tate-chu-yoko Settings › Left/Right, in points: a block of marked characters in vertical type
/// moves right by a positive value and left by a negative one, as [`Self::tcy_up_down`] does.
#[serde(default, rename = "tcyLeftRight", skip_serializing_if = "crate::skip::is_default")]
pub tcy_left_right: f64,
/// Standard Vertical Roman Alignment (Character panel menu, East Asian options): in vertical
/// type the half-width characters among these (Latin letters, digits and other characters that
/// would lie on their side) stand upright one below the other instead of being rotated
/// individually. Spaces and tate-chu-yoko blocks do not change, and horizontal type keeps the
/// mark and does not change.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, rename = "standardVerticalRoman", skip_serializing_if = "crate::skip::is_default")]
pub standard_vertical_roman: bool,
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub digits: Digits,
/// Font height reference (Character panel menu › Show Font Height Options): the height of the
/// font that [`Self::size`] measures, the em box by default.
#[serde(default, rename = "fontHeightReference", skip_serializing_if = "crate::skip::is_default")]
pub font_height_reference: FontHeightReference,
/// No Break (Character panel menu): these characters are not broken across lines, including
/// at a space or a hyphenation point inside the run. A run longer than the line stays together
/// and can overflow it.
@@ -122,6 +145,21 @@ pub struct CharStyle {
pub language: Option<String>,
}
/// What a character's font size measures ([`CharStyle::font_height_reference`]): the em square
/// of the font, the height of its capitals or of its lowercase letters (the cap height and x
/// height of its OS/2 table), or its ideographic character face (ICF, OpenType's `icfb` and
/// `icft` baselines). Layout sets the characters at the em size at which the chosen height equals
/// the font size; with a font that has no such value, the size measures the em box.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FontHeightReference {
#[default]
EmBox,
CapHeight,
XHeight,
IcfBox,
}
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
/// the Western, Arabic-Indic and Eastern Arabic-Indic sets drawn in one of them or in the digits
/// of an Indic script. The text keeps the digits that were typed; only the glyphs change (like
@@ -369,8 +407,12 @@ impl Default for CharStyle {
char_direction: None,
proportional_metrics: false,
tate_chu_yoko: false,
tcy_up_down: 0.0,
tcy_left_right: 0.0,
standard_vertical_roman: false,
small_caps: None,
digits: Digits::Default,
font_height_reference: FontHeightReference::EmBox,
no_break: false,
language: None,
}
@@ -385,7 +427,8 @@ impl CharStyle {
/// or scale of 0 or less becomes the default new type takes (12 pt, 100%), a negative leading
/// becomes Auto, and a value past [`crate::MAX_COORD`] (a baseline shift past it either way)
/// becomes the nearer end. A leading of 0, which legacy `.ai` import stores, stays, and a
/// baseline shift that is not a number becomes 0.
/// baseline shift that is not a number becomes 0. The Tate-chu-yoko Up/Down and Left/Right
/// offsets are kept as the baseline shift is.
pub fn keep_dimensions_in_range(&mut self) {
let default = Self::default();
let within = |v: f64, or: f64| if v > 0.0 { v.min(crate::MAX_COORD) } else { or };
@@ -393,7 +436,10 @@ impl CharStyle {
self.h_scale = within(self.h_scale, default.h_scale);
self.v_scale = within(self.v_scale, default.v_scale);
self.leading = self.leading.filter(|l| *l >= 0.0).map(|l| l.min(crate::MAX_COORD));
self.baseline_shift = if self.baseline_shift.is_nan() { 0.0 } else { self.baseline_shift.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
let shift = |v: f64| if v.is_nan() { 0.0 } else { v.clamp(-crate::MAX_COORD, crate::MAX_COORD) };
self.baseline_shift = shift(self.baseline_shift);
self.tcy_up_down = shift(self.tcy_up_down);
self.tcy_left_right = shift(self.tcy_left_right);
}
/// Do the characters draw a stroke (a paint and a positive weight)?
pub fn has_stroke(&self) -> bool {
+6 -1
View File
@@ -32,8 +32,13 @@ impl TextObject {
style.size *= scale.points;
style.leading = style.leading.map(|v| v * scale.points);
style.baseline_shift *= scale.points;
style.tcy_up_down *= scale.points;
style.tcy_left_right *= scale.points;
style.h_scale *= scale.horizontal;
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.h_scale].iter().all(|v| v.is_finite()) {
if ![style.size, style.leading.unwrap_or(0.0), style.baseline_shift, style.tcy_up_down, style.tcy_left_right, style.h_scale]
.iter()
.all(|v| v.is_finite())
{
return None;
}
Some(style)
+18
View File
@@ -60,6 +60,24 @@ fn digits_map_between_the_numeral_systems() {
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// The font height reference is the em box by default and is saved only when it is another.
#[test]
fn font_height_reference_is_saved_only_when_not_the_em_box() {
let st = CharStyle::default();
assert_eq!(st.font_height_reference, FontHeightReference::EmBox);
assert!(serde_json::to_value(&st).unwrap().get("fontHeightReference").is_none());
for (r, key) in
[(FontHeightReference::CapHeight, "capHeight"), (FontHeightReference::XHeight, "xHeight"), (FontHeightReference::IcfBox, "icfBox")]
{
let set = CharStyle { font_height_reference: r, ..CharStyle::default() };
assert_eq!(serde_json::to_value(&set).unwrap()["fontHeightReference"], key);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
// Styles written before the attribute existed read as the em box.
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().font_height_reference, FontHeightReference::EmBox);
}
/// Each Indic numeral system draws 0–9 (and the Arabic-Indic sets) as the ten digits of its
/// script's block; every system saves under its id, which reads back, and other names are refused.
#[test]
+142
View File
@@ -0,0 +1,142 @@
//! Object → Text Wrap: area type flows around the wrap objects stacked above it in the same layer.
use std::sync::Arc;
use vectorcraft_geom::{PathData, shapes};
use super::{TextKind, WrapShape};
use crate::{Document, Node, NodeId, NodeKind};
/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art.
fn outline(n: &Node, out: &mut PathData) {
match &n.kind {
NodeKind::Path { path, guide: false, .. } => {
out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned())
}
NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children.iter().filter(|c| c.visible) {
outline(c, out);
}
}
// A clip group wraps by its clipping path.
NodeKind::Group { children, clip: true } => {
if let Some(c) = children.first() {
outline(c, out);
}
}
_ => {
if let Some(b) = n.geometric_bounds() {
out.subpaths.extend(shapes::rectangle(b).subpaths);
}
}
}
}
enum Item<'a> {
Wrap(&'a Node),
Text(&'a Node),
}
fn collect<'a>(n: &'a Node, out: &mut Vec<Item<'a>>) {
if !n.visible {
return;
}
if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) {
out.push(Item::Wrap(n));
return;
}
match &n.kind {
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n)),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
for c in children {
collect(c, out);
}
}
_ => {}
}
}
/// How much [`resolve_wrap`] would copy into the wrap shapes of every area text object in `doc`:
/// each wrap object's outline (its anchors, plus one for the shape) once for each area text object
/// below it in its layer.
pub fn wrap_work(doc: &Document) -> usize {
let mut total = 0usize;
for layer in &doc.layers {
let mut items = vec![];
collect(layer, &mut items);
let mut above = 0usize;
for it in items.iter().rev() {
match it {
Item::Wrap(n) => {
let mut pd = PathData::default();
outline(n, &mut pd);
if !pd.is_empty() {
let anchors: usize = pd.subpaths.iter().map(|sp| sp.anchors.len()).sum();
above = above.saturating_add(anchors.saturating_add(1));
}
}
Item::Text(_) => total = total.saturating_add(above),
}
}
}
total
}
/// Resolve the wrap shapes (`TextObject::wrap`, text space) of each area text object from the
/// outlines of the wrap objects above it in its layer. With `before`, the layers it holds unchanged
/// are skipped; without it, every layer is resolved. Returns the area text objects whose shapes
/// changed, whose cached bounds are then out of date.
pub fn resolve_wrap(before: Option<&Document>, doc: &mut Document) -> Vec<NodeId> {
let mut updates: Vec<(NodeId, Vec<WrapShape>)> = vec![];
for (idx, layer) in doc.layers.iter().enumerate() {
if let Some(before) = before
&& (before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer))
|| before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer)))
{
continue;
}
let mut items = vec![];
collect(layer, &mut items);
if items.is_empty() {
continue;
}
// From the top down, each wrap object's outline is made once; each area text object takes
// those above it, bottom first.
let mut above: Vec<WrapShape> = vec![];
for it in items.iter().rev() {
match it {
Item::Wrap(n) => {
let mut pd = PathData::default();
outline(n, &mut pd);
if !pd.is_empty() {
above.push(WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() });
}
}
Item::Text(n) => {
let NodeKind::Text(t) = &n.kind else { continue };
let inv = t.xf.inverse();
let shapes: Vec<WrapShape> = above
.iter()
.rev()
.map(|s| {
let mut path = s.path.clone();
path.transform(inv);
WrapShape { path, wrap: s.wrap }
})
.collect();
if shapes != t.wrap {
updates.push((n.id, shapes));
}
}
}
}
}
let mut changed = Vec::with_capacity(updates.len());
for (id, shapes) in updates {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.wrap = shapes;
changed.push(id);
}
}
changed
}
+51 -10
View File
@@ -14,6 +14,11 @@ pub enum Unit {
Meters,
Yards,
Feet,
/// Q (級), a quarter of a millimetre: the Japanese typesetting unit of type size.
Q,
/// Ha (歯), also a quarter of a millimetre: the Japanese unit of spacing, leading and
/// positions.
Ha,
}
impl Unit {
@@ -29,6 +34,23 @@ impl Unit {
Unit::Yards,
Unit::Feet,
];
/// The units Preferences › Units › East Asian Type offers: all of [`Unit::ALL`], then Q and
/// Ha. Only that preference lists Q and Ha; rulers, documents and the other Units
/// preferences stay on [`Unit::ALL`].
pub const EAST_ASIAN: [Unit; 12] = [
Unit::Points,
Unit::Picas,
Unit::Inches,
Unit::Millimeters,
Unit::Centimeters,
Unit::Pixels,
Unit::FeetInches,
Unit::Meters,
Unit::Yards,
Unit::Feet,
Unit::Q,
Unit::Ha,
];
/// Points per unit.
pub fn points(self) -> f64 {
match self {
@@ -37,6 +59,8 @@ impl Unit {
Unit::Inches => 72.0,
Unit::Millimeters => 72.0 / 25.4,
Unit::Centimeters => 72.0 / 2.54,
// 0.25 mm each.
Unit::Q | Unit::Ha => 72.0 / 101.6,
Unit::Meters => 72.0 / 0.0254,
Unit::Yards => 72.0 * 36.0,
Unit::Feet | Unit::FeetInches => 72.0 * 12.0,
@@ -53,9 +77,11 @@ impl Unit {
Unit::FeetInches | Unit::Feet => "ft",
Unit::Meters => "m",
Unit::Yards => "yd",
Unit::Q => "Q",
Unit::Ha => "H",
}
}
pub fn label(self) -> &'static str {
pub const fn label(self) -> &'static str {
match self {
Unit::Points => "Points",
Unit::Picas => "Picas",
@@ -66,6 +92,8 @@ impl Unit {
Unit::FeetInches => "Feet & Inches",
Unit::Meters => "Meters",
Unit::Yards => "Yards",
Unit::Q => "Q",
Unit::Ha => "Ha",
Unit::Feet => "Feet",
}
}
@@ -115,12 +143,13 @@ impl Unit {
pub fn number(self, pt: f64) -> String {
// Three decimals for the small units (`595.276 pt`), four for the large ones (`8.2677 in`,
// `35.2778 mm`, the `0.0078 in` stroke preset); trailing zeros are trimmed (`1 pt` is `1`).
let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas) { 3 } else { 4 };
let decimals = if matches!(self, Unit::Points | Unit::Pixels | Unit::Picas | Unit::Q | Unit::Ha) { 3 } else { 4 };
let s = format!("{:.*}", decimals, self.from_pt(pt));
let s = s.trim_end_matches('0').trim_end_matches('.');
if s == "-0" { "0".into() } else { s.into() }
}
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), simple `+ - * /` arithmetic.
/// Parse `12`, `12pt`, `1in`, `3 mm`, `2p6` (picas+points), `6q` (Q) and `6h` (Ha), simple
/// `+ - * /` arithmetic.
///
/// A unit after a `*` or `/` operand measures the whole expression (`1080/2 px` is 540 px), so
/// math typed before a field's unit suffix works. After `*` or `/`, a percentage scales the term
@@ -132,7 +161,7 @@ impl Unit {
}
/// The value naming this unit in the Units preferences (`points`, `millimeters`,
/// `feetInches`).
pub fn key(self) -> &'static str {
pub const fn key(self) -> &'static str {
match self {
Unit::Points => "points",
Unit::Picas => "picas",
@@ -144,19 +173,29 @@ impl Unit {
Unit::Meters => "meters",
Unit::Yards => "yards",
Unit::Feet => "feet",
Unit::Q => "q",
Unit::Ha => "ha",
}
}
/// The unit `name` names: its label, suffix or [`Unit::key`] in any case, ignoring spaces and
/// punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`).
/// The unit of [`Unit::ALL`] that `name` names: its label, suffix or [`Unit::key`] in any
/// case, ignoring spaces and punctuation (`Millimeters`, `mm`, `Feet & Inches`, `feetInches`).
/// Q and Ha are named by [`Unit::named_east_asian`] only.
pub fn named(name: &str) -> Option<Unit> {
Self::named_in(&Unit::ALL, name)
}
/// [`Unit::named`] among the units of Preferences › Units › East Asian Type
/// ([`Unit::EAST_ASIAN`]): also `Q` and `Ha` (`q`, `H`).
pub fn named_east_asian(name: &str) -> Option<Unit> {
Self::named_in(&Unit::EAST_ASIAN, name)
}
fn named_in(units: &[Unit], name: &str) -> Option<Unit> {
let letters = |s: &'static str| s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
let given = || name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase());
// Feet and Feet & Inches share the suffix `ft`: it names the plain unit, so Feet & Inches
// is left to its label and key.
Unit::ALL
.into_iter()
.find(|u| letters(u.label()).eq(given()))
.or_else(|| Unit::ALL.into_iter().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().eq(given())))
units.iter().copied().find(|u| letters(u.label()).eq(given())).or_else(|| {
units.iter().copied().filter(|u| *u != Unit::FeetInches).find(|u| u.suffix().chars().map(|c| c.to_ascii_lowercase()).eq(given()))
})
}
/// A point value as the canvas measurement labels show it: two decimals and the suffix
/// (`12.50 mm`).
@@ -254,6 +293,8 @@ fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
"ft" | "'" => Some(Unit::Feet),
"yd" => Some(Unit::Yards),
"p" | "pc" => Some(Unit::Picas),
"q" | "Q" => Some(Unit::Q),
"h" | "H" | "ha" | "Ha" => Some(Unit::Ha),
_ => return None,
};
Some((v, unit))
+79 -17
View File
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::{Document, NodeId, NodeKind};
use super::{Document, LockedArtboards, NodeId, NodeKind, TextKind};
/// What a variable drives.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -21,11 +21,15 @@ pub enum VariableKind {
Image,
/// A graph object's data (CSV of categories and series, as Graph Data uses).
Graph,
/// A variable a file declares without a kind (or with one VectorCraft doesn't know): it drives
/// nothing, no value fits it, and nothing can be bound to it.
Unknown,
}
impl VariableKind {
/// Parse the `kind` of `variable.define`: `text`, `visibility` (`visible` is accepted
/// as the same thing, as the panel and the docs call it both), `image` or `graph`.
/// as the same thing, as the panel and the docs call it both), `image` or `graph`. A variable
/// of [`Self::Unknown`] kind is never defined this way.
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"text" => Some(Self::Text),
@@ -43,6 +47,7 @@ impl VariableKind {
Self::Visibility => "visibility",
Self::Image => "image",
Self::Graph => "graph",
Self::Unknown => "unknown",
}
}
}
@@ -117,7 +122,8 @@ pub struct Variables {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_dataset: Option<String>,
/// Lock Variables: while set, variables can't be defined, deleted or renamed. Objects can
/// still be bound to them and unbound, and datasets still change and apply.
/// still be bound to them and unbound, and datasets still change and apply, except that an
/// import whose header would define a variable is refused.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub locked: bool,
}
@@ -189,34 +195,89 @@ impl Document {
}
/// Whether a variable may be bound to the object `id`: it is in the document, neither it nor
/// anything above it is locked, and nothing above it is hidden. Its own visibility doesn't
/// count, as that is what a visibility variable drives.
/// anything above it is locked, nothing above it is hidden, and it is not on a locked artboard
/// ([`Document::on_locked_artboard`]). Its own visibility doesn't count, as that is what a
/// visibility variable drives.
pub fn variable_target(&self, id: NodeId) -> bool {
self.variable_target_with(&self.locked_artboards(), id)
}
/// [`Document::variable_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn variable_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked && (n.visible || *i == id))))
&& !locks.holds_id(id)
}
/// Whether a data set reads and rewrites the bound object `id`: it is in the document and
/// neither it nor anything above it is locked. Hidden objects count, and so do objects in a
/// hidden layer or group.
/// Whether a data set reads and rewrites the bound object `id`: it is in the document, neither
/// it nor anything above it is locked, and it is not on a locked artboard
/// ([`Document::on_locked_artboard`]). Hidden objects count, and so do objects in a hidden
/// layer or group.
pub fn dataset_target(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked)))
self.dataset_target_with(&self.locked_artboards(), id)
}
/// What the object `id` holds for the variable of `kind`: its characters, whether it shows,
/// or a linked image's path. `None` when the object is gone, is not of that kind, has no
/// link path yet (an embedded image), or is locked or in a locked layer or group (the
/// document's value is whatever it was left at).
/// [`Document::dataset_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn dataset_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| !n.locked))) && !locks.holds_id(id)
}
/// The type objects of the story that the type object `id` belongs to, in thread order: the
/// frames of its threaded text (area type and type on a path), else `id` alone.
pub fn story_of(&self, id: NodeId) -> Vec<NodeId> {
let frame = |f: &NodeId| matches!(self.node(*f).map(|n| &n.kind), Some(NodeKind::Text(t)) if !matches!(t.kind, TextKind::Point));
match self.text_threads.iter().find(|thread| thread.contains(&id)) {
Some(thread) if frame(&id) => thread.iter().filter(|f| frame(f)).copied().collect(),
_ => vec![id],
}
}
/// The characters of the story that the type object `id` belongs to ([`Document::story_of`]):
/// every frame's text joined in thread order.
pub fn story_text(&self, id: NodeId) -> String {
self.story_of(id)
.into_iter()
.filter_map(|f| match self.node(f).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.plain_text()),
_ => None,
})
.collect()
}
/// Whether a data set reads and rewrites the text of the type object `id`: every frame of its
/// story ([`Document::story_of`]) is a [`Document::dataset_target`].
pub fn story_target(&self, id: NodeId) -> bool {
self.story_target_with(&self.locked_artboards(), id)
}
/// [`Document::story_target`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn story_target_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.story_of(id).into_iter().all(|f| self.dataset_target_with(locks, f))
}
/// What the object `id` holds for the variable of `kind`: its characters (the whole story
/// for a frame of threaded text), whether it shows, or a linked image's path. `None` when the
/// object is gone, is not of that kind, has no link path yet (an embedded image), or is locked,
/// in a locked layer or group, or on a locked artboard, or for text when a frame of its story
/// is (the document's value is whatever it was left at).
pub fn variable_value(&self, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.dataset_target(id) {
self.variable_value_with(&self.locked_artboards(), id, kind)
}
/// [`Document::variable_value`] with the locked artboards `locks` of this document.
fn variable_value_with(&self, locks: &LockedArtboards, id: NodeId, kind: VariableKind) -> Option<DataValue> {
if !self.dataset_target_with(locks, id) {
return None;
}
let node = self.node(id)?;
match (kind, &node.kind) {
(VariableKind::Text, NodeKind::Text(t)) => Some(DataValue::Text(t.plain_text())),
(VariableKind::Text, NodeKind::Text(_)) if self.story_target_with(locks, id) => Some(DataValue::Text(self.story_text(id))),
(VariableKind::Visibility, _) => Some(DataValue::Visible(node.visible)),
(VariableKind::Image, NodeKind::Image(im)) => im.link.as_ref().map(|l| DataValue::Image(l.path.clone())),
(VariableKind::Graph, _) => node.extras().graph.as_ref().map(|g| DataValue::Graph(g.to_csv())),
(VariableKind::Text | VariableKind::Image, _) => None,
(VariableKind::Text | VariableKind::Image | VariableKind::Unknown, _) => None,
}
}
@@ -227,10 +288,11 @@ impl Document {
/// still matches what it says.
pub fn captured_values(&self) -> BTreeMap<String, DataValue> {
let mut out = BTreeMap::new();
let locks = self.locked_artboards();
for v in &self.variables.variables {
// An object driven by several variables of the same kind reads from the first one
// bound, so a capture is one value per variable and applies back the same way.
let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value(id, v.kind));
let value = self.variables.objects_of(&v.name).into_iter().find_map(|id| self.variable_value_with(&locks, id, v.kind));
if let Some(value) = value {
out.insert(v.name.clone(), value);
}
+26 -2
View File
@@ -528,8 +528,10 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
/// painted by that fill and every visible stroke without a brush its filled outline in the
/// stroke's paint, opacity, blend mode and raster effects (a gradient along or across the stroke:
/// its gradient meshes clipped to the outline; a brushed stroke stays a stroke), in paint order, as
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. A path
/// with [`has_transform_art`](crate::has_transform_art) is first made into the art [`reshape`]
/// makes of it, and each copy is baked this way. `None` when it has neither a stroke to outline nor
/// a geometry effect, so bending first changes nothing.
pub fn bake_appearance(n: &Node) -> Option<Node> {
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
return Some(m);
@@ -537,6 +539,15 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
return None;
}
// A path whose Transform effect copies it with a gradient, or scales its strokes or moves its
// patterns: the art Expand Appearance makes of it (each copy an object with its own strokes,
// patterns and gradients), each piece baked in turn.
if crate::has_transform_art(n)
&& let Some(mut m) = reshape(None, n, None)
{
bake_appearance_pieces(&mut m);
return Some(m);
}
let outlined = |i: &AppearanceItem| paints(i) && matches!(i, AppearanceItem::Stroke(s) if s.brush.is_none());
let geometric = has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i)));
if !geometric && !n.appearance.items.iter().any(outlined) {
@@ -589,6 +600,19 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
Some(m)
}
/// [`bake_appearance`] every path and compound path in `n` that it changes, in place.
fn bake_appearance_pieces(n: &mut Node) {
if matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) {
if let Some(m) = bake_appearance(n) {
*n = m;
}
return;
}
for c in n.children_mut().into_iter().flatten() {
bake_appearance_pieces(Arc::make_mut(c));
}
}
/// A copy of `doc` ready to export: every live geometry effect baked into plain paths, and the
/// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when
/// there is nothing to do (export it as is).
+57
View File
@@ -106,6 +106,63 @@ fn a_baked_stroke_with_a_gradient_along_it_becomes_meshes_clipped_to_its_outline
assert_eq!(ch[1].opacity, 0.5);
}
/// A 100 pt square at the origin painted by `fill` and `stroke` (`width` pt), with the Transform
/// effect `params`.
fn transformed(fill: Paint, stroke: Paint, width: f64, params: serde_json::Value) -> Node {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill, stroke, width));
n.appearance.effects.push(fx("distort.transform", params));
n
}
/// The paths under `n`, in document order.
fn baked_paths(n: &Node) -> Vec<&Node> {
match n.children() {
Some(ch) if !matches!(n.kind, NodeKind::Compound { .. }) => ch.iter().flat_map(|c| baked_paths(c)).collect(),
_ => vec![n],
}
}
fn widths(n: &Node) -> Vec<f64> {
baked_paths(n).iter().map(|p| p.geometric_bounds().unwrap().width()).collect()
}
#[test]
fn a_baked_transform_copy_keeps_its_scaled_stroke() {
// Distort Appearance: the 200% copy's stroke is 4 pt, as outside the envelope, so its outline
// is 204 pt wide.
let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
let m = bake_appearance(&n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 2, "{w:?}");
assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}");
assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined");
}
#[test]
fn a_baked_transform_copy_keeps_its_moved_pattern() {
let paint = Paint::Pattern { pattern: "p".into(), xf: vectorcraft_geom::Affine::IDENTITY };
let n = transformed(paint, Paint::None, 0.0, json!({"moveH": 30, "copies": 1, "patterns": true}));
let m = bake_appearance(&n).unwrap();
let xs: Vec<f64> = baked_paths(&m)
.iter()
.map(|p| match p.appearance.fill_paint() {
Paint::Pattern { xf, .. } => xf.as_coeffs()[4],
p => panic!("not a pattern: {p:?}"),
})
.collect();
assert_eq!(xs, vec![0.0, 30.0]);
}
#[test]
fn a_baked_transform_without_its_options_outlines_the_copies_together() {
// Without Scale Strokes & Effects the copy keeps the 2 pt stroke: one outline of both squares.
let n = transformed(Paint::None, Paint::solid(Color::BLACK), 2.0, json!({"scaleH": 200, "scaleV": 200, "copies": 1}));
let m = bake_appearance(&n).unwrap();
let w = widths(&m);
assert_eq!(w.len(), 1, "{w:?}");
assert!((w[0] - 202.0).abs() < 0.5, "{w:?}");
}
/// Black 24 pt "Type" with its baseline at (20, 60).
fn type_node(id: u64) -> Node {
let style = CharStyle { size: 24.0, fill: Paint::solid(Color::BLACK), ..Default::default() };
+4 -2
View File
@@ -298,7 +298,8 @@ pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData {
}
/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle,
/// making at most `max` anchors ([`map_nonlinear_within`]).
/// clockwise on the page for a positive `angle`, making at most `max` anchors
/// ([`map_nonlinear_within`]).
pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData {
let ang = angle.clamp(-3600.0, 3600.0).to_radians();
if ang == 0.0 {
@@ -309,7 +310,8 @@ pub fn twist(path: &PathData, b: Rect, angle: f64, max: usize) -> PathData {
let f = move |q: Point| {
let d = q - c;
let t = (1.0 - d.hypot() / r).max(0.0);
let a = -ang * t;
// y runs down, so a positive turn is clockwise on the page.
let a = ang * t;
let (s, co) = a.sin_cos();
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
};
+197 -27
View File
@@ -19,11 +19,13 @@
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{Color, Paint};
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeId, NodeKind, ShapeMode, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Rect};
use vectorcraft_pathops::{self as po, PathfinderOp};
use crate::reshape::SymbolNodes;
/// Effect ids of the Pathfinder submenu with their operation, in Illustrator's menu order.
pub const PATHFINDER_EFFECTS: [(&str, &str, PathfinderOp); 12] = [
("pathfinder.add", "Add", PathfinderOp::Unite),
@@ -61,35 +63,188 @@ pub fn has_pathfinder(n: &Node) -> bool {
/// with the object's opacity and blend mode.
pub type OutlineHook<'a> = &'a dyn Fn(&Node) -> Option<Node>;
fn leaves(n: &Node, hook: Option<OutlineHook>, out: &mut Vec<Node>) {
/// The paths and compound paths `n` takes part in a compound shape or a Pathfinder operation as,
/// back to front: a compound shape as its area, a blend, an envelope or an object with a warp as
/// the art it draws, and type and symbol instances as their art through `hook`. Past
/// [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) objects of symbol art, further
/// instances add nothing.
pub fn member_leaves(n: &Node, hook: Option<OutlineHook>) -> Vec<Node> {
let mut out = vec![];
leaves(n, hook, &SymbolNodes::default(), &mut out);
out
}
/// [`member_leaves`], with the symbol art given so far counted in `nodes`.
fn leaves(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes, out: &mut Vec<Node>) {
if !n.visible {
return;
}
// A blend, an envelope or an object with a warp takes part as the art it draws.
if let Some(art) = shape_art_in(n, hook, nodes) {
let first = out.len();
leaves(&art, hook, nodes, out);
// Every path of a live object's, type's or symbol instance's art is drawn inside the
// object's opacity and blend mode.
if crate::needs_outline(n) {
for l in out.iter_mut().skip(first) {
within(l, n);
}
}
return;
}
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.clone()),
// A compound shape takes part as its area, styled like it.
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art(n, hook) {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => match compound_shape_art_in(n, hook, nodes) {
Some(art) => out.push(art),
None => {
for c in children {
leaves(c, hook, out);
leaves(c, hook, nodes, out);
}
}
},
_ => {
// Every path of the outline art, at any depth, takes the object's opacity and blend
// mode.
if let Some(art) = hook.and_then(|h| h(n)) {
// Every path of the outline art, at any depth, is drawn inside the object's opacity
// and blend mode. A symbol's art comes with the live objects and the instances of
// other symbols inside it given as their art, and the type inside it outlined, so the
// art has nothing left for `hook` to give.
if let Some(h) = hook
&& let Some(art) = h(n)
{
let art = match &n.kind {
NodeKind::SymbolInstance { symbol, .. } if nodes.charge(&art) => {
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(&mut art, false);
transparency_into_members(&mut art);
art
}
NodeKind::SymbolInstance { .. } => return,
_ => art,
};
let first = out.len();
leaves(&art, None, out);
leaves(&art, None, nodes, out);
for l in out.iter_mut().skip(first) {
(l.opacity, l.blend) = (n.opacity, n.blend);
within(l, n);
}
}
}
}
}
/// `l`, a path drawn inside `n`: its opacity multiplied by `n`'s, and `n`'s blend mode when it is
/// not Normal.
fn within(l: &mut Node, n: &Node) {
drawn_in(l, n.opacity, n.blend);
}
/// `l` drawn inside a group of `opacity` and `blend`: its opacity multiplied by `opacity`, and
/// `blend` when it is not Normal.
fn drawn_in(l: &mut Node, opacity: f32, blend: BlendMode) {
l.opacity *= opacity;
if blend != BlendMode::Normal {
l.blend = blend;
}
}
/// Move the opacity and blend mode of every group and layer inside `n` (not `n` itself, and not
/// compound shapes, which take part with their own) onto the objects it holds ([`drawn_in`]), so
/// that each path of `n` carries the transparency it is drawn with.
fn transparency_into_members(n: &mut Node) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if !is_container(c) || c.is_compound_shape() {
continue;
}
let c = Arc::make_mut(c);
let (opacity, blend) = (c.opacity, c.blend);
if let Some(inner) = c.children_mut() {
for g in inner.iter_mut() {
drawn_in(Arc::make_mut(g), opacity, blend);
}
}
(c.opacity, c.blend) = (1.0, BlendMode::Normal);
transparency_into_members(c);
}
}
/// `n` as the art its visible geometry gives a compound shape or a Pathfinder operation, where that
/// is not its own paths: a live object evaluated (a blend's steps, an envelope's distorted art,
/// type inside it outlined), and a path, compound path, group, compound shape, live object, type or
/// symbol instance with visible geometry effects (such as a warp) reshaped by them, as the renderer
/// draws it. Type and symbol instances inside the art are given as their art by `hook` (without
/// one, type is outlined and symbol instances stay as they are);
/// [`outline_object`](crate::outline_object) gives a symbol instance its symbol's art. The art
/// keeps the effects of `n` other than its geometry effects, and the groups inside it pass their
/// opacity and blend mode to the objects they hold. `None` when `n` is none of these, for images,
/// for symbol instances without geometry effects, and for one without art from `hook`.
pub fn shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
shape_art_in(n, hook, &SymbolNodes::default())
}
/// [`shape_art`], with the symbol art given so far counted in `nodes`.
fn shape_art_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let mut art = reshaped_art(n, hook, nodes)?;
transparency_into_members(&mut art);
Some(art)
}
/// [`shape_art_in`] before the groups inside the art pass on their transparency.
fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let fx = crate::reshape::geometry_effects(n);
let outline = hook.unwrap_or(&crate::outline_text);
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } if !fx.is_empty() => Some(reshaped(n, n)),
NodeKind::Group { .. } | NodeKind::Layer { .. } if !fx.is_empty() => match compound_shape_art_in(n, hook, nodes) {
Some(area) => Some(reshaped(&area, n)),
None => {
let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes);
crate::reshape::reshape_leaves(&mut m, &fx);
without_geometry_effects(&mut m);
Some(m)
}
},
// A symbol instance with a warp: its symbol's art placed by the instance, with the type
// inside it outlined, reshaped as one piece, as the renderer draws it.
NodeKind::SymbolInstance { symbol, .. } if !fx.is_empty() => {
let h = hook?;
let art = h(n).filter(|art| nodes.charge(art))?;
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
crate::reshape::outline_nested_text(&mut art, false);
crate::reshape::reshape_leaves(&mut art, &fx);
(art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate);
art.appearance.effects = n.appearance.effects.clone();
without_geometry_effects(&mut art);
Some(art)
}
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => None,
_ if crate::needs_outline(n) && (vectorcraft_doc::live::is_live(n) || !fx.is_empty()) => {
crate::reshape::as_art_with(None, n, None, outline, nodes)
}
_ => None,
}
}
/// `src`, a path, compound path or compound shape's area, as one path reshaped by the visible
/// geometry effects of `n`, without those effects.
fn reshaped(src: &Node, n: &Node) -> Node {
let mut m = src.clone();
if let Some((path, rule)) = geometry(src) {
let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n));
m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false };
}
without_geometry_effects(&mut m);
m
}
fn without_geometry_effects(n: &mut Node) {
if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) {
let mut ap = (*n.appearance).clone();
ap.effects.retain(|e| !crate::is_geometry(&e.id));
n.appearance = ap.into();
}
}
fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)),
@@ -114,8 +269,13 @@ fn shape_op(mode: ShapeMode) -> po::BoolOp {
/// as its outlines through `hook`, open paths closed) combined with the area of the members below
/// it by the member's shape mode; the backmost member starts the area. `None` when `n` is not a
/// compound shape, or its members are past a live Pathfinder's budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`]).
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES)).
pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathData> {
compound_shape_area_in(n, hook, &SymbolNodes::default())
}
/// [`compound_shape_area`], with the symbol art given so far counted in `nodes`.
fn compound_shape_area_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<PathData> {
if !n.is_compound_shape() {
return None;
}
@@ -124,12 +284,12 @@ pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathDa
let mut members: Vec<(po::BoolOp, usize)> = vec![];
for c in n.children()? {
let before = all.len();
leaves(c, hook, &mut all);
leaves(c, hook, nodes, &mut all);
if all.len() > before {
members.push((shape_op(c.extras().shape_mode), all.len() - before));
}
}
if !within_budget(&all) {
if nodes.spent() || !within_budget(&all) {
return None;
}
if all.is_empty() {
@@ -151,7 +311,12 @@ pub fn compound_shape_area(n: &Node, hook: Option<OutlineHook>) -> Option<PathDa
/// Compound shape `n` as one path of its area ([`compound_shape_area`]) with its appearance,
/// opacity and blend mode: how it takes part in a Pathfinder operation.
pub fn compound_shape_art(n: &Node, hook: Option<OutlineHook>) -> Option<Node> {
let area = compound_shape_area(n, hook)?;
compound_shape_art_in(n, hook, &SymbolNodes::default())
}
/// [`compound_shape_art`], with the symbol art given so far counted in `nodes`.
fn compound_shape_art_in(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Option<Node> {
let area = compound_shape_area_in(n, hook, nodes)?;
let mut art = Node::path(n.id, area, Appearance { items: n.appearance.items.clone(), ..Default::default() });
(art.opacity, art.blend) = (n.opacity, n.blend);
Some(art)
@@ -256,7 +421,8 @@ fn within_budget<'a>(leaves: impl IntoIterator<Item = &'a Node>) -> bool {
/// Evaluate the visible Pathfinder effects of `group` in order: the resulting child objects.
/// `None` when the group has no Pathfinder effect. Past the budget ([`PATHFINDER_MAX_SEGMENTS`],
/// [`PATHFINDER_MAX_OVERLAPS`]) it returns the children as they are.
/// [`PATHFINDER_MAX_OVERLAPS`], [`SYMBOL_ART_MAX_NODES`](crate::SYMBOL_ART_MAX_NODES) for
/// each effect) it returns the children as they are.
pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Vec<Arc<Node>>> {
if !has_pathfinder(group) {
return None;
@@ -274,11 +440,11 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
};
for e in group.appearance.effects.iter().filter(|e| e.visible) {
if e.id == crate::trap::TRAP {
let mut lv = vec![];
let (mut lv, nodes) = (vec![], SymbolNodes::default());
for c in &stack {
leaves(c, hook, &mut lv);
leaves(c, hook, &nodes, &mut lv);
}
if !within_budget(&lv) {
if nodes.spent() || !within_budget(&lv) {
return Some(source.to_vec());
}
let (ids, srcs): (Vec<NodeId>, Vec<crate::trap::TrapSource>) = lv
@@ -295,15 +461,16 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
let Some(op) = op_of(&e.id) else { continue };
let shape_mode =
matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack);
let nodes = SymbolNodes::default();
let all: Vec<Vec<Node>> = stack
.iter()
.map(|c| {
let mut lv = vec![];
leaves(c, hook, &mut lv);
leaves(c, hook, &nodes, &mut lv);
lv
})
.collect();
if !within_budget(all.iter().flatten()) {
if nodes.spent() || !within_budget(all.iter().flatten()) {
return Some(source.to_vec());
}
// (style source, shape) per stack entry; keys index `sources`.
@@ -440,9 +607,10 @@ pub fn has_container_appearance(n: &Node) -> bool {
/// The regions a group's or layer's own fills and strokes paint, in document space, each with its
/// fill rule: every visible member path and compound path, type as its glyph outlines and live
/// objects evaluated; the members of a clip group or clipping layer without its clipping path.
/// Images, symbol instances and guides add nothing. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
/// objects evaluated; a compound shape as its area, with the symbol instances among its members
/// as their symbols' art from `doc`; the members of a clip group or clipping layer without its
/// clipping path. Images, other symbol instances and guides add nothing. A Repeat counts the
/// Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRule)> {
fn push(doc: Option<&Document>, n: &Node, out: &mut Vec<(PathData, FillRule)>) {
if !n.visible {
@@ -451,8 +619,8 @@ pub fn member_shapes(doc: Option<&Document>, n: &Node) -> Vec<(PathData, FillRul
match &n.kind {
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.extend(crate::reshape::leaf_geometry(n)),
NodeKind::Group { .. } if n.is_compound_shape() => {
let hook: &dyn Fn(&Node) -> Option<Node> = &crate::outline_text;
match compound_shape_area(n, Some(hook)) {
let hook = |c: &Node| crate::outline_object(doc, c);
match compound_shape_area(n, Some(&hook)) {
Some(area) => out.push((area, FillRule::NonZero)),
None => members(doc, n, out),
}
@@ -518,7 +686,9 @@ fn item_art(id: NodeId, item: &AppearanceItem, shapes: &[(PathData, FillRule)],
/// own fills and strokes turned into art painting the members' geometry ([`member_shapes`]) below
/// the members (after a clipping path) or above them, as its contents slot says. Its
/// transparency, opacity mask and raster effects stay. `None` when it has none of these. Live
/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]).
/// objects among the members are evaluated with the symbols of `doc` (see [`member_shapes`]), and
/// a Pathfinder operation or compound shape takes the symbol instances among its members as their
/// symbols' art from `doc`.
pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
if !is_container(n) {
return None;
@@ -531,8 +701,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
}
let mut m = n.clone();
if pathfinder {
let hook: &dyn Fn(&Node) -> Option<Node> = &crate::outline_text;
let result = pathfinder_children(n, Some(hook)).unwrap_or_default();
let hook = |c: &Node| crate::outline_object(doc, c);
let result = pathfinder_children(n, Some(&hook)).unwrap_or_default();
if let Some(ch) = m.children_mut() {
*ch = result;
}
+4 -4
View File
@@ -71,16 +71,16 @@ pub use bake::{StrokeArt, bake_appearance, bake_document, expand_art, expand_lea
pub use clip::clip_outline;
pub use group::{
OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder,
is_pathfinder, member_shapes, paints, pathfinder_children,
is_pathfinder, member_leaves, member_shapes, paints, pathfinder_children, shape_art,
};
pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_params, matching_lighting_preset};
pub use live::{expand_live, expand_live_deep, expanded_live_group, text_outliner};
pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner};
pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
pub use raster::{RASTERIZE, RasterFx, Rasterize, outset, raster_effects};
pub use reshape::{
expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint, needs_outline, outline_art,
outline_text, reshape,
SYMBOL_ART_MAX_NODES, expand_outlined, has_gradient_copies, has_item_geometry, has_transform_art, has_transform_options, image_paint,
needs_outline, outline_art, outline_text, reshape,
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
+10 -6
View File
@@ -19,13 +19,17 @@ pub fn text_outliner() -> Outliner<'static> {
Some(&outline_text)
}
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance.
fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance,
/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None`
/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and
/// Pathfinder operations the symbol instances among their members.
pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option<Node> {
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
let sym = doc?.symbols.iter().find(|s| s.name == *symbol)?;
let mut art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
art.transform(*xf, false);
let doc = doc?;
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
let art = doc.place_symbol_art(symbol, &art, *xf)?;
Some(Node::group(n.id, vec![Arc::new(art)]))
}
_ => outline_text(n),
@@ -35,7 +39,7 @@ fn outline(doc: Option<&Document>, n: &Node) -> Option<Node> {
/// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a
/// document, symbol instances and pattern tiles stay as they are).
fn with_hooks<R>(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R {
let outline = |n: &Node| outline(doc, n);
let outline = |n: &Node| outline_object(doc, n);
let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region));
f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc })
}
+5 -2
View File
@@ -29,8 +29,8 @@ use vectorcraft_geom::{Affine, PathData, Rect, Shape, union_opt};
use crate::group::has_own_paint;
use crate::reshape::{geometry_effects, leaf_geometry};
use crate::{
GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, needs_outline,
outline_art, paints, reshape,
GeomContext, apply_geometry_with, evaluate_container, expanded_live_group, has_geometry, has_item_geometry, has_pathfinder, has_transform_art,
needs_outline, outline_art, paints, reshape,
};
/// Most symbol instances in one object whose art [`Drawn`] measures with its geometry effects:
@@ -187,6 +187,9 @@ impl<'s, 'd> Drawn<'s, 'd> {
let painted = || has_own_paint(n) && inner();
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } if reshapes(n) || painted() => evaluate_container(Some(self.symbols.document()), n),
// A Transform effect that copies with a gradient, scales strokes or moves patterns makes
// objects of their own, as the renderer draws them.
NodeKind::Path { .. } | NodeKind::Compound { .. } if has_transform_art(n) => reshape(Some(self.symbols.document()), n, None),
NodeKind::Path { .. } | NodeKind::Compound { .. } if reshapes(n) => path_art(n),
NodeKind::SymbolInstance { symbol, .. } => {
if geometry_effects(n).is_empty() && !self.symbol_draws(symbol) {
+293 -17
View File
@@ -17,7 +17,7 @@ use vectorcraft_doc::live::Hooks;
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, shapes};
use crate::{Budget, GeomContext, apply_one, is_geometry, merged_params};
use crate::{Budget, GeomContext, OutlineHook, apply_one, is_geometry, merged_params};
/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is
/// neither a path nor a container)?
@@ -47,16 +47,28 @@ pub fn has_gradient_copies(n: &Node) -> bool {
}
/// Is `n` a path or compound path with a visible Transform effect whose Transform Patterns or
/// Scale Strokes & Effects is on (#885)? Such an effect changes the fills and strokes as well as
/// the geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of
/// its own with its strokes scaled and its patterns moved ([`transform_leaf`]).
/// Scale Strokes & Effects is on (#885), its own or one of a fill or stroke that paints
/// ([`item_transform_options`])? Such an effect changes the fills and strokes as well as the
/// geometry, so the object is drawn through its art ([`outline_art`]), each copy an object of its
/// own with its strokes scaled and its patterns moved ([`transform_leaf`], [`transform_items`]).
pub fn has_transform_options(n: &Node) -> bool {
matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. })
&& geometry_effects(n)
&& (geometry_effects(n)
.iter()
// Both options default to off, so the stored params tell without merging the defaults
// (this runs for every object drawn).
.any(|e| e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance())
|| n.appearance.items.iter().any(item_transform_options))
}
/// Does `item` paint and carry a Transform effect of its own that [`reaches_appearance`]?
fn item_transform_options(item: &AppearanceItem) -> bool {
crate::paints(item) && item.effects().iter().any(reaches_appearance)
}
/// Is `e` a visible Transform effect whose Transform Patterns or Scale Strokes & Effects is on?
fn reaches_appearance(e: &Effect) -> bool {
e.visible && e.id == "distort.transform" && crate::distort::TransformOptions::of(&e.params).reach_appearance()
}
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
@@ -232,14 +244,27 @@ fn with_id(art: &Node, id: NodeId) -> Node {
/// `n` as plain art that geometry effects can reshape, without its transparency and object
/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined),
/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by
/// the instance's transform. `None` for other kinds (images, paths, containers). A Repeat counts
/// the Repeats in the art of `doc`'s symbols that its instances show toward its instance budget.
/// the instance's transform (through the symbol's 9-slice guides in `doc`, when it has them).
/// `None` for other kinds (images, paths, containers), and for an instance whose art placed
/// through the guides could hold more than [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects. A Repeat counts the Repeats in the art of
/// `doc`'s symbols that its instances show toward its instance budget.
pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
outline_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
}
/// [`outline_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
fn outline_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
let mut art = match &n.kind {
NodeKind::Text(_) => outline_text(n)?,
NodeKind::SymbolInstance { xf, .. } => {
let mut a = symbol_art?.clone();
a.transform(*xf, false);
NodeKind::SymbolInstance { symbol, xf } => {
let a = match doc {
Some(d) => d.place_symbol_art(symbol, symbol_art?, *xf)?,
None => {
let mut a = symbol_art?.clone();
a.transform(*xf, false);
a
}
};
Node::group(n.id, vec![Arc::new(a)])
}
// The object as the one piece the copies of its Transform effect are made from.
@@ -252,7 +277,7 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>)
one.appearance.effects.clear();
Node::group(n.id, vec![Arc::new(one)])
}
_ if vectorcraft_doc::live::is_live(n) => evaluated(doc, n),
_ if vectorcraft_doc::live::is_live(n) => evaluated_with(doc, n, outline, nodes),
_ => return None,
};
outline_nested_text(&mut art, false);
@@ -270,8 +295,93 @@ pub fn outline_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>)
/// Repeats among them count the Repeats in the art of `doc`'s symbols that their instances show
/// toward their instance budget ([`vectorcraft_doc::RepeatSpec::expand`]).
pub(crate) fn evaluated(doc: Option<&Document>, n: &Node) -> Node {
let hook: &dyn Fn(&Node) -> Option<Node> = &outline_text;
vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(hook), symbols: doc, ..Default::default() })
evaluated_with(doc, n, &outline_text, &SymbolNodes::default())
}
/// Most objects of symbol art that one compound shape or live Pathfinder operation gives its
/// members in all ([`SymbolNodes`]): four for each segment the operation takes in
/// ([`crate::group::PATHFINDER_MAX_SEGMENTS`]).
pub const SYMBOL_ART_MAX_NODES: u64 = 4 * crate::group::PATHFINDER_MAX_SEGMENTS as u64;
/// The objects of symbol art given to one compound shape or live Pathfinder operation so far,
/// counted against [`SYMBOL_ART_MAX_NODES`]. Instances of symbols that hold several instances of
/// others multiply at each level, which the depth limit does not stop.
#[derive(Debug, Default)]
pub(crate) struct SymbolNodes(std::cell::Cell<u64>);
impl SymbolNodes {
/// Count `art`'s objects: `false` once they would pass [`SYMBOL_ART_MAX_NODES`], and from then
/// on for all art.
pub(crate) fn charge(&self, art: &Node) -> bool {
let total = self.0.get().saturating_add(art.count() as u64);
let fits = total <= SYMBOL_ART_MAX_NODES;
self.0.set(if fits { total } else { u64::MAX });
fits
}
/// Has some symbol art been left out for passing [`SYMBOL_ART_MAX_NODES`]?
pub(crate) fn spent(&self) -> bool {
self.0.get() > SYMBOL_ART_MAX_NODES
}
}
/// [`evaluated`] with type and symbol instances inside live objects given as their art by
/// `outline`, and every symbol instance left in the result (a blend's steps, an instance in a
/// plain group) replaced by its art from `outline`, with the instance's name, visibility and
/// transparency. The live objects and symbol instances inside that art are given as their art in
/// turn, except an instance of a symbol that holds an instance of itself, of a symbol nested
/// more than [`vectorcraft_doc::depth::MAX_DEPTH`] deep, or whose art would take `nodes` past
/// [`SYMBOL_ART_MAX_NODES`], which stays as it is. With [`outline_text`], symbol instances stay as
/// they are.
pub(crate) fn evaluated_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node {
resolved(doc, n, outline, nodes, &[])
}
/// `art`, the art `outline` gave an instance of the symbol named `symbol`, with the live objects
/// and symbol instances inside it given as their art as [`evaluated_with`] does.
pub(crate) fn symbol_art_with(art: &Node, outline: OutlineHook, nodes: &SymbolNodes, symbol: &str) -> Node {
resolved(None, art, outline, nodes, &[symbol.to_string()])
}
/// [`evaluated_with`] inside the art of the symbols named in `shown`, outermost first.
fn resolved(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Node {
let inner = |m: &Node| art_of(doc, m, outline, nodes, shown);
let mut art = vectorcraft_doc::live::expand_deep_hooks(n, Hooks { outline: Some(&inner), symbols: doc, ..Default::default() });
instances_as_art(doc, &mut art, outline, nodes, shown);
art
}
/// `n`'s art from `outline`, inside the art of the symbols named in `shown`. For a symbol instance
/// it is the symbol's art with the live objects and instances inside it given as their art in turn
/// ([`resolved`]); `None` for an instance of a symbol in `shown` (one that holds an instance of
/// itself), for every instance once `shown` holds [`vectorcraft_doc::depth::MAX_DEPTH`] symbols,
/// and for an instance whose symbol's art `nodes` cannot take ([`SymbolNodes::charge`]).
fn art_of(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) -> Option<Node> {
let NodeKind::SymbolInstance { symbol, .. } = &n.kind else { return outline(n) };
if shown.len() >= vectorcraft_doc::depth::MAX_DEPTH || shown.contains(symbol) {
return None;
}
let art = outline(n).filter(|art| nodes.charge(art))?;
let mut inside = shown.to_vec();
inside.push(symbol.clone());
Some(resolved(doc, &art, outline, nodes, &inside))
}
/// Replace every symbol instance under `n` for which [`art_of`] gives art by that art. `shown`
/// names the symbols whose art `n` is in, outermost first.
fn instances_as_art(doc: Option<&Document>, n: &mut Node, outline: OutlineHook, nodes: &SymbolNodes, shown: &[String]) {
let Some(ch) = n.children_mut() else { return };
for c in ch.iter_mut() {
if matches!(c.kind, NodeKind::SymbolInstance { .. }) {
if let Some(mut o) = art_of(doc, c, outline, nodes, shown) {
o.name = c.name.clone();
(o.visible, o.opacity, o.blend, o.isolate) = (c.visible, c.opacity, c.blend, c.isolate);
*c = Arc::new(o);
}
} else if c.children().is_some() {
instances_as_art(doc, Arc::make_mut(c), outline, nodes, shown);
}
}
}
/// Replace type anywhere inside `n` by its outlines, as art that keeps the type's look
@@ -304,9 +414,11 @@ pub(crate) fn outline_nested_text(n: &mut Node, clipping: bool) {
/// Object on. The result is a group with `n`'s id, name, visibility, transparency, opacity mask
/// and remaining (raster) effects; `None` when `n` has no visible geometry effect, no perspective
/// and is not type or an image with a fill or stroke of its own that [`has_item_geometry`] counts,
/// or has no art (a symbol instance without `symbol_art`).
/// or has no art (a symbol instance without `symbol_art`). A path with [`has_transform_art`] is
/// always reshaped. The fills and strokes in the art whose own Transform effects reach their
/// appearance become objects of their own ([`transform_items`]).
pub fn reshape(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
if (geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !(needs_outline(n) || has_transform_art(n)) {
if !has_transform_art(n) && ((geometry_effects(n).is_empty() && n.projection().is_none() && !has_item_geometry(n)) || !needs_outline(n)) {
return None;
}
as_art(doc, n, symbol_art)
@@ -334,6 +446,11 @@ pub fn expand_outlined(doc: Option<&Document>, n: &Node, symbol_art: Option<&Nod
/// [`reshape`] without its checks (no geometry effects: just the art).
pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>) -> Option<Node> {
as_art_with(doc, n, symbol_art, &outline_text, &SymbolNodes::default())
}
/// [`as_art`] with live objects evaluated through `outline` ([`evaluated_with`]).
pub(crate) fn as_art_with(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>, outline: OutlineHook, nodes: &SymbolNodes) -> Option<Node> {
let fx = geometry_effects(n);
let mut art = match &n.kind {
NodeKind::Image(im) => {
@@ -361,9 +478,10 @@ pub(crate) fn as_art(doc: Option<&Document>, n: &Node, symbol_art: Option<&Node>
Node::group(n.id, rows(below).into_iter().chain([Arc::new(image)]).chain(rows(above)).collect())
}
}
_ => outline_art(doc, n, symbol_art)?,
_ => outline_art_with(doc, n, symbol_art, outline, nodes)?,
};
reshape_leaves(&mut art, &fx);
transform_items(&mut art);
// Type and symbols in perspective: the art projected onto their plane.
if let Some(h) = n.projection() {
project_leaves(&mut art, &h);
@@ -577,6 +695,158 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
made
}
/// Every path and compound path under `art` (other than clipping paths) with a fill or stroke whose
/// own Transform effect has Transform Patterns or Scale Strokes & Effects on
/// ([`item_transform_options`]), made into a group of objects of its own ([`transform_item_leaf`]).
/// The new paths keep to one [`Budget::EFFECTS`] together (or to what `art` costs, when that is
/// more).
fn transform_items(art: &mut Node) {
let mut used = art_cost(art);
let max = Budget::EFFECTS.at_least(used);
for_each_leaf(art, &mut |leaf| transform_item_leaf(leaf, max, &mut used));
}
/// `leaf`, a path or compound path with a fill or stroke whose own Transform effect has Transform
/// Patterns or Scale Strokes & Effects on, as a group with its id, name, transparency and object
/// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after
/// the geometry effects of that fill or stroke before its first such Transform effect, transformed
/// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the
/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the
/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer
/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then
/// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other
/// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's
/// geometry between them, with their own effects. `max` is the budget of the whole art and `used`
/// what it has spent.
fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
if matches!(leaf.kind, NodeKind::Path { clipping: true, .. }) || !leaf.appearance.items.iter().any(item_transform_options) {
return;
}
let Some((path, rule)) = leaf_geometry(leaf) else { return };
let original = leaf.clone();
let ctx = GeomContext::of(&original);
let bounds = path.to_bezpath().bounding_box();
// A path of the leaf's geometry painted by `items` alone, without the leaf's transparency and
// object effects (the group keeps them).
let piece = |items: Vec<AppearanceItem>| {
let mut p = original.clone();
p.appearance.items = items;
p.appearance.effects.clear();
p.appearance.contents_index = None;
p.opacity = 1.0;
p.blend = Default::default();
p.isolate = false;
p.knockout = Default::default();
p.extras_mut().mask = None;
p
};
// The pieces replace the leaf's own geometry (counted in `used`): each plain piece costs that
// geometry again, and each transformed fill or stroke what its paths cost.
let cost = Budget::of(&path);
*used = used.minus(cost);
let mut pieces: Vec<Arc<Node>> = vec![];
let mut plain: Vec<AppearanceItem> = vec![];
// Which items become a transformed piece of their own; every other run of items is one plain piece.
let transformed: Vec<bool> = original.appearance.items.iter().map(|i| crate::paints(i) && i.effects().iter().any(reaches_appearance)).collect();
// The number of pieces after item `i` in paint order: each costs at least the leaf's geometry.
let later = |i: usize| (i + 1..transformed.len()).filter(|&j| transformed[j] || j == i + 1 || transformed[j - 1]).count();
for (i, item) in original.appearance.items.iter().enumerate() {
let fx = item.effects();
let Some(at) = fx.iter().position(reaches_appearance).filter(|_| crate::paints(item)) else {
plain.push(item.clone());
continue;
};
if !plain.is_empty() {
pieces.push(Arc::new(piece(std::mem::take(&mut plain))));
*used = used.plus(cost);
}
let before = &fx[..at];
// What the art's other paths leave of the budget, less the leaf's geometry for each piece
// still to come: the geometry before the Transform effect and its copies keep to it.
let reserved = (0..later(i)).fold(Budget { anchors: 0, lines: 0 }, |r, _| r.plus(cost));
let limit = max.minus(reserved);
let room = limit.minus(*used);
let g = if crate::has_geometry(before) && !path.is_empty() {
crate::geometry::apply_geometry_within(before, &path, bounds, &ctx.item(item), room)
} else {
path.clone()
};
let Some(tb) = g.bounds() else { continue };
let params = merged_params(&fx[at].id, &fx[at].params);
let (_, copies) = crate::distort::transform_map(tb, &params, ctx.seed);
let options = crate::distort::TransformOptions::of(&params);
let map = crate::distort::transform_affine(tb, &params, ctx.seed);
let mut it = item.clone();
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
*it.effects_mut() = rest;
let mut p = piece(vec![it]);
set_geometry(&mut p, g.clone(), rule);
let made = transform_leaf(&mut p, &g, map, copies, options, room);
*used = used.plus(made);
transform_piece(&mut p, ctx.seed, limit, used);
pieces.push(Arc::new(p));
}
if !plain.is_empty() {
pieces.push(Arc::new(piece(plain)));
*used = used.plus(cost);
}
leaf.appearance.items.clear();
leaf.appearance.contents_index = None;
leaf.knockout = vectorcraft_doc::Knockout::Off;
leaf.kind = NodeKind::Group { children: pieces, clip: false };
}
/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a
/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece:
/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's
/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
/// The geometry effects then leave the fills and strokes; their other effects stay. The paths keep
/// to `max` together with the rest of the art (`used`).
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
let mut count = 0;
for_each_leaf(p, &mut |l| {
if let Some(item) = l.appearance.items.first() {
count = count.max(item.effects().len());
}
});
for j in 0..count {
let Some(bounds) = p.geometric_bounds() else { break };
for_each_leaf(p, &mut |leaf| {
let Some(item) = leaf.appearance.items.first().cloned() else { return };
let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return };
let Some((path, rule)) = leaf_geometry(leaf) else { return };
// What the other paths leave of the budget.
let before = Budget::of(&path);
let room = max.minus(used.minus(before));
if reaches_appearance(&e) {
let params = merged_params(&e.id, &e.params);
let (_, copies) = crate::distort::transform_map(bounds, &params, seed);
let options = crate::distort::TransformOptions::of(&params);
let map = crate::distort::transform_affine(bounds, &params, seed);
let made = transform_leaf(leaf, &path, map, copies, options, room);
*used = used.minus(before).plus(made);
return;
}
let appearance = leaf.appearance.clone();
let stroke = match &item {
AppearanceItem::Stroke(s) => Some(s),
AppearanceItem::Fill(_) => None,
};
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) };
let out = crate::geometry::apply_geometry_within(std::slice::from_ref(&e), &path, bounds, &ctx, room);
*used = used.minus(before).plus(Budget::of(&out));
set_geometry(leaf, out, rule);
});
}
for_each_leaf(p, &mut |leaf| {
for item in &mut leaf.appearance.items {
item.effects_mut().retain(|e| !(e.visible && is_geometry(&e.id)));
}
});
}
/// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in
/// order. A group inside one of them is part of it.
fn type_outline_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
@@ -722,7 +992,13 @@ fn placed_count(n: &Node) -> usize {
/// `n` with type anywhere inside it outlined and live objects evaluated, so geometry effects on
/// it reach every member.
pub(crate) fn outlined_members(doc: Option<&Document>, n: &Node) -> Node {
let mut art = evaluated(doc, n);
outlined_members_with(doc, n, &outline_text, &SymbolNodes::default())
}
/// [`outlined_members`] with live objects and symbol instances given as art through `outline`
/// ([`evaluated_with`]).
pub(crate) fn outlined_members_with(doc: Option<&Document>, n: &Node, outline: OutlineHook, nodes: &SymbolNodes) -> Node {
let mut art = evaluated_with(doc, n, outline, nodes);
outline_nested_text(&mut art, false);
art
}
+21
View File
@@ -133,6 +133,15 @@ fn geometry_effects_are_deterministic() {
}
}
/// The effects that symbol art across 9-slice guides draws once around all its cells are raster
/// effects that do not reshape it.
#[test]
fn nine_slice_painted_effects_are_raster_effects() {
for id in vectorcraft_doc::PAINTED_EFFECTS {
assert!(is_raster(id) && !is_geometry(id), "{id}");
}
}
#[test]
fn open_paths_stay_open() {
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(50.0, 20.0), Point::new(100.0, 0.0)], false));
@@ -933,6 +942,18 @@ fn round_corners_keeps_where_a_closed_path_starts() {
assert_eq!(stylize::round_corners(&d, f64::INFINITY), stylize::round_corners(&d, 1e9));
}
#[test]
fn twist_turns_clockwise_for_positive_angles() {
// A positive angle turns clockwise, a negative one counterclockwise
// (https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html).
// The document's y runs down, so clockwise on the page takes a point right of the center down.
let right = PathData::single(SubPath::polyline(&[Point::new(55.0, 50.0), Point::new(56.0, 50.0)], false));
let bounds = Rect::new(0.0, 0.0, 100.0, 100.0);
let turned = |angle: f64| apply_geometry(&[fx("distort.twist", json!({"angle": angle}))], &right, bounds).subpaths[0].anchors[0].p;
assert!(turned(45.0).y > 50.5, "{:?}", turned(45.0));
assert!(turned(-45.0).y < 49.5, "{:?}", turned(-45.0));
}
#[test]
fn twist_fixes_the_outer_circle_and_rotates_inside() {
let out = run("distort.twist", json!({"angle": 0}), &square());
+229 -2
View File
@@ -3,11 +3,11 @@
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Dash, Effect, Node, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Effect, Node, NodeId, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::distort::{TransformOptions, transform_map, transform_move};
use crate::{has_transform_art, has_transform_options, reshape};
use crate::{bake_document, expand_outlined, has_transform_art, has_transform_options, reshape};
fn transform(params: Value) -> Effect {
Effect { id: "distort.transform".into(), params, visible: true }
@@ -157,3 +157,230 @@ fn stroke_scaling_across_copies_stays_finite() {
}
}
}
/// A 100 pt square at the origin with a solid gray fill and a 2 pt black stroke (dashed 4/2) that
/// carries the Transform effect `params` itself.
fn stroke_with_effect(params: Value) -> Node {
let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5));
let mut n =
Node::path(NodeId(10), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gray, Paint::solid(Color::BLACK), 2.0));
if let Some(s) = n.appearance.stroke_mut() {
s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false });
s.effects.push(transform(params));
}
n
}
/// A 100 pt square at the origin filled with pattern "p" placed at the identity, the fill carrying
/// the Transform effect `params` itself.
fn fill_with_effect(params: Value) -> Node {
let mut n = patterned(json!({}));
n.appearance.effects.clear();
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.effects.push(transform(params));
}
n
}
/// The paths under `n` that a stroke paints.
fn stroked_leaves(n: &Node) -> Vec<&Node> {
leaves(n).into_iter().filter(|p| p.appearance.stroke().is_some()).collect()
}
/// The paths under `n` that a pattern fills.
fn pattern_leaves(n: &Node) -> Vec<&Node> {
leaves(n).into_iter().filter(|p| matches!(p.appearance.fill_paint(), Paint::Pattern { .. })).collect()
}
#[test]
fn a_strokes_own_transform_scales_its_weight_and_dashes_with_scale_strokes_and_effects() {
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "strokes": true}));
assert!(has_transform_options(&n));
let art = reshape(None, &n, None).unwrap();
let paths = stroked_leaves(&art);
assert_eq!(paths.len(), 1);
let (w, dash) = stroke_of(paths[0]);
assert!(close(w, 4.0), "{w}");
assert_eq!(dash, vec![8.0, 4.0]);
assert!(close(paths[0].geometric_bounds().unwrap().width(), 200.0));
// The fill stays on the path as it was, below the stroke.
let all = leaves(&art);
assert_eq!(all.len(), 2);
assert!(all[0].appearance.stroke().is_none());
assert_eq!(all[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
// Off, the stroke keeps its weight and dashes: the effect stays geometry alone.
let plain = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "strokes": false}));
assert!(!has_transform_art(&plain));
}
#[test]
fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() {
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
let art = reshape(None, &n, None).unwrap();
let widths: Vec<f64> = stroked_leaves(&art).iter().map(|p| stroke_of(p).0).collect();
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
}
#[test]
fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() {
let mut n = stroke_with_effect(json!({}));
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true})));
f.effects.push(Effect { id: "path.offsetPath".into(), params: json!({"offset": 5.0}), visible: true });
}
let art = reshape(None, &n, None).unwrap();
let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap();
let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") };
// The Offset Path, scaled from 5 to 15, applies to the transformed fill's path.
assert!(f.effects.is_empty(), "{:?}", f.effects);
let w = fill.geometric_bounds().unwrap().width();
assert!(close(w, 330.0), "{w}");
}
#[test]
fn a_later_transform_on_a_stroke_turns_its_copies_about_their_combined_center() {
for later in [json!({"rotate": 90}), json!({"rotate": 90, "strokes": true})] {
let mut n = stroke_with_effect(json!({"moveH": 200, "copies": 1, "strokes": true}));
if let Some(s) = n.appearance.stroke_mut() {
s.effects.push(transform(later.clone()));
}
let art = reshape(None, &n, None).unwrap();
let paths = stroked_leaves(&art);
assert_eq!(paths.len(), 2, "{later}");
// The copies span 0 to 300 by 0 to 100 before the turn; about the center (150, 50) both
// go to x 100 to 200, one above the other.
let boxes: Vec<Rect> = paths.iter().map(|p| p.geometric_bounds().unwrap()).collect();
for b in &boxes {
assert!(close(b.x0, 100.0) && close(b.x1, 200.0), "{later}: {boxes:?}");
}
let top = boxes.iter().map(|b| b.y0).fold(f64::INFINITY, f64::min);
let bottom = boxes.iter().map(|b| b.y1).fold(f64::NEG_INFINITY, f64::max);
assert!(close(top, -100.0) && close(bottom, 200.0), "{later}: {boxes:?}");
for p in &paths {
assert!(p.appearance.stroke().unwrap().effects.is_empty(), "{later}");
}
}
}
#[test]
fn a_fills_own_transform_moves_each_copys_pattern_with_transform_patterns() {
let n = fill_with_effect(json!({"moveH": 30, "copies": 2, "patterns": true}));
assert!(has_transform_options(&n));
let art = reshape(None, &n, None).unwrap();
let xs: Vec<f64> = pattern_leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
// Off, the pattern stays put.
assert!(!has_transform_art(&fill_with_effect(json!({"moveH": 30, "copies": 2}))));
}
#[test]
fn a_strokes_own_transform_with_transform_objects_off_keeps_its_geometry() {
let n = stroke_with_effect(json!({"scaleH": 50, "scaleV": 50, "moveH": 40, "copies": 3, "objects": false, "strokes": true}));
let art = reshape(None, &n, None).unwrap();
let paths = stroked_leaves(&art);
assert_eq!(paths.len(), 1, "no copies");
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
assert!(close(stroke_of(paths[0]).0, 1.0));
let n = fill_with_effect(json!({"rotate": 45, "objects": false, "patterns": true}));
let art = reshape(None, &n, None).unwrap();
let paths = pattern_leaves(&art);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
assert_ne!(pattern_xf(paths[0]), Affine::IDENTITY);
}
#[test]
fn a_strokes_own_transform_draws_bakes_expands_and_measures_alike() {
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
let drawn = reshape(None, &n, None).unwrap();
let widths = |art: &Node| stroked_leaves(art).iter().map(|p| stroke_of(p).0).collect::<Vec<f64>>();
assert_eq!(widths(&drawn), vec![2.0, 4.0]);
// Expand Appearance.
assert_eq!(widths(&expand_outlined(None, &n, None).unwrap()), widths(&drawn));
// Export.
let mut d = Document::new(800.0, 800.0);
let layer = d.layers[0].id;
d.insert(Some(layer), 0, n.clone()).unwrap();
let baked = bake_document(&d).unwrap();
assert_eq!(widths(baked.node(NodeId(10)).unwrap()), widths(&drawn));
// Use Preview Bounds: the copy's 4 pt stroke (on -50..150) reaches 2 pt past it.
let symbols = d.symbol_boxes();
let b = crate::preview::visual_bounds(&symbols, &n).unwrap();
assert!(close(b.width(), 204.0), "{b:?}");
assert_eq!(Some(b), drawn.visual_bounds_with(&symbols));
}
/// Three strokes, each with three max-detail Roughens (#3578: nearly one effect budget on their
/// own) before its own Transform effect: their paths keep to one effect budget together, not one
/// each.
#[test]
fn strokes_with_their_own_transform_keep_to_one_effect_budget() {
use crate::geometry::Budget;
let rough = Effect { id: "distort.roughen".into(), params: json!({"size": 10000, "relative": false, "detail": 100}), visible: true };
let mut n = Node::path(
NodeId(11),
shapes::rectangle(Rect::new(0.0, 0.0, 1000.0, 1000.0)),
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0),
);
let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).cloned() else {
panic!("no stroke")
};
n.appearance.items.retain(|i| !matches!(i, AppearanceItem::Stroke(_)));
for _ in 0..3 {
let mut s = s.clone();
s.effects = vec![rough.clone(); 3];
s.effects.push(transform(json!({"scaleH": 50, "scaleV": 50, "strokes": true})));
n.appearance.items.push(AppearanceItem::Stroke(s));
}
let art = reshape(None, &n, None).unwrap();
let paths = stroked_leaves(&art);
assert_eq!(paths.len(), 3);
let cost =
paths.iter().filter_map(|p| crate::reshape::leaf_geometry(p)).fold(Budget { anchors: 0, lines: 0 }, |c, (g, _)| c.plus(Budget::of(&g)));
assert!(Budget::EFFECTS.fits(cost) && cost.lines > crate::MAX_EFFECT_LINES / 2, "{cost:?}");
}
/// A path of 600,000 anchors with a fill below a stroke whose own Transform effect makes five
/// copies: the fill's path counts toward the effect budget, so the stroke's copies stop where the
/// whole art would pass it.
#[test]
fn a_fill_below_a_strokes_own_transform_counts_toward_the_effect_budget() {
use crate::geometry::Budget;
use vectorcraft_geom::{PathData, Point, SubPath};
let points: Vec<Point> = (0..600_000).map(|i| Point::new(f64::from(i) * 1.0e-4, f64::from(i % 2))).collect();
let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5));
let mut n = Node::path(NodeId(12), PathData::single(SubPath::polyline(&points, false)), Appearance::basic(gray, Paint::solid(Color::BLACK), 1.0));
if let Some(s) = n.appearance.stroke_mut() {
s.effects.push(transform(json!({"moveH": 100, "copies": 5, "strokes": true})));
}
let art = reshape(None, &n, None).unwrap();
let cost = leaves(&art)
.iter()
.filter_map(|p| crate::reshape::leaf_geometry(p))
.fold(Budget { anchors: 0, lines: 0 }, |c, (g, _)| c.plus(Budget::of(&g)));
assert_eq!(stroked_leaves(&art).len(), 2, "{cost:?}");
assert!(Budget::EFFECTS.fits(cost), "{cost:?}");
}
/// A path of 600,000 anchors with a stroke whose own Transform effect makes two copies, below a
/// fill: the stroke's copies leave room for the fill painted after them.
#[test]
fn a_strokes_own_transform_leaves_room_for_a_fill_above_it() {
use crate::geometry::Budget;
use vectorcraft_geom::{PathData, Point, SubPath};
let points: Vec<Point> = (0..600_000).map(|i| Point::new(f64::from(i) * 1.0e-4, f64::from(i % 2))).collect();
let gray = Paint::solid(Color::rgb(0.5, 0.5, 0.5));
let mut n = Node::path(NodeId(13), PathData::single(SubPath::polyline(&points, false)), Appearance::basic(gray, Paint::solid(Color::BLACK), 1.0));
if let Some(s) = n.appearance.stroke_mut() {
s.effects.push(transform(json!({"moveH": 100, "copies": 2, "strokes": true})));
}
n.appearance.items.reverse();
assert!(matches!(n.appearance.items.first(), Some(AppearanceItem::Stroke(_))));
let art = reshape(None, &n, None).unwrap();
let cost = leaves(&art)
.iter()
.filter_map(|p| crate::reshape::leaf_geometry(p))
.fold(Budget { anchors: 0, lines: 0 }, |c, (g, _)| c.plus(Budget::of(&g)));
assert_eq!(stroked_leaves(&art).len(), 2, "{cost:?}");
assert!(Budget::EFFECTS.fits(cost), "{cost:?}");
}
+3
View File
@@ -38,8 +38,11 @@ vectorcraft-scriptcheck = { workspace = true }
image = { workspace = true }
hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["std", "simd"] }
flate2 = { workspace = true }
chrono = { workspace = true }
# The XML parts of Word documents placed as type (place/docx.rs).
roxmltree = "0.21"
# The compound file that holds a Word 97-2003 document placed as type (place/doc.rs).
cfb = "0.15"
kurbo = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
+45 -8
View File
@@ -62,6 +62,7 @@ pub mod params;
mod path;
pub(crate) mod pathops;
mod patterncmds;
pub mod patternlib;
pub mod pdfcmds;
pub mod perspgrid;
pub mod place;
@@ -84,6 +85,7 @@ mod style;
pub mod stylelib;
mod swatch;
pub mod swatchlib;
pub(crate) mod symboledit;
pub mod symbollib;
pub(crate) mod tabs;
mod textedit;
@@ -183,11 +185,15 @@ pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
let st = s.active().ok_or("no document open")?;
if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
}
/// The isolated group, layer or object in isolation mode. Pattern editing mode and opacity mask
/// editing isolate their own layer, which does not count here; an object isolated inside them
/// counts.
/// The isolated group, layer or object in isolation mode. Pattern editing mode, opacity mask
/// editing and symbol editing mode isolate their own layer, which does not count here; an object
/// isolated inside them counts.
pub(crate) fn ordinary_isolation(st: &crate::DocState) -> Option<vectorcraft_doc::NodeId> {
st.isolation.filter(|l| !st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l) && !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l))
st.isolation.filter(|l| {
!st.doc.pattern_edit.as_ref().is_some_and(|e| e.layer == *l)
&& !st.doc.mask_edit.as_ref().is_some_and(|e| e.layer == *l)
&& !st.doc.symbol_edit.as_ref().is_some_and(|e| e.layer == *l)
})
}
/// Objects are selected ([`has_selection`]), and in isolation mode ([`ordinary_isolation`]) at
/// least one of them is inside the isolated object: Object › Lock's and Object › Hide's items.
@@ -506,11 +512,44 @@ pub fn color_value(v: &Value) -> Option<Color> {
}
}
/// `v` as an object id: an error unless it is a non-negative integer.
fn object_id(v: &Value) -> Result<NodeId> {
v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}")))
}
/// The error for `id` given as step reference `r`, which only a batch resolves.
fn unresolved_reference(r: &str) -> EngineError {
EngineError::Other(format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))
}
/// The objects `p` names for a command whose `id` can only be an object id: its `ids`, else its
/// `id` as `ids` of one. A value that isn't an object id is an error, never the selection (#785).
/// `None` when `p` gives neither (or gives them as `null`). The ids are not looked up in the
/// document.
pub(crate) fn named_ids(p: &Value) -> Result<Option<Vec<NodeId>>> {
match p.get("ids").filter(|v| !v.is_null()) {
Some(Value::Array(a)) => return a.iter().map(object_id).collect::<Result<Vec<_>>>().map(Some),
Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))),
None => {}
}
Ok(named_id(p)?.map(|id| vec![id]))
}
/// The object `p`'s `id` names, for a command whose `id` can only be an object id: a value that
/// isn't an object id is an error, never the selection (#785). `None` when `p` leaves `id` out (or
/// gives it as `null`). The id is not looked up in the document.
pub(crate) fn named_id(p: &Value) -> Result<Option<NodeId>> {
match p.get("id").filter(|v| !v.is_null()) {
None => Ok(None),
Some(Value::String(r)) if r.starts_with('$') => Err(unresolved_reference(r)),
Some(v) => object_id(v).map(Some),
}
}
/// Objects a command targets: explicit `ids` param or the selection.
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
// Objects given are used as given: a value that isn't an object id fails rather than the
// command acting on the selection instead (#785).
let object_id = |v: &Value| v.as_u64().map(NodeId).ok_or_else(|| EngineError::Other(format!("object ids are non-negative integers, not {v}")));
match p.get("ids").filter(|v| !v.is_null()) {
Some(Value::Array(a)) => return a.iter().map(object_id).collect(),
Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))),
@@ -520,9 +559,7 @@ pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]),
// A step reference a batch couldn't resolve (other strings are other ids, such as an
// effect's).
Some(Value::String(r)) if r.starts_with('$') => {
Err(EngineError::Other(format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve")))
}
Some(Value::String(r)) if r.starts_with('$') => Err(unresolved_reference(r)),
_ => Ok(s.doc()?.selection.objects.clone()),
}
}
+182 -5
View File
@@ -3,23 +3,28 @@
//! Replace with Top Object puts a copy of the topmost selected object in place of each of the
//! others; and Set Size of Each gives every selected object an exact width and height. Places and
//! sizes are measured and set as `object.setBounds` does. Group Overlapping Objects groups the
//! objects whose visual bounds overlap. Its submenu (Object › Plug-ins › Arrange
//! Tools), its dialogs and Zoom to Selection (a view command) are the UI's.
//! objects whose visual bounds overlap, Randomize Opacity gives each selected object a random
//! opacity, and Sort Objects sorts the selected objects by one attribute and gives them another in
//! that order. Its submenu (Object › Plug-ins › Arrange Tools), its dialogs and Zoom to Selection
//! (a view command) are the UI's.
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::recolor::Rng;
use vectorcraft_doc::{Node, NodeId, OrientedBox};
use vectorcraft_geom::{Affine, Point, Rect};
use vectorcraft_render::effects;
use super::super::edit::selected_roots;
use super::super::menucmds::whole_or;
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 super::super::{CommandSpec, bad, checked_ids_or_selection, 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.
@@ -28,8 +33,10 @@ const LIMIT: f64 = 1e6;
const LEAST: f64 = 0.001;
/// The most columns Arrange in Grid takes.
const MAX_COLUMNS: u64 = 10_000;
/// The most objects Group Overlapping Objects looks at.
/// The most objects Group Overlapping Objects and Sort Objects look at.
const MAX_OBJECTS: usize = 20_000;
/// The attributes Sort Objects sorts by and gives (`addon.arrange.sort`).
pub const SORT_KEYS: [&str; 7] = ["width", "height", "area", "x", "y", "opacity", "stacking"];
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -78,6 +85,24 @@ pub fn specs() -> Vec<CommandSpec> {
has_multi,
group_overlapping
),
cmd!(
"addon.arrange.randomizeOpacity",
"Randomize Opacity",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}",
has_selection,
randomize_opacity
),
cmd!(
"addon.arrange.sort",
"Sort Objects",
["Object", "Plug-ins", "Arrange Tools"],
None,
"{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}",
has_multi,
sort
),
]
}
@@ -95,7 +120,7 @@ fn number_in(p: &Value, key: &str, default: f64, (lo, hi): (f64, f64), cmd: &str
}
/// 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>> {
pub(super) 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}"))) }
@@ -356,3 +381,155 @@ fn group_overlapping(s: &mut Session, _: &Value) -> Result<Value> {
})?;
Ok(json!({ "groups": groups.iter().map(|i| i.0).collect::<Vec<_>>(), "grouped": grouped.len() }))
}
fn randomize_opacity(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.randomizeOpacity";
let min = number_in(p, "min", 30.0, (0.0, 100.0), C)?;
let max = number_in(p, "max", 100.0, (0.0, 100.0), C)?;
if min > max {
return Err(bad(C, "`min` is greater than `max`"));
}
let mut rng = Rng::new(whole_or(p, "seed", 0, C)?);
let ids = checked_ids_or_selection(s, p, C)?;
if ids.is_empty() {
return Ok(json!({ "count": 0 }));
}
let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32);
s.edit("Randomize Opacity", |d, _| {
for id in &ids {
let o = rng.range(lo, hi);
if let Some(n) = d.node_mut(*id) {
n.opacity = o;
}
}
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
/// An object Sort Objects sorts: its layer or group, its place in it, its box as
/// `object.setBounds` measures it and its opacity (%).
struct Sorted {
id: NodeId,
parent: Option<NodeId>,
slot: usize,
b: OrientedBox,
opacity: f64,
}
/// Attribute `key` (one of [`SORT_KEYS`]) of `o`, the `i`th of the objects in stacking order.
fn attribute(key: &str, o: &Sorted, i: usize) -> f64 {
let r = o.b.rect;
// The top-left corner where `object.setBounds` places it (reference point 0).
let corner = o.b.to_doc() * Point::new(r.x0, r.y0);
match key {
"width" => r.width(),
"height" => r.height(),
"area" => r.width() * r.height(),
"x" => corner.x,
"y" => corner.y,
"opacity" => o.opacity,
_ => i as f64,
}
}
fn sort(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.arrange.sort";
let by = choice(p, C, "by", &SORT_KEYS)?.unwrap_or("area");
let apply = choice(p, C, "apply", &SORT_KEYS)?.unwrap_or("stacking");
let order = choice(p, C, "order", &["ascending", "descending", "random"])?.unwrap_or("ascending");
let distribute = switch_or(p, "distribute", false, C)?;
let mut rng = Rng::new(whole_or(p, "seed", 0, C)?);
let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?;
let ids = checked_ids_or_selection(s, p, C)?;
if ids.len() > MAX_OBJECTS {
return Err(bad(C, "too many objects selected (at most 20000)"));
}
let d = &s.doc()?.doc;
// The objects in stacking order, from the back (their index paths, found in one walk).
let paths = d.index_paths(ids.iter().copied());
let mut placed: Vec<(&Vec<usize>, NodeId)> = ids.iter().filter_map(|id| Some((paths.get(id)?, *id))).collect();
placed.sort_by(|a, b| a.0.cmp(b.0));
let items: Vec<Sorted> = placed
.into_iter()
.filter_map(|(path, id)| {
let (&slot, up) = path.split_last()?;
let parent = if up.is_empty() { None } else { Some(d.node_at(up)?.id) };
Some(Sorted { id, parent, slot, b: s.transform_box(&[id])?, opacity: f64::from(d.node(id)?.opacity) * 100.0 })
})
.collect();
let keys: Vec<f64> = items.iter().enumerate().map(|(i, o)| attribute(by, o, i)).collect();
// The objects in sorted order; equal keys keep their stacking order.
let mut rank: Vec<usize> = (0..items.len()).collect();
match order {
"random" => rng.shuffle(&mut rank),
"descending" => rank.sort_by(|a, b| keys[*b].total_cmp(&keys[*a])),
_ => rank.sort_by(|a, b| keys[*a].total_cmp(&keys[*b])),
}
let count = json!({ "count": items.len() });
if apply == "stacking" {
// Each layer or group's objects take its objects' places, from the back, in sorted order.
let mut groups: HashMap<Option<NodeId>, Vec<usize>> = HashMap::new();
for o in rank.iter().filter_map(|i| items.get(*i)) {
groups.entry(o.parent).or_default().push(o.slot);
}
if groups.values().all(|slots| slots.is_sorted()) {
return Ok(count);
}
s.edit("Sort Objects", |d, _| {
for (parent, from) in groups {
let ch = d.children_mut(parent)?;
let nodes: Vec<Arc<Node>> = from.iter().filter_map(|i| ch.get(*i).cloned()).collect();
let mut to = from;
to.sort_unstable();
for (slot, n) in to.into_iter().zip(nodes) {
if let Some(c) = ch.get_mut(slot) {
*c = n;
}
}
}
Ok(())
})?;
return Ok(count);
}
// The values the objects get, smallest first: theirs, or evenly from the smallest to the largest.
let mut values: Vec<f64> = items.iter().enumerate().map(|(i, o)| attribute(apply, o, i)).collect();
values.sort_by(f64::total_cmp);
if let (true, Some(&lo), Some(&hi)) = (distribute && values.len() > 1, values.first(), values.last()) {
let steps = (values.len() - 1) as f64;
values = (0..values.len()).map(|j| lo + (hi - lo) * j as f64 / steps).collect();
}
let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect();
if apply == "opacity" {
if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) {
return Ok(count);
}
s.edit("Sort Objects", |d, _| {
for (o, v) in &wanted {
if let Some(n) = d.node_mut(o.id) {
n.opacity = (v / 100.0) as f32;
}
}
Ok(())
})?;
return Ok(count);
}
let mut xfs = Vec::with_capacity(wanted.len());
for (o, v) in wanted {
let q = match apply {
"x" => json!({ "x": v, "reference": 0 }),
"y" => json!({ "y": v, "reference": 0 }),
"width" => json!({ "width": v, "strokes": strokes }),
"height" => json!({ "height": v, "strokes": strokes }),
// An area: both sides in proportion; an object without one keeps its size.
_ => {
let (w, h) = (o.b.rect.width(), o.b.rect.height());
let k = if w * h > 1e-12 { (v / (w * h)).sqrt() } else { 1.0 };
json!({ "width": w * k, "height": h * k, "strokes": strokes })
}
};
xfs.push((o.id, object_bounds_transform(s, &q, o.id)?));
}
transform_each(s, "Sort Objects", strokes, &xfs)?;
Ok(count)
}
+227 -1
View File
@@ -256,7 +256,15 @@ fn off_without_a_selection() {
let mut s = session();
rect(&mut s, 0.0, 0.0, 20.0, 10.0);
s.execute("select.none", &json!({})).unwrap();
for c in ["addon.arrange.grid", "addon.arrange.swap", "addon.arrange.replace", "addon.arrange.size", "addon.arrange.groupOverlapping"] {
for c in [
"addon.arrange.grid",
"addon.arrange.swap",
"addon.arrange.replace",
"addon.arrange.size",
"addon.arrange.groupOverlapping",
"addon.arrange.randomizeOpacity",
"addon.arrange.sort",
] {
assert!(!enabled(&s, c), "{c}");
}
assert!(!enabled(&Session::new(), "addon.arrange.swap"));
@@ -336,3 +344,221 @@ fn group_overlapping_is_off_with_one_object_and_scales_to_many() {
assert_eq!(sets.len(), 2000);
assert!(sets.iter().all(|s| s.len() == 2 && s[1] == s[0] + 1));
}
fn opacity(s: &Session, id: NodeId) -> f32 {
s.doc().unwrap().doc.node(id).unwrap().opacity
}
#[test]
fn randomize_opacity_gives_each_object_its_own_opacity_within_the_range() {
let mut s = session();
let ids: Vec<NodeId> = (0..20).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect();
select(&mut s, &ids);
let n = steps(&s);
let r = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 7})).unwrap();
assert_eq!(r["count"], json!(20));
assert_eq!(steps(&s), n + 1, "one undo step");
let first: Vec<f32> = ids.iter().map(|id| opacity(&s, *id)).collect();
assert!(first.iter().all(|o| (0.3..=0.6).contains(o)), "{first:?}");
assert!(first.windows(2).any(|w| w[0] != w[1]), "each its own: {first:?}");
s.execute("edit.undo", &json!({})).unwrap();
assert!(ids.iter().all(|id| opacity(&s, *id) == 1.0), "undone");
// The same seed repeats them; another seed doesn't.
s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 7})).unwrap();
assert_eq!(ids.iter().map(|id| opacity(&s, *id)).collect::<Vec<_>>(), first);
s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 8})).unwrap();
assert_ne!(ids.iter().map(|id| opacity(&s, *id)).collect::<Vec<_>>(), first);
// Defaults 30 to 100; min = max gives every object that opacity.
s.execute("addon.arrange.randomizeOpacity", &json!({})).unwrap();
assert!(ids.iter().all(|id| (0.3..=1.0).contains(&opacity(&s, *id))));
s.execute("addon.arrange.randomizeOpacity", &json!({"min": 45, "max": 45})).unwrap();
assert!(ids.iter().all(|id| opacity(&s, *id) == 0.45));
}
#[test]
fn randomize_opacity_takes_ids_and_leaves_the_rest() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 5.0, 5.0);
let b = rect(&mut s, 10.0, 0.0, 5.0, 5.0);
select(&mut s, &[a, b]);
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
let c = rect(&mut s, 20.0, 0.0, 5.0, 5.0);
s.execute("select.none", &json!({})).unwrap();
assert!(!enabled(&s, "addon.arrange.randomizeOpacity"));
s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [c.0], "min": 10, "max": 10})).unwrap();
assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (1.0, 1.0, 0.1));
// A group takes one opacity of its own; the objects inside keep theirs.
select(&mut s, &[g]);
s.execute("addon.arrange.randomizeOpacity", &json!({"min": 20, "max": 20})).unwrap();
assert_eq!((opacity(&s, g), opacity(&s, a), opacity(&s, b)), (0.2, 1.0, 1.0));
}
#[test]
fn randomize_opacity_refuses_bad_params() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0);
select(&mut s, &[a]);
refuses(
&mut s,
"addon.arrange.randomizeOpacity",
&[
json!({"min": -1}),
json!({"max": 101}),
json!({"min": 80, "max": 20}),
json!({"min": 1e308}),
json!({"max": "50"}),
json!({"seed": -1}),
json!({"seed": 1.5}),
json!({"seed": "x"}),
json!({"ids": [999_999]}),
json!({"ids": "x"}),
],
);
let e = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 80, "max": 20})).unwrap_err().to_string();
assert!(e.contains("`min` is greater than `max`"), "{e}");
}
fn layer_order(s: &Session) -> Vec<NodeId> {
s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect()
}
/// Three squares: `a` 30 wide at x 0, `b` 10 wide at x 100, `c` 20 wide at x 200, all selected.
fn squares(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let a = rect(s, 0.0, 0.0, 30.0, 30.0);
let b = rect(s, 100.0, 0.0, 10.0, 10.0);
let c = rect(s, 200.0, 0.0, 20.0, 20.0);
select(s, &[a, b, c]);
(a, b, c)
}
#[test]
fn sort_by_area_lines_objects_up_along_x() {
let mut s = session();
let (a, b, c) = squares(&mut s);
let n = steps(&s);
let r = s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x"})).unwrap();
assert_eq!(r["count"], json!(3));
assert_eq!(steps(&s), n + 1, "one undo step");
// Smallest first: b, c, a take the left edges 0, 100, 200; y stays.
assert!(near(bounds(&s, b), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, b));
assert!(near(bounds(&s, c), (100.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, c));
assert!(near(bounds(&s, a), (200.0, 0.0, 30.0, 30.0)), "{:?}", bounds(&s, a));
// Large to small: a, c, b.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap();
assert_eq!((bounds(&s, a).x0, bounds(&s, c).x0, bounds(&s, b).x0), (0.0, 100.0, 200.0));
// Already in order: no step.
let n = steps(&s);
s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap();
assert_eq!(steps(&s), n);
}
#[test]
fn sort_distributes_values_evenly() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
let b = rect(&mut s, 10.0, 50.0, 10.0, 50.0);
let c = rect(&mut s, 200.0, 0.0, 10.0, 20.0);
select(&mut s, &[a, b, c]);
// Left edges 0, 10, 200 step evenly: 0, 100, 200.
s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "x", "distribute": true})).unwrap();
assert_eq!((bounds(&s, a).x0, bounds(&s, b).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0));
// Heights that step evenly with x (10 to 50), each about its centre.
s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "height", "distribute": true})).unwrap();
assert!(near(bounds(&s, a), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, a));
assert!(near(bounds(&s, b), (100.0, 60.0, 10.0, 30.0)), "{:?}", bounds(&s, b));
assert!(near(bounds(&s, c), (200.0, -15.0, 10.0, 50.0)), "{:?}", bounds(&s, c));
}
#[test]
fn sort_restacks_within_each_layer() {
let mut s = session();
let (a, b, c) = squares(&mut s);
// Largest at the back.
s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "stacking", "order": "descending"})).unwrap();
assert_eq!(layer_order(&s), [a, c, b]);
s.execute("addon.arrange.sort", &json!({})).unwrap();
assert_eq!(layer_order(&s), [b, c, a], "by area, small to large, into the stacking order by default");
// An object in another layer keeps its layer; the others trade places among themselves.
s.execute("layer.new", &json!({})).unwrap();
let big = rect(&mut s, 0.0, 100.0, 50.0, 50.0);
select(&mut s, &[a, b, c, big]);
s.execute("addon.arrange.sort", &json!({"order": "descending"})).unwrap();
assert_eq!(layer_order(&s), [a, c, b]);
let doc = &s.doc().unwrap().doc;
assert_ne!(doc.layer_of(big), doc.layer_of(a), "left in its own layer");
}
#[test]
fn sort_by_stacking_and_opacity() {
let mut s = session();
let (a, b, c) = squares(&mut s);
// Back to front a, b, c; reversed: c, b, a take the left edges 0, 100, 200.
s.execute("addon.arrange.sort", &json!({"by": "stacking", "apply": "x", "order": "descending"})).unwrap();
assert_eq!((bounds(&s, c).x0, bounds(&s, b).x0, bounds(&s, a).x0), (0.0, 100.0, 200.0));
for (id, o) in [(a, 20), (b, 80), (c, 50)] {
s.execute("object.setProps", &json!({"ids": [id.0], "opacity": o})).unwrap();
}
select(&mut s, &[a, b, c]);
let opacity = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().opacity;
// By x, large to small: a (x 200) gets the least opacity, then b, then c.
s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "opacity", "order": "descending"})).unwrap();
assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (0.2, 0.5, 0.8));
// By opacity into the stacking order, large to small: the most opaque at the back.
s.execute("addon.arrange.sort", &json!({"by": "opacity", "order": "descending"})).unwrap();
assert_eq!(layer_order(&s), [c, b, a]);
}
#[test]
fn sort_by_area_into_areas_scales_in_proportion() {
let mut s = session();
let (a, b, c) = squares(&mut s);
// Areas 900, 100, 400 by x: a (x 0) 100, b 500, c 900.
s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "area", "distribute": true})).unwrap();
let side = |s: &Session, id| bounds(s, id).width();
assert!((side(&s, a) - 10.0).abs() < 1e-6 && (side(&s, b) - 500f64.sqrt()).abs() < 1e-6 && (side(&s, c) - 30.0).abs() < 1e-6);
assert!((bounds(&s, b).height() - 500f64.sqrt()).abs() < 1e-6, "in proportion");
}
#[test]
fn sort_in_random_order_repeats_with_a_seed() {
let mut s = session();
let ids: Vec<NodeId> = (0..12).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect();
select(&mut s, &ids);
s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap();
let first = layer_order(&s);
assert_ne!(first, ids, "shuffled");
let mut sorted = first.clone();
sorted.sort_by_key(|i| i.0);
assert_eq!(sorted, ids, "the same objects");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(layer_order(&s), ids);
s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap();
assert_eq!(layer_order(&s), first, "the same seed, the same order");
}
#[test]
fn sort_takes_ids_and_refuses_bad_params() {
let mut s = session();
let (a, b, c) = squares(&mut s);
select(&mut s, &[a]);
assert!(!enabled(&s, "addon.arrange.sort"));
s.execute("addon.arrange.sort", &json!({"ids": [b.0, a.0], "apply": "x"})).unwrap();
assert_eq!((bounds(&s, b).x0, bounds(&s, a).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0), "c is left alone");
select(&mut s, &[a, b, c]);
refuses(
&mut s,
"addon.arrange.sort",
&[
json!({"by": "color"}),
json!({"apply": 3}),
json!({"order": "up"}),
json!({"distribute": "yes"}),
json!({"seed": -1}),
json!({"seed": 0.5}),
json!({"scaleStrokes": 1}),
json!({"ids": "x"}),
json!({"ids": [999_999]}),
],
);
}
+24 -11
View File
@@ -37,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Simplify Anchors (Keep Shape)…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay → {ids, before, after} anchor counts",
"{tolerance?: 1..100 (20; higher removes more anchors and lets the outline move further from where it was, up to tolerance × 0.02 pt), protectCorners?: bool (false; anchors where the path turns by more than 30° stay), ids?: [id…] (default: the selection)} remove the anchors each path under the objects can do without, refitting the handles beside each so the outline keeps its shape, as one undo step; open ends stay, and locked and hidden paths are left out → {ids, before, after} anchor counts",
has_selection,
simplify
),
@@ -73,7 +73,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Deep Ungroup…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in → {ids (the objects left, selected), ungrouped (groups)}",
"{includeClipGroups?: bool (false: clipping groups, and the art inside them, stay as they are; true: they are ungrouped too and their clipping paths become plain paths), ids?: [id…] (default: the selection)} ungroup every level of the groups among the objects, as one undo step; each object keeps the opacity of the groups it was in; locked and hidden groups stay whole, and locked and hidden objects stay locked or hidden and are not selected → {ids (the objects selected: those left, or with no group to take apart the selection as it was), ungrouped (groups)}",
has_selection,
deep_ungroup
),
@@ -91,7 +91,8 @@ pub fn specs() -> Vec<CommandSpec> {
v
}
/// The paths (not guides) under `roots`, compound path members included.
/// The paths (not guides) under `roots`, compound path members included; locked and hidden
/// paths, and those in locked or hidden groups, stay out.
fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
for n in roots.iter().filter_map(|id| d.node(*id)) {
@@ -101,6 +102,8 @@ fn paths_under(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
}
});
}
let editable = d.editable();
out.retain(|id| editable(*id));
out
}
@@ -256,9 +259,9 @@ fn ungroupable(n: &Node, clips: bool) -> bool {
/// `n` with every level of grouping Deep Ungroup takes apart removed, into `out`; `count` counts
/// the groups. Members take on the opacity of the groups they were in, and a clipping path
/// becomes a plain path.
/// becomes a plain path. A locked or hidden group stays whole.
fn flatten(n: Node, clips: bool, out: &mut Vec<Node>, count: &mut usize) {
if !ungroupable(&n, clips) {
if !ungroupable(&n, clips) || n.locked || !n.visible {
out.push(n);
return;
}
@@ -282,6 +285,11 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
let clips = switch_or(p, "includeClipGroups", false, C)?;
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Locked and hidden objects, and those in locked or hidden groups, stay as they are.
let roots: Vec<NodeId> = {
let editable = d.editable();
roots.into_iter().filter(|id| editable(*id)).collect()
};
let (mut items, mut selected, mut count) = (vec![], vec![], 0);
for id in &roots {
match d.node(*id).filter(|n| ungroupable(n, clips)) {
@@ -294,13 +302,18 @@ fn deep_ungroup(s: &mut Session, p: &Value) -> Result<Value> {
None => selected.push(*id),
}
}
// With no group to take apart, the document and its undo history stay as they are.
if !items.is_empty() {
let ids = selected.clone();
s.edit("Deep Ungroup", |d, sel| {
// With no group to take apart, the document, the selection and the undo history stay as they
// are, and the reply lists the selection.
if items.is_empty() {
selected = s.doc()?.selection.objects.to_vec();
} else {
selected = s.edit("Deep Ungroup", move |d, sel| {
d.insert_beside(items, true)?;
sel.set(ids);
Ok(())
// Members that are locked or hidden themselves keep that, and are not selected.
let editable = d.editable();
selected.retain(|id| editable(*id));
sel.set(selected.iter().copied());
Ok(selected)
})?;
}
Ok(json!({ "ids": selected.iter().map(|i| i.0).collect::<Vec<_>>(), "ungrouped": count }))
+10 -3
View File
@@ -120,12 +120,12 @@ impl Kind {
let removable = !place.clips && !is_guide_or_clip(n);
let gone = |is: bool| is.then(Vec::new);
match self {
Kind::EmptyText => gone(matches!(&n.kind, NodeKind::Text(t) if t.plain_text().trim().is_empty())),
Kind::EmptyText => gone(removable && matches!(&n.kind, NodeKind::Text(t) if t.plain_text().trim().is_empty())),
Kind::StrayPoints => gone(removable && matches!(&n.kind, NodeKind::Path { path, .. } if path.anchor_count() <= 1)),
Kind::Unpainted => gone(removable && !place.in_compound && is_shape(n) && !is_painted(&n.appearance)),
Kind::ZeroOpacity => gone(removable && !place.in_compound && n.opacity <= 0.0),
Kind::DuplicateObjects => gone(removable && duplicates_the_one_below(cx.d, n)),
Kind::OffArtboard => gone(place.top && !is_guide_or_clip(n) && off_artboards(cx, n)),
Kind::OffArtboard => gone(place.top && removable && off_artboards(cx, n)),
Kind::NeedlessGroups => needless_group(n),
Kind::NeedlessCompounds => needless_compound(n),
Kind::NeedlessClipGroups => needless_clip_group(cx, n),
@@ -175,7 +175,9 @@ fn objects<'a>(d: &'a Document, scope: &Scope) -> Vec<(&'a Node, Place)> {
let top = Place { top: true, ..Place::default() };
match scope {
Scope::Document => d.layers.iter().for_each(|l| visit(l, top, &mut out)),
Scope::Objects(ids) => ids.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, top, &mut out)),
Scope::Objects(ids) => {
ids.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, Place { clips: is_clipping_path(d, n.id), ..top }, &mut out))
}
}
let editable = d.editable();
out.retain(|(n, _)| editable(n.id));
@@ -202,6 +204,11 @@ fn visit<'a>(n: &'a Node, place: Place, out: &mut Vec<(&'a Node, Place)>) {
}
}
/// Is `id` the clipping path of its group or layer?
fn is_clipping_path(d: &Document, id: NodeId) -> bool {
d.position(id).is_some_and(|(parent, i, _)| i == 0 && parent.and_then(|p| d.node(p)).is_some_and(Node::clips))
}
/// A group that stands for live or generated art: its members aren't loose art to clean.
fn is_generated(n: &Node) -> bool {
let e = n.extras();
@@ -104,6 +104,39 @@ fn simplify_keeps_the_shape_with_fewer_anchors_in_one_step() {
}
}
#[test]
fn simplify_leaves_locked_and_hidden_paths_alone() {
let mut s = session();
let mut ids = vec![];
for x in [20.0, 140.0, 260.0] {
ids.push(id_of(&s.execute("shape.ellipse", &json!({"x": x, "y": 50, "width": 100, "height": 100})).unwrap()));
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
s.execute("object.path.addAnchorPoints", &json!({})).unwrap();
}
select(&mut s, &ids);
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
change(&mut s, ids[1], |n| n.locked = true);
change(&mut s, ids[2], |n| n.visible = false);
select(&mut s, &[g]);
let r = s.execute("addon.cleanup.simplify", &json!({})).unwrap();
assert_eq!(r["ids"], json!([ids[0].0]));
assert_eq!(r["before"], 16);
assert!(anchors(&s, ids[0]) < 16);
assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16));
// Named by `ids`, or in a locked group, a path stays too, with no undo step.
let steps = undo_steps(&s);
s.execute("addon.cleanup.simplify", &json!({"ids": [ids[1].0, ids[2].0]})).unwrap();
assert_eq!((anchors(&s, ids[1]), anchors(&s, ids[2])), (16, 16));
assert_eq!(undo_steps(&s), steps);
change(&mut s, ids[1], |n| n.locked = false);
change(&mut s, g, |n| n.locked = true);
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.simplify", &json!({"ids": [g.0]})).unwrap();
assert_eq!((r["before"].as_u64(), r["after"].as_u64()), (Some(0), Some(0)));
assert_eq!(anchors(&s, ids[1]), 16);
assert_eq!(undo_steps(&s), steps);
}
/// Each kind: what makes the issue in a fresh document (beside a clean rectangle).
fn make_issue(s: &mut Session, kind: &str) {
match kind {
@@ -226,6 +259,49 @@ fn each_check_finds_and_fixes_its_issue_and_leaves_clean_art_alone() {
}
}
/// The pixel at (100, 100) of the document drawn on white.
fn pixel(s: &Session) -> [u8; 4] {
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 400, 300, vectorcraft_geom::Affine::IDENTITY, &opts).pixel(100, 100)
}
/// A clipping path hides the art outside it: empty type that clips a group, and type off every
/// artboard that clips a layer, stay, and the art stays hidden.
#[test]
fn clipping_type_stays_with_the_art_it_hides() {
const WHITE: [u8; 4] = [255, 255, 255, 255];
let mut s = session();
let art = rect(&mut s, 50.0, 50.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let t = id_of(&s.execute("text.create", &json!({"x": 60, "y": 80, "text": " ", "size": 40})).unwrap());
select(&mut s, &[art, t]);
s.execute("object.clippingMask.make", &json!({})).unwrap();
select(&mut s, &[]);
assert_eq!(pixel(&s), WHITE);
assert_eq!(found(&mut s, "emptyText", "document"), 0);
assert_eq!(s.execute("addon.cleanup.fix", &json!({"scope": "document"})).unwrap()["total"], 0);
select(&mut s, &[t]);
assert_eq!(found(&mut s, "emptyText", "selection"), 0, "the clipping path selected");
assert_eq!(s.execute("addon.cleanup.fix", &json!({"scope": "selection"})).unwrap()["total"], 0);
assert!(node(&s, t).is_some());
assert_eq!(pixel(&s), WHITE);
let mut s = session();
rect(&mut s, 50.0, 50.0, 100.0, 100.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
let t = id_of(&s.execute("text.create", &json!({"x": -300, "y": -300, "text": "Clip", "size": 40})).unwrap());
s.execute("layer.clippingMask.toggle", &json!({})).unwrap();
select(&mut s, &[]);
assert_eq!(pixel(&s), WHITE);
assert_eq!(found(&mut s, "offArtboard", "document"), 0);
assert_eq!(s.execute("addon.cleanup.fix", &json!({"scope": "document"})).unwrap()["total"], 0);
select(&mut s, &[t]);
assert_eq!(found(&mut s, "offArtboard", "selection"), 0, "the clipping path selected");
assert_eq!(s.execute("addon.cleanup.fix", &json!({"scope": "selection"})).unwrap()["total"], 0);
assert!(node(&s, t).is_some());
assert_eq!(pixel(&s), WHITE);
}
/// 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]
@@ -333,6 +409,51 @@ fn deep_ungroup_takes_apart_every_level_but_clipping_groups() {
assert!(matches!(d.node(clip).unwrap().kind, NodeKind::Path { clipping: false, .. }));
}
#[test]
fn deep_ungroup_keeps_hidden_art_hidden_and_locked_art_locked() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 40.0, 10.0, 20.0, 20.0);
let c = rect(&mut s, 70.0, 10.0, 20.0, 20.0);
let e = rect(&mut s, 100.0, 10.0, 20.0, 20.0);
select(&mut s, &[b]);
let hidden = id_of(&s.execute("object.group", &json!({})).unwrap());
select(&mut s, &[c]);
let locked = id_of(&s.execute("object.group", &json!({})).unwrap());
change(&mut s, hidden, |n| n.visible = false);
change(&mut s, locked, |n| n.locked = true);
change(&mut s, e, |n| n.locked = true);
s.edit("t", |d, _| {
let w = d.alloc_id();
Ok(d.wrap(&[a, hidden, locked, e], |ch| Node::group(w, ch))?)
})
.unwrap();
let outer = s.doc().unwrap().doc.layers[0].children().unwrap()[0].id;
select(&mut s, &[outer]);
let r = s.execute("addon.cleanup.deepUngroup", &json!({})).unwrap();
assert_eq!(r["ungrouped"], 1);
assert_eq!(r["ids"], json!([a.0]));
let d = &s.doc().unwrap().doc;
let layer: Vec<NodeId> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect();
assert_eq!(layer, [a, hidden, locked, e], "the hidden and locked groups stay whole");
assert!(!d.node(hidden).unwrap().visible && d.node(b).unwrap().visible);
assert!(d.node(locked).unwrap().locked && d.node(e).unwrap().locked);
assert_eq!(s.doc().unwrap().selection.objects, [a]);
// Named by `ids`, a locked group stays as it is, with no undo step.
let steps = undo_steps(&s);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [locked.0]})).unwrap();
assert_eq!(r["ungrouped"], 0);
assert!(node(&s, locked).is_some());
assert_eq!(undo_steps(&s), steps);
// With nothing ungrouped, the selection stays and the reply lists it.
assert_eq!(r["ids"], json!([a.0]));
let f = rect(&mut s, 130.0, 10.0, 20.0, 20.0);
select(&mut s, &[a]);
let r = s.execute("addon.cleanup.deepUngroup", &json!({"ids": [f.0]})).unwrap();
assert_eq!((r["ids"].clone(), r["ungrouped"].clone()), (json!([a.0]), json!(0)));
assert_eq!(s.doc().unwrap().selection.objects, [a]);
}
#[test]
fn rejoin_joins_pieces_within_the_gap_only() {
let mut s = session();
+14 -5
View File
@@ -12,6 +12,7 @@ use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, Para
use vectorcraft_geom::{Affine, Point, Vec2};
use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit};
use crate::cmd::fonts::{edit_symbol_texts, symbol_texts};
use crate::cmd::textedit::editing_range;
use crate::cmd::typecmd::refresh_bounds;
use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param};
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Replace Missing Fonts…",
["Object", "Plug-ins", "Artwork Cleanup"],
None,
"{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}",
"{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type (the type in the art of its symbols included) whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}",
has_doc,
replace_fonts
),
@@ -918,24 +919,25 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(C, "the replacement font is not available")),
};
let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing);
let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style));
let d = &s.doc()?.doc;
let mut ids = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind
&& t.runs.iter().any(|r| missing(&r.style))
&& lacks(t)
{
ids.push(n.id);
}
});
}
if ids.is_empty() {
// The type in the symbols' art too, which exports name when its font is missing.
if ids.is_empty() && !symbol_texts(d, None).into_iter().any(|(t, _)| lacks(t)) {
return Err(EngineError::Other("No fonts are missing".into()));
}
let runs = s.edit("Replace Missing Fonts", |d, _| {
let mut runs = 0;
for id in &ids {
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
let mut replace = |t: &mut TextObject| {
for r in t.runs.iter_mut().filter(|r| missing(&r.style)) {
// The style the font has closest to the one the character had.
r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone());
@@ -946,7 +948,14 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
}
edit::normalize(&mut t.runs);
refresh_bounds(t);
};
for id in &ids {
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
replace(t);
}
}
edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace);
drop(replace);
Ok(runs)
})?;
Ok(json!({ "runs": runs, "font": family }))
@@ -339,3 +339,25 @@ fn replace_missing_fonts_with_the_default_or_a_chosen_font() {
let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "No Such Font Family"}));
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
}
#[test]
fn replace_missing_fonts_reaches_the_type_in_symbols() {
let mut s = session();
let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family"}));
s.execute("symbol.new", &json!({"name": "Mark", "ids": [gone.0]})).unwrap();
let family = |s: &Session| crate::cmd::fonts::symbol_texts(&s.doc().unwrap().doc, None)[0].0.runs[0].style.font_family.clone();
assert_eq!(family(&s), "No Such Font Family");
let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap();
assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3")), "{r}");
assert_eq!(family(&s), "Source Sans 3");
assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None);
assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing");
// A symbol being edited: its type on the editing layer is replaced once.
let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family"}));
s.execute("symbol.new", &json!({"name": "Mark 2", "ids": [gone.0]})).unwrap();
s.execute("symbol.edit", &json!({"name": "Mark 2"})).unwrap();
let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap();
assert_eq!(r["runs"].as_u64(), Some(1), "{r}");
s.execute("symbol.exitEditing", &json!({})).unwrap();
assert_eq!(crate::cmd::fonts::substitution_warning(&s.doc().unwrap().doc), None);
}
+27 -2
View File
@@ -2,9 +2,13 @@
//! Duotone and Invert Colors, in Effect › Color Effects) are the effects crate's; here, Apply as
//! Filter writes any colour adjustment into the colours themselves, Select by Color Kind selects the
//! objects that use spot colours, carry a live colour adjustment, or are images of a colour model,
//! and Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print. Its panel and
//! its submenu, Object › Plug-ins › Color Effects, are the UI's.
//! Print Ink Fixes ([`print_fixes`]) prepares a CMYK document's inks for print, Find Nearest
//! Swatch ([`nearest`]) matches colours to a swatch library's, and Create Swatch Legend
//! ([`legend`]) draws labelled chips of the document's swatches. Its panel and its submenu, Object ›
//! Plug-ins › Color Effects, are the UI's.
pub mod legend;
mod nearest;
mod print_fixes;
#[cfg(test)]
mod tests;
@@ -50,7 +54,28 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
print_fixes::run
),
cmd!(
query "addon.color.nearestSwatch",
"Find Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: a swatch library's id or name (swatch.library.list: the built-in ones, and the colour books and libraries the user saved or loaded) or \"document\" (the document's swatches; the default), color?: colour (look this colour up; default: the selection's), fill?: true, stroke?: true (the selected objects' fills and strokes, their type's and those of the objects inside them: solid colours and gradient stops, once each, the first 100), count?: 1..50 (5), ids?: [id…] (default: the selection)} list the library's colour swatches closest to each colour, closest first, by CIEDE2000 difference (ΔE; under 1 hardly tells apart, about 2 to 3 close). Tint swatches, None and Registration aren't matched → {library, colors: [{values, hex, matches: [{name, values, hex, spot, deltaE}]}]}",
has_doc,
nearest::find
),
cmd!(
"addon.color.applyNearestSwatch",
"Apply Nearest Swatch",
["Object", "Plug-ins", "Color Effects"],
None,
"{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}",
has_selection,
nearest::apply
),
]
.into_iter()
.chain(legend::specs())
.collect()
}
fn select(s: &mut Session, p: &Value) -> Result<Value> {
@@ -0,0 +1,183 @@
//! Create Swatch Legend (`addon.color.swatchLegend`; Object › Plug-ins › Color Effects › Create
//! Swatch Legend…, #4919): a grid of chips on the artboard, one per colour swatch, each filled with
//! its swatch and labelled with the swatch's name and colour values, as one group made in one undo
//! step.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint, cms::Model};
use vectorcraft_doc::{Appearance, AreaFit, Node, NodeKind, TextKind, TextObject};
use vectorcraft_geom::{Point, Rect, shapes};
use super::super::super::{CommandSpec, bad, cmd, has_doc};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"addon.color.swatchLegend",
"Create Swatch Legend",
["Object", "Plug-ins", "Color Effects"],
None,
"{names?: [swatch name…] (default: every colour swatch of the document, in panel order), columns?: 1..50 (4), size?: chip side in pt 8..1000 (72), gap?: pt 0..1000 (12), values?: [hex|rgb|cmyk|lab|gray…] (each at most once; default: each swatch's own colour model; spot swatches also say spot), x?, y? (the legend's top-left; default: the first artboard's, inset by `gap`)} draw a legend of colour swatches: one square chip per swatch filled with it (linked to it), labelled inside with the swatch name and the values asked for, in black on light chips and white on dark ones, as one group in one undo step; the group is selected → {id, count} (#4919)",
has_doc,
legend
)]
}
/// The command.
pub const COMMAND: &str = "addon.color.swatchLegend";
/// Most swatches one legend draws.
const MAX_CHIPS: usize = 2000;
const VALUES: [&str; 5] = ["hex", "rgb", "cmyk", "lab", "gray"];
fn number(p: &Value, key: &str, default: f64, range: std::ops::RangeInclusive<f64>, cmd: &str) -> Result<f64> {
match p.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => v
.as_f64()
.filter(|n| n.is_finite() && range.contains(n))
.ok_or_else(|| bad(cmd, format!("`{key}` must be a number {}..{}", range.start(), range.end()))),
}
}
/// The label lines of colour `c`: the values `values` names, else those of its own model.
fn value_lines(c: Color, values: &[String], spot: bool) -> Vec<String> {
let own = match c.model() {
Model::Rgb => "rgb",
Model::Cmyk => "cmyk",
Model::Gray => "gray",
Model::Lab => "lab",
};
let picked: Vec<&str> = if values.is_empty() { vec![own] } else { values.iter().map(String::as_str).collect() };
let pct = |v: f32| (v * 100.0).round() as i32;
let mut lines: Vec<String> = picked
.into_iter()
.map(|v| match v {
"hex" => c.to_hex().to_uppercase(),
"rgb" => {
let [r, g, b] = c.to_rgb().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as i32);
format!("R={r} G={g} B={b}")
}
"cmyk" => {
let [cy, m, y, k] = c.to_cmyk().map(|v| pct(v.clamp(0.0, 1.0)));
format!("C={cy} M={m} Y={y} K={k}")
}
"lab" => {
let l = c.to_lab();
format!("L={} a={} b={}", l.l.round() as i32, l.a.round() as i32, l.b.round() as i32)
}
_ => {
// As Edit Colors › Convert to Grayscale and the Color panel convert it.
let k = match c.in_model(Model::Gray) {
Color::Gray { k } => k,
_ => 1.0 - c.to_lab().l / 100.0,
};
format!("K={}%", pct(k.clamp(0.0, 1.0)))
}
})
.collect();
if spot {
lines.insert(0, "Spot".into());
}
lines
}
fn legend(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = COMMAND;
let columns = number(p, "columns", 4.0, 1.0..=50.0, C)?;
if columns.fract() != 0.0 {
return Err(bad(C, "`columns` must be a whole number 1..50"));
}
let columns = columns as usize;
let size = number(p, "size", 72.0, 8.0..=1000.0, C)?;
let gap = number(p, "gap", 12.0, 0.0..=1000.0, C)?;
let values: Vec<String> = match p.get("values") {
None | Some(Value::Null) => vec![],
Some(Value::Array(a)) => a
.iter()
.map(|v| {
v.as_str().filter(|v| VALUES.contains(v)).map(str::to_string).ok_or_else(|| bad(C, "`values` must name hex, rgb, cmyk, lab or gray"))
})
.collect::<Result<_>>()?,
Some(_) => return Err(bad(C, "`values` must be a list")),
};
// Each kind once: every value line goes into every chip, so repeats would only multiply them.
if values.iter().enumerate().any(|(i, v)| values[..i].contains(v)) {
return Err(bad(C, "`values` names a kind twice"));
}
let st = s.doc()?;
let doc = &st.doc;
let colour = |w: &&vectorcraft_color::Swatch| w.paint.color().is_some();
let swatches: Vec<vectorcraft_color::Swatch> = match p.get("names") {
None | Some(Value::Null) => doc.swatches_iter().filter(colour).cloned().collect(),
Some(Value::Array(a)) => {
let mut out = vec![];
for n in a {
let n = n.as_str().ok_or_else(|| bad(C, "`names` must be a list of swatch names"))?;
let w = doc.swatches_iter().find(|w| w.name == n).ok_or_else(|| bad(C, format!("no swatch named `{n}`")))?;
if w.paint.color().is_none() {
return Err(bad(C, format!("`{n}` is not a colour swatch")));
}
out.push(w.clone());
}
out
}
Some(_) => return Err(bad(C, "`names` must be a list of swatch names")),
};
if swatches.is_empty() {
return Err(bad(C, "there are no colour swatches to draw"));
}
if swatches.len() > MAX_CHIPS {
return Err(bad(C, format!("a legend draws at most {MAX_CHIPS} swatches")));
}
let origin = doc.artboards.first().map_or(Point::ZERO, |a| a.rect.origin());
let x0 = number(p, "x", origin.x + gap, -1e6..=1e6, C)?;
let y0 = number(p, "y", origin.y + gap, -1e6..=1e6, C)?;
let system_layout = doc.setup.system_layout;
let parent = st.target_parent()?;
let text_size = (size / 7.0).clamp(4.0, 12.0);
let pad = (size / 12.0).clamp(1.0, 8.0);
let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }));
let count = swatches.len();
// Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or
// spot swatch then recolours the legend too.
let fills: Vec<Paint> =
swatches.iter().map(|w| super::super::super::paint::swatch_solid(doc, &w.name, None).unwrap_or_else(|_| w.paint.clone())).collect();
let gid = s.edit("Create Swatch Legend", |d, sel| {
let mut children = Vec::with_capacity(count * 2);
for (i, (w, fill)) in swatches.iter().zip(fills).enumerate() {
let Some(c) = w.paint.color() else { continue };
let (col, row) = (i % columns, i / columns);
let (x, y) = (x0 + col as f64 * (size + gap), y0 + row as f64 * (size + gap));
let mut chip =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + size, y + size)), Appearance::basic(fill, Paint::None, 0.0));
chip.name = Some(w.name.clone());
children.push(std::sync::Arc::new(chip));
let mut lines = vec![w.name.clone()];
lines.extend(value_lines(c, &values, w.spot));
let mut label_style = style.clone();
label_style.fill = Paint::solid(if c.to_lab().l < 50.0 { Color::WHITE } else { Color::BLACK });
label_style.stroke = Paint::None;
// Area type the size of the chip (less its inset) that shrinks text to fit, so a long
// name or value line stays on its chip.
let mut t = TextObject::point(Point::new(x + pad, y + pad), &lines.join("\n"), label_style);
let inner = (size - 2.0 * pad).max(1.0);
t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, inner, inner)) };
t.area.fit = AreaFit::ShrinkText { min_percent: AreaFit::MIN_PERCENT };
t.para = super::super::super::create::new_type_para();
t.system_layout = system_layout;
super::super::super::typecmd::refresh_bounds(&mut t);
children.push(std::sync::Arc::new(Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)))));
}
let gid = d.alloc_id();
let mut g = Node::new(gid, NodeKind::Group { children, clip: false });
g.name = Some("Swatch Legend".into());
d.insert(parent, usize::MAX, g)?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0, "count": count }))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,118 @@
use serde_json::json;
use vectorcraft_color::Color;
use vectorcraft_doc::{Node, NodeId, NodeKind};
use crate::Session;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
s
}
fn group(s: &Session, id: u64) -> Node {
s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone()
}
/// The text of each label in group `g`, in order.
fn labels(g: &Node) -> Vec<(String, Color)> {
g.children()
.unwrap()
.iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some((t.plain_text(), t.runs[0].style.fill.color().unwrap())),
_ => None,
})
.collect()
}
#[test]
fn a_legend_draws_a_labelled_chip_per_colour_swatch_in_one_undo_step() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Ink", "color": "#101010"})).unwrap();
s.execute("swatch.new", &json!({"name": "Paper", "color": "#f8f4e8"})).unwrap();
let colours = s.doc().unwrap().doc.swatches_iter().filter(|w| w.paint.color().is_some()).count();
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("addon.color.swatchLegend", &json!({"columns": 3, "size": 60, "gap": 10})).unwrap();
assert_eq!(r["count"].as_u64().unwrap() as usize, colours);
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(r["id"].as_u64().unwrap())], "the legend is selected");
let g = group(&s, r["id"].as_u64().unwrap());
assert_eq!(g.name.as_deref(), Some("Swatch Legend"));
let kids = g.children().unwrap();
assert_eq!(kids.len(), 2 * colours, "a chip and a label each");
// Chips fill with their swatch, three to a row, 70 pt apart.
let chips: Vec<&Node> = kids.iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| &**n).collect();
let doc = &s.doc().unwrap().doc;
for (chip, w) in chips.iter().zip(doc.swatches_iter().filter(|w| w.paint.color().is_some())) {
assert_eq!(chip.appearance.fill_paint().color(), w.paint.color(), "{}", w.name);
assert_eq!(chip.name.as_deref(), Some(w.name.as_str()));
}
let b = |i: usize| chips[i].geometric_bounds().unwrap();
assert!((b(1).x0 - b(0).x0 - 70.0).abs() < 1e-6 && (b(3).y0 - b(0).y0 - 70.0).abs() < 1e-6 && (b(3).x0 - b(0).x0).abs() < 1e-6);
assert!((b(0).width() - 60.0).abs() < 1e-6);
// Labels: the name, then the swatch's own values; white on the dark chip, black on the light.
let l = labels(&g);
let ink = l.iter().find(|(t, _)| t.starts_with("Ink")).unwrap();
let paper = l.iter().find(|(t, _)| t.starts_with("Paper")).unwrap();
assert!(ink.0.contains("R=16 G=16 B=16"), "{}", ink.0);
assert_eq!((ink.1, paper.1), (Color::WHITE, Color::BLACK));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(g.id).is_none());
}
#[test]
fn a_legend_takes_names_values_and_a_position() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "Signal", "color": "cmyk 0 100 100 0", "spot": true})).unwrap();
let r =
s.execute("addon.color.swatchLegend", &json!({"names": ["Signal"], "values": ["hex", "cmyk", "lab", "gray"], "x": 100, "y": 50})).unwrap();
assert_eq!(r["count"], 1);
let g = group(&s, r["id"].as_u64().unwrap());
let (text, _) = labels(&g).remove(0);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "Signal");
assert_eq!(lines[1], "Spot", "a spot swatch says so");
assert!(lines[2].starts_with('#') && lines[3] == "C=0 M=100 Y=100 K=0" && lines[4].starts_with("L=") && lines[5].starts_with("K="), "{lines:?}");
let chip = g.children().unwrap().iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap().geometric_bounds().unwrap();
assert!((chip.x0 - 100.0).abs() < 1e-6 && (chip.y0 - 50.0).abs() < 1e-6);
// Refused, with nothing drawn.
let before = s.doc().unwrap().history.undo.len();
for bad in [
json!({"names": ["Nope"]}),
json!({"names": "Signal"}),
json!({"values": ["xyz"]}),
json!({"columns": 0}),
json!({"columns": 2.5}),
json!({"size": 2}),
json!({"gap": -1}),
json!({"x": f64::MAX}),
json!({"names": []}),
json!({"values": ["rgb", "rgb"]}),
] {
assert!(s.execute("addon.color.swatchLegend", &bad).is_err(), "{bad}");
}
assert_eq!(s.doc().unwrap().history.undo.len(), before);
}
/// A chip of a global swatch is linked to it: editing the swatch recolours the chip. Each label is
/// area type inside its chip that shrinks a long name to fit (#4919 review).
#[test]
fn chips_stay_linked_and_labels_stay_on_their_chips() {
let mut s = session();
s.execute("swatch.new", &json!({"name": "A Rather Long Brand Colour Name", "color": "#2050a0", "global": true})).unwrap();
let r = s
.execute("addon.color.swatchLegend", &json!({"names": ["A Rather Long Brand Colour Name"], "size": 40, "values": ["hex", "rgb", "cmyk"]}))
.unwrap();
let id = r["id"].as_u64().unwrap();
s.execute("swatch.edit", &json!({"name": "A Rather Long Brand Colour Name", "color": "#a02050"})).unwrap();
let g = group(&s, id);
let kids = g.children().unwrap();
let chip = kids.iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap();
assert_eq!(chip.appearance.fill_paint().color().map(|c| c.to_hex()), Some("#a02050".into()), "the edit reached the chip");
let label = kids.iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
let NodeKind::Text(t) = &label.kind else { panic!("not type") };
assert!(matches!(t.kind, vectorcraft_doc::TextKind::Area { .. }) && matches!(t.area.fit, vectorcraft_doc::AreaFit::ShrinkText { .. }));
let (c, l) = (chip.geometric_bounds().unwrap(), label.geometric_bounds().unwrap());
assert!(l.x0 >= c.x0 - 1e-6 && l.y0 >= c.y0 - 1e-6 && l.x1 <= c.x1 + 1e-6 && l.y1 <= c.y1 + 1e-6, "label {l:?} inside chip {c:?}");
}
@@ -0,0 +1,198 @@
//! Find Nearest Swatch (`addon.color.nearestSwatch`, `addon.color.applyNearestSwatch`; Object ›
//! Plug-ins › Color Effects › Find Nearest Swatch…): the swatches of a library, a colour book the
//! user loaded or the document closest to each colour of the selection (CIEDE2000), and the
//! colours replaced by them as one undo step (#4923).
use serde_json::{Value, json};
use vectorcraft_color::cms::{Lab, delta_e2000};
use vectorcraft_color::{Color, Paint, Swatch};
use vectorcraft_doc::swatches::color_name;
use vectorcraft_doc::{Document, NodeId, NodeKind};
use super::super::super::colorcmds::recolor_links;
use super::super::super::menucmds::squash;
use super::super::super::swatchlib::{DOCUMENT_SWATCHES, library_swatches};
use super::super::super::{bad, checked_ids_or_selection, color_value, index, switch_or, text_param};
use super::super::arrange::whole_in;
use crate::{Result, Session};
const FIND: &str = "addon.color.nearestSwatch";
const APPLY: &str = "addon.color.applyNearestSwatch";
/// The most colours of the selection looked up.
const MAX_COLORS: usize = 100;
/// The `library` param ([`DOCUMENT_SWATCHES`] when left out) and its colour swatches with their Lab
/// values.
fn candidates(s: &Session, p: &Value, cmd: &str) -> Result<(String, Vec<(Swatch, Lab)>)> {
let key = text_param(p, "library", cmd)?.unwrap_or(DOCUMENT_SWATCHES).to_string();
let swatches = library_swatches(s, &key).ok_or_else(|| bad(cmd, format!("no swatch library `{key}` (see swatch.library.list)")))?;
let labs: Vec<(Swatch, Lab)> = swatches
.into_iter()
.filter(|w| !w.is_reserved())
.filter_map(|w| {
let lab = w.paint.color()?.to_lab();
Some((w, lab))
})
.collect();
if labs.is_empty() {
return Err(bad(cmd, format!("`{key}` has no colours")));
}
Ok((key, labs))
}
/// The `count` swatches of `among` closest to `c`, closest first, with their difference (ΔE 2000).
fn nearest(among: &[(Swatch, Lab)], c: Color, count: usize) -> Vec<(&Swatch, f32)> {
let lab = c.to_lab();
let mut v: Vec<(&Swatch, f32)> = among.iter().map(|(w, l)| (w, delta_e2000(*l, lab))).collect();
v.sort_by(|a, b| a.1.total_cmp(&b.1));
v.truncate(count);
v
}
/// The solid colours and gradient stops of the fills (`fill`) and strokes (`stroke`) of `ids`, of
/// everything inside them and of their type, once each and at most [`MAX_COLORS`].
fn art_colors(d: &Document, ids: &[NodeId], fill: bool, stroke: bool) -> Vec<Color> {
let mut out: Vec<Color> = vec![];
let mut add = |p: &Paint| {
let colors: Vec<Color> = match p {
Paint::Solid { color, .. } => vec![*color],
Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(),
_ => vec![],
};
for c in colors {
if out.len() < MAX_COLORS && !out.contains(&c) {
out.push(c);
}
}
};
for n in ids.iter().filter_map(|id| d.node(*id)) {
n.walk(&mut |m| {
for it in &m.appearance.items {
if if it.is_fill() { fill } else { stroke } {
add(it.paint());
}
}
if let NodeKind::Text(t) = &m.kind {
for r in &t.runs {
if fill {
add(&r.style.fill);
}
if stroke {
add(&r.style.stroke);
}
}
}
});
}
out
}
/// A swatch's name and values as the results give them.
fn described(w: &Swatch) -> Value {
let c = w.paint.color().unwrap_or_default();
json!({ "name": w.name, "values": color_name(c), "hex": c.to_hex(), "spot": w.spot })
}
pub(super) fn find(s: &mut Session, p: &Value) -> Result<Value> {
let count = whole_in(p, "count", (1, 50), FIND)?.map_or(5, index);
let (fill, stroke) = (switch_or(p, "fill", true, FIND)?, switch_or(p, "stroke", true, FIND)?);
let colors = match p.get("color").filter(|v| !v.is_null()) {
Some(v) => vec![color_value(v).ok_or_else(|| bad(FIND, "`color` is not a colour"))?],
None => {
let ids = checked_ids_or_selection(s, p, FIND)?;
if ids.is_empty() {
return Err(bad(FIND, "select objects or give `color`"));
}
art_colors(&s.doc()?.doc, &ids, fill, stroke)
}
};
let (library, among) = candidates(s, p, FIND)?;
let found: Vec<Value> = colors
.iter()
.map(|c| {
let matches: Vec<Value> = nearest(&among, *c, count)
.into_iter()
.map(|(w, de)| {
let mut m = described(w);
m["deltaE"] = json!((f64::from(de) * 100.0).round() / 100.0);
m
})
.collect();
json!({ "values": color_name(*c), "hex": c.to_hex(), "matches": matches })
})
.collect();
Ok(json!({ "library": library, "colors": found }))
}
pub(super) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let (fill, stroke) = (switch_or(p, "fill", true, APPLY)?, switch_or(p, "stroke", true, APPLY)?);
let ids = checked_ids_or_selection(s, p, APPLY)?;
if ids.is_empty() {
return Err(bad(APPLY, "nothing selected"));
}
let (library, among) = candidates(s, p, APPLY)?;
// The swatch each colour takes, once each.
let colors = art_colors(&s.doc()?.doc, &ids, fill, stroke);
let mut used: Vec<&Swatch> = vec![];
for c in &colors {
if let Some((w, _)) = nearest(&among, *c, 1).first()
&& !used.iter().any(|u| u.name == w.name)
{
used.push(w);
}
}
if used.is_empty() {
return Ok(json!({ "changed": 0, "swatches": [], "added": [] }));
}
let undo = s.doc()?.history.undo.len();
// A library's swatches join the document's first, as Add to Swatches adds them, so spot and
// global colours stay linked to theirs.
let mut added = vec![];
let mut names: Vec<String> = used.iter().map(|w| w.name.clone()).collect();
if library != DOCUMENT_SWATCHES {
let r = s.execute("swatch.library.add", &json!({ "library": library, "names": names }))?;
let listed = |k: &str| r.get(k).and_then(Value::as_array).cloned().unwrap_or_default();
let in_doc: Vec<String> = listed("added").iter().chain(&listed("existing")).filter_map(Value::as_str).map(str::to_string).collect();
let d = &s.doc()?.doc;
// Each library swatch's copy: the document swatch with its paint among those added or kept.
names = used
.iter()
.map(|w| in_doc.iter().find(|n| d.swatch(n).is_some_and(|x| x.paint == w.paint)).cloned().unwrap_or_else(|| w.name.clone()))
.collect();
added = listed("added");
}
// Each used swatch's paint as art takes it: linked to a global or spot swatch.
let d = &s.doc()?.doc;
let paints: Vec<(Lab, Paint)> = used
.iter()
.zip(&names)
.filter_map(|(w, name)| {
let lab = w.paint.color()?.to_lab();
let paint = d.swatch(name).filter(|x| x.global).and_then(|_| d.tint_paint(name, 1.0)).unwrap_or_else(|| w.paint.clone());
Some((lab, paint))
})
.collect();
// shortcut: a colour past the MAX_COLORS looked up takes the closest of the swatches those
// took; look up every colour if documents with more colours need their own matches.
let to = |c: Color| -> Option<&Paint> {
let lab = c.to_lab();
paints.iter().min_by(|a, b| delta_e2000(a.0, lab).total_cmp(&delta_e2000(b.0, lab))).map(|(_, p)| p)
};
let links = |c: &mut Color, link: &mut Option<String>, tint: &mut f32| {
let Some(Paint::Solid { color, swatch, .. }) = to(*c) else { return false };
if *c == *color && *link == *swatch {
return false;
}
(*c, *link, *tint) = (*color, swatch.clone(), 1.0);
true
};
let given = json!({
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false,
});
let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?;
squash(s, undo, "Apply Nearest Swatch");
Ok(json!({ "changed": r["changed"], "swatches": names, "added": added }))
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,115 @@
//! Find Nearest Swatch.
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::NodeId;
use crate::Session;
const FIND: &str = "addon.color.nearestSwatch";
const APPLY: &str = "addon.color.applyNearestSwatch";
/// A document with a global swatch "Brand" and a selected rectangle filled `fill`, stroked `stroke`.
fn doc(fill: &str, stroke: Option<&str>) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
s.execute("swatch.new", &json!({"name": "Brand", "color": "#ff8000", "global": true})).unwrap();
let id = NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap());
s.execute("paint.setFill", &json!({"color": fill})).unwrap();
match stroke {
Some(c) => s.execute("paint.setStroke", &json!({"color": c})).unwrap(),
None => s.execute("paint.setStroke", &json!({"none": true})).unwrap(),
};
(s, id)
}
fn paints(s: &Session, id: NodeId) -> (Paint, Paint) {
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
(n.appearance.fill_paint(), n.appearance.items.iter().find(|i| !i.is_fill()).unwrap().paint().clone())
}
fn names(matches: &Value) -> Vec<&str> {
matches.as_array().unwrap().iter().map(|m| m["name"].as_str().unwrap()).collect()
}
#[test]
fn the_closest_swatches_of_a_library_are_listed_closest_first() {
let (mut s, _) = doc("#ff6634", None);
let v = s.execute(FIND, &json!({"library": "web-safe-216", "count": 3})).unwrap();
assert_eq!(v["library"], "web-safe-216");
let colors = v["colors"].as_array().unwrap();
assert_eq!(colors.len(), 1, "the fill; no stroke");
assert_eq!((colors[0]["hex"].as_str(), colors[0]["values"].as_str()), (Some("#ff6634"), Some("R=255 G=102 B=52")));
let m = &colors[0]["matches"];
assert_eq!(names(m)[0], "#FF6633");
let de: Vec<f64> = m.as_array().unwrap().iter().map(|x| x["deltaE"].as_f64().unwrap()).collect();
assert_eq!(de.len(), 3);
assert!(de[0] < 1.0 && de.windows(2).all(|w| w[0] <= w[1]), "{de:?}");
// A colour given is looked up instead of the selection's.
let v = s.execute(FIND, &json!({"library": "web-safe-216", "color": "#010203", "count": 1})).unwrap();
assert_eq!(names(&v["colors"][0]["matches"]), ["#000000"]);
}
#[test]
fn the_document_swatches_are_the_default_library() {
let (mut s, _) = doc("#ff8010", Some("#ff8010"));
let v = s.execute(FIND, &json!({})).unwrap();
assert_eq!(v["library"], "document");
assert_eq!(v["colors"].as_array().unwrap().len(), 1, "fill and stroke share a colour: looked up once");
assert_eq!(names(&v["colors"][0]["matches"])[0], "Brand");
assert!(!names(&v["colors"][0]["matches"]).contains(&"[Registration]"));
}
#[test]
fn applying_links_each_colour_to_its_nearest_document_swatch_as_one_undo_step() {
let (mut s, id) = doc("#ff8010", Some("#ff8010"));
let undo = s.doc().unwrap().history.undo.len();
let v = s.execute(APPLY, &json!({"stroke": false})).unwrap();
assert_eq!((v["changed"].as_u64(), v["swatches"].clone(), v["added"].clone()), (Some(1), json!(["Brand"]), json!([])));
let (fill, stroke) = paints(&s, id);
assert!(matches!(&fill, Paint::Solid { swatch: Some(n), color, .. } if n == "Brand" && color.to_hex() == "#ff8000"), "{fill:?}");
assert_eq!(stroke.color().unwrap().to_hex(), "#ff8010", "strokes left alone");
let st = s.doc().unwrap();
assert_eq!((st.history.undo.len(), st.history.undo.last().unwrap().label.as_str()), (undo + 1, "Apply Nearest Swatch"));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paints(&s, id).0.color().unwrap().to_hex(), "#ff8010");
}
#[test]
fn a_library_spot_colour_joins_the_document_and_stays_spot() {
let (mut s, id) = doc("#e01525", None);
// A colour book of one spot colour, loaded as a user would load one they own.
s.execute("swatch.new", &json!({"name": "Ink Red", "color": "#e01020", "spot": true})).unwrap();
let data = s.execute("swatch.library.save", &json!({"names": ["Ink Red"]})).unwrap()["data"].as_str().unwrap().to_string();
s.execute("swatch.delete", &json!({"names": ["Ink Red"]})).unwrap();
let lib = s.execute("swatch.library.load", &json!({"data": data, "name": "Book.vcswatches"})).unwrap()["library"].as_str().unwrap().to_string();
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
let v = s.execute(APPLY, &json!({"library": lib})).unwrap();
assert_eq!((v["swatches"].clone(), v["added"].clone()), (json!(["Ink Red"]), json!(["Ink Red"])));
let d = &s.doc().unwrap().doc;
assert!(d.swatch("Ink Red").is_some_and(|w| w.spot && w.global));
assert!(matches!(paints(&s, id).0, Paint::Solid { swatch: Some(n), .. } if n == "Ink Red"));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "adding and applying undo together");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.swatch("Ink Red").is_none());
assert_eq!(paints(&s, id).0, Paint::solid(Color::from_hex("#e01525").unwrap()));
}
#[test]
fn bad_requests_are_refused() {
let (mut s, _) = doc("#ff8010", None);
for (p, why) in [
(json!({"library": "nope"}), "no swatch library `nope`"),
(json!({"library": "spectrum-gradients"}), "has no colours"),
(json!({"count": 0}), "`count` must be from 1 to 50"),
(json!({"color": "zzz"}), "`color` is not a colour"),
(json!({"fill": "yes"}), "`fill` must be true or false"),
] {
let e = s.execute(FIND, &p).unwrap_err().to_string();
assert!(e.contains(why), "{p}: {e}");
}
s.execute("select.none", &json!({})).unwrap();
assert!(s.execute(FIND, &json!({})).unwrap_err().to_string().contains("select objects or give `color`"));
assert!(s.execute(APPLY, &json!({})).is_err(), "nothing selected");
}
+65 -1
View File
@@ -1,6 +1,7 @@
//! Document Tools (`addon.document.*`), a built-in add-on: Save a Timestamped Copy writes a copy of
//! the document named with the date and time through File › Save a Copy's save; New Symbols from
//! Layers makes a symbol of each layer's art as New Symbol does. Its submenu (Object › Plug-ins ›
//! Layers makes a symbol of each layer's art as New Symbol does; Export All Open Documents writes a
//! copy of every open document to one folder as Save a Copy does (#4920). Its submenu (Object › Plug-ins ›
//! Document Tools) is the UI's.
#[cfg(test)]
@@ -30,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_clock,
save_timestamped_copy
),
cmd!(
"addon.document.exportAllOpen",
"Export All Open Documents",
["Object", "Plug-ins", "Document Tools"],
None,
"{folder, format?: vectorcraft|pdf|svg|svgz|ai|eps (default: pdf)} write a copy of every open document, in tab order, to `folder` as File › Save a Copy writes it, each named after its document (<name>.<ext>; -2, -3… added when that name is taken, so no file is replaced), one self-contained file each with all of its art (images embedded or included, every artboard; no remembered save options apply): every document keeps its path, title and modified state, and no undo step is made. A document that can't be written is listed in `failed` and the others are still written; the active document stays active → {files: [{title, path, warnings}], failed: [{title, error}], format}",
has_doc,
export_all_open
),
cmd!(
"addon.document.symbolsFromLayers",
"New Symbols from Layers",
@@ -88,6 +98,60 @@ fn save_timestamped_copy(s: &mut Session, p: &Value) -> Result<Value> {
fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id }))
}
/// The save options of every copy, given in full so that no document's remembered options (linked
/// SVG images, a PDF page range, separate artboard files) apply: one self-contained file per
/// document with all of its art. Each format reads only the options it has, and `range` is left
/// out of SVG, where it would pick artboards over `useArtboards`.
fn copy_options(format: &str) -> Value {
match format {
"svg" | "svgz" => json!({ "images": "embed", "useArtboards": false }),
"pdf" => json!({ "range": "all" }),
"eps" => json!({ "includeLinkedFiles": true, "useArtboards": false }),
"ai" | "ait" => json!({ "range": "all", "includeLinked": true, "separateArtboards": false }),
_ => json!({ "includeLinked": true, "separateArtboards": false }),
}
}
fn export_all_open(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.document.exportAllOpen";
let format = fileio::save_format(Some(str_param(p, "format").unwrap_or("pdf")), None).map_err(|e| bad(C, e))?;
let folder = str_param(p, "folder").map(str::trim).filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "give `folder`"))?.to_string();
let ext = format.extensions.first().copied().ok_or_else(|| bad(C, "the format has no file extension"))?;
let (mut files, mut failed, mut written) = (vec![], vec![], Vec::<String>::new());
let active = s.active;
for i in 0..s.docs.len() {
// Each document in turn is the one Save a Copy writes; switching tabs this way leaves every
// document's tool and interaction alone, and the active one comes back below.
s.active = Some(i);
let Ok(st) = s.doc() else { continue };
let title = st.doc.title.clone();
let stem = fileio::file_stem(st.path.as_deref().unwrap_or(&title));
// Names the copies already took count as taken, as do files on disk.
let Some(path) = free_name(&folder, &stem, ext, |p| written.iter().any(|w| w == p) || std::path::Path::new(p).exists()) else {
failed.push(json!({ "title": title, "error": "every numbered name for the copy is taken" }));
continue;
};
let options = copy_options(format.id);
match fileio::save_document(s, SaveMode::Copy, &json!({ "path": path, "format": format.id, "options": options })) {
Ok(r) => {
written.push(path.clone());
// Any other file the save wrote is taken for the copies still to come.
for key in ["files", "linked"] {
for f in r.get(key).and_then(Value::as_array).into_iter().flatten() {
if let Some(f) = f.as_str().or_else(|| f.get("path").and_then(Value::as_str)) {
written.push(f.to_string());
}
}
}
files.push(json!({ "title": title, "path": r.get("path").cloned().unwrap_or(json!(path)), "warnings": r.get("warnings").cloned().unwrap_or(json!([])) }));
}
Err(e) => failed.push(json!({ "title": title, "error": e.to_string() })),
}
}
s.active = active;
Ok(json!({ "files": files, "failed": failed, "format": format.id }))
}
/// `n` with each layer in it, and itself, made a group of the same art and clipping (a symbol holds
/// no layers).
fn without_layers(n: &Node) -> Node {
@@ -194,3 +194,85 @@ fn the_layers_highlighted_in_the_layers_panel_are_the_default() {
let r = s.execute("addon.document.symbolsFromLayers", &json!({})).unwrap();
assert_eq!((r["symbols"].clone(), r["skipped"].as_array().unwrap().len()), (json!(["Icons"]), 1));
}
/// #4920: Export All Open Documents writes an SVG of each open document; a folder that can't be
/// written lists every document in `failed`, and either way the active document stays active.
#[test]
fn export_all_open_documents_writes_each_and_reports_failures() {
let dir = std::env::temp_dir().join(format!("vc-export-all-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "one"})).unwrap();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "two"})).unwrap();
s.execute("document.activate", &json!({"index": 0})).unwrap();
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": dir, "format": "svg"})).unwrap();
assert_eq!(r["files"].as_array().unwrap().len(), 2, "{r}");
assert!(dir.join("one.svg").exists() && dir.join("two.svg").exists());
assert_eq!(s.active_index(), Some(0));
assert!(s.documents().iter().all(|d| d.path.is_none()), "the documents stay unsaved");
let missing = dir.join("missing").join("deeper");
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": missing, "format": "svg"})).unwrap();
assert_eq!((r["files"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (0, 2), "{r}");
assert_eq!(s.active_index(), Some(0));
let _ = std::fs::remove_dir_all(&dir);
}
/// An SVG document last saved with linked images exports as one SVG with its images embedded: no
/// image file is written beside the copies (#4920 review).
#[test]
fn export_all_open_documents_writes_one_file_each() {
let dir = std::env::temp_dir().join(format!("vc-export-all-one-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("out")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "name": "pic"})).unwrap();
let mut png = vec![];
image::RgbaImage::from_pixel(4, 4, image::Rgba([1, 2, 3, 255])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
s.execute("file.place", &json!({"name": "a.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap();
s.execute("document.save", &json!({"path": dir.join("pic.svg"), "format": "svg", "options": {"images": "link"}})).unwrap();
let saved: Vec<_> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()).filter(|e| e.path().is_file()).collect();
assert!(saved.len() > 1, "the linked save wrote its image beside it: {saved:?}");
let out = dir.join("out");
let r = s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap();
let names: Vec<String> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
assert_eq!(names, ["pic.svg"], "{r}");
assert!(std::fs::read_to_string(out.join("pic.svg")).unwrap().contains("data:image/png;base64,"), "the image is embedded");
let _ = std::fs::remove_dir_all(&dir);
}
/// A PDF document last saved with a page range exports with every page, and an SVG document with
/// two artboards exports as one SVG holding the art of both (#4920 review).
#[test]
fn export_all_open_documents_keeps_every_page_and_artboard() {
let dir = std::env::temp_dir().join(format!("vc-export-all-pages-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("out")).unwrap();
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100, "artboards": 2, "name": "pages"})).unwrap();
s.execute("document.save", &json!({"path": dir.join("pages.pdf"), "format": "pdf", "options": {"range": "1"}})).unwrap();
let pages = |b: &[u8]| {
String::from_utf8_lossy(b).matches("/Type /Page\n").count()
+ String::from_utf8_lossy(b).matches("/Type/Page/").count()
+ String::from_utf8_lossy(b).matches("/Type /Page/").count()
+ String::from_utf8_lossy(b).matches("/Type /Page ").count()
};
let first = pages(&std::fs::read(dir.join("pages.pdf")).unwrap());
let out = dir.join("out");
s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "pdf"})).unwrap();
let copy = pages(&std::fs::read(out.join("pages.pdf")).unwrap());
assert!(copy > first, "the copy has every page: {first} saved, {copy} exported");
// SVG: one file with the art on both artboards.
let a = s.doc().unwrap().doc.artboards[1].rect;
s.execute("shape.rectangle", &json!({"x": a.x0 + 10.0, "y": a.y0 + 10.0, "width": 20, "height": 20})).unwrap();
s.execute("addon.document.exportAllOpen", &json!({"folder": out, "format": "svg"})).unwrap();
let svgs: Vec<String> =
std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).filter(|n| n.ends_with(".svg")).collect();
assert_eq!(svgs, ["pages.svg"]);
let svg = std::fs::read_to_string(out.join("pages.svg")).unwrap();
// Not the first artboard alone (its 100 × 100 box at the origin): the art's own bounds.
let view = svg.split("viewBox=\"").nth(1).and_then(|v| v.split('"').next()).unwrap();
let w: f64 = view.split_whitespace().nth(2).unwrap().parse().unwrap();
assert!(w > 19.0 && w < 30.0, "the SVG shows the art (the square on the second artboard), not the first artboard: viewBox {view}");
let _ = std::fs::remove_dir_all(&dir);
}
+110 -3
View File
@@ -2,7 +2,8 @@
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), the Shared Edge Merge Tool's
//! merges with Keep Shared Points Together ([`shared_edge`]), and Divide into Dashes.
//! merges with Keep Shared Points Together ([`shared_edge`]), Divide into Dashes and Expand Dashes
//! into Paths.
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
//! and the UI's.
@@ -17,7 +18,7 @@ mod tests;
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
use vectorcraft_doc::{AnchorRef, Appearance, AppearanceItem, Document, LineCap, NodeId, NodeKind};
use vectorcraft_geom::{PathData, Point, SubPath};
use vectorcraft_pathops::addons::{self as po, pathtools as pt};
@@ -40,7 +41,7 @@ const MIN_RADIUS: f64 = 0.05;
const MAX_DIAMETER: f64 = 2500.0;
/// The farthest an end is extended at once (pt).
const MAX_EXTEND: f64 = 1e6;
/// Most pieces Divide into Dashes makes at once.
/// Most pieces Divide into Dashes and Expand Dashes into Paths make at once.
const MAX_PIECES: usize = 100_000;
/// Most anchors Add Points puts on each segment.
const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64;
@@ -164,6 +165,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
divide_dashes
),
cmd!(
"addon.pathtools.expandDashes",
"Expand Dashes into Paths",
["Object", "Plug-ins", "Path Precision Tools"],
None,
"{ids?: [id…] (default: the selection)} turn the dashed stroke of each path and compound path under the objects into separate open paths, one per dash, laid out as the stroke draws them (its dash pattern, offset and Align Dashes to Corners and Path Ends), each keeping the path's appearance with the dashes off, so the gaps are real gaps: a dash running all the way round a closed path stays closed, and with a round or projecting cap a dash of no length (a dot) becomes a path on its point. The top stroke's dashes are the ones expanded; its arrowheads go, and the path's fills stay on it, below its dashes. Paths without a dashed stroke stay as they are. One undo step; the dashes are selected; at most 100000 → {ids, dashes}",
has_selection,
expand_dashes
),
];
v.extend(shape::specs());
v.extend(corners::specs());
@@ -793,3 +803,100 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": dashes }))
}
// ---------- Expand Dashes into Paths ----------
/// The paths and compound paths under `roots` whose top stroke is dashed: a compound path's
/// members stand for it, which holds their paint. Clipping paths are left alone.
fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
let editable = d.editable();
let mut seen = BTreeSet::new();
let mut out = vec![];
for id in paths_under(d, roots) {
let compound = d.parent_of(id).and_then(|p| d.node(p)).filter(|p| matches!(p.kind, NodeKind::Compound { .. }));
let Some(n) = compound.or_else(|| d.node(id)) else { continue };
let dashed = n.appearance.stroke().and_then(|st| st.dash.as_ref()).is_some_and(|dash| dash.is_dashed());
if dashed && !matches!(n.kind, NodeKind::Path { clipping: true, .. }) && editable(n.id) && seen.insert(n.id) {
out.push(n.id);
}
}
out
}
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "addon.pathtools.expandDashes";
let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer");
let roots = checked_ids_or_selection(s, p, C)?;
let d = &s.doc()?.doc;
// Each dashed path and its dashes, as the canvas draws them.
let mut changed: Vec<(NodeId, Vec<SubPath>)> = vec![];
let mut count = 0;
for id in dashed_paths(d, &roots) {
let Some(n) = d.node(id) else { continue };
let (Some(st), Some(bp)) = (n.appearance.stroke(), n.stroke_path()) else { continue };
let Some(dash) = &st.dash else { continue };
// The line the stroke draws: the path, its ends trimmed under any arrowheads.
let line = vectorcraft_render::effects::stroke::stroke_pieces(&bp, st).line;
// A pattern too fine to draw is drawn solid: it has too many dashes to expand.
let dashed = vectorcraft_render::effects::stroke::dash(&line, dash).ok_or_else(too_many)?;
let mut pieces = PathData::from_bezpath(&dashed.path).subpaths;
// A dot paints its cap; a path that stays on its point is drawn so too. Under a butt cap it
// paints nothing.
if st.cap != LineCap::Butt {
pieces.extend(dashed.dots.iter().map(|dot| SubPath::polyline(&[dot.at, dot.at], false)));
}
count += pieces.len();
if count > MAX_PIECES {
return Err(too_many());
}
if !pieces.is_empty() {
changed.push((id, pieces));
}
}
if changed.is_empty() {
return Err(bad(C, "select paths with dashed strokes to expand"));
}
let ids = s.edit("Expand Dashes into Paths", |d, sel| {
let mut all = vec![];
let mut beside = Beside::replacing(d, &changed.iter().map(|(id, _)| *id).collect::<Vec<_>>());
for (id, pieces) in changed {
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let (NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. }) = &n.kind else { continue };
let rule = *rule;
let filled = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && !f.paint.is_none());
// The dashes: open paths with the strokes (the top one's dashes and arrowheads off).
let mut look = Appearance::clone(&n.appearance);
look.items.retain(|i| !matches!(i, AppearanceItem::Fill(_)));
if let Some(st) = look.stroke_mut() {
(st.dash, st.start_arrow, st.end_arrow) = (None, None, None);
}
let mut dash = n.clone();
dash.kind = NodeKind::Path { path: PathData::default(), rule, live: None, clipping: false, guide: false };
dash.appearance = look.into();
// The fills stay on the path, below its dashes; without fills it goes.
let mut parts = vec![];
if n.appearance.items.iter().any(filled) {
let mut fill = n.clone();
let mut look = Appearance::clone(&n.appearance);
look.items.retain(filled);
fill.appearance = look.into();
parts.push(fill);
}
for sp in pieces {
let mut piece = d.reid(&dash);
if let NodeKind::Path { path, .. } = &mut piece.kind {
*path = PathData::single(sp);
}
all.push(piece.id);
parts.push(piece);
}
beside.put(d, id, parts)?;
}
beside.finish(d)?;
// The dashes join the objects selected (whole, in one go: there may be many).
let before = std::mem::take(&mut sel.objects);
sel.set(before.into_iter().filter(|id| d.node(*id).is_some()).chain(all.iter().copied()));
Ok(all)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "dashes": ids.len() }))
}
@@ -748,3 +748,126 @@ fn divide_into_dashes_refuses_bad_params_and_too_many_pieces() {
s.execute("select.none", &json!({})).unwrap();
refuses(&mut s, "addon.pathtools.divideDashes", &[json!({})]);
}
/// The dashes made of `r`'s ids: (first x, last x) of each, by its anchors.
fn dash_spans(s: &Session, r: &Value) -> Vec<(f64, f64)> {
ids(r).iter().map(|id| path(s, *id).bounds().map(|b| (b.x0, b.x1)).unwrap()).collect()
}
fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6)
}
#[test]
fn expand_dashes_makes_an_open_path_of_each_dash_keeping_the_stroke() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("paint.setFill", &json!({"none": true})).unwrap();
s.execute("stroke.set", &json!({"weight": 3, "cap": "round", "dash": [30, 10]})).unwrap();
let n = steps(&s);
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(steps(&s), n + 1, "one undo step");
assert!(near(&dash_spans(&s, &r), &[(0.0, 30.0), (40.0, 70.0), (80.0, 100.0)]), "{:?}", dash_spans(&s, &r));
assert_eq!(r["dashes"], json!(3));
let st = s.doc().unwrap();
for id in ids(&r) {
let node = st.doc.node(id).unwrap();
let stroke = node.appearance.stroke().unwrap();
assert_eq!((stroke.width, stroke.cap, stroke.dash.clone()), (3.0, vectorcraft_doc::LineCap::Round, None), "the stroke stays, dashes off");
assert!(!path(&s, id).subpaths[0].closed);
}
assert_eq!(st.selection.objects, ids(&r));
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.node(l).unwrap().appearance.stroke().unwrap().dash.is_some());
// The offset shifts the pattern as the stroke draws it: 20 into the first dash.
s.execute("stroke.set", &json!({"dashOffset": 20})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({"ids": [l.0]})).unwrap();
assert!(near(&dash_spans(&s, &r), &[(0.0, 10.0), (20.0, 50.0), (60.0, 90.0)]), "{:?}", dash_spans(&s, &r));
}
#[test]
fn expand_dashes_keeps_the_fill_below_and_follows_aligned_corners() {
let mut s = session();
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 60})).unwrap());
select(&mut s, &[rect]);
s.execute("stroke.set", &json!({"dash": [20, 20]})).unwrap();
let had_fill = !s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint().is_none();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// 320 round: eight dashes of 20, a dash bending round each corner it meets.
let pieces = ids(&r);
assert_eq!(pieces.len(), 8);
let st = s.doc().unwrap();
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
if had_fill {
let kept = st.doc.node(rect).unwrap();
assert!(kept.appearance.stroke().is_none() && kept.appearance.fill().is_some(), "the fill stays on the rectangle");
assert_eq!(layer, [vec![rect], pieces.clone()].concat(), "the fill below its dashes");
} else {
assert_eq!(layer, pieces);
}
assert!(pieces.iter().all(|id| st.doc.node(*id).unwrap().appearance.fill().is_none()), "an open dash isn't filled");
s.execute("edit.undo", &json!({})).unwrap();
// Aligned to corners: a dash centred on each corner, the four sides each starting and ending
// on half a dash; 12 dashes along the sides.
s.execute("stroke.set", &json!({"dash": [10, 10], "alignDashes": true})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 60.0), Point::new(0.0, 60.0)];
let mut centred = 0;
for id in ids(&r) {
let sp = path(&s, id).subpaths[0].clone();
if let [a, b, c] = sp.anchors.as_slice()
&& corners.iter().any(|k| b.p.distance(*k) < 1e-6)
{
assert!((a.p.distance(b.p) - 5.0).abs() < 1e-6 && (c.p.distance(b.p) - 5.0).abs() < 1e-6, "{sp:?}");
centred += 1;
}
}
assert_eq!((ids(&r).len(), centred), (16, 4));
}
#[test]
fn expand_dashes_turns_dots_into_paths_on_their_point_and_releases_compound_paths() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0)], false);
select(&mut s, &[l]);
s.execute("stroke.set", &json!({"dash": [0, 20], "cap": "round"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
assert_eq!(ids(&r).len(), 6, "a dot every 20 from 0 to 100");
assert!(ids(&r).iter().all(|id| path(&s, *id).bounds().unwrap().width() == 0.0));
s.execute("edit.undo", &json!({})).unwrap();
// Under a butt cap a dot paints nothing, so there is nothing to expand.
s.execute("stroke.set", &json!({"cap": "butt"})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
// A compound path's dashes are paths of their own with its stroke.
let a = line(&mut s, &[(0.0, 100.0), (50.0, 100.0)], false);
let b = line(&mut s, &[(0.0, 120.0), (50.0, 120.0)], false);
select(&mut s, &[a, b]);
let c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
s.execute("stroke.set", &json!({"weight": 2, "dash": [20, 10], "cap": "butt"})).unwrap();
let r = s.execute("addon.pathtools.expandDashes", &json!({})).unwrap();
// Each 50 line: 20, gap, 20.
let st = s.doc().unwrap();
assert_eq!(ids(&r).len(), 4);
for id in ids(&r) {
let n = st.doc.node(id).unwrap();
assert!(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. }), "a plain path");
assert_eq!(n.appearance.stroke_width(), 2.0);
assert!(n.appearance.stroke().unwrap().dash.is_none());
}
assert!(st.doc.node(c).is_none_or(|n| n.appearance.stroke().is_none()), "the compound path's dashes are gone from it");
}
#[test]
fn expand_dashes_refuses_without_dashes_and_past_the_limit() {
let mut s = session();
let l = line(&mut s, &[(0.0, 0.0), (1e5, 0.0)], false);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
select(&mut s, &[l]);
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({}), json!({"ids": "x"}), json!({"zzz": 1})]);
// 125000 dashes refused; 1000 are fine.
s.execute("stroke.set", &json!({"dash": [0.4, 0.4]})).unwrap();
refuses(&mut s, "addon.pathtools.expandDashes", &[json!({})]);
s.execute("stroke.set", &json!({"dash": [50, 50]})).unwrap();
assert_eq!(s.execute("addon.pathtools.expandDashes", &json!({})).unwrap()["dashes"], json!(1000));
}
+94 -12
View File
@@ -37,6 +37,7 @@ use super::{
};
use crate::EngineError;
/// The Appearance panel's commands: fills and strokes, their order and copies, and the active item.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -107,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Duplicate Item",
["Window", "Appearance"],
None,
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (one `index` only; default: right above it, as Alt-dragging a row places it; not with aligned rows of a mixed selection), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects",
"{index | indices: [..] (paint-order item indices), to?: paint-order index of the copy (with several, of the lowest copy: they land together in their order; default: each right above its item; not with aligned rows of a mixed selection), contents?: count (with `to`: how many items paint below the Contents (Characters) row afterwards; default: it stays between the same items), ids?|id?, target?, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} copy fills/strokes with their effects, as Alt-dragging rows places them",
has_doc,
duplicate_item
),
@@ -116,7 +117,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Reorder Appearance Item",
[],
None,
"{from: paint-order item index | \"contents\", to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
"{from: paint-order item index | \"contents\", indices?: [..] (several items instead of from: they move as one block in their order, the lowest landing at `to`; a block landing next to the Contents row keeps its lowest item's side), to, contents?: count, ids?|id?, target?, aligned?: bool (refused when the targets' appearances differ)} move fill/stroke `from` to paint-order index `to`; on a group, layer or type the Contents (Characters) row stays between the same items (a moved item landing next to it keeps its side) unless `contents` says how many items paint below it afterwards. from \"contents\": move that row so `to` items paint below the members (characters) and the rest above → {index: where the item landed | contents: the row's slot}",
has_doc,
move_item
),
@@ -736,7 +737,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
/// The paint-order item indices a command acts on (`indices`, else `index`), ascending and
/// without repeats.
fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
pub(super) fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
let mut v = match p.get("indices") {
Some(a) => a
.as_array()
@@ -750,6 +751,7 @@ fn item_indices(p: &Value, cmd: &str) -> Result<Vec<usize>> {
Ok(v)
}
/// `appearance.removeItem`: removes fill/stroke `index`, or every one of `indices`, from the selected objects in one undo step.
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = item_indices(p, "appearance.removeItem")?;
let ids = require_targets(s, p, "appearance.removeItem")?;
@@ -774,16 +776,22 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
/// `appearance.duplicateItem`: copies fill/stroke `index`, or the rows `indices` as one block at `to` (with the Contents row when `contents` says so), in one undo step.
fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.duplicateItem";
let idx = item_indices(p, C)?;
let to = p.get("to").map(|_| index_param(p, "to", C)).transpose()?;
if to.is_some() && idx.len() > 1 {
return Err(bad(C, "`to` takes a single `index`"));
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
if contents.is_some() && to.is_none() {
return Err(bad(C, "`contents` goes with `to`"));
}
let ids = require_targets(s, p, C)?;
// Where the first object's copy landed (the active item lives in the first object's stack).
// Where the first target's copy landed, and where it landed on the active row's object (the
// first selected one, not always the first target): copies placed at `to` land at a different
// index on stacks of other lengths.
let mut first_at = None;
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let mut active_at = None;
// Aligned rows of a mixed selection are copied right above themselves in each object.
if aligned(p) && to.is_some() && shared_stack(&s.doc()?.doc, &ids).is_some() {
return Err(bad(C, "`to` takes the rows of one object's stack, not aligned rows"));
@@ -793,31 +801,54 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
let mut any = false;
for (id, idx) in ids.iter().zip(&per) {
let Some(n) = d.node_mut(*id) else { continue };
let has_contents = n.contents_label().is_some();
let ap = &mut n.appearance;
if let Some(t) = to {
// The copies land together at `to`, in their order; every item must exist.
if idx.iter().any(|i| *i >= ap.items.len()) {
return Err(bad(C, format!("no item at one of the indices {idx:?}")));
}
let copies: Vec<_> = idx.iter().filter_map(|&i| ap.items.get(i).cloned()).collect();
let at = t.min(ap.items.len());
first_at.get_or_insert(at);
if active == Some(*id) {
active_at = Some(at);
}
for (k, item) in copies.into_iter().enumerate() {
ap.insert_item(at + k, item);
}
if let Some(c) = contents.filter(|_| has_contents) {
ap.set_contents_at(c);
}
any = true;
continue;
}
let mut idx = idx.clone();
idx.sort_unstable();
// From the top down, so the lower indices stay valid.
for &i in idx.iter().rev() {
if let Some(item) = ap.items.get(i).cloned() {
let at = to.map_or(i + 1, |t| t.min(ap.items.len()));
first_at.get_or_insert(at);
ap.insert_item(at, item);
first_at.get_or_insert(i + 1);
ap.insert_item(i + 1, item);
any = true;
}
}
}
if any { Ok(()) } else { Err(bad(C, format!("no item at index {}", idx[0]))) }
})?;
// Rows at or above each copy move up by one.
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
let m = idx.len();
let landed = active_at.or(first_at);
s.remap_appearance_item(&ids, |a| {
Some(match to {
Some(_) => a + usize::from(first_at.is_some_and(|at| at <= a)),
Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 },
None => a + idx.iter().filter(|i| **i < a).count(),
})
});
ok()
}
/// `appearance.showAllHidden`: shows every hidden fill, stroke and effect of the selected objects.
fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
let roots = require_targets(s, p, "appearance.showAllHidden")?;
let ids = s.edit("Show All Hidden Attributes", |d, _| {
@@ -830,6 +861,7 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// `appearance.moveItem`: moves fill/stroke `from` (or the Contents row) to `to`, or the rows `indices` as one block; the active item stays on its row.
fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "appearance.moveItem";
let to = index_param(p, "to", C)?;
@@ -837,11 +869,17 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("from").and_then(Value::as_str) == Some("contents") {
return move_contents(s, &ids, to);
}
let from = index_param(p, "from", C)?;
// Where an aligned row would go in each of several different stacks isn't one place.
if aligned(p) && appearances_differ(&s.doc()?.doc, &ids) {
return Err(bad(C, "rows of objects whose appearances differ can't be reordered together"));
}
if p.get("indices").is_some() {
if p.get("from").is_some() {
return Err(bad(C, "give `from` or `indices`, not both"));
}
return move_items(s, p, &ids, to);
}
let from = index_param(p, "from", C)?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
let mut landed = to;
// When every object's stack comes out as it was, the document and its undo history stay as they
@@ -872,6 +910,50 @@ fn move_item(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "index": landed }))
}
/// `appearance.moveItem {indices}`: move several items as one block (the lowest landing at `to`),
/// placing the Contents row with `contents` when given, as one undo step. The active row moves with
/// its row; the rows the block passes are renumbered.
fn move_items(s: &mut Session, p: &Value, ids: &[NodeId], to: usize) -> Result<Value> {
const C: &str = "appearance.moveItem";
let idx = item_indices(p, C)?;
let contents = p.get("contents").map(|_| index_param(p, "contents", C)).transpose()?;
// Where the block landed on the first target (stacks of other lengths clamp `to` differently):
// the result, as the panel lists the first object's stack.
let mut first_landed = None;
// Where it landed on the active row's object: the active row is in its stack.
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
let mut active_landed = None;
let before = s.doc()?.doc.clone();
super::overprint::edit_counted(s, "Reorder Appearance", |d| {
for id in ids {
let Some(n) = d.node_mut(*id) else { continue };
let landed = n.appearance.move_items(&idx, to).ok_or_else(|| bad(C, format!("no item at one of the indices {idx:?}")))?;
first_landed.get_or_insert(landed);
if active == Some(*id) {
active_landed = Some(landed);
}
if let Some(k) = contents.filter(|_| n.contents_label().is_some()) {
n.appearance.set_contents_at(k);
}
}
Ok(count_changed(d, &before, ids))
})?;
let m = idx.len();
let landed = first_landed.unwrap_or(to);
let at = active_landed.unwrap_or(landed);
s.remap_appearance_item(ids, |a| {
Some(match idx.iter().position(|i| *i == a) {
Some(k) => at + k,
None => {
// Its place among the unmoved rows, then above the block if it is at or above it.
let r = a - idx.iter().filter(|i| **i < a).count();
if r < at { r } else { r + m }
}
})
});
Ok(json!({ "index": landed }))
}
/// `appearance.moveItem {from: "contents"}`: put the Contents (Characters) row of each target that
/// has one above its bottom `to` items.
fn move_contents(s: &mut Session, ids: &[NodeId], to: usize) -> Result<Value> {
+20 -5
View File
@@ -7,7 +7,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::{ExportAsset, NodeId};
use super::edit::{roots_of, selected_roots};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings};
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset};
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param};
/// The longest asset name kept (characters).
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Assets",
[],
None,
"{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
"{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
has_doc,
export
),
@@ -57,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Asset Export Settings",
[],
None,
"{formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step",
"{exportPreset?: (a preset of document.exportPresets.list: its formats or preset, its format settings over the saved ones, its prefix (none when it has none), its folders (or subfolders: rows or none) with subfolders on for rows, and antiAlias; the other keys go over it), formats?, preset?: mobile|density|\"\" (none), settings?, prefix?, subfolders?, antiAlias?} change the export settings the Asset Export panel and Export for Screens share (document.exportSettings; the values of document.exportForScreens; null removes one): formats replace a preset, a preset replaces the formats → {settings}. Not an undo step",
has_doc,
settings_set
),
@@ -223,6 +223,10 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(o) = p.as_object() {
overlay(&mut q, o);
}
// Create Sub-folders off puts every file in the folder itself, mobile and density rows too.
if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() {
q.insert("folders".into(), json!("none"));
}
if !q.contains_key("assets") {
let all: Vec<u64> = s.doc()?.doc.assets.iter().map(|a| a.id).collect();
if all.is_empty() {
@@ -236,11 +240,22 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
fn settings_set(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "assets.settings.set";
let o = p.as_object().ok_or_else(|| bad(C, "give the settings to change, e.g. {\"formats\": [{\"format\": \"png\", \"scale\": \"2x\"}]}"))?;
if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str())) {
if let Some(k) = o.keys().find(|k| !SCREEN_SETTINGS_KEYS.contains(&k.as_str()) && k.as_str() != "exportPreset") {
return Err(bad(C, format!("`{k}` isn't an export setting: {}", SCREEN_SETTINGS_KEYS.join(", "))));
}
let mut settings = s.doc()?.doc.export_settings.clone();
overlay(&mut settings, o);
let mut given = o.clone();
if let Some(name) = given.remove("exportPreset") {
let name = name.as_str().ok_or_else(|| bad(C, "`exportPreset` is the name of a preset of document.exportPresets.list"))?;
with_screen_export_preset(s, C, &mut settings, name)?;
}
// Create Sub-folders is Export for Screens' folder per row: the dialog opens on the choice.
match given.get("subfolders").and_then(Value::as_bool) {
Some(true) => _ = settings.insert("folders".into(), json!("rows")),
Some(false) if settings.get("folders").and_then(Value::as_str) == Some("rows") => _ = settings.insert("folders".into(), json!("none")),
_ => {}
}
overlay(&mut settings, &given);
check_screen_settings(s, &Value::Object(settings.clone())).map_err(|e| of_cmd(C, e))?;
store_screen_settings(s, settings)?;
Ok(json!({ "settings": shared_settings(s)? }))
+30 -12
View File
@@ -19,7 +19,7 @@ use super::edit::{outermost, selected_roots};
use super::pathops::{remove_each, separate};
use super::swatch::{anchor, names_param};
use super::{
CommandSpec, Result, Session, bad, check_numbers, cmd, color_value, find_command, has_doc, has_selection, id_param, ids_param, number_or, ok,
CommandSpec, Result, Session, bad, check_numbers, cmd, color_value, find_command, has_doc, has_selection, id_param, named_ids, number_or, ok,
str_param, switch_or,
};
use crate::EngineError;
@@ -37,8 +37,8 @@ use dynamic::{symbol_instance_reset, symbol_instance_set_appearance};
use symbols::selected_instances;
pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol};
use symbols::{
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place,
symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_update,
symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace,
symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update,
};
const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES;
@@ -137,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Brush Options…",
[],
None,
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
has_doc,
brush_options
),
@@ -174,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New Symbol…",
[],
None,
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a third each) → {name, id, registration, exportType, nineSlice, type}",
"{name?, ids?|id?, registration?: 0..8 (9-point grid, 4 = centre; Symbol Options › Registration), exportType?: movieClip (default)|graphic, nineSlice?: bool, nineSliceGuides?: array, type?: static (default)|dynamic (Symbol Options › Symbol Type)} make a symbol from the selection and replace it with an instance; a frame of threaded text in the selection leaves its thread with the text it shows, which the symbol's art holds; nineSlice is Symbol Options › Enable Guides for 9-Slice Scaling, nineSliceGuides [left, top, right, bottom] as fractions 0..1 of the natural size (default a quarter each); an instance of a symbol with guides keeps its corners' size when scaled, its edges stretch along their length and its center in both directions → {name, id, registration, exportType, nineSlice, type}",
has_selection,
symbol_new
),
@@ -201,7 +201,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Break Link to Symbol",
[],
None,
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol) → {ids}",
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them → {ids}",
has_selection,
symbol_break_link
),
@@ -210,9 +210,18 @@ pub fn specs() -> Vec<CommandSpec> {
"Edit Symbol",
[],
None,
"{id?, ids?: [id…] (instead of id)} break the link of the selected instance so the symbol's art can be edited (without the instance's own appearance changes to a dynamic symbol); finish with symbol.update → {name, ids}",
has_selection,
symbol_edit
"{id?, ids?: [id…] (instead of id), name?, x?, y?, artboard?: index=0 (the active artboard; the app passes it), viewCenter?: [x, y] (the app passes the view's center)} edit a symbol's art in symbol editing mode: the instance `id` (default: the selected instances, which must share one symbol) where it shows the art, else the symbol `name` (default: the current symbol) centered on (x, y) (default: the center of artboard `artboard`, or of the first artboard when there is no such artboard; in a document without artboards, viewCenter, else (0, 0)); every instance follows each edit and the instance stays linked (symbol.breakLink expands one); refused while a pattern or an opacity mask is edited → {name, layer, id?}",
has_doc,
super::symboledit::enter
),
cmd!(
"symbol.exitEditing",
"Exit Symbol Editing Mode",
[],
None,
"{} leave symbol editing mode; after an edit, select the instance it was entered from; leaving without having changed the art adds no undo step and restores the selection from before entry (object.exitIsolation also leaves it) → {name?}",
has_doc,
super::symboledit::leave
),
cmd!(
"symbol.update",
@@ -228,7 +237,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Delete Symbol",
[],
None,
"{name, expandInstances?: true} delete a symbol; its instances are expanded (or deleted with false)",
"{name | names: [name…], expandInstances?: true} delete a symbol (or several, in one undo step); its instances are expanded (or deleted with false)",
has_doc,
symbol_delete
),
@@ -238,7 +247,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Replace Symbol",
[],
None,
"{name, ids?|id?} swap the symbol of the selected instances (they keep their appearance changes to the objects at paths a dynamic new symbol's art has) → {ids}",
"{name, ids?|id?} swap the symbol of the selected instances (an instance given a different symbol drops its appearance changes; one given its own symbol keeps them) → {ids}",
has_selection,
symbol_replace
),
@@ -269,6 +278,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
symbol_set_current
),
cmd!(
query "symbol.unused",
"Select All Unused",
[],
None,
"{} → {names: [the symbols nothing places, in panel order]} (the Symbols panel selects them): instances in the art (opacity masks included), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and brush art count, and so do instances in the art of a symbol that is used",
has_doc,
symbol_unused
),
cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances),
cmd!(
"symbol.spray",
@@ -296,7 +314,7 @@ fn ids_json(ids: &[NodeId]) -> Value {
}
pub(crate) fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
match named_ids(p)? {
Some(mut v) => {
// Explicit ids: only existing, non-layer objects, none inside another of them.
let d = &s.doc()?.doc;
+173 -38
View File
@@ -47,7 +47,8 @@ fn export_type_param(p: &Value, cmd: &str) -> Result<Option<SymbolExportType>> {
/// `nineSlice` (bool) and `nineSliceGuides` ([left, top, right, bottom] fractions of the natural
/// size): `None` leaves the symbol alone, `Some(None)` turns 9-slice off, `Some(Some(g))` turns it
/// on with guides `g` (the symbol's current ones, else the centre third, when none are given).
/// on with guides `g` (the symbol's current ones, else a quarter in from each edge, when none are
/// given).
fn nine_slice_param(p: &Value, cmd: &str, current: Option<SymbolNineSlice>) -> Result<Option<Option<SymbolNineSlice>>> {
let on = match p.get("nineSlice") {
None | Some(Value::Null) => None,
@@ -107,6 +108,37 @@ fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
v
}
/// The symbols an instance in `n` (its opacity masks included) places, pushed onto `out`.
fn art_symbols(n: &Node, out: &mut Vec<String>) {
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
if let NodeKind::SymbolInstance { symbol, .. } = &m.kind {
out.push(symbol.clone());
}
});
}
/// Select All Unused (#4918): the symbols nothing places. Instances in the art (opacity masks
/// included), graph designs, pattern tiles and the tiles that pattern editing's Cancel puts back,
/// and the art of the document's brushes count, and so do the instances in the art of every symbol
/// a counted instance places. A symbol placed only inside an unused symbol is unused too.
pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let mut todo = vec![];
let lib = brush::library(d);
let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()));
let trees = d.layers.iter().map(|l| &**l).chain(d.graph_designs.iter().map(|g| &*g.art));
trees.chain(patterns.flat_map(|p| p.art.iter().map(|n| &**n))).chain(lib.iter().flat_map(|b| b.art())).for_each(|n| art_symbols(n, &mut todo));
let mut used = std::collections::BTreeSet::new();
while let Some(name) = todo.pop() {
if !used.contains(&name) {
d.symbols.iter().filter(|sym| sym.name == name).for_each(|sym| art_symbols(&sym.art, &mut todo));
used.insert(name);
}
}
let names: Vec<&str> = d.symbols.iter().map(|sym| sym.name.as_str()).filter(|n| !used.contains(*n)).collect();
Ok(json!({ "names": names }))
}
fn symbol_names(d: &Document) -> Vec<String> {
d.symbols.iter().map(|s| s.name.clone()).collect()
}
@@ -243,6 +275,14 @@ pub(super) fn symbol_options(s: &mut Session, p: &Value) -> Result<Value> {
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.insert(CURRENT_SYMBOL.into(), json!(n));
}
// Symbol editing mode follows the new name, and so does its layer.
if let Some(e) = d.symbol_edit.as_mut().filter(|e| e.symbol == name) {
e.symbol = n.clone();
let layer = e.layer;
if let Some(l) = d.node_mut(layer) {
l.name = Some(n.clone());
}
}
}
if let Some(r) = reg {
d.set_symbol_registration(&final_name, r);
@@ -300,7 +340,8 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
}
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
/// appearance changes to a dynamic symbol's art when `own`.
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
/// guides when it has them.
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
@@ -310,7 +351,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
{
brush::tint_node(&mut art, &c, f.opacity);
}
art.transform(*xf, false);
let art = d.place_symbol_art(symbol, &art, *xf)?;
let mut art = d.reid(&art);
if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal {
if !matches!(art.kind, NodeKind::Group { .. }) {
@@ -323,9 +364,31 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
Some(art)
}
/// The most objects [`expanded_instance`] makes of instance `inst` in `d` (`own` as there): its
/// symbol's object count with the art of its opacity masks, or
/// [`vectorcraft_doc::NineSliceMap::most_placed`] when it is placed through 9-slice guides, plus
/// the group that opacity or a blending mode adds. 0 for any other object.
fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return 0 };
let Some(sym) = d.symbols.iter().find(|s| &s.name == symbol) else { return 0 };
let dynamic = own && !inst.extras().symbol_overrides.is_empty();
let most = match d.symbol_slice_map(symbol, *xf) {
Some(m) if dynamic => m.most_placed(&vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst), d),
Some(m) => m.most_placed(&sym.art, d),
None if dynamic => vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, inst).count_with_masks() as u64,
None => sym.art.count_with_masks() as u64,
};
most.saturating_add(1)
}
/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the
/// art's ids.
/// art's ids. Fails before any edit when the instances together would make more than
/// [`MAX_EXPAND_NODES`] objects.
fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId>> {
let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own)));
if total > MAX_EXPAND_NODES as u64 {
return Err(EngineError::Other("Too many pieces to make at once".into()));
}
let mut out = vec![];
for id in ids {
let Some(inst) = d.node(*id).cloned() else { continue };
@@ -339,6 +402,9 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId
Ok(out)
}
/// How many objects [`break_links`] may make at once.
const MAX_EXPAND_NODES: usize = 1_000_000;
pub(super) fn selected_instances(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let mut out = vec![];
@@ -367,34 +433,35 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "ids": ids_json(&out) }))
}
pub(super) fn symbol_edit(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_instances(s, p)?;
let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?;
let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) {
Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(),
_ => return Err(EngineError::Other("select a symbol instance".into())),
};
// The symbol's own art: one instance's appearance changes would become every instance's.
let out = s.edit("Edit Symbol", |d, sel| {
let out = break_links(d, &[first], false)?;
sel.set(out.iter().copied());
Ok(out)
})?;
Ok(json!({ "name": name, "ids": ids_json(&out) }))
}
pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.update";
let name = name_param(p, C)?.to_string();
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
return Err(bad(C, format!("no symbol named `{name}`")));
}
// Redefining the symbol being edited leaves symbol editing mode in the same step.
let session = s.doc()?.doc.symbol_edit.clone().filter(|e| e.symbol == name);
if session.is_some() {
crate::cmd::settle_interaction(s)?;
s.end_text_edit();
}
let ids = roots(s, p)?;
// The art on the editing layer can't redefine it: the symbol would hold an instance of itself.
if let Some(se) = &session {
let d = &s.doc()?.doc;
if ids.iter().any(|i| d.ancestry(*i).is_some_and(|a| a.contains(&se.layer))) {
return Err(EngineError::Other("a symbol can't hold an instance of itself".into()));
}
}
let mut art = selection_art(s, p)?.ok_or_else(|| EngineError::Other("nothing selected".into()))?;
crate::cmd::threads::keep_shown_text(&s.doc()?.doc, &mut art);
let (art, size, c) = normalize(art)?;
let old = natural_size(&s.doc()?.doc, &name);
let editing = session.is_some();
let id = s.edit("Redefine Symbol", |d, sel| {
if editing {
crate::cmd::symboledit::finish(d, sel);
}
let art = d.reid(&art);
if let Some(sym) = d.symbols.iter_mut().find(|x| x.name == name) {
sym.art = Arc::new(art);
@@ -414,33 +481,96 @@ pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result<Value> {
sel.set([id]);
Ok(id)
})?;
if let Some(se) = &session {
crate::cmd::symboledit::leave_editing(s, se)?;
}
Ok(json!({ "name": name, "id": id.0 }))
}
/// How many levels of nested instances Delete Symbol expands before it gives up.
const MAX_DELETE_ROUNDS: usize = 256;
pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "symbol.delete";
let name = name_param(p, C)?.to_string();
if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) {
// `names` deletes several symbols in one undo step (the Symbols panel's Select All Unused, #4918).
let names: Vec<String> = match p.get("names") {
Some(v) => {
let a = v.as_array().ok_or_else(|| bad(C, "names must be an array of symbol names"))?;
let mut names = Vec::with_capacity(a.len());
for n in a {
let n = n.as_str().ok_or_else(|| bad(C, "names must be an array of symbol names"))?;
if !names.iter().any(|x: &String| x == n) {
names.push(n.to_string());
}
}
if names.is_empty() {
return Err(bad(C, "names is empty"));
}
names
}
None => vec![name_param(p, C)?.to_string()],
};
let d = &s.doc()?.doc;
if let Some(name) = names.iter().find(|n| !d.symbols.iter().any(|x| &x.name == *n)) {
return Err(bad(C, format!("no symbol named `{name}`")));
}
let expand = switch_or(p, "expandInstances", true, C)?;
s.edit("Delete Symbol", |d, _| {
let inst = instances_of(d, Some(&name));
if expand {
break_links(d, &inst, true)?;
} else {
remove_each(d, &inst)?;
// Deleting the symbol being edited leaves symbol editing mode in the same step.
let session = s.doc()?.doc.symbol_edit.clone().filter(|e| names.contains(&e.symbol));
let editing = session.is_some();
if editing {
crate::cmd::settle_interaction(s)?;
s.end_text_edit();
}
let label = if names.len() == 1 { "Delete Symbol" } else { "Delete Symbols" };
s.edit(label, |d, sel| {
if editing {
crate::cmd::symboledit::finish(d, sel);
}
d.symbols.retain(|x| x.name != name);
set_natural_size(d, &name, None);
d.set_symbol_registration(&name, SymbolRegistration::CENTER);
d.clear_symbol_export_type(&name);
d.set_symbol_nine_slice(&name, None);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.remove(CURRENT_SYMBOL);
// Every instance goes before any definition, whatever the order of `names`: expanding an
// instance of one symbol can bring out instances of another being deleted (nested in its
// art), which expand in turn while their definitions are still there. Each round expands
// one level of nesting, so the rounds are capped.
let of_names = |d: &Document| {
let mut v = vec![];
d.walk(|n| {
if let NodeKind::SymbolInstance { symbol, .. } = &n.kind
&& names.contains(symbol)
{
v.push(n.id);
}
});
v
};
for _ in 0..=MAX_DELETE_ROUNDS {
let inst = of_names(d);
if inst.is_empty() {
break;
}
if expand {
break_links(d, &inst, true)?;
} else {
remove_each(d, &inst)?;
}
}
if !of_names(d).is_empty() {
return Err(EngineError::Other("the symbols are nested too deeply to expand their instances".into()));
}
for name in &names {
d.symbols.retain(|x| &x.name != name);
set_natural_size(d, name, None);
d.set_symbol_registration(name, SymbolRegistration::CENTER);
d.clear_symbol_export_type(name);
d.set_symbol_nine_slice(name, None);
if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) {
d.unknown.remove(CURRENT_SYMBOL);
}
}
Ok(())
})?;
if let Some(se) = &session {
crate::cmd::symboledit::leave_editing(s, se)?;
}
ok()
}
@@ -480,14 +610,19 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result<Value> {
s.edit("Replace Symbol", |d, _| {
let new = natural_size(d, &name);
for id in &ids {
let old = match d.node(*id).map(|n| &n.kind) {
Some(NodeKind::SymbolInstance { symbol, .. }) => natural_size(d, symbol),
let (old, other) = match d.node(*id).map(|n| &n.kind) {
Some(NodeKind::SymbolInstance { symbol, .. }) => (natural_size(d, symbol), *symbol != name),
_ => continue,
};
if let Some(NodeKind::SymbolInstance { symbol, xf }) = d.node_mut(*id).map(|n| &mut n.kind) {
let Some(n) = d.node_mut(*id) else { continue };
if let NodeKind::SymbolInstance { symbol, xf } = &mut n.kind {
*symbol = name.clone();
*xf *= Affine::scale_non_uniform(new.0 / old.0, new.1 / old.1);
}
// An override's path names an object of the old symbol's art.
if other && !n.extras().symbol_overrides.is_empty() {
n.extras_mut().symbol_overrides.clear();
}
}
fit_overrides(d, &ids);
Ok(())
+2 -2
View File
@@ -29,7 +29,7 @@ use super::edit::{outermost, selected_roots};
use super::paint::paint_from;
use super::pathops::{Beside, shape_node};
use super::{
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, ids_param,
CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_value, f64_or, finite_numbers, has_doc, id_param, named_ids,
number_or, point_param, str_param, switch_or,
};
@@ -220,7 +220,7 @@ fn has_multi_or_ids(s: &Session) -> std::result::Result<(), String> {
}
fn root_ids(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
let ids = match named_ids(p)? {
Some(v) => v,
None => selected_roots(s)?,
};
+1
View File
@@ -22,6 +22,7 @@ use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDe
use vectorcraft_geom::Rect;
pub use flavours::{AI, BITMAP, EMF, EPS, FILE_HEAD, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT, file_flavour, is_address, names_a_picture};
pub use flavours::{ImageRead, read_image};
pub(crate) use resources::SwatchChoices;
pub use resources::{ArtResources, art_resources};
+27 -3
View File
@@ -413,14 +413,38 @@ fn flavours(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "flavours": s.clipboard_flavour_types() }))
}
/// A bitmap read as `clipboard.importImage` reads its `dataBase64` ([`read_image`]), for
/// [`Session::import_image_read`].
pub struct ImageRead(fileio::RasterImage);
/// Read `bytes` as `clipboard.importImage` reads its `dataBase64`, on any thread: the formats it
/// stores as PNG are decoded and encoded here.
pub fn read_image(bytes: &[u8]) -> Result<ImageRead> {
let mut img = fileio::raster_image(bytes).map_err(|e| bad("clipboard.importImage", e.to_string()))?;
img.blob = links::readable_and_kept(img.blob, links::kept_unread(bytes), false).map_err(|e| bad("clipboard.importImage", e.to_string()))?;
Ok(ImageRead(img))
}
impl Session {
/// `clipboard.importImage` with `p` (its `mime` and `center`) for an image [`read_image`] read
/// (off the UI thread in the desktop app), which it loads instead of reading `dataBase64`.
pub fn import_image_read(&mut self, read: ImageRead, p: &Value) -> Result<Value> {
self.image_hand_off = Some(read);
let r = self.execute("clipboard.importImage", p);
self.image_hand_off = None;
r
}
}
fn import_image(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "clipboard.importImage";
if let Some(m) = str_param(p, "mime").filter(|m| !m.starts_with("image/") || *m == SVG) {
return Err(bad(C, format!("`{m}` isn't a bitmap: use clipboard.importSvg, importPdf or importText")));
}
let data = data_param(p, C)?;
let mut img = fileio::raster_image(&data).map_err(|e| bad(C, e.to_string()))?;
img.blob = links::readable_and_kept(img.blob, links::kept_unread(&data), false).map_err(|e| bad(C, e.to_string()))?;
let ImageRead(img) = match s.image_hand_off.take() {
Some(read) => read,
None => read_image(&data_param(p, C)?)?,
};
let (sx, sy) = pt_per_px(img.ppi);
let (w, h) = (img.width, img.height);
let image = ImageObject {
+42 -10
View File
@@ -11,7 +11,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ShapeMode};
use vectorcraft_render::effects;
use super::edit::{roots_of, selected_roots};
use super::pathops::{pf_leaves, shape_node, style_of};
use super::pathops::{Style, pf_leaves, shape_node, style_of};
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_multi};
const MODE_NAMES: [&str; 4] = ["add", "subtract", "intersect", "exclude"];
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Make Compound Shape",
["Window", "Pathfinder"],
None,
"{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}",
"{ids?: [id…] (selected first; default: the selection), mode?: \"add\"|\"subtract\"|\"intersect\"|\"exclude\" (default add; Alt-clicking a Shape Mode button gives its mode)} the two or more selected objects (paths, compound paths, groups, compound shapes, type, blends, envelopes) become the members of one compound shape, each with the shape mode, in their stacking order where the frontmost was: it draws as the area they make from the backmost up (open paths closed; a blend as its steps, an envelope as its distorted art, an object with a warp or other geometry effect as it draws), painted with the appearance and transparency of the topmost member in Add, Intersect or Exclude mode (with every member in Subtract, the backmost), and the members stay editable (Direct Selection, Layers panel, isolation mode) → {id}",
has_multi,
make
),
@@ -140,19 +140,24 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
let mut members = Vec::with_capacity(roots.len());
for id in &roots {
let n = d.node(*id).ok_or(EngineError::NoNode(*id))?;
if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Text(_)) {
return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes and type"));
if !matches!(
n.kind,
NodeKind::Path { guide: false, .. }
| NodeKind::Compound { .. }
| NodeKind::Group { .. }
| NodeKind::Text(_)
| NodeKind::Blend { .. }
| NodeKind::Envelope { .. }
) {
return Err(bad(C, "compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes"));
}
members.push(n);
}
// The paint and transparency of the topmost member in Add, Intersect or Exclude mode: as they
// all have `mode`, the topmost, or the backmost when they all subtract.
let order: Vec<&Node> = if mode == ShapeMode::Subtract { members } else { members.into_iter().rev().collect() };
let style = order.into_iter().find_map(|n| {
let mut lv = vec![];
pf_leaves(n, &mut lv);
lv.last().map(style_of)
});
let hook = |c: &Node| effects::outline_object(Some(d), c);
let style = order.into_iter().find_map(|n| top_style(d, n, &hook));
let id = s.edit("Make Compound Shape", |d, sel| {
let gid = d.alloc_id();
let id = d.wrap(&roots, |children| {
@@ -177,6 +182,32 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "id": id.0 }))
}
/// The paint and transparency of the frontmost shape `n` adds to a compound shape's area, from the
/// same objects the area takes: a blend, an envelope or an object with a warp gives those of the
/// art it draws, without its geometry effects; a symbol instance those of its symbol's art (with
/// `hook`); type its first style; a compound shape its own; a group those of its frontmost member
/// that has a shape. A shape taken from inside a group, or from the art of a blend, an envelope or
/// a warped group, also takes that object's opacity and blend mode.
fn top_style(d: &Document, n: &Node, hook: effects::OutlineHook) -> Option<Style> {
if let Some(art) = effects::shape_art(n, Some(hook)) {
let mut lv = vec![];
pf_leaves(d, &art, &mut lv);
let inside = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !art.is_compound_shape();
return lv.last().map(|l| if inside { style_of(l).within(n) } else { style_of(l) });
}
match &n.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.is_compound_shape() => {
children.iter().rev().find_map(|c| top_style(d, c, hook)).map(|s| s.within(n))
}
NodeKind::SymbolInstance { .. } => effects::member_leaves(n, Some(hook)).last().map(style_of),
_ => {
let mut lv = vec![];
pf_leaves(d, n, &mut lv);
lv.last().map(style_of)
}
}
}
fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "object.compoundShape.setMode";
let mode = mode_param(p, C, None)?;
@@ -235,7 +266,8 @@ fn expand(s: &mut Session, _: &Value) -> Result<Value> {
let (mut items, mut made) = (vec![], vec![]);
for id in &shapes {
let shape = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
let area = effects::compound_shape_area(&shape, effects::text_outliner())
let hook = |c: &Node| effects::outline_object(Some(d), c);
let area = effects::compound_shape_area(&shape, Some(&hook))
.ok_or_else(|| EngineError::Other("the compound shape is too complex to expand".into()))?;
if area.is_empty() {
return Err(EngineError::Other(format!("{LABEL} produced an empty result")));
+3 -3
View File
@@ -16,7 +16,7 @@ use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project
use super::edit::{duplicate_in, outermost, selected_roots};
use super::{
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param,
ids_param, matrix_param, number_or, ok, point_indices, point_param, str_param, switch_or, targets,
matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets,
};
use crate::EngineError;
@@ -658,7 +658,7 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
if pts.iter().any(|(q, f)| !q.x.is_finite() || !q.y.is_finite() || !f.is_finite()) {
return Err(bad(C, "points must be finite"));
}
let roots = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
let roots = match named_ids(p)? {
Some(v) => v,
None => s.doc()?.selection.objects.clone(),
};
@@ -708,7 +708,7 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
/// The objects a Puppet Warp command works on: `id`/`ids`, else `default`.
fn puppet_ids(p: &Value, default: impl FnOnce() -> Result<Vec<NodeId>>) -> Result<Vec<NodeId>> {
let ids = match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
let ids = match named_ids(p)? {
Some(v) => v,
None => default()?,
};
@@ -1,6 +1,6 @@
use super::{
Affine, Arc, Document, EngineError, Homography, NodeId, NodeKind, PerspectiveGrid, Plane, Point, Result, Session, Value, bad, check_numbers,
check_switches, collect_points, duplicate_in, finite_numbers, id_param, ids_param, json, matrix_param, number_or, ok, outermost, persp,
check_switches, collect_points, duplicate_in, finite_numbers, json, matrix_param, named_id, named_ids, number_or, ok, outermost, persp,
point_param, project_node, selected_roots, str_param, switch_or,
};
@@ -92,7 +92,7 @@ fn plane_param(g: &PerspectiveGrid, p: &Value, cmd: &str) -> Result<Plane> {
}
pub(super) fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])) {
match named_ids(p)? {
Some(v) => Ok(outermost(&s.doc()?.doc, v)),
None => selected_roots(s),
}
@@ -478,9 +478,8 @@ pub(super) fn plane_move(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn plane_match(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = id_param(p, "id")
.or_else(|| st.selection.objects.first().copied())
.ok_or_else(|| EngineError::Other("select an object in perspective".into()))?;
let id =
named_id(p)?.or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select an object in perspective".into()))?;
let mut g = grid_of(&st.doc);
let (plane, depth) = g.attachment_of(&st.doc, id).ok_or_else(|| EngineError::Other("the object isn't in perspective".into()))?;
g.set_offset(plane, depth);
@@ -586,8 +585,7 @@ pub fn perspective_click(s: &Session, cmd: &str, params: &Value, at: Point) -> O
/// mode, to be edited; exiting isolation projects it again ([`finish_edit_text`]).
pub(super) fn edit_text(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id =
id_param(p, "id").or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
let id = named_id(p)?.or_else(|| st.selection.objects.first().copied()).ok_or_else(|| EngineError::Other("select type in perspective".into()))?;
if !st.doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)) && n.projection().is_some()) {
return Err(EngineError::Other("select type in perspective".into()));
}
+89 -2
View File
@@ -2,7 +2,7 @@
//! brush strokes, Save/recall selections), View → Guides, and File → Close All / Document Color
//! Mode / File Info.
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
@@ -11,7 +11,7 @@ use vectorcraft_geom::Vec2;
use super::edit::selected_roots;
use super::pathops::Beside;
use super::select::{selectable_ids, walk_selectable};
use super::select::{is_selectable, selectable_ids, walk_selectable};
use super::{
CommandSpec, EngineError, Point, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, f64_req, has_doc, has_selection, id_param, ok,
str_param,
@@ -27,6 +27,15 @@ pub fn specs() -> Vec<CommandSpec> {
_ => false,
}
)),
cmd!(
"select.same.linkBlockSeries",
"Link Block Series",
["Select", "Same"],
None,
"{} select every frame of the threads of the selected type objects (and of the type inside selected groups); type in no thread selects itself; type and frames that are hidden or locked, inside a hidden or locked object or layer, on a template layer, or outside the isolated group or layer neither count as selected nor are selected → {count}",
has_text_selection,
same_link_block_series
),
cmd!("select.same.fontFamily", "Font Family", ["Select", "Same"], None, "{} → {count}", has_selection, |s, _| same_text(
s,
"select.same.fontFamily",
@@ -313,6 +322,84 @@ fn same_text(s: &mut Session, cmd: &str, eq: fn(&CharStyle, &CharStyle) -> bool)
finish_same(s, cmd, ids)
}
/// Call `f` with each type object among the selected objects and inside them, in selection order,
/// keeping only those [`walk_selectable`] visits: none that is hidden, locked, on a hidden, locked
/// or template layer, or outside the isolated group. A type object inside two selected objects is
/// given twice. The walk stops when `f` returns false.
fn each_selected_text(s: &Session, mut f: impl FnMut(NodeId) -> bool) {
fn inside(n: &Node, f: &mut impl FnMut(NodeId) -> bool) -> bool {
if !n.visible || n.locked {
return true;
}
if matches!(n.kind, NodeKind::Text(_)) && !f(n.id) {
return false;
}
n.children().into_iter().flatten().all(|c| inside(c, f))
}
let Some(st) = s.active() else { return };
let locks = st.doc.locked_artboards();
for id in &st.selection.objects {
if is_selectable(&st.doc, st.isolation, &locks, *id)
&& let Some(n) = st.doc.node(*id)
&& !inside(n, &mut f)
{
return;
}
}
}
/// The type objects [`each_selected_text`] gives, each once, in selection order.
fn selected_text(s: &Session) -> Vec<NodeId> {
let (mut out, mut seen) = (vec![], HashSet::new());
each_selected_text(s, |id| {
if seen.insert(id) {
out.push(id);
}
true
});
out
}
/// A type object is selected, or is inside a selected object.
fn has_text_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let mut any = false;
each_selected_text(s, |_| {
any = true;
false
});
if any { Ok(()) } else { Err("select a text object".into()) }
}
/// Select › Same › Link Block Series: the frames of the threads the selected type objects are
/// in, in thread order; a type object in no thread is its own series.
fn same_link_block_series(s: &mut Session, _: &Value) -> Result<Value> {
const C: &str = "select.same.linkBlockSeries";
let seeds = selected_text(s);
if seeds.is_empty() {
return Err(bad(C, "select a text object"));
}
let st = s.doc()?;
let can = selectable_ids(&st.doc, st.isolation);
// Each frame's thread: the first thread that lists it.
let mut threads: HashMap<NodeId, &[NodeId]> = HashMap::new();
for t in &st.doc.text_threads {
for id in t {
threads.entry(*id).or_insert(t.as_slice());
}
}
let (mut ids, mut seen): (Vec<NodeId>, HashSet<NodeId>) = (vec![], HashSet::new());
for seed in &seeds {
let series = threads.get(seed).copied().unwrap_or(std::slice::from_ref(seed));
for id in series {
if can.contains(id) && seen.insert(*id) {
ids.push(*id);
}
}
}
finish_same(s, C, ids)
}
fn by_kind(s: &mut Session, f: impl Fn(&Node) -> bool) -> Result<Value> {
let ids = candidates(s, f)?;
s.select(|_, sel| sel.set(ids))?;

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